From 8d061ea3bd1336ff2fb97dcd4d90cc8f851ce133 Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Tue, 23 Jun 2026 16:54:35 +0800 Subject: [PATCH 01/34] feat: add apps observability helpers --- shortcuts/apps/apps_observability_common.go | 166 ++++++++++++++++++ .../apps/apps_observability_common_test.go | 132 ++++++++++++++ 2 files changed, 298 insertions(+) create mode 100644 shortcuts/apps/apps_observability_common.go create mode 100644 shortcuts/apps/apps_observability_common_test.go diff --git a/shortcuts/apps/apps_observability_common.go b/shortcuts/apps/apps_observability_common.go new file mode 100644 index 000000000..612000fa4 --- /dev/null +++ b/shortcuts/apps/apps_observability_common.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/internal/validate" +) + +const ( + defaultAppsPageSize = 50 + maxAppsPageSize = 100 +) + +func appScopedPath(appID, suffix string) string { + base := apiBasePath + "/apps/" + validate.EncodePathSegment(strings.TrimSpace(appID)) + suffix = strings.TrimLeft(strings.TrimSpace(suffix), "/") + if suffix == "" { + return base + } + return base + "/" + suffix +} + +func validateObservabilityEnv(env string) error { + switch strings.TrimSpace(env) { + case "", "online": + return nil + default: + return appsValidationParamError("--env", "observability commands only support --env online (got %q)", env) + } +} + +func validateEnvVarEnv(env string) error { + switch strings.TrimSpace(env) { + case "dev", "online": + return nil + default: + return appsValidationParamError("--env", "env var commands only support --env dev or --env online (got %q)", env) + } +} + +func validateAppsPageSize(n int) error { + if n < 1 || n > maxAppsPageSize { + return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxAppsPageSize) + } + return nil +} + +func cleanRepeatedStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func parseAppsTimeRange(sinceName, sinceRaw, untilName, untilRaw string) (time.Time, time.Time, bool, bool, error) { + var since, until time.Time + var hasSince, hasUntil bool + now := time.Now() + if strings.TrimSpace(sinceRaw) != "" { + parsed, err := parseAppsTimeFlag(sinceName, sinceRaw, now) + if err != nil { + return time.Time{}, time.Time{}, false, false, err + } + since = parsed + hasSince = true + } + if strings.TrimSpace(untilRaw) != "" { + parsed, err := parseAppsTimeFlag(untilName, untilRaw, now) + if err != nil { + return since, time.Time{}, hasSince, false, err + } + until = parsed + hasUntil = true + } + if hasSince && hasUntil && since.After(until) { + return since, until, true, true, appsValidationParamError(untilName, "%s must be greater than or equal to %s", untilName, sinceName) + } + return since, until, hasSince, hasUntil, nil +} + +func parseAppsTimeFlag(param, raw string, now time.Time) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, appsValidationParamError(param, "%s is required", param) + } + if d, ok := parseAppsRelativeDuration(raw); ok { + return now.Add(-d), nil + } + if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { + return t, nil + } + for _, layout := range []string{ + "2006-01-02", + "2006-01-02T15:04:05", + "2006-01-02T15:04:05.000", + } { + if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil { + return t, nil + } + } + return time.Time{}, appsValidationParamError(param, "invalid %s %q: expected relative duration (30s, 5m, 2h, 3d, 1w), YYYY-MM-DD, local YYYY-MM-DDTHH:mm:ss(.SSS), or RFC3339", param, raw) +} + +func parseAppsRelativeDuration(s string) (time.Duration, bool) { + s = strings.TrimSpace(s) + if len(s) < 2 { + return 0, false + } + unit := s[len(s)-1] + number := s[:len(s)-1] + for i := 0; i < len(number); i++ { + if number[i] < '0' || number[i] > '9' { + return 0, false + } + } + n, err := strconv.ParseInt(number, 10, 64) + if err != nil || n <= 0 { + return 0, false + } + var unitDuration time.Duration + switch unit { + case 's': + unitDuration = time.Second + case 'm': + unitDuration = time.Minute + case 'h': + unitDuration = time.Hour + case 'd': + unitDuration = 24 * time.Hour + case 'w': + unitDuration = 7 * 24 * time.Hour + default: + return 0, false + } + const maxDuration = time.Duration(1<<63 - 1) + if n > int64(maxDuration)/int64(unitDuration) { + return 0, false + } + return time.Duration(n) * unitDuration, true +} + +func nsString(t time.Time) string { + return strconv.FormatInt(t.UnixNano(), 10) +} + +func secString(t time.Time) string { + return strconv.FormatInt(t.Unix(), 10) +} diff --git a/shortcuts/apps/apps_observability_common_test.go b/shortcuts/apps/apps_observability_common_test.go new file mode 100644 index 000000000..291d9d1b4 --- /dev/null +++ b/shortcuts/apps/apps_observability_common_test.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "errors" + "testing" + "time" + + "github.com/larksuite/cli/errs" +) + +func requireAppsValidationParam(t *testing.T, err error, want string) *errs.Problem { + t.Helper() + p := requireAppsValidationProblem(t, err) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected validation error with param %q, got %T: %v", want, err, err) + } + if validationErr.Param != want { + t.Fatalf("param = %q, want %s", validationErr.Param, want) + } + return p +} + +func TestAppsObservabilityValidateEnvOnlyOnline(t *testing.T) { + if err := validateObservabilityEnv(""); err != nil { + t.Fatalf("empty env should default/pass as online: %v", err) + } + if err := validateObservabilityEnv("online"); err != nil { + t.Fatalf("online should pass: %v", err) + } + err := validateObservabilityEnv("dev") + p := requireAppsValidationParam(t, err, "--env") + if p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %#v, want invalid_argument param --env", p) + } +} + +func TestAppsObservabilityPageSizeRange(t *testing.T) { + for _, n := range []int{1, 50, 100} { + if err := validateAppsPageSize(n); err != nil { + t.Fatalf("page size %d should pass: %v", n, err) + } + } + for _, n := range []int{0, 101} { + err := validateAppsPageSize(n) + requireAppsValidationParam(t, err, "--page-size") + } +} + +func TestAppsObservabilityCommonHelpers(t *testing.T) { + if got := appScopedPath("app/x", "observability/logs"); got != "/open-apis/spark/v1/apps/app%2Fx/observability/logs" { + t.Fatalf("appScopedPath = %q", got) + } + for _, env := range []string{"dev", "online"} { + if err := validateEnvVarEnv(env); err != nil { + t.Fatalf("validateEnvVarEnv(%q) err=%v", env, err) + } + } + requireAppsValidationParam(t, validateEnvVarEnv(""), "--env") + requireAppsValidationParam(t, validateEnvVarEnv("boe"), "--env") + got := cleanRepeatedStrings([]string{" a ", "b", "a", "", "b", "c"}) + want := []string{"a", "b", "c"} + if len(got) != len(want) { + t.Fatalf("cleanRepeatedStrings len=%d, want %d: %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("cleanRepeatedStrings[%d]=%q, want %q", i, got[i], want[i]) + } + } + ts := time.Date(2026, 6, 23, 10, 11, 12, 123456789, time.UTC) + if got := nsString(ts); got != "1782209472123456789" { + t.Fatalf("nsString = %q", got) + } + if got := secString(ts); got != "1782209472" { + t.Fatalf("secString = %q", got) + } +} + +func TestParseAppsTimeAcceptsSupportedInputs(t *testing.T) { + now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.Local) + cases := []struct { + raw string + want time.Time + wantOffset *int + }{ + {raw: "30s", want: now.Add(-30 * time.Second)}, + {raw: "5m", want: now.Add(-5 * time.Minute)}, + {raw: "2h", want: now.Add(-2 * time.Hour)}, + {raw: "3d", want: now.Add(-72 * time.Hour)}, + {raw: "1w", want: now.Add(-7 * 24 * time.Hour)}, + {raw: "2026-06-23", want: time.Date(2026, 6, 23, 0, 0, 0, 0, time.Local)}, + {raw: "2026-06-23T10:11:12", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.Local)}, + {raw: "2026-06-23T10:11:12.123", want: time.Date(2026, 6, 23, 10, 11, 12, 123000000, time.Local)}, + {raw: "2026-06-23T10:11:12Z", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.UTC), wantOffset: ptrInt(0)}, + {raw: "2026-06-23T10:11:12+08:00", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.FixedZone("", 8*60*60)), wantOffset: ptrInt(8 * 60 * 60)}, + } + for _, tc := range cases { + got, err := parseAppsTimeFlag("--since", tc.raw, now) + if err != nil { + t.Fatalf("parseAppsTimeFlag(%q) err=%v", tc.raw, err) + } + if !got.Equal(tc.want) { + t.Fatalf("parseAppsTimeFlag(%q)=%s, want %s", tc.raw, got.Format(time.RFC3339Nano), tc.want.Format(time.RFC3339Nano)) + } + if tc.wantOffset != nil { + _, offset := got.Zone() + if offset != *tc.wantOffset { + t.Fatalf("parseAppsTimeFlag(%q) zone offset=%d, want %d", tc.raw, offset, *tc.wantOffset) + } + } + } +} + +func TestParseAppsTimeRejectsUnsupportedInputs(t *testing.T) { + for _, in := range []string{"2026/06/23", "yesterday", "2026-06-23 10:11:12", "999999999999999999w", "2147483647w"} { + _, _, _, _, err := parseAppsTimeRange("--since", in, "--until", "") + requireAppsValidationParam(t, err, "--since") + } +} + +func TestParseAppsTimeRangeRejectsSinceAfterUntil(t *testing.T) { + _, _, _, _, err := parseAppsTimeRange("--since", "2026-06-24", "--until", "2026-06-23") + requireAppsValidationParam(t, err, "--until") +} + +func ptrInt(n int) *int { + return &n +} From e9fde3e8f79171b08060d3f51ed5b6774a291c12 Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Tue, 23 Jun 2026 17:21:37 +0800 Subject: [PATCH 02/34] feat: add apps log observability shortcuts --- shortcuts/apps/apps_observability_logs.go | 562 ++++++++++++++++++ .../apps/apps_observability_logs_test.go | 298 ++++++++++ 2 files changed, 860 insertions(+) create mode 100644 shortcuts/apps/apps_observability_logs.go create mode 100644 shortcuts/apps/apps_observability_logs_test.go diff --git a/shortcuts/apps/apps_observability_logs.go b/shortcuts/apps/apps_observability_logs.go new file mode 100644 index 000000000..a7a99714b --- /dev/null +++ b/shortcuts/apps/apps_observability_logs.go @@ -0,0 +1,562 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultAppsLogEnv = "online" + logSearchEndpoint = "search_logs" + resolveStackEndpoint = "resolve_stack_trace" + sourceStackStatusOK = "resolved" + sourceStackStatusError = "unresolved" +) + +// AppsLogList searches online app logs with observability filters. +var AppsLogList = common.Shortcut{ + Service: appsService, + Command: "+log-list", + Description: "Search online app logs with observability filters", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +log-list --app-id --level error --keyword timeout --since 1h", + "Tip: use --page-token from the response to fetch the next page.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true}, + {Name: "env", Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339"}, + {Name: "level", Type: "string_array", Desc: "log level filter; repeatable, one of DEBUG, INFO, WARN, ERROR (case-insensitive)"}, + {Name: "log-id", Type: "string_array", Desc: "log ID filter; repeatable"}, + {Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"}, + {Name: "keyword", Desc: "keyword filter applied by the log search backend"}, + {Name: "module", Desc: "module name filter"}, + {Name: "user-id", Desc: "end user ID filter"}, + {Name: "page", Desc: "frontend page or route filter"}, + {Name: "api", Desc: "API path/name filter"}, + {Name: "min-duration", Type: "int", Desc: "minimum duration in milliseconds; must be non-negative"}, + {Name: "max-duration", Type: "int", Desc: "maximum duration in milliseconds; must be non-negative and >= --min-duration"}, + {Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", defaultAppsPageSize), Desc: "page size, 1..100"}, + {Name: "page-token", Desc: "pagination cursor from a previous log search response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, err := buildLogSearchBody(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildLogSearchBody(rctx) + return common.NewDryRunAPI(). + POST(logSearchPath(rctx.Str("app-id"))). + Desc("Search online app logs"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + body, err := buildLogSearchBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", logSearchPath(appID), nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := normalizeLogSearchResponse(data) + rctx.OutFormat(out, nil, func(w io.Writer) { + output.PrintTable(w, logListRows(out.Items)) + }) + return nil + }, +} + +// AppsLogGet fetches one log by log ID through the search_logs endpoint. +var AppsLogGet = common.Shortcut{ + Service: appsService, + Command: "+log-get", + Description: "Get one online app log by log ID", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +log-get --app-id --log-id ", + "Tip: +log-get searches online logs with limit=1; use +log-list first if the log ID is unknown.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true}, + {Name: "log-id", Desc: "log ID to fetch", Required: true}, + {Name: "env", Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, + }, + 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("log-id")) == "" { + return appsValidationParamError("--log-id", "--log-id is required") + } + return validateObservabilityEnv(rctx.Str("env")) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST(logSearchPath(rctx.Str("app-id"))). + Desc("Search online app logs by log ID"). + Body(buildLogGetSearchBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + data, err := rctx.CallAPITyped("POST", logSearchPath(appID), nil, buildLogGetSearchBody(rctx)) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := normalizeLogSearchResponse(data) + if len(out.Items) == 0 { + return appsFailedPreconditionParamError("--log-id", "log not found"). + WithHint("verify --log-id and --env online") + } + log := out.Items[0] + enrichLogSourceStack(rctx, appID, log) + rctx.OutFormat(log, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{logSummaryRow(log)}) + }) + return nil + }, +} + +type logSearchOutput struct { + Items []map[string]interface{} `json:"items"` + PageToken string `json:"page_token,omitempty"` + HasMore bool `json:"has_more"` +} + +func logSearchPath(appID string) string { + return appScopedPath(appID, logSearchEndpoint) +} + +func resolveStackPath(appID string) string { + return appScopedPath(appID, resolveStackEndpoint) +} + +func buildLogSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { + env := strings.TrimSpace(rctx.Str("env")) + if env == "" { + env = defaultAppsLogEnv + } + if err := validateObservabilityEnv(env); err != nil { + return nil, err + } + if err := validateAppsPageSize(rctx.Int("page-size")); err != nil { + return nil, err + } + body := map[string]interface{}{ + "app_env": env, + "limit": rctx.Int("page-size"), + } + if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { + body["page_token"] = token + } + if err := addLogSearchTimeRange(body, rctx); err != nil { + return nil, err + } + filter, err := buildLogSearchFilter(rctx) + if err != nil { + return nil, err + } + if len(filter) > 0 { + body["filter"] = filter + } + return body, nil +} + +func buildLogGetSearchBody(rctx *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "app_env": defaultAppsLogEnv, + "limit": 1, + "filter": map[string]interface{}{ + "log_ids": []string{strings.TrimSpace(rctx.Str("log-id"))}, + }, + } +} + +func addLogSearchTimeRange(body map[string]interface{}, rctx *common.RuntimeContext) error { + since, until, hasSince, hasUntil, err := parseAppsTimeRange("--since", rctx.Str("since"), "--until", rctx.Str("until")) + if err != nil { + return err + } + if hasSince { + body["start_timestamp_ns"] = nsString(since) + } + if hasUntil { + body["end_timestamp_ns"] = nsString(until) + } + return nil +} + +func buildLogSearchFilter(rctx *common.RuntimeContext) (map[string]interface{}, error) { + filter := make(map[string]interface{}) + levels, err := normalizeLogLevels(rctx.StrArray("level")) + if err != nil { + return nil, err + } + if len(levels) > 0 { + filter["levels"] = levels + } + if logIDs := cleanRepeatedStrings(rctx.StrArray("log-id")); len(logIDs) > 0 { + filter["log_ids"] = logIDs + } + if traceIDs := cleanRepeatedStrings(rctx.StrArray("trace-id")); len(traceIDs) > 0 { + filter["trace_ids"] = traceIDs + } + addTrimmedLogFilterString(filter, "keyword", rctx.Str("keyword")) + addTrimmedLogFilterString(filter, "module", rctx.Str("module")) + addTrimmedLogFilterString(filter, "user_id", rctx.Str("user-id")) + addTrimmedLogFilterString(filter, "page", rctx.Str("page")) + addTrimmedLogFilterString(filter, "api", rctx.Str("api")) + if err := addDurationFilters(filter, rctx); err != nil { + return nil, err + } + return filter, nil +} + +func addTrimmedLogFilterString(filter map[string]interface{}, key, value string) { + if value = strings.TrimSpace(value); value != "" { + filter[key] = value + } +} + +func addDurationFilters(filter map[string]interface{}, rctx *common.RuntimeContext) error { + hasMin := rctx.Changed("min-duration") + hasMax := rctx.Changed("max-duration") + minDuration := rctx.Int("min-duration") + maxDuration := rctx.Int("max-duration") + if hasMin { + if minDuration < 0 { + return appsValidationParamError("--min-duration", "--min-duration must be non-negative") + } + filter["min_duration_ms"] = minDuration + } + if hasMax { + if maxDuration < 0 { + return appsValidationParamError("--max-duration", "--max-duration must be non-negative") + } + filter["max_duration_ms"] = maxDuration + } + if hasMin && hasMax && minDuration > maxDuration { + return appsValidationParamError("--max-duration", "--max-duration must be greater than or equal to --min-duration") + } + return nil +} + +func normalizeLogLevels(values []string) ([]string, error) { + values = cleanRepeatedStrings(values) + if len(values) == 0 { + return nil, nil + } + out := make([]string, 0, len(values)) + for _, value := range values { + level := strings.ToUpper(strings.TrimSpace(value)) + switch level { + case "DEBUG", "INFO", "WARN", "ERROR": + out = append(out, level) + default: + return nil, appsValidationParamError("--level", "--level must be one of DEBUG, INFO, WARN, ERROR") + } + } + return out, nil +} + +func normalizeLogSearchResponse(data map[string]interface{}) logSearchOutput { + items := firstMapSlice(data, "items", "log_items", "logItems") + normalized := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + normalized = append(normalized, normalizeLogItem(item)) + } + return logSearchOutput{ + Items: normalized, + PageToken: firstLogString(data, "page_token", "next_page_token", "pageToken", "nextPageToken"), + HasMore: firstLogBool(data, "has_more", "hasMore"), + } +} + +func normalizeLogItem(item map[string]interface{}) map[string]interface{} { + out := cloneMap(item) + copyFirstAlias(out, item, "log_id", "log_id", "id", "logID", "logId") + copyFirstAlias(out, item, "trace_id", "trace_id", "traceID", "traceId") + copyFirstAlias(out, item, "timestamp_ns", "timestamp_ns", "timestampNs") + copyFirstAlias(out, item, "severity_text", "severity_text", "severityText") + if level := firstItemString(out, "level", "severity_text", "severityText"); level != "" { + out["level"] = level + } + return out +} + +func firstMapSlice(data map[string]interface{}, keys ...string) []map[string]interface{} { + for _, key := range keys { + raw, ok := data[key] + if !ok { + continue + } + switch items := raw.(type) { + case []map[string]interface{}: + return items + case []interface{}: + out := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out + } + } + return nil +} + +func firstLogString(data map[string]interface{}, keys ...string) string { + for _, key := range keys { + if s, ok := data[key].(string); ok && strings.TrimSpace(s) != "" { + return s + } + } + return "" +} + +func firstLogBool(data map[string]interface{}, keys ...string) bool { + for _, key := range keys { + if b, ok := data[key].(bool); ok { + return b + } + } + return false +} + +func copyFirstAlias(dst, src map[string]interface{}, canonical string, keys ...string) { + for _, key := range keys { + if value, ok := src[key]; ok { + dst[canonical] = value + return + } + } +} + +func cloneMap(src map[string]interface{}) map[string]interface{} { + dst := make(map[string]interface{}, len(src)+4) + for key, value := range src { + dst[key] = value + } + return dst +} + +func logListRows(items []map[string]interface{}) []map[string]interface{} { + rows := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + rows = append(rows, logSummaryRow(item)) + } + return rows +} + +func logSummaryRow(item map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "log_id": item["log_id"], + "level": firstItemString(item, "level", "severity_text"), + "trace_id": item["trace_id"], + "timestamp_ns": item["timestamp_ns"], + "message": firstItemString(item, "message", "body"), + } +} + +func firstItemString(item map[string]interface{}, keys ...string) string { + for _, key := range keys { + if s, ok := item[key].(string); ok && strings.TrimSpace(s) != "" { + return s + } + } + return "" +} + +func enrichLogSourceStack(rctx *common.RuntimeContext, appID string, log map[string]interface{}) { + if !shouldResolveSourceStack(log) { + return + } + body, ok := extractSourceStackResolveBody(log) + if !ok { + log["source_stack_status"] = sourceStackStatusError + log["source_stack_reason"] = "source stack fields incomplete" + return + } + data, err := rctx.CallAPITyped("POST", resolveStackPath(appID), nil, body) + if err != nil { + if _, typed := errs.ProblemOf(err); typed { + log["source_stack_status"] = sourceStackStatusError + log["source_stack_reason"] = "resolve_stack_trace failed" + } + return + } + stack := firstLogValue(data, "source_stack", "sourceStack", "frames") + if stack == nil { + stack = data + } + log["source_stack_status"] = sourceStackStatusOK + log["source_stack"] = stack +} + +func shouldResolveSourceStack(log map[string]interface{}) bool { + level := strings.ToUpper(firstItemString(log, "level", "severity_text", "severityText")) + if level != "ERROR" { + return false + } + if _, ok := extractSourceStackResolveBody(log); ok { + return true + } + return hasFrontendSourceMapSignal(log) +} + +func hasFrontendSourceMapSignal(value interface{}) bool { + switch v := value.(type) { + case map[string]interface{}: + for key, nested := range v { + if isSourceMapSignal(key) || hasFrontendSourceMapSignal(nested) { + return true + } + } + case []interface{}: + for _, nested := range v { + if hasFrontendSourceMapSignal(nested) { + return true + } + } + case string: + return isSourceMapSignal(v) || strings.Contains(strings.ToLower(v), ".js") + } + return false +} + +func isSourceMapSignal(value string) bool { + normalized := strings.NewReplacer("-", "_", " ", "_").Replace(strings.ToLower(value)) + return strings.Contains(normalized, "source_map") || strings.Contains(normalized, "sourcemap") +} + +func extractSourceStackResolveBody(log map[string]interface{}) (map[string]interface{}, bool) { + sources := []map[string]interface{}{log} + if attrs, ok := log["attributes"].(map[string]interface{}); ok { + sources = append([]map[string]interface{}{attrs}, sources...) + } + if bodyMap, ok := log["body"].(map[string]interface{}); ok { + sources = append([]map[string]interface{}{bodyMap}, sources...) + } + commitID := firstStringInMaps(sources, "commit_id", "commitID", "commitId") + prefix := firstStringInMaps(sources, "source_map_file_prefix", "sourceMapFilePrefix", "source_map_prefix", "sourceMapPrefix") + frames := firstFramesInMaps(sources, "frames", "stack_frames", "stackFrames", "source_stack_frames", "sourceStackFrames") + if commitID == "" || prefix == "" || len(frames) == 0 { + return nil, false + } + return map[string]interface{}{ + "commit_id": commitID, + "source_map_file_prefix": prefix, + "frames": frames, + }, true +} + +func firstStringInMaps(sources []map[string]interface{}, keys ...string) string { + for _, source := range sources { + if s := firstLogString(source, keys...); s != "" { + return s + } + } + return "" +} + +func firstFramesInMaps(sources []map[string]interface{}, keys ...string) []interface{} { + for _, source := range sources { + for _, key := range keys { + frames := normalizeFrames(source[key]) + if len(frames) > 0 { + return frames + } + } + } + return nil +} + +func normalizeFrames(raw interface{}) []interface{} { + switch frames := raw.(type) { + case []interface{}: + out := make([]interface{}, 0, len(frames)) + for _, frame := range frames { + if isNonEmptyFrame(frame) { + out = append(out, frame) + } + } + return out + case []map[string]interface{}: + out := make([]interface{}, 0, len(frames)) + for _, frame := range frames { + if len(frame) > 0 { + out = append(out, frame) + } + } + return out + case string: + return parseFrameString(frames) + default: + return nil + } +} + +func isNonEmptyFrame(frame interface{}) bool { + switch f := frame.(type) { + case map[string]interface{}: + return len(f) > 0 + case map[string]string: + return len(f) > 0 + case string: + return strings.TrimSpace(f) != "" + default: + return frame != nil + } +} + +func parseFrameString(raw string) []interface{} { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var decoded []interface{} + if err := json.Unmarshal([]byte(raw), &decoded); err == nil { + return normalizeFrames(decoded) + } + lines := strings.Split(raw, "\n") + out := make([]interface{}, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + out = append(out, map[string]interface{}{"raw": line}) + } + } + return out +} + +func firstLogValue(data map[string]interface{}, keys ...string) interface{} { + for _, key := range keys { + if value, ok := data[key]; ok { + return value + } + } + return nil +} diff --git a/shortcuts/apps/apps_observability_logs_test.go b/shortcuts/apps/apps_observability_logs_test.go new file mode 100644 index 000000000..437fd7d83 --- /dev/null +++ b/shortcuts/apps/apps_observability_logs_test.go @@ -0,0 +1,298 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsLogList, []string{ + "+log-list", "--app-id", "app_x", "--level", "error", + "--log-id", "LOG1", "--log-id", "LOG2", "--trace-id", "trace-1", + "--keyword", "timeout", "--min-duration", "200", + "--page-size", "20", "--dry-run", "--as", "user", + }, factory, stdout) + if 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"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/search_logs" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + if env.API[0].Body["app_env"] != "online" || env.API[0].Body["limit"] != float64(20) { + t.Fatalf("body = %#v", env.API[0].Body) + } + filter := env.API[0].Body["filter"].(map[string]interface{}) + if got := filter["keyword"]; got != "timeout" { + t.Fatalf("filter.keyword = %v", got) + } +} + +func TestAppsLogList_RejectsDevEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, factory, stdout) + requireAppsValidationParam(t, err, "--env") +} + +func TestAppsLogGet_SearchesByLogIDLimitOne(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{"log_id": "LOG1", "level": "INFO"}, + }, + }, + }, + } + reg.Register(stub) + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatal(err) + } + if sent["limit"] != float64(1) { + t.Fatalf("limit = %v, want 1", sent["limit"]) + } +} + +func TestAppsLogList_NormalizesResponseVariantsAndCanonicalLevel(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "logItems": []interface{}{ + map[string]interface{}{ + "id": "LOG1", + "traceID": "trace-1", + "timestampNs": "1782209472123456789", + "severityText": "ERROR", + }, + }, + "nextPageToken": "tok-next", + "hasMore": true, + }, + }, + }) + + if err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + PageToken string `json:"page_token"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.PageToken != "tok-next" || !env.Data.HasMore { + t.Fatalf("pagination = token %q has_more %v", env.Data.PageToken, env.Data.HasMore) + } + if len(env.Data.Items) != 1 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + item := env.Data.Items[0] + if item["level"] != "ERROR" || item["severity_text"] != "ERROR" || item["severityText"] != "ERROR" { + t.Fatalf("level fields = %#v", item) + } +} + +func TestAppsLogGet_ResolvesSourceStackWhenFieldsPresent(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG1", + "level": "ERROR", + "attributes": map[string]interface{}{ + "commit_id": "commit_1", + "source_map_file_prefix": "sourcemaps/app", + "frames": []interface{}{ + map[string]interface{}{"file": "main.js", "line": 10, "column": 20}, + }, + }, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/App.tsx", "line": 7, "column": 9}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil { + t.Fatal(err) + } + if sent["commit_id"] != "commit_1" || sent["source_map_file_prefix"] != "sourcemaps/app" { + t.Fatalf("resolve body missing source map fields: %#v", sent) + } + frames, ok := sent["frames"].([]interface{}) + if !ok || len(frames) != 1 { + t.Fatalf("resolve frames = %#v", sent["frames"]) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") { + t.Fatalf("stdout missing resolved source stack: %s", got) + } +} + +func TestAppsLogGet_SourceStackMissingFieldsDoesNotFail(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG1", + "level": "ERROR", + "message": "TypeError at https://cdn.example.com/main.js:10:20", + "attributes": map[string]interface{}{"commit_id": "commit_1"}, + }, + }, + }, + }, + } + reg.Register(search) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"log_id": "LOG1"`) { + t.Fatalf("stdout missing original log: %s", got) + } else if !strings.Contains(got, `"source_stack_status": "unresolved"`) { + t.Fatalf("stdout missing unresolved source stack status: %s", got) + } else if !strings.Contains(got, `"source_stack_reason"`) { + t.Fatalf("stdout missing sanitized source stack reason: %s", got) + } + for _, banned := range []string{"secret", "token", "raw request payload"} { + if strings.Contains(strings.ToLower(stdout.String()), banned) { + t.Fatalf("stdout leaked %q: %s", banned, stdout.String()) + } + } +} + +func TestAppsLogGet_ErrorNonFrontendMissingFieldsDoesNotMarkUnresolved(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG1", + "level": "ERROR", + "message": "go stack trace: database query failed", + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); strings.Contains(got, "source_stack_status") { + t.Fatalf("non-frontend error log should not be marked unresolved: %s", got) + } +} + +func TestAppsLogGet_SourceStackResolveFailureIsRedacted(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG1", + "level": "ERROR", + "attributes": map[string]interface{}{ + "commit_id": "commit_1", + "source_map_file_prefix": "sourcemaps/app", + "frames": []interface{}{ + map[string]interface{}{"file": "main.js", "line": 10, "column": 20}, + }, + }, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 999, + "msg": "secret token raw request payload should be redacted", + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"source_stack_status": "unresolved"`) { + t.Fatalf("stdout missing unresolved status: %s", got) + } + for _, banned := range []string{"secret", "token", "raw request payload"} { + if strings.Contains(strings.ToLower(got), banned) { + t.Fatalf("stdout leaked %q: %s", banned, got) + } + } +} From fdcd9f6dde92b2dea1c80120877c5a84d3eb479d Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Tue, 23 Jun 2026 17:48:38 +0800 Subject: [PATCH 03/34] feat: add apps trace observability shortcuts --- shortcuts/apps/apps_observability_traces.go | 621 ++++++++++++++++++ .../apps/apps_observability_traces_test.go | 293 +++++++++ 2 files changed, 914 insertions(+) create mode 100644 shortcuts/apps/apps_observability_traces.go create mode 100644 shortcuts/apps/apps_observability_traces_test.go diff --git a/shortcuts/apps/apps_observability_traces.go b/shortcuts/apps/apps_observability_traces.go new file mode 100644 index 000000000..b8b0a28dc --- /dev/null +++ b/shortcuts/apps/apps_observability_traces.go @@ -0,0 +1,621 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultAppsTraceEnv = "online" + traceSearchEndpoint = "search_traces" + traceGetEndpoint = "get_trace" +) + +// AppsTraceList searches online app traces with observability filters. +var AppsTraceList = common.Shortcut{ + Service: appsService, + Command: "+trace-list", + Description: "Search online app traces with observability filters", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +trace-list --app-id --trace-id ", + "Tip: use --page-token from the response to fetch the next page.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online traces should be searched", Required: true}, + {Name: "env", Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, + {Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"}, + {Name: "root-span", Desc: "root span keyword filter applied by the trace search backend"}, + {Name: "user-id", Desc: "end user ID filter"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339"}, + {Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", defaultAppsPageSize), Desc: "page size, 1..100"}, + {Name: "page-token", Desc: "pagination cursor from a previous trace search response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, err := buildTraceSearchBody(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildTraceSearchBody(rctx) + return common.NewDryRunAPI(). + POST(traceSearchPath(rctx.Str("app-id"))). + Desc("Search online app traces"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + body, err := buildTraceSearchBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", traceSearchPath(appID), nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := normalizeTraceSearchResponse(data) + rctx.OutFormat(out, nil, func(w io.Writer) { + output.PrintTable(w, traceListRows(out.Items)) + }) + return nil + }, +} + +// AppsTraceGet fetches one online app trace by trace ID. +var AppsTraceGet = common.Shortcut{ + Service: appsService, + Command: "+trace-get", + Description: "Get one online app trace by trace ID", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +trace-get --app-id --trace-id ", + "Tip: use +trace-list first if the trace ID is unknown.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online trace should be fetched", Required: true}, + {Name: "env", Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, + {Name: "trace-id", Desc: "trace ID to fetch", Required: true}, + }, + 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("trace-id")) == "" { + return appsValidationParamError("--trace-id", "--trace-id is required") + } + return validateObservabilityEnv(rctx.Str("env")) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST(traceGetPath(rctx.Str("app-id"))). + Desc("Get online app trace by trace ID"). + Body(buildTraceGetBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + data, err := rctx.CallAPITyped("POST", traceGetPath(appID), nil, buildTraceGetBody(rctx)) + if err != nil { + return withAppsHint(err, appIDListHint) + } + trace := normalizeTraceDetail(data) + rctx.OutFormat(trace, nil, func(w io.Writer) { + output.PrintTable(w, []map[string]interface{}{traceSummaryRow(trace)}) + }) + return nil + }, +} + +type traceSearchOutput struct { + Items []map[string]interface{} `json:"items"` + PageToken string `json:"page_token,omitempty"` + HasMore bool `json:"has_more"` +} + +func traceSearchPath(appID string) string { + return appScopedPath(appID, traceSearchEndpoint) +} + +func traceGetPath(appID string) string { + return appScopedPath(appID, traceGetEndpoint) +} + +func buildTraceSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { + env := strings.TrimSpace(rctx.Str("env")) + if env == "" { + env = defaultAppsTraceEnv + } + if err := validateObservabilityEnv(env); err != nil { + return nil, err + } + if err := validateAppsPageSize(rctx.Int("page-size")); err != nil { + return nil, err + } + body := map[string]interface{}{ + "app_env": env, + "limit": rctx.Int("page-size"), + } + if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { + body["page_token"] = token + } + if err := addTraceSearchTimeRange(body, rctx); err != nil { + return nil, err + } + if filter := buildTraceSearchFilter(rctx); len(filter) > 0 { + body["filter"] = filter + } + return body, nil +} + +func buildTraceGetBody(rctx *common.RuntimeContext) map[string]interface{} { + env := strings.TrimSpace(rctx.Str("env")) + if env == "" { + env = defaultAppsTraceEnv + } + return map[string]interface{}{ + "app_env": env, + "trace_id": strings.TrimSpace(rctx.Str("trace-id")), + } +} + +func addTraceSearchTimeRange(body map[string]interface{}, rctx *common.RuntimeContext) error { + since, until, hasSince, hasUntil, err := parseAppsTimeRange("--since", rctx.Str("since"), "--until", rctx.Str("until")) + if err != nil { + return err + } + if hasSince { + body["start_timestamp_ns"] = nsString(since) + } + if hasUntil { + body["end_timestamp_ns"] = nsString(until) + } + return nil +} + +func buildTraceSearchFilter(rctx *common.RuntimeContext) map[string]interface{} { + filter := make(map[string]interface{}) + if traceIDs := cleanRepeatedStrings(rctx.StrArray("trace-id")); len(traceIDs) > 0 { + filter["trace_ids"] = traceIDs + } + addTrimmedTraceFilterString(filter, "keyword", rctx.Str("root-span")) + addTrimmedTraceFilterString(filter, "user_id", rctx.Str("user-id")) + return filter +} + +func addTrimmedTraceFilterString(filter map[string]interface{}, key, value string) { + if value = strings.TrimSpace(value); value != "" { + filter[key] = value + } +} + +func normalizeTraceSearchResponse(data map[string]interface{}) traceSearchOutput { + items, sourceKey := firstTraceMapSliceWithKey(data, "items", "trace_items", "traceItems", "spans", "span_items", "spanItems") + normalized := normalizeTraceSummaries(items) + if isTraceSpanItemsKey(sourceKey) { + normalized = aggregateTraceSpanSummaries(items) + } + return traceSearchOutput{ + Items: normalized, + PageToken: firstLogString(data, "page_token", "next_page_token", "pageToken", "nextPageToken"), + HasMore: firstLogBool(data, "has_more", "hasMore"), + } +} + +func firstTraceMapSliceWithKey(data map[string]interface{}, keys ...string) ([]map[string]interface{}, string) { + for _, key := range keys { + raw, ok := data[key] + if !ok { + continue + } + return traceMapSlice(raw), key + } + return nil, "" +} + +func traceMapSlice(raw interface{}) []map[string]interface{} { + switch items := raw.(type) { + case []map[string]interface{}: + return items + case []interface{}: + out := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out + default: + return nil + } +} + +func isTraceSpanItemsKey(key string) bool { + switch key { + case "spans", "span_items", "spanItems": + return true + default: + return false + } +} + +func normalizeTraceSummaries(items []map[string]interface{}) []map[string]interface{} { + if len(items) == 0 { + return nil + } + if hasRepeatedTraceID(items) { + return aggregateTraceSpanSummaries(items) + } + out := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + out = append(out, normalizeTraceSummary(item)) + } + return out +} + +func hasRepeatedTraceID(items []map[string]interface{}) bool { + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + traceID := firstTraceString(item, "trace_id", "traceID", "traceId") + if traceID == "" { + continue + } + if _, ok := seen[traceID]; ok { + return true + } + seen[traceID] = struct{}{} + } + return false +} + +func normalizeTraceSummary(item map[string]interface{}) map[string]interface{} { + out := cloneMap(item) + copyFirstAlias(out, item, "trace_id", "trace_id", "traceID", "traceId") + copyFirstAlias(out, item, "start_time_ns", "start_time_ns", "startTimeNs") + copyFirstAlias(out, item, "root_span", "root_span", "rootSpan") + copyFirstAlias(out, item, "user_id", "user_id", "userID", "userId") + copyFirstAlias(out, item, "duration_ms", "duration_ms", "durationMs") + copyFirstAlias(out, item, "status", "status") + copyFirstAlias(out, item, "span_count", "span_count", "spanCount") + return out +} + +func aggregateTraceSpanSummaries(spans []map[string]interface{}) []map[string]interface{} { + groups := make([]traceSpanGroup, 0, len(spans)) + indexByTraceID := make(map[string]int, len(spans)) + ungrouped := make([]map[string]interface{}, 0) + for _, span := range spans { + traceID := firstTraceString(span, "trace_id", "traceID", "traceId") + if traceID == "" { + ungrouped = append(ungrouped, normalizeTraceSummary(span)) + continue + } + idx, ok := indexByTraceID[traceID] + if !ok { + indexByTraceID[traceID] = len(groups) + groups = append(groups, traceSpanGroup{traceID: traceID, spans: []map[string]interface{}{span}}) + continue + } + groups[idx].spans = append(groups[idx].spans, span) + } + out := make([]map[string]interface{}, 0, len(groups)+len(ungrouped)) + for _, group := range groups { + out = append(out, buildTraceSpanSummary(group.traceID, group.spans)) + } + out = append(out, ungrouped...) + return out +} + +type traceSpanGroup struct { + traceID string + spans []map[string]interface{} +} + +func buildTraceSpanSummary(traceID string, spans []map[string]interface{}) map[string]interface{} { + root := selectTraceRootCandidate(spans) + summary := normalizeTraceSummary(root) + summary["trace_id"] = traceID + summary["span_count"] = len(spans) + if firstItemString(summary, "root_span") == "" { + if rootName := firstItemString(root, "name", "span_name", "spanName"); rootName != "" { + summary["root_span"] = rootName + } else if fallbackName := firstTraceSpanName(spans); fallbackName != "" { + summary["root_span"] = fallbackName + } + } + if firstItemString(summary, "user_id") == "" { + if userID := firstStringInTraceSpans(spans, "user_id", "userID", "userId"); userID != "" { + summary["user_id"] = userID + } + } + if startValue, ok := earliestTraceSpanValue(spans, "start_time_ns", "startTimeNs"); ok { + summary["start_time_ns"] = startValue + } + if durationValue, ok := maxTraceSpanValue(spans, "duration_ms", "durationMs"); ok { + summary["duration_ms"] = durationValue + } + if status := aggregateTraceSpanStatus(spans); status != "" { + summary["status"] = status + } + return summary +} + +func selectTraceRootCandidate(spans []map[string]interface{}) map[string]interface{} { + for _, span := range spans { + if firstItemString(span, "root_span", "rootSpan") != "" { + return span + } + } + for _, span := range spans { + if isTraceRootParentCandidate(span) { + return span + } + } + for _, span := range spans { + if firstItemString(span, "name", "span_name", "spanName") != "" { + return span + } + } + if len(spans) == 0 { + return map[string]interface{}{} + } + return spans[0] +} + +func isTraceRootParentCandidate(span map[string]interface{}) bool { + parent, ok := firstTraceValue(span, "parent_span_id", "parentSpanID", "parentSpanId") + if !ok || parent == nil { + return true + } + parentID, ok := parent.(string) + return ok && strings.TrimSpace(parentID) == "" +} + +func firstTraceSpanName(spans []map[string]interface{}) string { + return firstStringInTraceSpans(spans, "name", "span_name", "spanName") +} + +func firstStringInTraceSpans(spans []map[string]interface{}, keys ...string) string { + for _, span := range spans { + if value := firstItemString(span, keys...); value != "" { + return value + } + } + return "" +} + +func earliestTraceSpanValue(spans []map[string]interface{}, keys ...string) (interface{}, bool) { + var bestValue interface{} + var bestNumber traceNumber + var found bool + for _, span := range spans { + value, number, ok := firstTraceNumericValue(span, keys...) + if !ok { + continue + } + if !found || number.less(bestNumber) { + bestValue = value + bestNumber = number + found = true + } + } + return bestValue, found +} + +func maxTraceSpanValue(spans []map[string]interface{}, keys ...string) (interface{}, bool) { + var bestValue interface{} + var bestNumber traceNumber + var found bool + for _, span := range spans { + value, number, ok := firstTraceNumericValue(span, keys...) + if !ok { + continue + } + if !found || number.greater(bestNumber) { + bestValue = value + bestNumber = number + found = true + } + } + return bestValue, found +} + +func firstTraceNumericValue(span map[string]interface{}, keys ...string) (interface{}, traceNumber, bool) { + value, ok := firstTraceValue(span, keys...) + if !ok { + return nil, traceNumber{}, false + } + number, ok := parseTraceNumber(value) + return value, number, ok +} + +type traceNumber struct { + floatValue float64 + intValue int64 + exactInt bool +} + +func (n traceNumber) less(other traceNumber) bool { + if n.exactInt && other.exactInt { + return n.intValue < other.intValue + } + return n.floatValue < other.floatValue +} + +func (n traceNumber) greater(other traceNumber) bool { + if n.exactInt && other.exactInt { + return n.intValue > other.intValue + } + return n.floatValue > other.floatValue +} + +func parseTraceNumber(value interface{}) (traceNumber, bool) { + switch v := value.(type) { + case int: + return exactTraceInt(int64(v)), true + case int8: + return exactTraceInt(int64(v)), true + case int16: + return exactTraceInt(int64(v)), true + case int32: + return exactTraceInt(int64(v)), true + case int64: + return exactTraceInt(v), true + case uint: + return traceUintNumber(uint64(v)) + case uint8: + return traceUintNumber(uint64(v)) + case uint16: + return traceUintNumber(uint64(v)) + case uint32: + return traceUintNumber(uint64(v)) + case uint64: + return traceUintNumber(v) + case float32: + return traceFloatNumber(float64(v)), true + case float64: + return traceFloatNumber(v), true + case string: + raw := strings.TrimSpace(v) + if number, err := strconv.ParseInt(raw, 10, 64); err == nil { + return exactTraceInt(number), true + } + number, err := strconv.ParseFloat(raw, 64) + return traceFloatNumber(number), err == nil + default: + return traceNumber{}, false + } +} + +func exactTraceInt(value int64) traceNumber { + return traceNumber{floatValue: float64(value), intValue: value, exactInt: true} +} + +func traceFloatNumber(value float64) traceNumber { + return traceNumber{floatValue: value} +} + +func traceUintNumber(value uint64) (traceNumber, bool) { + const maxInt64AsUint = uint64(1<<63 - 1) + if value <= maxInt64AsUint { + return exactTraceInt(int64(value)), true + } + return traceFloatNumber(float64(value)), true +} + +func aggregateTraceSpanStatus(spans []map[string]interface{}) string { + firstStatus := "" + for _, span := range spans { + status := firstItemString(span, "status") + if status == "" { + continue + } + if strings.EqualFold(status, "ERROR") { + return "ERROR" + } + if firstStatus == "" { + firstStatus = status + } + } + return firstStatus +} + +func normalizeTraceDetail(data map[string]interface{}) map[string]interface{} { + trace := firstTraceMap(data, "trace", "trace_detail", "traceDetail") + if trace == nil { + trace = data + } + out := normalizeTraceObject(trace) + if spans := firstMapSlice(trace, "spans", "span_items", "spanItems"); len(spans) > 0 { + normalized := make([]map[string]interface{}, 0, len(spans)) + for _, span := range spans { + normalized = append(normalized, normalizeTraceSpan(span)) + } + out["spans"] = normalized + } + return out +} + +func normalizeTraceObject(trace map[string]interface{}) map[string]interface{} { + out := cloneMap(trace) + copyFirstAlias(out, trace, "trace_id", "trace_id", "traceID", "traceId") + copyFirstAlias(out, trace, "is_break", "is_break", "isBreak") + return out +} + +func normalizeTraceSpan(span map[string]interface{}) map[string]interface{} { + out := cloneMap(span) + copyFirstAlias(out, span, "trace_id", "trace_id", "traceID", "traceId") + copyFirstAlias(out, span, "span_id", "span_id", "spanID", "spanId") + copyFirstAlias(out, span, "parent_span_id", "parent_span_id", "parentSpanID", "parentSpanId") + copyFirstAlias(out, span, "start_time_ns", "start_time_ns", "startTimeNs") + copyFirstAlias(out, span, "end_time_ns", "end_time_ns", "endTimeNs") + copyFirstAlias(out, span, "duration_ms", "duration_ms", "durationMs") + copyFirstAlias(out, span, "is_break", "is_break", "isBreak") + return out +} + +func traceListRows(items []map[string]interface{}) []map[string]interface{} { + rows := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + rows = append(rows, traceSummaryRow(item)) + } + return rows +} + +func traceSummaryRow(item map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "trace_id": item["trace_id"], + "start_time_ns": item["start_time_ns"], + "root_span": item["root_span"], + "user_id": item["user_id"], + "duration_ms": item["duration_ms"], + "status": item["status"], + "span_count": item["span_count"], + } +} + +func firstTraceMap(data map[string]interface{}, keys ...string) map[string]interface{} { + for _, key := range keys { + if value, ok := data[key].(map[string]interface{}); ok { + return value + } + } + return nil +} + +func firstTraceString(data map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value, ok := firstTraceValue(data, key); ok { + if s, ok := value.(string); ok && strings.TrimSpace(s) != "" { + return s + } + } + } + return "" +} + +func firstTraceValue(data map[string]interface{}, keys ...string) (interface{}, bool) { + for _, key := range keys { + if value, ok := data[key]; ok { + return value, true + } + } + return nil, false +} diff --git a/shortcuts/apps/apps_observability_traces_test.go b/shortcuts/apps/apps_observability_traces_test.go new file mode 100644 index 000000000..538c0ad90 --- /dev/null +++ b/shortcuts/apps/apps_observability_traces_test.go @@ -0,0 +1,293 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsTraceList, []string{ + "+trace-list", "--app-id", "app_x", "--trace-id", "trace-1", + "--root-span", "gateway", "--page-size", "10", "--dry-run", "--as", "user", + }, factory, stdout) + if 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"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/search_traces" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + if env.API[0].Body["app_env"] != "online" || env.API[0].Body["limit"] != float64(10) { + t.Fatalf("body = %#v", env.API[0].Body) + } + filter := env.API[0].Body["filter"].(map[string]interface{}) + traceIDs := filter["trace_ids"].([]interface{}) + if len(traceIDs) != 1 || traceIDs[0] != "trace-1" { + t.Fatalf("filter.trace_ids = %#v", traceIDs) + } + if got := filter["keyword"]; got != "gateway" { + t.Fatalf("filter.keyword = %v", got) + } +} + +func TestAppsTraceList_RejectsDevEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, factory, stdout) + requireAppsValidationParam(t, err, "--env") +} + +func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsTraceGet, []string{ + "+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--dry-run", "--as", "user", + }, factory, stdout) + if 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"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/get_trace" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + if env.API[0].Body["app_env"] != "online" || env.API[0].Body["trace_id"] != "trace-1" { + t.Fatalf("body = %#v", env.API[0].Body) + } +} + +func TestNormalizeTraceSummaries_DeduplicatesSpanList(t *testing.T) { + got := normalizeTraceSummaries([]map[string]interface{}{ + {"trace_id": "trace-1", "name": "gateway"}, + {"traceId": "trace-1", "name": "handler"}, + }) + if len(got) != 1 { + t.Fatalf("summaries len = %d, want 1: %#v", len(got), got) + } + if got[0]["trace_id"] != "trace-1" || got[0]["span_count"] != 2 { + t.Fatalf("summary = %#v", got[0]) + } +} + +func TestNormalizeTraceSummaries_PrefersRootCandidateOverChildOrder(t *testing.T) { + got := normalizeTraceSummaries([]map[string]interface{}{ + { + "trace_id": "trace-1", + "parent_span_id": "span-root", + "name": "child", + "status": "ERROR", + "start_time_ns": "200", + "duration_ms": 10, + }, + { + "traceID": "trace-1", + "parentSpanID": "", + "spanName": "root", + "status": "OK", + "startTimeNs": "100", + "durationMs": 200, + "userID": "ou_root", + "parent_span_id": "", + }, + }) + if len(got) != 1 { + t.Fatalf("summaries len = %d, want 1: %#v", len(got), got) + } + summary := got[0] + if summary["trace_id"] != "trace-1" || summary["span_count"] != 2 { + t.Fatalf("summary identity/count = %#v", summary) + } + if summary["root_span"] != "root" { + t.Fatalf("root_span = %#v, want root: %#v", summary["root_span"], summary) + } + if summary["status"] != "ERROR" { + t.Fatalf("status = %#v, want ERROR: %#v", summary["status"], summary) + } + if summary["start_time_ns"] != "100" { + t.Fatalf("start_time_ns = %#v, want earliest 100: %#v", summary["start_time_ns"], summary) + } + if summary["duration_ms"] != 200 { + t.Fatalf("duration_ms = %#v, want max 200: %#v", summary["duration_ms"], summary) + } + if summary["user_id"] != "ou_root" { + t.Fatalf("user_id = %#v, want root candidate user: %#v", summary["user_id"], summary) + } +} + +func TestAppsTraceList_NormalizesTraceItemsPaginationVariants(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_traces", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "traceItems": []interface{}{ + map[string]interface{}{ + "traceID": "trace-1", + "startTimeNs": "1782209472123456789", + "rootSpan": "gateway", + "userID": "ou_1", + "durationMs": float64(123), + "spanCount": float64(7), + }, + }, + "nextPageToken": "tok-next", + "hasMore": true, + }, + }, + }) + + if err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + PageToken string `json:"page_token"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.PageToken != "tok-next" || !env.Data.HasMore { + t.Fatalf("pagination = token %q has_more %v", env.Data.PageToken, env.Data.HasMore) + } + if len(env.Data.Items) != 1 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + item := env.Data.Items[0] + if item["trace_id"] != "trace-1" || item["root_span"] != "gateway" || item["user_id"] != "ou_1" { + t.Fatalf("item aliases = %#v", item) + } + if item["span_count"] != float64(7) { + t.Fatalf("span_count = %#v", item["span_count"]) + } +} + +func TestAppsTraceList_AggregatesSpansSourceWithSingleSpanPerTrace(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_traces", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "spans": []interface{}{ + map[string]interface{}{ + "traceID": "trace-1", + "name": "gateway", + }, + map[string]interface{}{ + "trace_id": "trace-2", + "span_name": "worker", + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if len(env.Data.Items) != 2 { + t.Fatalf("items len = %d, want 2: %#v", len(env.Data.Items), env.Data.Items) + } + wantRootSpan := map[string]string{ + "trace-1": "gateway", + "trace-2": "worker", + } + for _, item := range env.Data.Items { + traceID, ok := item["trace_id"].(string) + if !ok || traceID == "" { + t.Fatalf("missing canonical trace_id: %#v", item) + } + if item["span_count"] != float64(1) { + t.Fatalf("span_count for %s = %#v, want 1: %#v", traceID, item["span_count"], item) + } + if item["root_span"] != wantRootSpan[traceID] { + t.Fatalf("root_span for %s = %#v, want %q: %#v", traceID, item["root_span"], wantRootSpan[traceID], item) + } + } +} + +func TestAppsTraceGet_NormalizesTraceDetailCamelFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/get_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "traceDetail": map[string]interface{}{ + "traceID": "trace-1", + "isBreak": true, + "spans": []interface{}{ + map[string]interface{}{ + "spanID": "span-1", + "parentSpanID": "root", + "traceID": "trace-1", + "startTimeNs": "1782209472123456789", + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsTraceGet, []string{"+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if _, wrapped := env.Data["trace"]; wrapped { + t.Fatalf("trace-get should output the trace object directly: %#v", env.Data) + } + if env.Data["trace_id"] != "trace-1" || env.Data["is_break"] != true { + t.Fatalf("trace aliases = %#v", env.Data) + } + spans := env.Data["spans"].([]interface{}) + span := spans[0].(map[string]interface{}) + if span["span_id"] != "span-1" || span["parent_span_id"] != "root" || span["trace_id"] != "trace-1" { + t.Fatalf("span aliases = %#v", span) + } +} From 9b9ac8759e24fe6cac44145bca8f8021b67d3296 Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Tue, 23 Jun 2026 20:05:33 +0800 Subject: [PATCH 04/34] feat: add apps metric analytics shortcuts --- shortcuts/apps/apps_observability_metrics.go | 616 ++++++++++++++++++ .../apps/apps_observability_metrics_test.go | 380 +++++++++++ 2 files changed, 996 insertions(+) create mode 100644 shortcuts/apps/apps_observability_metrics.go create mode 100644 shortcuts/apps/apps_observability_metrics_test.go diff --git a/shortcuts/apps/apps_observability_metrics.go b/shortcuts/apps/apps_observability_metrics.go new file mode 100644 index 000000000..e8049f5f8 --- /dev/null +++ b/shortcuts/apps/apps_observability_metrics.go @@ -0,0 +1,616 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultAppsMetricEnv = "online" + defaultAppsMetricDownSample = "1m" + defaultAppsAnalyticsEnv = "online" + defaultAppsAnalyticsGranular = "day" + metricQueryEndpoint = "query_metrics_data" + analyticsQueryEndpoint = "query_analytics_data" + defaultObservabilityRangeDays = 30 +) + +// AppsMetricQuery queries online app observability metrics. +var AppsMetricQuery = common.Shortcut{ + Service: appsService, + Command: "+metric-query", + Description: "Query online app request, latency, CPU, and memory metrics", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +metric-query --app-id --metric requests --series total --since 1d", + "Tip: metric timestamps use second strings; use +analytics-query for PV/UV-style analytics.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online metrics should be queried", Required: true}, + {Name: "env", Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"}, + {Name: "metric", Desc: "metric family to query", Required: true, Enum: []string{"requests", "latency", "cpu", "memory"}}, + {Name: "series", Desc: "metric series within the family, such as total/error or p50/p99"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, + {Name: "page", Type: "string_array", Desc: "frontend page or route filter; repeatable"}, + {Name: "api", Type: "string_array", Desc: "API path/name filter; repeatable"}, + {Name: "down-sample", Default: defaultAppsMetricDownSample, Desc: "metric down-sample interval", Enum: []string{"1m", "1h", "1d"}}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, _, _, err := buildMetricQueryBody(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _, _, _ := buildMetricQueryBody(rctx) + return common.NewDryRunAPI(). + POST(metricQueryPath(rctx.Str("app-id"))). + Desc("Query online app metrics"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + body, labels, fillZero, err := buildMetricQueryBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", metricQueryPath(appID), nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := observabilitySeriesOutput{ + Items: normalizeMetricSeries(data, labels, fillZero), + HasMore: false, + } + rctx.OutFormat(out, nil, func(w io.Writer) { + output.PrintTable(w, observabilitySeriesRows(out.Items)) + }) + return nil + }, +} + +// AppsAnalyticsQuery queries online app product analytics. +var AppsAnalyticsQuery = common.Shortcut{ + Service: appsService, + Command: "+analytics-query", + Description: "Query online app user and page-view analytics", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +analytics-query --app-id --analytics users --granularity week", + "Tip: analytics timestamps use nanosecond strings; use +metric-query for request/runtime metrics.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online analytics should be queried", Required: true}, + {Name: "env", Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"}, + {Name: "analytics", Desc: "analytics family to query", Required: true, Enum: []string{"users", "page-view"}}, + {Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, + {Name: "page", Type: "string_array", Desc: "frontend page or route filter; repeatable"}, + {Name: "device-type", Desc: "device type filter", Enum: []string{"desktop", "mobile"}}, + {Name: "granularity", Default: defaultAppsAnalyticsGranular, Desc: "analytics aggregation granularity", Enum: []string{"day", "week", "month"}}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, _, err := buildAnalyticsQueryBody(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _, _ := buildAnalyticsQueryBody(rctx) + return common.NewDryRunAPI(). + POST(analyticsQueryPath(rctx.Str("app-id"))). + Desc("Query online app analytics"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + body, labels, err := buildAnalyticsQueryBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", analyticsQueryPath(appID), nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := observabilitySeriesOutput{ + Items: normalizeAnalyticsSeries(data, labels), + HasMore: false, + } + rctx.OutFormat(out, nil, func(w io.Writer) { + output.PrintTable(w, observabilitySeriesRows(out.Items)) + }) + return nil + }, +} + +type observabilitySeriesOutput struct { + Items []map[string]interface{} `json:"items"` + HasMore bool `json:"has_more"` +} + +func metricQueryPath(appID string) string { + return appScopedPath(appID, metricQueryEndpoint) +} + +func analyticsQueryPath(appID string) string { + return appScopedPath(appID, analyticsQueryEndpoint) +} + +func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, bool, error) { + env := strings.TrimSpace(rctx.Str("env")) + if env == "" { + env = defaultAppsMetricEnv + } + if err := validateObservabilityEnv(env); err != nil { + return nil, nil, false, err + } + names, labels, err := metricNamesForCLI(rctx.Str("metric"), rctx.Str("series")) + if err != nil { + return nil, nil, false, err + } + since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until")) + if err != nil { + return nil, nil, false, err + } + downSample := strings.TrimSpace(rctx.Str("down-sample")) + if downSample == "" { + downSample = defaultAppsMetricDownSample + } + body := map[string]interface{}{ + "app_env": env, + "metric_names": names, + "start_timestamp": secString(since), + "end_timestamp": secString(until), + "down_sample": downSample, + "need_pack_lack_point": false, + } + if filter := buildMetricQueryFilter(rctx); len(filter) > 0 { + body["filter"] = filter + } + return body, labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "requests", nil +} + +func buildMetricQueryFilter(rctx *common.RuntimeContext) map[string]interface{} { + filter := make(map[string]interface{}) + if pages := cleanRepeatedStrings(rctx.StrArray("page")); len(pages) > 0 { + filter["pages"] = pages + } + if apis := cleanRepeatedStrings(rctx.StrArray("api")); len(apis) > 0 { + filter["apis"] = apis + } + return filter +} + +func buildAnalyticsQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, error) { + env := strings.TrimSpace(rctx.Str("env")) + if env == "" { + env = defaultAppsAnalyticsEnv + } + if err := validateObservabilityEnv(env); err != nil { + return nil, nil, err + } + types, labels, filter, err := analyticsTypesForCLI(rctx.Str("analytics"), rctx.Str("series"), rctx.Str("device-type")) + if err != nil { + return nil, nil, err + } + since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until")) + if err != nil { + return nil, nil, err + } + aggregation, err := analyticsGranularityForCLI(rctx.Str("granularity")) + if err != nil { + return nil, nil, err + } + if pages := cleanRepeatedStrings(rctx.StrArray("page")); len(pages) > 0 { + filter["pages"] = pages + } + body := map[string]interface{}{ + "app_env": env, + "analytics_types": types, + "start_timestamp_ns": nsString(since), + "end_timestamp_ns": nsString(until), + "time_aggregation_unit": aggregation, + } + if len(filter) > 0 { + body["filter"] = filter + } + return body, labels, nil +} + +func defaultedObservabilityTimeRange(sinceRaw, untilRaw string) (time.Time, time.Time, error) { + since, until, hasSince, hasUntil, err := parseAppsTimeRange("--since", sinceRaw, "--until", untilRaw) + if err != nil { + return time.Time{}, time.Time{}, err + } + if !hasUntil { + until = time.Now() + } + if !hasSince { + since = until.Add(-defaultObservabilityRangeDays * 24 * time.Hour) + } + if since.After(until) { + return time.Time{}, time.Time{}, appsValidationParamError("--until", "--until must be greater than or equal to --since") + } + return since, until, nil +} + +func metricNamesForCLI(metric, series string) ([]string, []string, error) { + metric = strings.TrimSpace(strings.ToLower(metric)) + series = strings.TrimSpace(strings.ToLower(series)) + switch metric { + case "requests": + switch series { + case "": + return []string{"client_api_request_count", "client_api_request_error_count"}, []string{"total", "error"}, nil + case "total": + return []string{"client_api_request_count"}, []string{"total"}, nil + case "error": + return []string{"client_api_request_error_count"}, []string{"error"}, nil + default: + return nil, nil, appsValidationParamError("--series", "--series for --metric requests must be total or error") + } + case "latency": + switch series { + case "": + return []string{"client_api_request_latency_p50", "client_api_request_latency_p99"}, []string{"p50", "p99"}, nil + case "p50": + return []string{"client_api_request_latency_p50"}, []string{"p50"}, nil + case "p99": + return []string{"client_api_request_latency_p99"}, []string{"p99"}, nil + default: + return nil, nil, appsValidationParamError("--series", "--series for --metric latency must be p50 or p99") + } + case "cpu": + if series != "" { + return nil, nil, appsValidationParamError("--series", "--metric cpu does not support --series") + } + return []string{"cpu_usage"}, []string{"cpu"}, nil + case "memory": + if series != "" { + return nil, nil, appsValidationParamError("--series", "--metric memory does not support --series") + } + return []string{"mem_usage"}, []string{"memory"}, nil + default: + return nil, nil, appsValidationParamError("--metric", "--metric must be one of requests, latency, cpu, memory") + } +} + +func analyticsTypesForCLI(name, series, deviceType string) ([]string, []string, map[string]interface{}, error) { + name = strings.TrimSpace(strings.ToLower(name)) + series = strings.TrimSpace(strings.ToLower(series)) + deviceType = strings.TrimSpace(strings.ToLower(deviceType)) + filter := make(map[string]interface{}) + if deviceType != "" { + switch deviceType { + case "desktop", "mobile": + filter["device_types"] = []string{deviceType} + default: + return nil, nil, nil, appsValidationParamError("--device-type", "--device-type must be desktop or mobile") + } + } + + switch name { + case "users": + switch series { + case "": + return []string{"ACTIVE_USER", "NEW_USER", "TOTAL_USER"}, []string{"active-users", "new-users", "total-users"}, filter, nil + case "active", "active-users": + return []string{"ACTIVE_USER"}, []string{"active-users"}, filter, nil + case "new", "new-users": + return []string{"NEW_USER"}, []string{"new-users"}, filter, nil + case "total", "total-users": + return []string{"TOTAL_USER"}, []string{"total-users"}, filter, nil + default: + return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics users must be active, new, or total") + } + case "page-view": + switch series { + case "", "all": + return []string{"PAGE_VIEW"}, []string{"all"}, filter, nil + case "desktop", "desktop-view": + if err := mergeAnalyticsDeviceFilter(filter, "desktop"); err != nil { + return nil, nil, nil, err + } + return []string{"PAGE_VIEW"}, []string{"desktop"}, filter, nil + case "mobile", "mobile-view": + if err := mergeAnalyticsDeviceFilter(filter, "mobile"); err != nil { + return nil, nil, nil, err + } + return []string{"PAGE_VIEW"}, []string{"mobile"}, filter, nil + default: + return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics page-view must be all, desktop, or mobile") + } + default: + return nil, nil, nil, appsValidationParamError("--analytics", "--analytics must be users or page-view") + } +} + +func mergeAnalyticsDeviceFilter(filter map[string]interface{}, deviceType string) error { + if existing, ok := filter["device_types"].([]string); ok && len(existing) > 0 && existing[0] != deviceType { + return appsValidationParamError("--device-type", "--device-type conflicts with --series") + } + filter["device_types"] = []string{deviceType} + return nil +} + +func analyticsGranularityForCLI(granularity string) (string, error) { + switch strings.TrimSpace(strings.ToLower(granularity)) { + case "", "day": + return "DAY", nil + case "week": + return "WEEK", nil + case "month": + return "MONTH", nil + default: + return "", appsValidationParamError("--granularity", "--granularity must be day, week, or month") + } +} + +func normalizeMetricSeries(data map[string]interface{}, labels []string, fillZero bool) []map[string]interface{} { + return normalizeObservabilitySeries(data, labels, fillZero, "timestamp") +} + +func normalizeAnalyticsSeries(data map[string]interface{}, labels []string) []map[string]interface{} { + return normalizeObservabilitySeries(data, labels, false, "timestamp_ns") +} + +func normalizeObservabilitySeries(data map[string]interface{}, labels []string, fillZero bool, timeField string) []map[string]interface{} { + if series := observabilityMapSlice(data["series"]); len(series) > 0 { + return mergeObservabilitySeries(series, labels, fillZero, timeField) + } + if items := observabilityMapSlice(data["items"]); len(items) > 0 { + if observabilityHasNestedPoints(items) { + return mergeObservabilitySeries(items, labels, fillZero, timeField) + } + return normalizeObservabilityPoints(items, labels, fillZero, timeField) + } + for _, key := range []string{"data_points", "dataPoints"} { + if points := observabilityMapSlice(data[key]); len(points) > 0 { + return normalizeObservabilityPoints(points, labels, fillZero, timeField) + } + } + return []map[string]interface{}{} +} + +func observabilityHasNestedPoints(items []map[string]interface{}) bool { + for _, item := range items { + if len(observabilityNestedPoints(item)) > 0 { + return true + } + } + return false +} + +func mergeObservabilitySeries(series []map[string]interface{}, labels []string, fillZero bool, timeField string) []map[string]interface{} { + index := make(map[string]int) + items := make([]map[string]interface{}, 0) + for i, serie := range series { + label := observabilitySeriesLabel(serie, labels, i) + if label == "" { + continue + } + points := observabilityNestedPoints(serie) + if len(points) == 0 { + points = []map[string]interface{}{serie} + } + for _, point := range points { + timestamp := observabilityTimestamp(point, timeField) + dimensions := observabilityDimensions(point) + key := observabilityPointKey(timestamp, dimensions) + pos, ok := index[key] + if !ok { + pos = len(items) + index[key] = pos + items = append(items, map[string]interface{}{ + timeField: timestamp, + "dimensions": dimensions, + "values": map[string]interface{}{}, + }) + } + values := items[pos]["values"].(map[string]interface{}) + values[label] = observabilityPointValue(point, label) + } + } + if fillZero { + fillObservabilityZeroes(items, labels) + } + return items +} + +func normalizeObservabilityPoints(points []map[string]interface{}, labels []string, fillZero bool, timeField string) []map[string]interface{} { + items := make([]map[string]interface{}, 0, len(points)) + for _, point := range points { + values := observabilityPointValues(point, labels, fillZero) + items = append(items, map[string]interface{}{ + timeField: observabilityTimestamp(point, timeField), + "dimensions": observabilityDimensions(point), + "values": values, + }) + } + return items +} + +func fillObservabilityZeroes(items []map[string]interface{}, labels []string) { + for _, item := range items { + values, ok := item["values"].(map[string]interface{}) + if !ok { + values = map[string]interface{}{} + item["values"] = values + } + for _, label := range labels { + if value, ok := values[label]; !ok || value == nil { + values[label] = 0 + } + } + } +} + +func observabilityPointValues(point map[string]interface{}, labels []string, fillZero bool) map[string]interface{} { + values := make(map[string]interface{}, len(labels)) + switch raw := firstObservabilityValue(point, "values", "value_map", "valueMap"); v := raw.(type) { + case map[string]interface{}: + for _, label := range labels { + if value, ok := v[label]; ok { + values[label] = value + } + } + case []interface{}: + for i, label := range labels { + if i < len(v) { + values[label] = v[i] + } + } + } + for _, label := range labels { + if value, ok := point[label]; ok { + values[label] = value + } + } + if len(labels) == 1 { + if value, ok := point["value"]; ok { + values[labels[0]] = value + } + } + if fillZero { + for _, label := range labels { + if value, ok := values[label]; !ok || value == nil { + values[label] = 0 + } + } + } + return values +} + +func observabilityPointValue(point map[string]interface{}, label string) interface{} { + if value, ok := point["value"]; ok { + return value + } + switch raw := firstObservabilityValue(point, "values", "value_map", "valueMap"); values := raw.(type) { + case map[string]interface{}: + return values[label] + case []interface{}: + if len(values) > 0 { + return values[0] + } + } + return nil +} + +func observabilityNestedPoints(item map[string]interface{}) []map[string]interface{} { + for _, key := range []string{"data_points", "dataPoints", "points", "items"} { + if points := observabilityMapSlice(item[key]); len(points) > 0 { + return points + } + } + return nil +} + +func observabilityMapSlice(raw interface{}) []map[string]interface{} { + switch items := raw.(type) { + case []map[string]interface{}: + return items + case []interface{}: + out := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out + default: + return nil + } +} + +func observabilitySeriesLabel(serie map[string]interface{}, labels []string, index int) string { + for _, key := range []string{"label", "series", "name"} { + if value, ok := serie[key].(string); ok { + value = strings.TrimSpace(value) + for _, label := range labels { + if value == label { + return label + } + } + } + } + if index >= 0 && index < len(labels) { + return labels[index] + } + return "" +} + +func observabilityTimestamp(point map[string]interface{}, timeField string) interface{} { + keys := []string{timeField} + if timeField == "timestamp_ns" { + keys = append(keys, "timestampNs", "time_ns", "timeNs", "time", "ts") + } else { + keys = append(keys, "timestampSec", "time", "ts") + } + return firstObservabilityValue(point, keys...) +} + +func observabilityDimensions(point map[string]interface{}) map[string]interface{} { + for _, key := range []string{"dimensions", "dimension", "labels", "tags"} { + if dimensions, ok := point[key].(map[string]interface{}); ok { + return cloneMap(dimensions) + } + } + return map[string]interface{}{} +} + +func firstObservabilityValue(m map[string]interface{}, keys ...string) interface{} { + for _, key := range keys { + if value, ok := m[key]; ok { + return value + } + } + return nil +} + +func observabilityPointKey(timestamp interface{}, dimensions map[string]interface{}) string { + encoded, err := json.Marshal(dimensions) + if err != nil { + return fmt.Sprintf("%v|%v", timestamp, dimensions) + } + return fmt.Sprintf("%v|%s", timestamp, string(encoded)) +} + +func observabilitySeriesRows(items []map[string]interface{}) []map[string]interface{} { + rows := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + row := map[string]interface{}{} + for key, value := range item { + if key == "values" { + if values, ok := value.(map[string]interface{}); ok { + for label, metricValue := range values { + row[label] = metricValue + } + } + continue + } + row[key] = value + } + rows = append(rows, row) + } + return rows +} diff --git a/shortcuts/apps/apps_observability_metrics_test.go b/shortcuts/apps/apps_observability_metrics_test.go new file mode 100644 index 000000000..a6c808ec8 --- /dev/null +++ b/shortcuts/apps/apps_observability_metrics_test.go @@ -0,0 +1,380 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestMetricNamesMapping(t *testing.T) { + got, labels, err := metricNamesForCLI("requests", "") + if err != nil { + t.Fatal(err) + } + if strings.Join(got, ",") != "client_api_request_count,client_api_request_error_count" { + t.Fatalf("names = %#v", got) + } + if strings.Join(labels, ",") != "total,error" { + t.Fatalf("labels = %#v", labels) + } + if _, _, err := metricNamesForCLI("cpu", "p99"); err == nil { + t.Fatalf("cpu with p99 should fail") + } +} + +func TestAppsMetricQuery_DryRunUsesSeconds(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsMetricQuery, []string{ + "+metric-query", "--app-id", "app_x", "--metric", "requests", + "--series", "total", "--since", "2026-06-23T10:00:00Z", + "--until", "2026-06-23T10:01:00Z", "--down-sample", "1m", + "--dry-run", "--as", "user", + }, factory, stdout) + if 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"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_metrics_data" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + body := env.API[0].Body + if _, ok := body["start_timestamp"]; !ok { + t.Fatalf("metric dry-run missing start_timestamp: %#v", body) + } + if _, ok := body["start_timestamp_ns"]; ok { + t.Fatalf("metric should not use start_timestamp_ns: %#v", body) + } + if body["start_timestamp"] != "1782208800" || body["end_timestamp"] != "1782208860" { + t.Fatalf("metric timestamps = %v %v", body["start_timestamp"], body["end_timestamp"]) + } + if body["down_sample"] != "1m" { + t.Fatalf("down_sample = %v", body["down_sample"]) + } +} + +func TestAppsMetricQuery_RejectsDevEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsMetricQuery, []string{ + "+metric-query", "--app-id", "app_x", "--metric", "requests", "--env", "dev", "--as", "user", + }, factory, stdout) + requireAppsValidationParam(t, err, "--env") +} + +func TestAppsMetricQuery_FillsMissingRequestValuesWithZero(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "timestamp": "1782208800", + "dimensions": map[string]interface{}{"page": "/home"}, + "values": map[string]interface{}{"total": float64(12)}, + }, + map[string]interface{}{ + "timestamp": "1782208860", + "dimensions": map[string]interface{}{"page": "/settings"}, + "values": map[string]interface{}{"total": float64(8), "error": nil}, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsMetricQuery, []string{ + "+metric-query", "--app-id", "app_x", "--metric", "requests", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.HasMore { + t.Fatalf("has_more = true, want false") + } + if len(env.Data.Items) != 2 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + for i, item := range env.Data.Items { + if item.Values["error"] != float64(0) { + t.Fatalf("item %d error = %#v, want 0; values=%#v", i, item.Values["error"], item.Values) + } + } +} + +func TestAppsMetricQuery_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + }) + + if err := runAppsShortcut(t, AppsMetricQuery, []string{ + "+metric-query", "--app-id", "app_x", "--metric", "latency", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.Items == nil { + t.Fatalf("items decoded as nil; stdout=%s", stdout.String()) + } + if len(env.Data.Items) != 0 || env.Data.HasMore { + t.Fatalf("empty output = items %#v has_more %v", env.Data.Items, env.Data.HasMore) + } +} + +func TestAppsAnalyticsQuery_DryRunUsesNanoseconds(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ + "+analytics-query", "--app-id", "app_x", "--analytics", "users", + "--granularity", "week", "--dry-run", "--as", "user", + }, factory, stdout) + if 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"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + body := env.API[0].Body + if _, ok := body["start_timestamp_ns"]; !ok { + t.Fatalf("analytics dry-run missing start_timestamp_ns: %#v", body) + } + if _, ok := body["start_timestamp"]; ok { + t.Fatalf("analytics should not use start_timestamp: %#v", body) + } + if body["time_aggregation_unit"] != "WEEK" { + t.Fatalf("time_aggregation_unit = %v", body["time_aggregation_unit"]) + } +} + +func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + { + name: "series", + args: []string{ + "+analytics-query", "--app-id", "app_x", "--analytics", "page-view", + "--series", "desktop", "--dry-run", "--as", "user", + }, + }, + { + name: "device-type", + args: []string{ + "+analytics-query", "--app-id", "app_x", "--analytics", "page-view", + "--device-type", "desktop", "--dry-run", "--as", "user", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsAnalyticsQuery, tc.args, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + filter := env.API[0].Body["filter"].(map[string]interface{}) + deviceTypes := filter["device_types"].([]interface{}) + if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" { + t.Fatalf("device_types = %#v", deviceTypes) + } + }) + } +} + +func TestAppsAnalyticsQuery_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "data_points": []interface{}{ + map[string]interface{}{ + "timestamp_ns": "1782208800000000000", + "value": float64(21), + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ + "+analytics-query", "--app-id", "app_x", "--analytics", "page-view", + "--series", "desktop", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if len(env.Data.Items) != 1 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + if env.Data.Items[0].Values["desktop"] != float64(21) { + t.Fatalf("values = %#v, want desktop=21", env.Data.Items[0].Values) + } + if _, ok := env.Data.Items[0].Values["page-view"]; ok { + t.Fatalf("values should not use page-view label: %#v", env.Data.Items[0].Values) + } +} + +func TestAppsAnalyticsQuery_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + }) + + if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ + "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.Items == nil { + t.Fatalf("items decoded as nil; stdout=%s", stdout.String()) + } + if len(env.Data.Items) != 0 || env.Data.HasMore { + t.Fatalf("empty output = items %#v has_more %v", env.Data.Items, env.Data.HasMore) + } +} + +func TestAnalyticsTypesMapping(t *testing.T) { + types, labels, filter, err := analyticsTypesForCLI("users", "", "") + if err != nil { + t.Fatal(err) + } + if strings.Join(types, ",") != "ACTIVE_USER,NEW_USER,TOTAL_USER" { + t.Fatalf("types = %#v", types) + } + if strings.Join(labels, ",") != "active-users,new-users,total-users" { + t.Fatalf("labels = %#v", labels) + } + if len(filter) != 0 { + t.Fatalf("filter = %#v, want empty", filter) + } + + types, labels, filter, err = analyticsTypesForCLI("page-view", "", "") + if err != nil { + t.Fatal(err) + } + if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "all" { + t.Fatalf("page-view all mapping = %#v %#v", types, labels) + } + if len(filter) != 0 { + t.Fatalf("filter = %#v, want empty", filter) + } + + types, labels, filter, err = analyticsTypesForCLI("page-view", "desktop", "") + if err != nil { + t.Fatal(err) + } + if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "desktop" { + t.Fatalf("page-view mapping = %#v %#v", types, labels) + } + deviceTypes := filter["device_types"].([]string) + if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" { + t.Fatalf("device_types = %#v", deviceTypes) + } + + types, labels, filter, err = analyticsTypesForCLI("page-view", "mobile-view", "") + if err != nil { + t.Fatal(err) + } + if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "mobile" { + t.Fatalf("page-view mobile mapping = %#v %#v", types, labels) + } + deviceTypes = filter["device_types"].([]string) + if len(deviceTypes) != 1 || deviceTypes[0] != "mobile" { + t.Fatalf("device_types = %#v", deviceTypes) + } + + if _, _, _, err := analyticsTypesForCLI("users", "desktop", ""); err == nil { + t.Fatalf("users desktop series should fail") + } + if _, _, _, err := analyticsTypesForCLI("page-view", "tablet", ""); err == nil { + t.Fatalf("page-view tablet series should fail") + } + if _, _, _, err := analyticsTypesForCLI("page-view", "", "tablet"); err == nil { + t.Fatalf("tablet device type should fail") + } +} From 736db1ce72e654f66538fb53a9717a733071c05a Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Tue, 23 Jun 2026 20:50:04 +0800 Subject: [PATCH 05/34] feat: add apps envvar shortcuts --- shortcuts/apps/apps_env_pull.go | 5 +- shortcuts/apps/apps_env_pull_test.go | 44 ++-- shortcuts/apps/apps_envvar.go | 353 +++++++++++++++++++++++++++ shortcuts/apps/apps_envvar_test.go | 292 ++++++++++++++++++++++ shortcuts/apps/apps_hints_test.go | 8 +- 5 files changed, 682 insertions(+), 20 deletions(-) create mode 100644 shortcuts/apps/apps_envvar.go create mode 100644 shortcuts/apps/apps_envvar_test.go diff --git a/shortcuts/apps/apps_env_pull.go b/shortcuts/apps/apps_env_pull.go index e2242f9a1..bafe56186 100644 --- a/shortcuts/apps/apps_env_pull.go +++ b/shortcuts/apps/apps_env_pull.go @@ -62,8 +62,9 @@ var AppsEnvPull = common.Shortcut{ projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path"))) appID := strings.TrimSpace(rctx.Str("app-id")) return common.NewDryRunAPI(). - POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))). + GET(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))). Desc("Pull app startup env vars into the local .env.local file"). + Params(map[string]interface{}{"env": "dev", "include_values": true}). Set("project_path", projectPath). Set("env_file", envFile) }, @@ -81,7 +82,7 @@ var AppsEnvPull = common.Shortcut{ } path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID)) - data, err := rctx.CallAPITyped("POST", path, nil, nil) + data, err := rctx.CallAPITyped("GET", path, map[string]interface{}{"env": "dev", "include_values": true}, nil) if err != nil { return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`") } diff --git a/shortcuts/apps/apps_env_pull_test.go b/shortcuts/apps/apps_env_pull_test.go index 1e9b34247..74d237160 100644 --- a/shortcuts/apps/apps_env_pull_test.go +++ b/shortcuts/apps/apps_env_pull_test.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "errors" + "net/http" "os" "path/filepath" "strings" @@ -255,7 +256,7 @@ func TestBuildEnvPullSuccessDataSuppressesEnvKeysAndValues(t *testing.T) { } } -func TestAppsEnvPull_DryRunUsesPostAndResolvedEnvFile(t *testing.T) { +func TestAppsEnvPull_DryRunUsesGetQueryAndResolvedEnvFile(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) projectDir := t.TempDir() @@ -266,12 +267,15 @@ func TestAppsEnvPull_DryRunUsesPostAndResolvedEnvFile(t *testing.T) { } got := stdout.String() - if !strings.Contains(got, `"method": "POST"`) { - t.Fatalf("dry-run must use POST: %s", got) + if !strings.Contains(got, `"method": "GET"`) { + t.Fatalf("dry-run must use GET: %s", got) } if !strings.Contains(got, `/open-apis/spark/v1/apps/app_x/env_vars`) { t.Fatalf("dry-run missing endpoint: %s", got) } + if !strings.Contains(got, `"env": "dev"`) || !strings.Contains(got, `"include_values": true`) { + t.Fatalf("dry-run must include env=dev and include_values=true params: %s", got) + } if !strings.Contains(got, filepath.Join(projectDir, ".env.local")) { t.Fatalf("dry-run must include resolved env file path: %s", got) } @@ -281,8 +285,14 @@ func TestAppsEnvPull_PrettyOutput_WithDatabaseLine(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", + OnMatch: func(req *http.Request) { + assertEnvVarQuery(t, req, map[string]string{ + "env": "dev", + "include_values": "true", + }) + }, Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ @@ -326,7 +336,7 @@ func TestAppsEnvPull_JSONOutput_UsesSummaryFieldsOnly(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -370,7 +380,7 @@ func TestAppsEnvPull_MalformedPayloadSkipsInvalidEntries(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -419,7 +429,7 @@ func TestAppsEnvPull_WritesCanonicalEnvFile(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -488,7 +498,7 @@ func TestAppsEnvPull_JSONOutputOmitsDatabaseLineText(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -524,7 +534,7 @@ func TestAppsEnvPull_ExecuteUsesNestedDataEnvVars(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -554,7 +564,7 @@ func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -600,7 +610,7 @@ func TestAppsEnvPull_JSONOutputCanBeDecoded(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -647,7 +657,7 @@ func TestAppsEnvPull_PrettyOutputWithoutDatabaseLine(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -706,7 +716,7 @@ func TestAppsEnvPull_DatabaseExtrasWithoutExpiresAtDoesNotFail(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -822,7 +832,7 @@ func TestAppsEnvPull_InjectsForceDBBranchWhenAbsent(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -856,7 +866,7 @@ func TestAppsEnvPull_InjectsForceDBBranchAlongsideArrayEnvVars(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -886,7 +896,7 @@ func TestAppsEnvPull_ForceDBBranchOverwritesExistingLocalValue(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -929,7 +939,7 @@ func TestAppsEnvPull_ForceDBBranchInjectedEvenWhenUpstreamReturnsEmptyMap(t *tes factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, diff --git a/shortcuts/apps/apps_envvar.go b/shortcuts/apps/apps_envvar.go new file mode 100644 index 000000000..b6d5a3071 --- /dev/null +++ b/shortcuts/apps/apps_envvar.go @@ -0,0 +1,353 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "sort" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const defaultAppsEnvVarEnv = "dev" + +// AppsEnvVarList lists app environment variables without values by default. +var AppsEnvVarList = common.Shortcut{ + Service: appsService, + Command: "+envvar-list", + Description: "List app environment variables", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +envvar-list --app-id ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, + {Name: "include-values", Type: "bool", Desc: "include environment variable values"}, + {Name: "page-size", Type: "int", Default: "50", 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 err := validateEnvVarEnv(envVarEnv(rctx)); err != nil { + return err + } + if err := validateAppsPageSize(rctx.Int("page-size")); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(envVarCollectionPath(appID)). + Desc("List app environment variables"). + Params(buildEnvVarListParams(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + includeValues := rctx.Bool("include-values") + data, err := rctx.CallAPITyped("GET", envVarCollectionPath(appID), buildEnvVarListParams(rctx), nil) + if err != nil { + return withAppsHint(err, appIDListHint) + } + rctx.OutFormat(normalizeEnvVarListOutput(data, includeValues), nil, nil) + return nil + }, +} + +// AppsEnvVarSet sets one app environment variable. Values are never printed. +var AppsEnvVarSet = common.Shortcut{ + Service: appsService, + Command: "+envvar-set", + Description: "Set an app environment variable", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +envvar-set --app-id --key FOO --value bar", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, + {Name: "key", Desc: "environment variable key", Required: true}, + {Name: "value", Desc: "environment variable value", Required: true, Input: []string{common.File, common.Stdin}}, + {Name: "yes", Type: "bool", Desc: "confirm setting variables in online"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil { + return err + } + if _, err := requireEnvVarKey(rctx.Str("key")); err != nil { + return err + } + if rctx.Str("value") == "" { + return appsValidationParamError("--value", "--value is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + key, _ := requireEnvVarKey(rctx.Str("key")) + return common.NewDryRunAPI(). + PUT(envVarKeyPath(appID, key)). + Desc("Set app environment variable"). + Body(map[string]interface{}{ + "env": envVarEnv(rctx), + "value": "", + }) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + env := envVarEnv(rctx) + if env == "online" && !rctx.Bool("yes") { + return errs.NewConfirmationRequiredError( + errs.RiskWrite, + "apps +envvar-set --env online", + "apps +envvar-set --env online requires confirmation", + ).WithHint("add --yes to confirm") + } + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + key, err := requireEnvVarKey(rctx.Str("key")) + if err != nil { + return err + } + _, err = rctx.CallAPITyped("PUT", envVarKeyPath(appID, key), nil, map[string]interface{}{ + "env": env, + "value": rctx.Str("value"), + }) + if err != nil { + return withAppsHint(err, appIDListHint) + } + rctx.OutFormat(map[string]interface{}{ + "key": key, + "env": env, + "action": "set", + }, nil, nil) + return nil + }, +} + +// AppsEnvVarDelete deletes one or more app environment variables. +var AppsEnvVarDelete = common.Shortcut{ + Service: appsService, + Command: "+envvar-delete", + Description: "Delete app environment variables", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +envvar-delete --app-id --key FOO --yes", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, + {Name: "key", Type: "string_array", Desc: "environment variable key; repeatable", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil { + return err + } + _, err := requireEnvVarKeys(rctx.StrArray("key")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + keys, _ := requireEnvVarKeys(rctx.StrArray("key")) + return common.NewDryRunAPI(). + DELETE(envVarCollectionPath(appID)). + Desc("Delete app environment variables"). + Body(buildEnvVarDeleteBody(envVarEnv(rctx), keys)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + keys, err := requireEnvVarKeys(rctx.StrArray("key")) + if err != nil { + return err + } + env := envVarEnv(rctx) + _, err = rctx.CallAPITyped("DELETE", envVarCollectionPath(appID), nil, buildEnvVarDeleteBody(env, keys)) + if err != nil { + return withAppsHint(err, appIDListHint) + } + rctx.OutFormat(map[string]interface{}{ + "env": env, + "deleted_keys": keys, + }, nil, nil) + return nil + }, +} + +func envVarEnv(rctx *common.RuntimeContext) string { + env := strings.TrimSpace(rctx.Str("env")) + if env == "" { + return defaultAppsEnvVarEnv + } + return env +} + +func envVarCollectionPath(appID string) string { + return appScopedPath(appID, "env_vars") +} + +func envVarKeyPath(appID, key string) string { + return envVarCollectionPath(appID) + "/" + validate.EncodePathSegment(strings.TrimSpace(key)) +} + +func buildEnvVarListParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{ + "env": envVarEnv(rctx), + "include_values": rctx.Bool("include-values"), + "page_size": rctx.Int("page-size"), + } + if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { + params["page_token"] = token + } + return params +} + +func buildEnvVarDeleteBody(env string, keys []string) map[string]interface{} { + return map[string]interface{}{ + "env": env, + "keys": keys, + } +} + +func requireEnvVarKey(raw string) (string, error) { + key := strings.TrimSpace(raw) + if key == "" { + return "", appsValidationParamError("--key", "--key is required") + } + if !envKeyPattern.MatchString(key) { + return "", appsValidationParamError("--key", "--key must match [A-Za-z_][A-Za-z0-9_]*") + } + return key, nil +} + +func requireEnvVarKeys(raw []string) ([]string, error) { + keys := cleanRepeatedStrings(raw) + if len(keys) == 0 { + return nil, appsValidationParamError("--key", "--key is required") + } + for _, key := range keys { + if !envKeyPattern.MatchString(key) { + return nil, appsValidationParamError("--key", "--key must match [A-Za-z_][A-Za-z0-9_]*") + } + } + return keys, nil +} + +type envVarListOutput struct { + Items []map[string]interface{} `json:"items"` + PageToken string `json:"page_token"` + HasMore bool `json:"has_more"` +} + +func normalizeEnvVarListOutput(data map[string]interface{}, includeValues bool) envVarListOutput { + src := envVarResponseMap(data) + return envVarListOutput{ + Items: normalizeEnvVarItems(envVarItemsRaw(src), includeValues), + PageToken: envVarStringAny(src, "page_token", "next_page_token", "nextPageToken"), + HasMore: envVarBoolAny(src, "has_more", "hasMore"), + } +} + +func envVarResponseMap(data map[string]interface{}) map[string]interface{} { + if nested, ok := data["data"].(map[string]interface{}); ok { + return nested + } + return data +} + +func envVarItemsRaw(data map[string]interface{}) interface{} { + if raw := data["env_vars"]; raw != nil { + return raw + } + return data["items"] +} + +func normalizeEnvVarItems(raw interface{}, includeValues bool) []map[string]interface{} { + switch typed := raw.(type) { + case []interface{}: + out := make([]map[string]interface{}, 0, len(typed)) + for _, item := range typed { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + out = append(out, filterEnvVarItem(m, includeValues)) + } + return out + case map[string]interface{}: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]map[string]interface{}, 0, len(keys)) + for _, key := range keys { + item := map[string]interface{}{"key": key} + if includeValues { + item["value"] = typed[key] + } + out = append(out, item) + } + return out + default: + return []map[string]interface{}{} + } +} + +func filterEnvVarItem(item map[string]interface{}, includeValues bool) map[string]interface{} { + out := make(map[string]interface{}, len(item)) + for key, value := range item { + if key == "value" && !includeValues { + continue + } + out[key] = value + } + return out +} + +func envVarStringAny(data map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value, ok := data[key].(string); ok { + return value + } + } + return "" +} + +func envVarBoolAny(data map[string]interface{}, keys ...string) bool { + for _, key := range keys { + if value, ok := data[key].(bool); ok { + return value + } + } + return false +} diff --git a/shortcuts/apps/apps_envvar_test.go b/shortcuts/apps/apps_envvar_test.go new file mode 100644 index 000000000..9b4ae1611 --- /dev/null +++ b/shortcuts/apps/apps_envvar_test.go @@ -0,0 +1,292 @@ +// 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" +) + +func assertEnvVarQuery(t *testing.T, req *http.Request, want map[string]string, absent ...string) { + t.Helper() + query := req.URL.Query() + for key, value := range want { + if got := query.Get(key); got != value { + t.Fatalf("query %s = %q, want %q (raw query %q)", key, got, value, req.URL.RawQuery) + } + } + for _, key := range absent { + if _, ok := query[key]; ok { + t.Fatalf("query %s should be absent (raw query %q)", key, req.URL.RawQuery) + } + } +} + +func decodeEnvVarEnvelopeData(t *testing.T, stdout string) map[string]interface{} { + t.Helper() + var envelope struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode stdout: %v\n%s", err, stdout) + } + if !envelope.OK { + t.Fatalf("expected ok envelope, got %s", stdout) + } + return envelope.Data +} + +func requireEnvVarValidationProblem(t *testing.T, err error, param string) { + t.Helper() + p := requireAppsProblem(t, err, errs.CategoryValidation) + if p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("validation subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument) + } + var validation *errs.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if validation.Param != param { + t.Fatalf("validation param = %q, want %q", validation.Param, param) + } +} + +func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + OnMatch: func(req *http.Request) { + assertEnvVarQuery(t, req, map[string]string{ + "env": "dev", + "include_values": "false", + "page_size": "50", + }, "page_token") + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "env_vars": []interface{}{ + map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "dev"}, + }, + "next_page_token": "", + "has_more": false, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvVarList, + []string{"+envvar-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + got := stdout.String() + if strings.Contains(got, "super-secret") || strings.Contains(got, `"value"`) { + t.Fatalf("stdout must not expose values by default: %s", got) + } + data := decodeEnvVarEnvelopeData(t, got) + items, ok := data["items"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("items = %#v, want one item", data["items"]) + } + item, ok := items[0].(map[string]interface{}) + if !ok || item["key"] != "SECRET_TOKEN" { + t.Fatalf("item = %#v, want SECRET_TOKEN", items[0]) + } + if _, ok := item["value"]; ok { + t.Fatalf("item must not contain value by default: %#v", item) + } +} + +func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + OnMatch: func(req *http.Request) { + assertEnvVarQuery(t, req, map[string]string{ + "env": "online", + "include_values": "true", + "page_size": "20", + "page_token": "cursor-1", + }) + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "online"}, + }, + "nextPageToken": "cursor-2", + "hasMore": true, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvVarList, + []string{"+envvar-list", "--app-id", "app_x", "--env", "online", "--include-values", + "--page-size", "20", "--page-token", "cursor-1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + got := stdout.String() + if !strings.Contains(got, "super-secret") { + t.Fatalf("stdout should include values when requested: %s", got) + } + data := decodeEnvVarEnvelopeData(t, got) + if data["page_token"] != "cursor-2" { + t.Fatalf("page_token = %v, want cursor-2", data["page_token"]) + } + if data["has_more"] != true { + t.Fatalf("has_more = %v, want true", data["has_more"]) + } +} + +func TestAppsEnvVarSet_OnlineRequiresYesOutsideDryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarSet, + []string{"+envvar-set", "--app-id", "app_x", "--env", "online", + "--key", "SECRET_TOKEN", "--value", "super-secret", "--as", "user"}, factory, stdout) + + p := requireAppsProblem(t, err, errs.CategoryConfirmation) + if p.Subtype != errs.SubtypeConfirmationRequired { + t.Fatalf("confirmation subtype = %q, want %q", p.Subtype, errs.SubtypeConfirmationRequired) + } + if !strings.Contains(p.Hint, "add --yes") { + t.Fatalf("confirmation hint missing --yes guidance: %#v", p) + } +} + +func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsEnvVarSet, + []string{"+envvar-set", "--app-id", "app_x", "--env", "online", + "--key", "SECRET_TOKEN", "--value", "super-secret", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + + got := stdout.String() + if strings.Contains(got, "super-secret") { + t.Fatalf("dry-run must redact value: %s", got) + } + for _, want := range []string{`"method": "PUT"`, `/open-apis/spark/v1/apps/app_x/env_vars/SECRET_TOKEN`} { + if !strings.Contains(got, want) { + t.Fatalf("dry-run missing %q: %s", want, got) + } + } + var dryRun struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(got), &dryRun); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, got) + } + if len(dryRun.API) != 1 || dryRun.API[0].Body["value"] != "" { + t.Fatalf("dry-run body value = %#v, want ", dryRun.API) + } +} + +func TestAppsEnvVarSet_ExecutesWithYesAndDoesNotEchoValue(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/spark/v1/apps/app_x/env_vars/SECRET_TOKEN", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsEnvVarSet, + []string{"+envvar-set", "--app-id", "app_x", "--env", "online", + "--key", "SECRET_TOKEN", "--value", "super-secret", "--yes", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["env"] != "online" || sent["value"] != "super-secret" { + t.Fatalf("body = %#v, want real online value", sent) + } + got := stdout.String() + if strings.Contains(got, "super-secret") || strings.Contains(got, `"value"`) { + t.Fatalf("stdout must not echo value: %s", got) + } + for _, want := range []string{`"key": "SECRET_TOKEN"`, `"env": "online"`, `"action": "set"`} { + if !strings.Contains(got, want) { + t.Fatalf("stdout missing %q: %s", want, got) + } + } +} + +func TestAppsEnvVarDelete_IsHighRiskWrite(t *testing.T) { + if AppsEnvVarDelete.Risk != "high-risk-write" { + t.Fatalf("risk = %q, want high-risk-write", AppsEnvVarDelete.Risk) + } +} + +func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsEnvVarDelete, + []string{"+envvar-delete", "--app-id", "app_x", "--env", "online", + "--key", "SECRET_ONE", "--key", "SECRET_TWO", "--yes", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["env"] != "online" { + t.Fatalf("body.env = %v, want online", sent["env"]) + } + keys, ok := sent["keys"].([]interface{}) + if !ok || len(keys) != 2 || keys[0] != "SECRET_ONE" || keys[1] != "SECRET_TWO" { + t.Fatalf("body.keys = %#v, want SECRET_ONE/SECRET_TWO", sent["keys"]) + } + got := stdout.String() + for _, want := range []string{`"env": "online"`, `"deleted_keys"`, `"SECRET_ONE"`, `"SECRET_TWO"`} { + if !strings.Contains(got, want) { + t.Fatalf("stdout missing %q: %s", want, got) + } + } +} + +func TestAppsEnvVarList_InvalidEnvTypedValidation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarList, + []string{"+envvar-list", "--app-id", "app_x", "--env", "prod", "--as", "user"}, factory, stdout) + requireEnvVarValidationProblem(t, err, "--env") +} + +func TestAppsEnvVarSet_InvalidKeyTypedValidation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarSet, + []string{"+envvar-set", "--app-id", "app_x", "--key", "bad-key", + "--value", "super-secret", "--as", "user"}, factory, stdout) + requireEnvVarValidationProblem(t, err, "--key") +} + +func TestAppsEnvVarDelete_InvalidKeyTypedValidation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarDelete, + []string{"+envvar-delete", "--app-id", "app_x", "--key", "bad-key", + "--yes", "--as", "user"}, factory, stdout) + requireEnvVarValidationProblem(t, err, "--key") +} diff --git a/shortcuts/apps/apps_hints_test.go b/shortcuts/apps/apps_hints_test.go index 6c959f497..50f216a0f 100644 --- a/shortcuts/apps/apps_hints_test.go +++ b/shortcuts/apps/apps_hints_test.go @@ -17,10 +17,16 @@ import ( func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ - Method: "POST", + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}, + OnMatch: func(req *http.Request) { + assertEnvVarQuery(t, req, map[string]string{ + "env": "dev", + "include_values": "true", + }) + }, }) err := runAppsShortcut(t, AppsEnvPull, From 8939bff9c5e77275bacc5973dc55df2f23e9ddaf Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Tue, 23 Jun 2026 21:08:18 +0800 Subject: [PATCH 06/34] docs: document apps observability envvar shortcuts --- shortcuts/apps/apps_examples_test.go | 59 +++++++++++++++++++ shortcuts/apps/shortcuts.go | 14 +++++ shortcuts/apps/shortcuts_test.go | 19 ++++-- skills/lark-apps/SKILL.md | 2 + .../references/lark-apps-env-pull.md | 6 +- .../lark-apps/references/lark-apps-envvar.md | 42 +++++++++++++ .../references/lark-apps-observability.md | 34 +++++++++++ 7 files changed, 169 insertions(+), 7 deletions(-) create mode 100644 skills/lark-apps/references/lark-apps-envvar.md create mode 100644 skills/lark-apps/references/lark-apps-observability.md diff --git a/shortcuts/apps/apps_examples_test.go b/shortcuts/apps/apps_examples_test.go index 5b977d0ff..d2c1ed4fc 100644 --- a/shortcuts/apps/apps_examples_test.go +++ b/shortcuts/apps/apps_examples_test.go @@ -50,3 +50,62 @@ func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) { } } } + +func TestAppsEnvVarTipsCoverConfirmations(t *testing.T) { + envvarSet := requireShortcutForExamples(t, "+envvar-set") + if !tipsContainAll(envvarSet.Tips, "--env online", "--yes") { + t.Fatalf("+envvar-set tips must include an online write example with --env online --yes: %#v", envvarSet.Tips) + } + + envvarDelete := requireShortcutForExamples(t, "+envvar-delete") + if !tipsContainAll(envvarDelete.Tips, "--yes") { + t.Fatalf("+envvar-delete tips must include --yes: %#v", envvarDelete.Tips) + } +} + +func TestAppsObservabilityTipsMentionOnlineOnly(t *testing.T) { + for _, cmd := range []string{ + "+log-list", + "+log-get", + "+trace-list", + "+trace-get", + "+metric-query", + "+analytics-query", + } { + shortcut := requireShortcutForExamples(t, cmd) + if !tipsContainAll(shortcut.Tips, "online-only", "--env online") { + t.Fatalf("%s tips should mention online-only env: %#v", cmd, shortcut.Tips) + } + } +} + +func requireShortcutForExamples(t *testing.T, command string) shortcutForExamples { + t.Helper() + for _, sc := range Shortcuts() { + if sc.Command == command { + return shortcutForExamples{Tips: sc.Tips} + } + } + t.Fatalf("missing shortcut %s", command) + return shortcutForExamples{} +} + +type shortcutForExamples struct { + Tips []string +} + +func tipsContainAll(tips []string, needles ...string) bool { + for _, tip := range tips { + ok := true + for _, needle := range needles { + if !strings.Contains(tip, needle) { + ok = false + break + } + } + if ok { + return true + } + } + return false +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index e15489fa1..62581bdbb 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -19,6 +19,15 @@ func Shortcuts() []common.Shortcut { AppsReleaseList, AppsReleaseGet, AppsEnvPull, + withExtraTips(AppsLogList, "Tip: logs are online-only; keep --env omitted or set --env online."), + withExtraTips(AppsLogGet, "Tip: logs are online-only; keep --env omitted or set --env online."), + withExtraTips(AppsTraceList, "Tip: traces are online-only; keep --env omitted or set --env online."), + withExtraTips(AppsTraceGet, "Tip: traces are online-only; keep --env omitted or set --env online."), + withExtraTips(AppsMetricQuery, "Tip: metrics are online-only; keep --env omitted or set --env online."), + withExtraTips(AppsAnalyticsQuery, "Tip: analytics are online-only; keep --env omitted or set --env online."), + AppsEnvVarList, + withExtraTips(AppsEnvVarSet, "Example: lark-cli apps +envvar-set --app-id --env online --key FOO --value --yes"), + withExtraTips(AppsEnvVarDelete, "Tip: +envvar-delete is high-risk-write; only pass --yes after explicit confirmation."), AppsDBTableList, AppsDBTableGet, AppsDBExecute, @@ -34,3 +43,8 @@ func Shortcuts() []common.Shortcut { AppsChat, } } + +func withExtraTips(sc common.Shortcut, tips ...string) common.Shortcut { + sc.Tips = append(append([]string{}, sc.Tips...), tips...) + return sc +} diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 264c7ed4f..5abfa1e26 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -10,12 +10,21 @@ import ( ) // 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 -// 6 基础 + 1 init + 3 publish + 1 env-pull + 4 db(table-list/table-schema/sql/dev-init) -// + 3 git-credential + 5 session(create/list/get/stop/chat)+ 1 session-messages-list = 24。 -func TestAppsShortcuts_Returns24(t *testing.T) { +// 6 基础 + 1 init + 3 publish + 1 env-pull + 6 observability +// + 3 envvar + 4 db(table-list/table-schema/sql/dev-init) +// + 3 git-credential + 5 session(create/list/get/stop/chat)+ 1 session-messages-list = 33。 +func TestAppsShortcuts_Returns33(t *testing.T) { got := Shortcuts() - if len(got) != 24 { - t.Fatalf("Shortcuts() returned %d entries, want 24", len(got)) + if len(got) != 33 { + t.Fatalf("Shortcuts() returned %d entries, want 33", len(got)) + } +} + +func TestAppsShortcuts_DoesNotIncludeEnvVarGet(t *testing.T) { + for _, sc := range Shortcuts() { + if sc.Command == "+envvar-get" { + t.Fatalf("Shortcuts() must not register +envvar-get") + } } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index ef9da3746..5a19b200a 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -24,6 +24,8 @@ metadata: | 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) | | 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | +| 管理应用环境变量(查看/设置/删除) | `+envvar-list`, `+envvar-set`, `+envvar-delete` | [`lark-apps-envvar.md`](references/lark-apps-envvar.md) | +| 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-query`, `+analytics-query` | [`lark-apps-observability.md`](references/lark-apps-observability.md) | | 看表、看 schema、跑 SQL、初始化 dev/online 多环境 DB | `+db-table-list`, `+db-table-get`, `+db-execute`, `+db-env-create` | 对应 `lark-apps-db-*.md` | | **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) | | 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference | diff --git a/skills/lark-apps/references/lark-apps-env-pull.md b/skills/lark-apps/references/lark-apps-env-pull.md index e1e0082dc..4a7ea49a0 100644 --- a/skills/lark-apps/references/lark-apps-env-pull.md +++ b/skills/lark-apps/references/lark-apps-env-pull.md @@ -2,7 +2,9 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 -把妙搭应用的启动期环境变量拉取到本地项目根的 `.env.local`。身份固定 `--as user`;scope `spark:app:read`。`--app-id` 必填,目标项目根默认当前工作目录(`--project-path` 可指定)。 +把妙搭应用 dev 启动期环境变量拉取到本地项目根的 `.env.local`。身份固定 `--as user`;scope `spark:app:read`。`--app-id` 必填,目标项目根默认当前工作目录(`--project-path` 可指定)。 + +这个命令是 dev-only 的本地恢复工具:内部固定调用 `env_vars`,参数为 `env=dev` 和 `include_values=true`。它没有 `--env` flag,也不管理线上环境变量。 ## 何时别用(核心反模式) @@ -21,7 +23,7 @@ ## 示例 ```bash -lark-cli apps +env-pull --app-id app_xxx +lark-cli apps +env-pull --app-id ``` ## 失败处理 diff --git a/skills/lark-apps/references/lark-apps-envvar.md b/skills/lark-apps/references/lark-apps-envvar.md new file mode 100644 index 000000000..0050762c5 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-envvar.md @@ -0,0 +1,42 @@ +# apps envvar + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 + +管理妙搭应用环境变量。查看用 `+envvar-list`,设置用 `+envvar-set`,删除用 `+envvar-delete`。没有单变量 get 命令;要确认某个 key 是否存在,使用 list 后用 `--jq` 过滤。 + +## 查看 + +`+envvar-list` 默认查 dev,且默认不返回 value。只有显式传 `--include-values` 后,响应中才可能出现变量值;不要在公开日志里展示带值输出。 + +```bash +lark-cli apps +envvar-list --app-id +lark-cli apps +envvar-list --app-id --env online +lark-cli apps +envvar-list --app-id --include-values --jq '.data.items[] | select(.key == "FOO")' +``` + +## 设置 + +dev 环境设置不需要 `--yes`。设置 online 环境需要人类确认并显式传 `--yes`;`--dry-run` 可用于预览请求且不需要 `--yes`。变量值支持直接传 ``,也支持 `@file` 或 stdin 输入。 + +```bash +lark-cli apps +envvar-set --app-id --key FOO --value +lark-cli apps +envvar-set --app-id --key FOO --value @./secret.txt +lark-cli apps +envvar-set --app-id --env online --key FOO --value --dry-run +lark-cli apps +envvar-set --app-id --env online --key FOO --value --yes +``` + +## 删除 + +`+envvar-delete` 是 high-risk-write。尊重 exit 10 confirmation protocol:先让用户确认要删除哪些 key,再传 `--yes`。不要自动补 `--yes`。 + +```bash +lark-cli apps +envvar-delete --app-id --key FOO --dry-run +lark-cli apps +envvar-delete --app-id --key FOO --yes +lark-cli apps +envvar-delete --app-id --env online --key FOO --yes +``` + +## 反模式 + +- 不要把 `+env-pull` 当成环境变量管理命令;它只是刷新本地 `.env.local` 的兜底工具。 +- 不要为了看一个变量臆造名为 envvar-get 的 apps shortcut;用 `+envvar-list --include-values` 加 `--jq`。 +- 不要把真实 secret 写进示例或对话输出;需要示例时使用 ``、`@file` 或 stdin。 diff --git a/skills/lark-apps/references/lark-apps-observability.md b/skills/lark-apps/references/lark-apps-observability.md new file mode 100644 index 000000000..b91a0563e --- /dev/null +++ b/skills/lark-apps/references/lark-apps-observability.md @@ -0,0 +1,34 @@ +# apps observability + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 + +查询妙搭应用的线上运行观测和产品访问分析。所有 observability 命令只支持 `--env online`;省略 `--env` 时默认就是 online,传 dev 或其他环境是不支持的。 + +## 命令选择 + +- 日志检索:用 `+log-list` 搜索日志,用 `+log-get` 按 log ID 取单条日志。 +- 前端 ERROR 日志详情:`+log-get` 可能补充 `source_stack`;没有独立的 source-stack 命令。 +- Trace 检索:用 `+trace-list` 搜索 trace,用 `+trace-get` 按 trace ID 取详情。 +- 运行时指标:请求数、错误、延迟、CPU、memory 用 `+metric-query`。 +- 产品分析:PV、UV、访问量这类业务访问分析用 `+analytics-query`,不要放到 runtime metric 里混查。 + +## 示例 + +```bash +lark-cli apps +log-list --app-id --level error --keyword timeout --since 1h +lark-cli apps +log-get --app-id --log-id +lark-cli apps +trace-list --app-id --trace-id +lark-cli apps +trace-get --app-id --trace-id +lark-cli apps +metric-query --app-id --metric requests --series total --since 1d +lark-cli apps +metric-query --app-id --metric latency --series p99 --since 1d +lark-cli apps +metric-query --app-id --metric cpu --since 1h +lark-cli apps +metric-query --app-id --metric memory --since 1h +lark-cli apps +analytics-query --app-id --analytics users --series active-users --granularity day +lark-cli apps +analytics-query --app-id --analytics page-view --granularity day +``` + +## 使用边界 + +- 如果用户问“接口慢、报错多、CPU/内存高”,优先走 `+metric-query`。 +- 如果用户问“页面访问量、PV、UV、活跃用户”,优先走 `+analytics-query`。 +- 如果用户已有 `trace_id` 或 `log_id`,直接用对应 get 命令;不知道 ID 时先 list。 From 46c99cb8781d1645584e01f14cb3f732458cf8ae Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Tue, 23 Jun 2026 21:24:16 +0800 Subject: [PATCH 07/34] fix: add apps observability env hint --- shortcuts/apps/apps_observability_common.go | 3 ++- shortcuts/apps/apps_observability_common_test.go | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/shortcuts/apps/apps_observability_common.go b/shortcuts/apps/apps_observability_common.go index 612000fa4..0b1e587f4 100644 --- a/shortcuts/apps/apps_observability_common.go +++ b/shortcuts/apps/apps_observability_common.go @@ -30,7 +30,8 @@ func validateObservabilityEnv(env string) error { case "", "online": return nil default: - return appsValidationParamError("--env", "observability commands only support --env online (got %q)", env) + return appsValidationParamError("--env", "observability commands only support --env online (got %q)", env). + WithHint("only online is supported; omit --env to use the default online environment") } } diff --git a/shortcuts/apps/apps_observability_common_test.go b/shortcuts/apps/apps_observability_common_test.go index 291d9d1b4..b2ede09e0 100644 --- a/shortcuts/apps/apps_observability_common_test.go +++ b/shortcuts/apps/apps_observability_common_test.go @@ -5,6 +5,7 @@ package apps import ( "errors" + "strings" "testing" "time" @@ -36,6 +37,9 @@ func TestAppsObservabilityValidateEnvOnlyOnline(t *testing.T) { if p.Subtype != errs.SubtypeInvalidArgument { t.Fatalf("problem = %#v, want invalid_argument param --env", p) } + if !strings.Contains(p.Hint, "only online is supported") { + t.Fatalf("hint = %q, want only-online guidance", p.Hint) + } } func TestAppsObservabilityPageSizeRange(t *testing.T) { From 6ff02ea10c4a989b9bc047139f0260b63bc0cb5f Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Tue, 23 Jun 2026 21:29:59 +0800 Subject: [PATCH 08/34] test: cover apps envvar delete dry-run --- shortcuts/apps/apps_envvar_test.go | 31 ++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/shortcuts/apps/apps_envvar_test.go b/shortcuts/apps/apps_envvar_test.go index 9b4ae1611..1a6a53b3d 100644 --- a/shortcuts/apps/apps_envvar_test.go +++ b/shortcuts/apps/apps_envvar_test.go @@ -268,6 +268,37 @@ func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) { } } +func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsEnvVarDelete, + []string{"+envvar-delete", "--app-id", "app_x", "--env", "online", + "--key", "SECRET_ONE", "--key", "SECRET_TWO", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + + var dryRun struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + got := stdout.String() + if err := json.Unmarshal([]byte(got), &dryRun); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, got) + } + if len(dryRun.API) != 1 || dryRun.API[0].Method != "DELETE" || dryRun.API[0].URL != "/open-apis/spark/v1/apps/app_x/env_vars" { + t.Fatalf("dry-run api = %#v", dryRun.API) + } + if dryRun.API[0].Body["env"] != "online" { + t.Fatalf("dry-run body.env = %v, want online", dryRun.API[0].Body["env"]) + } + keys, ok := dryRun.API[0].Body["keys"].([]interface{}) + if !ok || len(keys) != 2 || keys[0] != "SECRET_ONE" || keys[1] != "SECRET_TWO" { + t.Fatalf("dry-run body.keys = %#v, want SECRET_ONE/SECRET_TWO", dryRun.API[0].Body["keys"]) + } +} + func TestAppsEnvVarList_InvalidEnvTypedValidation(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsEnvVarList, From 2cfe090c1dc6fdcfb692c153ae46d415419cc66e Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Tue, 23 Jun 2026 22:36:07 +0800 Subject: [PATCH 09/34] fix: align apps observability OpenAPI schema --- shortcuts/apps/apps_observability_common.go | 8 +- .../apps/apps_observability_common_test.go | 8 +- shortcuts/apps/apps_observability_logs.go | 18 ++- .../apps/apps_observability_logs_test.go | 19 ++- shortcuts/apps/apps_observability_metrics.go | 146 +++++++++++++----- .../apps/apps_observability_metrics_test.go | 50 ++++-- shortcuts/apps/apps_observability_traces.go | 14 +- .../apps/apps_observability_traces_test.go | 16 +- 8 files changed, 205 insertions(+), 74 deletions(-) diff --git a/shortcuts/apps/apps_observability_common.go b/shortcuts/apps/apps_observability_common.go index 0b1e587f4..68c0f1bda 100644 --- a/shortcuts/apps/apps_observability_common.go +++ b/shortcuts/apps/apps_observability_common.go @@ -158,10 +158,10 @@ func parseAppsRelativeDuration(s string) (time.Duration, bool) { return time.Duration(n) * unitDuration, true } -func nsString(t time.Time) string { - return strconv.FormatInt(t.UnixNano(), 10) +func nsNumber(t time.Time) int64 { + return t.UnixNano() } -func secString(t time.Time) string { - return strconv.FormatInt(t.Unix(), 10) +func secNumber(t time.Time) int64 { + return t.Unix() } diff --git a/shortcuts/apps/apps_observability_common_test.go b/shortcuts/apps/apps_observability_common_test.go index b2ede09e0..8ba8b9e1e 100644 --- a/shortcuts/apps/apps_observability_common_test.go +++ b/shortcuts/apps/apps_observability_common_test.go @@ -76,11 +76,11 @@ func TestAppsObservabilityCommonHelpers(t *testing.T) { } } ts := time.Date(2026, 6, 23, 10, 11, 12, 123456789, time.UTC) - if got := nsString(ts); got != "1782209472123456789" { - t.Fatalf("nsString = %q", got) + if got := nsNumber(ts); got != int64(1782209472123456789) { + t.Fatalf("nsNumber = %d", got) } - if got := secString(ts); got != "1782209472" { - t.Fatalf("secString = %q", got) + if got := secNumber(ts); got != int64(1782209472) { + t.Fatalf("secNumber = %d", got) } } diff --git a/shortcuts/apps/apps_observability_logs.go b/shortcuts/apps/apps_observability_logs.go index a7a99714b..52ef3fdc0 100644 --- a/shortcuts/apps/apps_observability_logs.go +++ b/shortcuts/apps/apps_observability_logs.go @@ -200,10 +200,10 @@ func addLogSearchTimeRange(body map[string]interface{}, rctx *common.RuntimeCont return err } if hasSince { - body["start_timestamp_ns"] = nsString(since) + body["start_timestamp_ns"] = nsNumber(since) } if hasUntil { - body["end_timestamp_ns"] = nsString(until) + body["end_timestamp_ns"] = nsNumber(until) } return nil } @@ -224,16 +224,22 @@ func buildLogSearchFilter(rctx *common.RuntimeContext) (map[string]interface{}, filter["trace_ids"] = traceIDs } addTrimmedLogFilterString(filter, "keyword", rctx.Str("keyword")) - addTrimmedLogFilterString(filter, "module", rctx.Str("module")) - addTrimmedLogFilterString(filter, "user_id", rctx.Str("user-id")) - addTrimmedLogFilterString(filter, "page", rctx.Str("page")) - addTrimmedLogFilterString(filter, "api", rctx.Str("api")) + addTrimmedLogFilterStrings(filter, "modules", rctx.Str("module")) + addTrimmedLogFilterStrings(filter, "user_ids", rctx.Str("user-id")) + addTrimmedLogFilterStrings(filter, "pages", rctx.Str("page")) + addTrimmedLogFilterStrings(filter, "apis", rctx.Str("api")) if err := addDurationFilters(filter, rctx); err != nil { return nil, err } return filter, nil } +func addTrimmedLogFilterStrings(filter map[string]interface{}, key, value string) { + if value = strings.TrimSpace(value); value != "" { + filter[key] = []string{value} + } +} + func addTrimmedLogFilterString(filter map[string]interface{}, key, value string) { if value = strings.TrimSpace(value); value != "" { filter[key] = value diff --git a/shortcuts/apps/apps_observability_logs_test.go b/shortcuts/apps/apps_observability_logs_test.go index 437fd7d83..7f0ddcaef 100644 --- a/shortcuts/apps/apps_observability_logs_test.go +++ b/shortcuts/apps/apps_observability_logs_test.go @@ -16,7 +16,9 @@ func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) { err := runAppsShortcut(t, AppsLogList, []string{ "+log-list", "--app-id", "app_x", "--level", "error", "--log-id", "LOG1", "--log-id", "LOG2", "--trace-id", "trace-1", - "--keyword", "timeout", "--min-duration", "200", + "--keyword", "timeout", "--module", "frontend", "--user-id", "ou_1", + "--page", "/home", "--api", "/api/orders", "--min-duration", "200", + "--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z", "--page-size", "20", "--dry-run", "--as", "user", }, factory, stdout) if err != nil { @@ -42,6 +44,21 @@ func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) { if got := filter["keyword"]; got != "timeout" { t.Fatalf("filter.keyword = %v", got) } + for key, want := range map[string]string{ + "modules": "frontend", + "user_ids": "ou_1", + "pages": "/home", + "apis": "/api/orders", + } { + values, ok := filter[key].([]interface{}) + if !ok || len(values) != 1 || values[0] != want { + t.Fatalf("filter.%s = %#v, want [%q]", key, filter[key], want) + } + } + if env.API[0].Body["start_timestamp_ns"] != float64(1782208800000000000) || + env.API[0].Body["end_timestamp_ns"] != float64(1782208860000000000) { + t.Fatalf("timestamps = %#v %#v", env.API[0].Body["start_timestamp_ns"], env.API[0].Body["end_timestamp_ns"]) + } } func TestAppsLogList_RejectsDevEnv(t *testing.T) { diff --git a/shortcuts/apps/apps_observability_metrics.go b/shortcuts/apps/apps_observability_metrics.go index e8049f5f8..618a24255 100644 --- a/shortcuts/apps/apps_observability_metrics.go +++ b/shortcuts/apps/apps_observability_metrics.go @@ -33,7 +33,7 @@ var AppsMetricQuery = common.Shortcut{ Risk: "read", Tips: []string{ "Example: lark-cli apps +metric-query --app-id --metric requests --series total --since 1d", - "Tip: metric timestamps use second strings; use +analytics-query for PV/UV-style analytics.", + "Tip: metric timestamps use seconds; use +analytics-query for PV/UV-style analytics.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, @@ -53,11 +53,11 @@ var AppsMetricQuery = common.Shortcut{ if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } - _, _, _, err := buildMetricQueryBody(rctx) + _, _, _, _, err := buildMetricQueryBody(rctx) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - body, _, _, _ := buildMetricQueryBody(rctx) + body, _, _, _, _ := buildMetricQueryBody(rctx) return common.NewDryRunAPI(). POST(metricQueryPath(rctx.Str("app-id"))). Desc("Query online app metrics"). @@ -65,7 +65,7 @@ var AppsMetricQuery = common.Shortcut{ }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, _ := requireAppID(rctx.Str("app-id")) - body, labels, fillZero, err := buildMetricQueryBody(rctx) + body, names, labels, fillZero, err := buildMetricQueryBody(rctx) if err != nil { return err } @@ -74,7 +74,7 @@ var AppsMetricQuery = common.Shortcut{ return withAppsHint(err, appIDListHint) } out := observabilitySeriesOutput{ - Items: normalizeMetricSeries(data, labels, fillZero), + Items: normalizeMetricSeries(data, names, labels, fillZero), HasMore: false, } rctx.OutFormat(out, nil, func(w io.Writer) { @@ -92,7 +92,7 @@ var AppsAnalyticsQuery = common.Shortcut{ Risk: "read", Tips: []string{ "Example: lark-cli apps +analytics-query --app-id --analytics users --granularity week", - "Tip: analytics timestamps use nanosecond strings; use +metric-query for request/runtime metrics.", + "Tip: analytics timestamps use nanoseconds; use +metric-query for request/runtime metrics.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, @@ -104,7 +104,7 @@ var AppsAnalyticsQuery = common.Shortcut{ {Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, {Name: "until", Desc: "end time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, - {Name: "page", Type: "string_array", Desc: "frontend page or route filter; repeatable"}, + {Name: "page", Desc: "frontend page or route filter"}, {Name: "device-type", Desc: "device type filter", Enum: []string{"desktop", "mobile"}}, {Name: "granularity", Default: defaultAppsAnalyticsGranular, Desc: "analytics aggregation granularity", Enum: []string{"day", "week", "month"}}, }, @@ -156,38 +156,37 @@ func analyticsQueryPath(appID string) string { return appScopedPath(appID, analyticsQueryEndpoint) } -func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, bool, error) { +func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, bool, error) { env := strings.TrimSpace(rctx.Str("env")) if env == "" { env = defaultAppsMetricEnv } if err := validateObservabilityEnv(env); err != nil { - return nil, nil, false, err + return nil, nil, nil, false, err } names, labels, err := metricNamesForCLI(rctx.Str("metric"), rctx.Str("series")) if err != nil { - return nil, nil, false, err + return nil, nil, nil, false, err } since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until")) if err != nil { - return nil, nil, false, err + return nil, nil, nil, false, err } downSample := strings.TrimSpace(rctx.Str("down-sample")) if downSample == "" { downSample = defaultAppsMetricDownSample } body := map[string]interface{}{ - "app_env": env, "metric_names": names, - "start_timestamp": secString(since), - "end_timestamp": secString(until), + "start_timestamp": secNumber(since), + "end_timestamp": secNumber(until), "down_sample": downSample, "need_pack_lack_point": false, } if filter := buildMetricQueryFilter(rctx); len(filter) > 0 { body["filter"] = filter } - return body, labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "requests", nil + return body, names, labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "requests", nil } func buildMetricQueryFilter(rctx *common.RuntimeContext) map[string]interface{} { @@ -221,14 +220,13 @@ func buildAnalyticsQueryBody(rctx *common.RuntimeContext) (map[string]interface{ if err != nil { return nil, nil, err } - if pages := cleanRepeatedStrings(rctx.StrArray("page")); len(pages) > 0 { - filter["pages"] = pages + if page := strings.TrimSpace(rctx.Str("page")); page != "" { + filter["page"] = page } body := map[string]interface{}{ - "app_env": env, - "analytics_types": types, - "start_timestamp_ns": nsString(since), - "end_timestamp_ns": nsString(until), + "metric_types": types, + "start_timestamp_ns": nsNumber(since), + "end_timestamp_ns": nsNumber(until), "time_aggregation_unit": aggregation, } if len(filter) > 0 { @@ -366,27 +364,27 @@ func analyticsGranularityForCLI(granularity string) (string, error) { } } -func normalizeMetricSeries(data map[string]interface{}, labels []string, fillZero bool) []map[string]interface{} { - return normalizeObservabilitySeries(data, labels, fillZero, "timestamp") +func normalizeMetricSeries(data map[string]interface{}, names, labels []string, fillZero bool) []map[string]interface{} { + return normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), fillZero, "timestamp") } func normalizeAnalyticsSeries(data map[string]interface{}, labels []string) []map[string]interface{} { - return normalizeObservabilitySeries(data, labels, false, "timestamp_ns") + return normalizeObservabilitySeries(data, labels, nil, false, "timestamp_ns") } -func normalizeObservabilitySeries(data map[string]interface{}, labels []string, fillZero bool, timeField string) []map[string]interface{} { +func normalizeObservabilitySeries(data map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} { if series := observabilityMapSlice(data["series"]); len(series) > 0 { - return mergeObservabilitySeries(series, labels, fillZero, timeField) + return mergeObservabilitySeries(series, labels, nameLabels, fillZero, timeField) } if items := observabilityMapSlice(data["items"]); len(items) > 0 { if observabilityHasNestedPoints(items) { - return mergeObservabilitySeries(items, labels, fillZero, timeField) + return mergeObservabilitySeries(items, labels, nameLabels, fillZero, timeField) } - return normalizeObservabilityPoints(items, labels, fillZero, timeField) + return normalizeObservabilityPoints(items, labels, nameLabels, fillZero, timeField) } - for _, key := range []string{"data_points", "dataPoints"} { + for _, key := range []string{"points", "data_points", "dataPoints"} { if points := observabilityMapSlice(data[key]); len(points) > 0 { - return normalizeObservabilityPoints(points, labels, fillZero, timeField) + return normalizeObservabilityPoints(points, labels, nameLabels, fillZero, timeField) } } return []map[string]interface{}{} @@ -401,7 +399,7 @@ func observabilityHasNestedPoints(items []map[string]interface{}) bool { return false } -func mergeObservabilitySeries(series []map[string]interface{}, labels []string, fillZero bool, timeField string) []map[string]interface{} { +func mergeObservabilitySeries(series []map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} { index := make(map[string]int) items := make([]map[string]interface{}, 0) for i, serie := range series { @@ -428,7 +426,7 @@ func mergeObservabilitySeries(series []map[string]interface{}, labels []string, }) } values := items[pos]["values"].(map[string]interface{}) - values[label] = observabilityPointValue(point, label) + values[label] = observabilityPointValue(point, label, nameLabels) } } if fillZero { @@ -437,10 +435,10 @@ func mergeObservabilitySeries(series []map[string]interface{}, labels []string, return items } -func normalizeObservabilityPoints(points []map[string]interface{}, labels []string, fillZero bool, timeField string) []map[string]interface{} { +func normalizeObservabilityPoints(points []map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} { items := make([]map[string]interface{}, 0, len(points)) for _, point := range points { - values := observabilityPointValues(point, labels, fillZero) + values := observabilityPointValues(point, labels, nameLabels, fillZero) items = append(items, map[string]interface{}{ timeField: observabilityTimestamp(point, timeField), "dimensions": observabilityDimensions(point), @@ -465,7 +463,7 @@ func fillObservabilityZeroes(items []map[string]interface{}, labels []string) { } } -func observabilityPointValues(point map[string]interface{}, labels []string, fillZero bool) map[string]interface{} { +func observabilityPointValues(point map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool) map[string]interface{} { values := make(map[string]interface{}, len(labels)) switch raw := firstObservabilityValue(point, "values", "value_map", "valueMap"); v := raw.(type) { case map[string]interface{}: @@ -474,10 +472,26 @@ func observabilityPointValues(point map[string]interface{}, labels []string, fil values[label] = value } } + for name, label := range nameLabels { + if value, ok := v[name]; ok { + values[label] = value + } + } case []interface{}: - for i, label := range labels { - if i < len(v) { - values[label] = v[i] + for i, rawItem := range v { + if item, ok := rawItem.(map[string]interface{}); ok { + name := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "metric_name", "metricName", "name"))) + label := nameLabels[name] + if label == "" && i < len(labels) { + label = labels[i] + } + if label != "" { + values[label] = firstObservabilityValue(item, "value") + } + continue + } + if i < len(labels) { + values[labels[i]] = rawItem } } } @@ -501,16 +515,35 @@ func observabilityPointValues(point map[string]interface{}, labels []string, fil return values } -func observabilityPointValue(point map[string]interface{}, label string) interface{} { +func observabilityPointValue(point map[string]interface{}, label string, nameLabels map[string]string) interface{} { if value, ok := point["value"]; ok { return value } switch raw := firstObservabilityValue(point, "values", "value_map", "valueMap"); values := raw.(type) { case map[string]interface{}: + for name, mappedLabel := range nameLabels { + if mappedLabel == label { + if value, ok := values[name]; ok { + return value + } + } + } return values[label] case []interface{}: - if len(values) > 0 { - return values[0] + for _, rawItem := range values { + item, ok := rawItem.(map[string]interface{}) + if !ok { + continue + } + name := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "metric_name", "metricName", "name"))) + if nameLabels[name] == label { + return firstObservabilityValue(item, "value") + } + } + for _, rawItem := range values { + if _, ok := rawItem.(map[string]interface{}); !ok { + return rawItem + } } } return nil @@ -543,7 +576,7 @@ func observabilityMapSlice(raw interface{}) []map[string]interface{} { } func observabilitySeriesLabel(serie map[string]interface{}, labels []string, index int) string { - for _, key := range []string{"label", "series", "name"} { + for _, key := range []string{"label", "series", "name", "metric_type", "metricType"} { if value, ok := serie[key].(string); ok { value = strings.TrimSpace(value) for _, label := range labels { @@ -574,10 +607,39 @@ func observabilityDimensions(point map[string]interface{}) map[string]interface{ if dimensions, ok := point[key].(map[string]interface{}); ok { return cloneMap(dimensions) } + if dimensions := observabilityKVList(point[key]); len(dimensions) > 0 { + return dimensions + } } return map[string]interface{}{} } +func observabilityNameLabels(names, labels []string) map[string]string { + out := make(map[string]string, len(names)) + for i, name := range names { + if i < len(labels) { + out[name] = labels[i] + } + } + return out +} + +func observabilityKVList(raw interface{}) map[string]interface{} { + items := observabilityMapSlice(raw) + if len(items) == 0 { + return nil + } + out := make(map[string]interface{}, len(items)) + for _, item := range items { + key := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "key", "name"))) + if key == "" { + continue + } + out[key] = firstObservabilityValue(item, "value") + } + return out +} + func firstObservabilityValue(m map[string]interface{}, keys ...string) interface{} { for _, key := range keys { if value, ok := m[key]; ok { diff --git a/shortcuts/apps/apps_observability_metrics_test.go b/shortcuts/apps/apps_observability_metrics_test.go index a6c808ec8..c3c8414c0 100644 --- a/shortcuts/apps/apps_observability_metrics_test.go +++ b/shortcuts/apps/apps_observability_metrics_test.go @@ -58,7 +58,10 @@ func TestAppsMetricQuery_DryRunUsesSeconds(t *testing.T) { if _, ok := body["start_timestamp_ns"]; ok { t.Fatalf("metric should not use start_timestamp_ns: %#v", body) } - if body["start_timestamp"] != "1782208800" || body["end_timestamp"] != "1782208860" { + if _, ok := body["app_env"]; ok { + t.Fatalf("metric OpenAPI body should not include app_env: %#v", body) + } + if body["start_timestamp"] != float64(1782208800) || body["end_timestamp"] != float64(1782208860) { t.Fatalf("metric timestamps = %v %v", body["start_timestamp"], body["end_timestamp"]) } if body["down_sample"] != "1m" { @@ -82,16 +85,21 @@ func TestAppsMetricQuery_FillsMissingRequestValuesWithZero(t *testing.T) { Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "items": []interface{}{ + "points": []interface{}{ map[string]interface{}{ - "timestamp": "1782208800", + "timestamp": float64(1782208800), "dimensions": map[string]interface{}{"page": "/home"}, - "values": map[string]interface{}{"total": float64(12)}, + "values": []interface{}{ + map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)}, + }, }, map[string]interface{}{ - "timestamp": "1782208860", + "timestamp": float64(1782208860), "dimensions": map[string]interface{}{"page": "/settings"}, - "values": map[string]interface{}{"total": float64(8), "error": nil}, + "values": []interface{}{ + map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(8)}, + map[string]interface{}{"metric_name": "client_api_request_error_count", "value": nil}, + }, }, }, }, @@ -166,6 +174,7 @@ func TestAppsAnalyticsQuery_DryRunUsesNanoseconds(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ "+analytics-query", "--app-id", "app_x", "--analytics", "users", + "--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z", "--granularity", "week", "--dry-run", "--as", "user", }, factory, stdout) if err != nil { @@ -194,6 +203,19 @@ func TestAppsAnalyticsQuery_DryRunUsesNanoseconds(t *testing.T) { if body["time_aggregation_unit"] != "WEEK" { t.Fatalf("time_aggregation_unit = %v", body["time_aggregation_unit"]) } + if _, ok := body["app_env"]; ok { + t.Fatalf("analytics OpenAPI body should not include app_env: %#v", body) + } + if _, ok := body["analytics_types"]; ok { + t.Fatalf("analytics OpenAPI body should use metric_types, not analytics_types: %#v", body) + } + if metricTypes, ok := body["metric_types"].([]interface{}); !ok || len(metricTypes) != 3 { + t.Fatalf("metric_types = %#v", body["metric_types"]) + } + if body["start_timestamp_ns"] != float64(1782208800000000000) || + body["end_timestamp_ns"] != float64(1782208860000000000) { + t.Fatalf("analytics timestamps = %#v %#v", body["start_timestamp_ns"], body["end_timestamp_ns"]) + } } func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) { @@ -205,7 +227,7 @@ func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) name: "series", args: []string{ "+analytics-query", "--app-id", "app_x", "--analytics", "page-view", - "--series", "desktop", "--dry-run", "--as", "user", + "--series", "desktop", "--page", "/home", "--dry-run", "--as", "user", }, }, { @@ -234,6 +256,9 @@ func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" { t.Fatalf("device_types = %#v", deviceTypes) } + if tc.name == "series" && filter["page"] != "/home" { + t.Fatalf("filter.page = %#v, want /home", filter["page"]) + } }) } } @@ -246,10 +271,15 @@ func TestAppsAnalyticsQuery_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "data_points": []interface{}{ + "series": []interface{}{ map[string]interface{}{ - "timestamp_ns": "1782208800000000000", - "value": float64(21), + "metric_type": "PAGE_VIEW", + "points": []interface{}{ + map[string]interface{}{ + "timestamp_ns": float64(1782208800000000000), + "value": float64(21), + }, + }, }, }, }, diff --git a/shortcuts/apps/apps_observability_traces.go b/shortcuts/apps/apps_observability_traces.go index b8b0a28dc..6e6b5d825 100644 --- a/shortcuts/apps/apps_observability_traces.go +++ b/shortcuts/apps/apps_observability_traces.go @@ -17,7 +17,7 @@ import ( const ( defaultAppsTraceEnv = "online" traceSearchEndpoint = "search_traces" - traceGetEndpoint = "get_trace" + traceGetEndpoint = "trace" ) // AppsTraceList searches online app traces with observability filters. @@ -181,10 +181,10 @@ func addTraceSearchTimeRange(body map[string]interface{}, rctx *common.RuntimeCo return err } if hasSince { - body["start_timestamp_ns"] = nsString(since) + body["start_timestamp_ns"] = nsNumber(since) } if hasUntil { - body["end_timestamp_ns"] = nsString(until) + body["end_timestamp_ns"] = nsNumber(until) } return nil } @@ -195,7 +195,7 @@ func buildTraceSearchFilter(rctx *common.RuntimeContext) map[string]interface{} filter["trace_ids"] = traceIDs } addTrimmedTraceFilterString(filter, "keyword", rctx.Str("root-span")) - addTrimmedTraceFilterString(filter, "user_id", rctx.Str("user-id")) + addTrimmedTraceFilterStrings(filter, "user_ids", rctx.Str("user-id")) return filter } @@ -205,6 +205,12 @@ func addTrimmedTraceFilterString(filter map[string]interface{}, key, value strin } } +func addTrimmedTraceFilterStrings(filter map[string]interface{}, key, value string) { + if value = strings.TrimSpace(value); value != "" { + filter[key] = []string{value} + } +} + func normalizeTraceSearchResponse(data map[string]interface{}) traceSearchOutput { items, sourceKey := firstTraceMapSliceWithKey(data, "items", "trace_items", "traceItems", "spans", "span_items", "spanItems") normalized := normalizeTraceSummaries(items) diff --git a/shortcuts/apps/apps_observability_traces_test.go b/shortcuts/apps/apps_observability_traces_test.go index 538c0ad90..08a51bc49 100644 --- a/shortcuts/apps/apps_observability_traces_test.go +++ b/shortcuts/apps/apps_observability_traces_test.go @@ -14,7 +14,9 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsTraceList, []string{ "+trace-list", "--app-id", "app_x", "--trace-id", "trace-1", - "--root-span", "gateway", "--page-size", "10", "--dry-run", "--as", "user", + "--root-span", "gateway", "--user-id", "ou_1", + "--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z", + "--page-size", "10", "--dry-run", "--as", "user", }, factory, stdout) if err != nil { t.Fatalf("dry-run err=%v", err) @@ -44,6 +46,14 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) { if got := filter["keyword"]; got != "gateway" { t.Fatalf("filter.keyword = %v", got) } + userIDs := filter["user_ids"].([]interface{}) + if len(userIDs) != 1 || userIDs[0] != "ou_1" { + t.Fatalf("filter.user_ids = %#v", userIDs) + } + if env.API[0].Body["start_timestamp_ns"] != float64(1782208800000000000) || + env.API[0].Body["end_timestamp_ns"] != float64(1782208860000000000) { + t.Fatalf("timestamps = %#v %#v", env.API[0].Body["start_timestamp_ns"], env.API[0].Body["end_timestamp_ns"]) + } } func TestAppsTraceList_RejectsDevEnv(t *testing.T) { @@ -71,7 +81,7 @@ func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) { if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) } - if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/get_trace" { + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/trace" { t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) } if env.API[0].Body["app_env"] != "online" || env.API[0].Body["trace_id"] != "trace-1" { @@ -249,7 +259,7 @@ func TestAppsTraceGet_NormalizesTraceDetailCamelFields(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", - URL: "/open-apis/spark/v1/apps/app_x/get_trace", + URL: "/open-apis/spark/v1/apps/app_x/trace", Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ From 0f88409ab8490ce4a9b362687497c6e4cd651586 Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Tue, 23 Jun 2026 23:11:08 +0800 Subject: [PATCH 10/34] fix: map apps observability named series --- shortcuts/apps/apps_observability_metrics.go | 48 +++++--- .../apps/apps_observability_metrics_test.go | 108 ++++++++++++++++++ 2 files changed, 137 insertions(+), 19 deletions(-) diff --git a/shortcuts/apps/apps_observability_metrics.go b/shortcuts/apps/apps_observability_metrics.go index 618a24255..7bdafd967 100644 --- a/shortcuts/apps/apps_observability_metrics.go +++ b/shortcuts/apps/apps_observability_metrics.go @@ -112,11 +112,11 @@ var AppsAnalyticsQuery = common.Shortcut{ if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } - _, _, err := buildAnalyticsQueryBody(rctx) + _, _, _, err := buildAnalyticsQueryBody(rctx) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - body, _, _ := buildAnalyticsQueryBody(rctx) + body, _, _, _ := buildAnalyticsQueryBody(rctx) return common.NewDryRunAPI(). POST(analyticsQueryPath(rctx.Str("app-id"))). Desc("Query online app analytics"). @@ -124,7 +124,7 @@ var AppsAnalyticsQuery = common.Shortcut{ }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, _ := requireAppID(rctx.Str("app-id")) - body, labels, err := buildAnalyticsQueryBody(rctx) + body, types, labels, err := buildAnalyticsQueryBody(rctx) if err != nil { return err } @@ -133,7 +133,7 @@ var AppsAnalyticsQuery = common.Shortcut{ return withAppsHint(err, appIDListHint) } out := observabilitySeriesOutput{ - Items: normalizeAnalyticsSeries(data, labels), + Items: normalizeAnalyticsSeries(data, types, labels), HasMore: false, } rctx.OutFormat(out, nil, func(w io.Writer) { @@ -200,25 +200,25 @@ func buildMetricQueryFilter(rctx *common.RuntimeContext) map[string]interface{} return filter } -func buildAnalyticsQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, error) { +func buildAnalyticsQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, error) { env := strings.TrimSpace(rctx.Str("env")) if env == "" { env = defaultAppsAnalyticsEnv } if err := validateObservabilityEnv(env); err != nil { - return nil, nil, err + return nil, nil, nil, err } types, labels, filter, err := analyticsTypesForCLI(rctx.Str("analytics"), rctx.Str("series"), rctx.Str("device-type")) if err != nil { - return nil, nil, err + return nil, nil, nil, err } since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until")) if err != nil { - return nil, nil, err + return nil, nil, nil, err } aggregation, err := analyticsGranularityForCLI(rctx.Str("granularity")) if err != nil { - return nil, nil, err + return nil, nil, nil, err } if page := strings.TrimSpace(rctx.Str("page")); page != "" { filter["page"] = page @@ -232,7 +232,7 @@ func buildAnalyticsQueryBody(rctx *common.RuntimeContext) (map[string]interface{ if len(filter) > 0 { body["filter"] = filter } - return body, labels, nil + return body, types, labels, nil } func defaultedObservabilityTimeRange(sinceRaw, untilRaw string) (time.Time, time.Time, error) { @@ -368,8 +368,8 @@ func normalizeMetricSeries(data map[string]interface{}, names, labels []string, return normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), fillZero, "timestamp") } -func normalizeAnalyticsSeries(data map[string]interface{}, labels []string) []map[string]interface{} { - return normalizeObservabilitySeries(data, labels, nil, false, "timestamp_ns") +func normalizeAnalyticsSeries(data map[string]interface{}, names, labels []string) []map[string]interface{} { + return normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), false, "timestamp_ns") } func normalizeObservabilitySeries(data map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} { @@ -403,7 +403,7 @@ func mergeObservabilitySeries(series []map[string]interface{}, labels []string, index := make(map[string]int) items := make([]map[string]interface{}, 0) for i, serie := range series { - label := observabilitySeriesLabel(serie, labels, i) + label := observabilitySeriesLabel(serie, labels, nameLabels, i) if label == "" { continue } @@ -575,14 +575,15 @@ func observabilityMapSlice(raw interface{}) []map[string]interface{} { } } -func observabilitySeriesLabel(serie map[string]interface{}, labels []string, index int) string { - for _, key := range []string{"label", "series", "name", "metric_type", "metricType"} { +func observabilitySeriesLabel(serie map[string]interface{}, labels []string, nameLabels map[string]string, index int) string { + for _, key := range []string{"label", "series", "name", "metric_name", "metricName", "metric_type", "metricType"} { if value, ok := serie[key].(string); ok { value = strings.TrimSpace(value) - for _, label := range labels { - if value == label { - return label - } + if label := nameLabels[value]; label != "" { + return label + } + if containsObservabilityLabel(labels, value) { + return value } } } @@ -592,6 +593,15 @@ func observabilitySeriesLabel(serie map[string]interface{}, labels []string, ind return "" } +func containsObservabilityLabel(labels []string, value string) bool { + for _, label := range labels { + if value == label { + return true + } + } + return false +} + func observabilityTimestamp(point map[string]interface{}, timeField string) interface{} { keys := []string{timeField} if timeField == "timestamp_ns" { diff --git a/shortcuts/apps/apps_observability_metrics_test.go b/shortcuts/apps/apps_observability_metrics_test.go index c3c8414c0..500bbf7b8 100644 --- a/shortcuts/apps/apps_observability_metrics_test.go +++ b/shortcuts/apps/apps_observability_metrics_test.go @@ -136,6 +136,57 @@ func TestAppsMetricQuery_FillsMissingRequestValuesWithZero(t *testing.T) { } } +func TestAppsMetricQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "series": []interface{}{ + map[string]interface{}{ + "name": "client_api_request_error_count", + "points": []interface{}{ + map[string]interface{}{"timestamp": float64(1782208800), "value": float64(2)}, + }, + }, + map[string]interface{}{ + "name": "client_api_request_count", + "points": []interface{}{ + map[string]interface{}{"timestamp": float64(1782208800), "value": float64(10)}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsMetricQuery, []string{ + "+metric-query", "--app-id", "app_x", "--metric", "requests", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if len(env.Data.Items) != 1 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + values := env.Data.Items[0].Values + if values["total"] != float64(10) || values["error"] != float64(2) { + t.Fatalf("values = %#v, want total=10 error=2", values) + } +} + func TestAppsMetricQuery_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -314,6 +365,63 @@ func TestAppsAnalyticsQuery_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { } } +func TestAppsAnalyticsQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "series": []interface{}{ + map[string]interface{}{ + "metric_type": "TOTAL_USER", + "points": []interface{}{ + map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(20)}, + }, + }, + map[string]interface{}{ + "metric_type": "ACTIVE_USER", + "points": []interface{}{ + map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(7)}, + }, + }, + map[string]interface{}{ + "metric_type": "NEW_USER", + "points": []interface{}{ + map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(3)}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ + "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if len(env.Data.Items) != 1 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + values := env.Data.Items[0].Values + if values["active-users"] != float64(7) || values["new-users"] != float64(3) || values["total-users"] != float64(20) { + t.Fatalf("values = %#v, want active-users=7 new-users=3 total-users=20", values) + } +} + func TestAppsAnalyticsQuery_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ From 0552c5c595bfece297d74647f1ff0d4a19f934a0 Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Wed, 24 Jun 2026 16:34:41 +0800 Subject: [PATCH 11/34] fix: apps observability api upgrade --- shortcuts/apps/apps_env_pull.go | 16 +- shortcuts/apps/apps_env_pull_test.go | 43 +- shortcuts/apps/apps_envvar.go | 77 ++- shortcuts/apps/apps_envvar_test.go | 79 +-- shortcuts/apps/apps_hints_test.go | 7 +- shortcuts/apps/apps_observability_common.go | 39 +- .../apps/apps_observability_common_test.go | 2 + shortcuts/apps/apps_observability_logs.go | 8 +- .../apps/apps_observability_logs_test.go | 5 +- shortcuts/apps/apps_observability_metrics.go | 9 +- .../apps/apps_observability_metrics_test.go | 6 + shortcuts/apps/apps_observability_traces.go | 12 +- .../apps/apps_observability_traces_test.go | 4 +- .../references/lark-apps-env-pull.md | 2 +- .../lark-apps/references/lark-apps-envvar.md | 2 + .../references/lark-apps-observability.md | 7 +- ...26_06_23_apps_observability_envvar_test.go | 632 ++++++++++++++++++ 17 files changed, 814 insertions(+), 136 deletions(-) create mode 100644 tests_e2e/apps/2026_06_23_apps_observability_envvar_test.go diff --git a/shortcuts/apps/apps_env_pull.go b/shortcuts/apps/apps_env_pull.go index bafe56186..211b0b4b5 100644 --- a/shortcuts/apps/apps_env_pull.go +++ b/shortcuts/apps/apps_env_pull.go @@ -62,9 +62,9 @@ var AppsEnvPull = common.Shortcut{ projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path"))) appID := strings.TrimSpace(rctx.Str("app-id")) return common.NewDryRunAPI(). - GET(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))). + POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))). Desc("Pull app startup env vars into the local .env.local file"). - Params(map[string]interface{}{"env": "dev", "include_values": true}). + Body(map[string]interface{}{"env": "dev"}). Set("project_path", projectPath). Set("env_file", envFile) }, @@ -82,7 +82,7 @@ var AppsEnvPull = common.Shortcut{ } path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID)) - data, err := rctx.CallAPITyped("GET", path, map[string]interface{}{"env": "dev", "include_values": true}, nil) + data, err := rctx.CallAPITyped("POST", path, nil, map[string]interface{}{"env": "dev"}) if err != nil { return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`") } @@ -151,13 +151,19 @@ func checkEnvPullTarget(envFile string) error { func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPullDatabaseInfo, []string, error) { raw := data["env_vars"] + if raw == nil { + raw = data["envVars"] + } if raw == nil { if nested, ok := data["data"].(map[string]interface{}); ok { raw = nested["env_vars"] + if raw == nil { + raw = nested["envVars"] + } } } if raw == nil { - return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "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/envVars must be an object or array of key/value entries") } var skippedKeys []string @@ -204,7 +210,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull } return out, info, skippedKeys, nil default: - return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "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/envVars must be an object or array of key/value entries") } } diff --git a/shortcuts/apps/apps_env_pull_test.go b/shortcuts/apps/apps_env_pull_test.go index 74d237160..4df081645 100644 --- a/shortcuts/apps/apps_env_pull_test.go +++ b/shortcuts/apps/apps_env_pull_test.go @@ -256,7 +256,7 @@ func TestBuildEnvPullSuccessDataSuppressesEnvKeysAndValues(t *testing.T) { } } -func TestAppsEnvPull_DryRunUsesGetQueryAndResolvedEnvFile(t *testing.T) { +func TestAppsEnvPull_DryRunUsesPostBodyAndResolvedEnvFile(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) projectDir := t.TempDir() @@ -267,14 +267,14 @@ func TestAppsEnvPull_DryRunUsesGetQueryAndResolvedEnvFile(t *testing.T) { } got := stdout.String() - if !strings.Contains(got, `"method": "GET"`) { - t.Fatalf("dry-run must use GET: %s", got) + if !strings.Contains(got, `"method": "POST"`) { + t.Fatalf("dry-run must use POST: %s", got) } if !strings.Contains(got, `/open-apis/spark/v1/apps/app_x/env_vars`) { t.Fatalf("dry-run missing endpoint: %s", got) } - if !strings.Contains(got, `"env": "dev"`) || !strings.Contains(got, `"include_values": true`) { - t.Fatalf("dry-run must include env=dev and include_values=true params: %s", got) + if !strings.Contains(got, `"env": "dev"`) || strings.Contains(got, `"include_values"`) { + t.Fatalf("dry-run must include only env=dev in the request body: %s", got) } if !strings.Contains(got, filepath.Join(projectDir, ".env.local")) { t.Fatalf("dry-run must include resolved env file path: %s", got) @@ -285,13 +285,10 @@ func TestAppsEnvPull_PrettyOutput_WithDatabaseLine(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", OnMatch: func(req *http.Request) { - assertEnvVarQuery(t, req, map[string]string{ - "env": "dev", - "include_values": "true", - }) + assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"}) }, Body: map[string]interface{}{ "code": 0, @@ -336,7 +333,7 @@ func TestAppsEnvPull_JSONOutput_UsesSummaryFieldsOnly(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -380,7 +377,7 @@ func TestAppsEnvPull_MalformedPayloadSkipsInvalidEntries(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -429,7 +426,7 @@ func TestAppsEnvPull_WritesCanonicalEnvFile(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -498,7 +495,7 @@ func TestAppsEnvPull_JSONOutputOmitsDatabaseLineText(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -534,7 +531,7 @@ func TestAppsEnvPull_ExecuteUsesNestedDataEnvVars(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -564,7 +561,7 @@ func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -610,7 +607,7 @@ func TestAppsEnvPull_JSONOutputCanBeDecoded(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -657,7 +654,7 @@ func TestAppsEnvPull_PrettyOutputWithoutDatabaseLine(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -716,7 +713,7 @@ func TestAppsEnvPull_DatabaseExtrasWithoutExpiresAtDoesNotFail(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -832,7 +829,7 @@ func TestAppsEnvPull_InjectsForceDBBranchWhenAbsent(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -866,7 +863,7 @@ func TestAppsEnvPull_InjectsForceDBBranchAlongsideArrayEnvVars(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -896,7 +893,7 @@ func TestAppsEnvPull_ForceDBBranchOverwritesExistingLocalValue(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, @@ -939,7 +936,7 @@ func TestAppsEnvPull_ForceDBBranchInjectedEvenWhenUpstreamReturnsEmptyMap(t *tes factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Body: map[string]interface{}{ "code": 0, diff --git a/shortcuts/apps/apps_envvar.go b/shortcuts/apps/apps_envvar.go index b6d5a3071..1661b4ffe 100644 --- a/shortcuts/apps/apps_envvar.go +++ b/shortcuts/apps/apps_envvar.go @@ -9,7 +9,6 @@ import ( "strings" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -31,8 +30,6 @@ var AppsEnvVarList = common.Shortcut{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, {Name: "include-values", Type: "bool", Desc: "include environment variable values"}, - {Name: "page-size", Type: "int", Default: "50", 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 { @@ -41,17 +38,14 @@ var AppsEnvVarList = common.Shortcut{ if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil { return err } - if err := validateAppsPageSize(rctx.Int("page-size")); err != nil { - return err - } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). - GET(envVarCollectionPath(appID)). + POST(envVarCollectionPath(appID)). Desc("List app environment variables"). - Params(buildEnvVarListParams(rctx)) + Body(buildEnvVarListBody(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, err := requireAppID(rctx.Str("app-id")) @@ -59,7 +53,7 @@ var AppsEnvVarList = common.Shortcut{ return err } includeValues := rctx.Bool("include-values") - data, err := rctx.CallAPITyped("GET", envVarCollectionPath(appID), buildEnvVarListParams(rctx), nil) + data, err := rctx.CallAPITyped("POST", envVarCollectionPath(appID), nil, buildEnvVarListBody(rctx)) if err != nil { return withAppsHint(err, appIDListHint) } @@ -106,9 +100,10 @@ var AppsEnvVarSet = common.Shortcut{ appID, _ := requireAppID(rctx.Str("app-id")) key, _ := requireEnvVarKey(rctx.Str("key")) return common.NewDryRunAPI(). - PUT(envVarKeyPath(appID, key)). + POST(envVarCreateOrUpdatePath(appID)). Desc("Set app environment variable"). Body(map[string]interface{}{ + "key": key, "env": envVarEnv(rctx), "value": "", }) @@ -130,17 +125,22 @@ var AppsEnvVarSet = common.Shortcut{ if err != nil { return err } - _, err = rctx.CallAPITyped("PUT", envVarKeyPath(appID, key), nil, map[string]interface{}{ + data, err := rctx.CallAPITyped("POST", envVarCreateOrUpdatePath(appID), nil, map[string]interface{}{ + "key": key, "env": env, "value": rctx.Str("value"), }) if err != nil { return withAppsHint(err, appIDListHint) } + action := envVarStringAny(data, "action") + if action == "" { + action = "set" + } rctx.OutFormat(map[string]interface{}{ "key": key, "env": env, - "action": "set", + "action": action, }, nil, nil) return nil }, @@ -177,7 +177,7 @@ var AppsEnvVarDelete = common.Shortcut{ appID, _ := requireAppID(rctx.Str("app-id")) keys, _ := requireEnvVarKeys(rctx.StrArray("key")) return common.NewDryRunAPI(). - DELETE(envVarCollectionPath(appID)). + POST(envVarDeletePath(appID)). Desc("Delete app environment variables"). Body(buildEnvVarDeleteBody(envVarEnv(rctx), keys)) }, @@ -191,13 +191,17 @@ var AppsEnvVarDelete = common.Shortcut{ return err } env := envVarEnv(rctx) - _, err = rctx.CallAPITyped("DELETE", envVarCollectionPath(appID), nil, buildEnvVarDeleteBody(env, keys)) + data, err := rctx.CallAPITyped("POST", envVarDeletePath(appID), nil, buildEnvVarDeleteBody(env, keys)) if err != nil { return withAppsHint(err, appIDListHint) } + deletedKeys := envVarStringSliceAny(data, "deleted_keys", "deletedKeys") + if len(deletedKeys) == 0 { + deletedKeys = keys + } rctx.OutFormat(map[string]interface{}{ "env": env, - "deleted_keys": keys, + "deleted_keys": deletedKeys, }, nil, nil) return nil }, @@ -215,20 +219,18 @@ func envVarCollectionPath(appID string) string { return appScopedPath(appID, "env_vars") } -func envVarKeyPath(appID, key string) string { - return envVarCollectionPath(appID) + "/" + validate.EncodePathSegment(strings.TrimSpace(key)) +func envVarCreateOrUpdatePath(appID string) string { + return appScopedPath(appID, "create_or_update_env_var") } -func buildEnvVarListParams(rctx *common.RuntimeContext) map[string]interface{} { - params := map[string]interface{}{ - "env": envVarEnv(rctx), - "include_values": rctx.Bool("include-values"), - "page_size": rctx.Int("page-size"), - } - if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { - params["page_token"] = token +func envVarDeletePath(appID string) string { + return appScopedPath(appID, "delete_env_vars") +} + +func buildEnvVarListBody(rctx *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "env": envVarEnv(rctx), } - return params } func buildEnvVarDeleteBody(env string, keys []string) map[string]interface{} { @@ -288,6 +290,9 @@ func envVarItemsRaw(data map[string]interface{}) interface{} { if raw := data["env_vars"]; raw != nil { return raw } + if raw := data["envVars"]; raw != nil { + return raw + } return data["items"] } @@ -343,6 +348,26 @@ func envVarStringAny(data map[string]interface{}, keys ...string) string { return "" } +func envVarStringSliceAny(data map[string]interface{}, keys ...string) []string { + for _, key := range keys { + switch raw := data[key].(type) { + case []string: + return append([]string(nil), raw...) + case []interface{}: + out := make([]string, 0, len(raw)) + for _, item := range raw { + if value, ok := item.(string); ok { + out = append(out, value) + } + } + if len(out) > 0 { + return out + } + } + } + return nil +} + func envVarBoolAny(data map[string]interface{}, keys ...string) bool { for _, key := range keys { if value, ok := data[key].(bool); ok { diff --git a/shortcuts/apps/apps_envvar_test.go b/shortcuts/apps/apps_envvar_test.go index 1a6a53b3d..533a72130 100644 --- a/shortcuts/apps/apps_envvar_test.go +++ b/shortcuts/apps/apps_envvar_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "net/http" + "reflect" "strings" "testing" @@ -14,18 +15,17 @@ import ( "github.com/larksuite/cli/internal/httpmock" ) -func assertEnvVarQuery(t *testing.T, req *http.Request, want map[string]string, absent ...string) { +func assertEnvVarBody(t *testing.T, req *http.Request, want map[string]interface{}) { t.Helper() - query := req.URL.Query() - for key, value := range want { - if got := query.Get(key); got != value { - t.Fatalf("query %s = %q, want %q (raw query %q)", key, got, value, req.URL.RawQuery) - } + if req.URL.RawQuery != "" { + t.Fatalf("query should be empty, got %q", req.URL.RawQuery) } - for _, key := range absent { - if _, ok := query[key]; ok { - t.Fatalf("query %s should be absent (raw query %q)", key, req.URL.RawQuery) - } + var got map[string]interface{} + if err := json.NewDecoder(req.Body).Decode(&got); err != nil { + t.Fatalf("decode body: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("body = %#v, want %#v", got, want) } } @@ -62,23 +62,17 @@ func requireEnvVarValidationProblem(t *testing.T, err error, param string) { func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", OnMatch: func(req *http.Request) { - assertEnvVarQuery(t, req, map[string]string{ - "env": "dev", - "include_values": "false", - "page_size": "50", - }, "page_token") + assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"}) }, Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "env_vars": []interface{}{ + "envVars": []interface{}{ map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "dev"}, }, - "next_page_token": "", - "has_more": false, }, }, }) @@ -109,31 +103,23 @@ func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) { func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", OnMatch: func(req *http.Request) { - assertEnvVarQuery(t, req, map[string]string{ - "env": "online", - "include_values": "true", - "page_size": "20", - "page_token": "cursor-1", - }) + assertEnvVarBody(t, req, map[string]interface{}{"env": "online"}) }, Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "items": []interface{}{ + "envVars": []interface{}{ map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "online"}, }, - "nextPageToken": "cursor-2", - "hasMore": true, }, }, }) if err := runAppsShortcut(t, AppsEnvVarList, - []string{"+envvar-list", "--app-id", "app_x", "--env", "online", "--include-values", - "--page-size", "20", "--page-token", "cursor-1", "--as", "user"}, factory, stdout); err != nil { + []string{"+envvar-list", "--app-id", "app_x", "--env", "online", "--include-values", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -141,13 +127,6 @@ func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) { if !strings.Contains(got, "super-secret") { t.Fatalf("stdout should include values when requested: %s", got) } - data := decodeEnvVarEnvelopeData(t, got) - if data["page_token"] != "cursor-2" { - t.Fatalf("page_token = %v, want cursor-2", data["page_token"]) - } - if data["has_more"] != true { - t.Fatalf("has_more = %v, want true", data["has_more"]) - } } func TestAppsEnvVarSet_OnlineRequiresYesOutsideDryRun(t *testing.T) { @@ -177,7 +156,7 @@ func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) { if strings.Contains(got, "super-secret") { t.Fatalf("dry-run must redact value: %s", got) } - for _, want := range []string{`"method": "PUT"`, `/open-apis/spark/v1/apps/app_x/env_vars/SECRET_TOKEN`} { + for _, want := range []string{`"method": "POST"`, `/open-apis/spark/v1/apps/app_x/create_or_update_env_var`} { if !strings.Contains(got, want) { t.Fatalf("dry-run missing %q: %s", want, got) } @@ -190,17 +169,17 @@ func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) { if err := json.Unmarshal([]byte(got), &dryRun); err != nil { t.Fatalf("decode dry-run: %v\n%s", err, got) } - if len(dryRun.API) != 1 || dryRun.API[0].Body["value"] != "" { - t.Fatalf("dry-run body value = %#v, want ", dryRun.API) + if len(dryRun.API) != 1 || dryRun.API[0].Body["value"] != "" || dryRun.API[0].Body["key"] != "SECRET_TOKEN" { + t.Fatalf("dry-run body = %#v, want redacted value and key", dryRun.API) } } func TestAppsEnvVarSet_ExecutesWithYesAndDoesNotEchoValue(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) stub := &httpmock.Stub{ - Method: "PUT", - URL: "/open-apis/spark/v1/apps/app_x/env_vars/SECRET_TOKEN", - Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/create_or_update_env_var", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"action": "updated"}}, } reg.Register(stub) @@ -214,14 +193,14 @@ func TestAppsEnvVarSet_ExecutesWithYesAndDoesNotEchoValue(t *testing.T) { if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { t.Fatalf("decode body: %v", err) } - if sent["env"] != "online" || sent["value"] != "super-secret" { + if sent["key"] != "SECRET_TOKEN" || sent["env"] != "online" || sent["value"] != "super-secret" { t.Fatalf("body = %#v, want real online value", sent) } got := stdout.String() if strings.Contains(got, "super-secret") || strings.Contains(got, `"value"`) { t.Fatalf("stdout must not echo value: %s", got) } - for _, want := range []string{`"key": "SECRET_TOKEN"`, `"env": "online"`, `"action": "set"`} { + for _, want := range []string{`"key": "SECRET_TOKEN"`, `"env": "online"`, `"action": "updated"`} { if !strings.Contains(got, want) { t.Fatalf("stdout missing %q: %s", want, got) } @@ -237,9 +216,9 @@ func TestAppsEnvVarDelete_IsHighRiskWrite(t *testing.T) { func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) stub := &httpmock.Stub{ - Method: "DELETE", - URL: "/open-apis/spark/v1/apps/app_x/env_vars", - Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/delete_env_vars", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"deleted_keys": []interface{}{"SECRET_ONE", "SECRET_TWO"}}}, } reg.Register(stub) @@ -287,7 +266,7 @@ func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) { if err := json.Unmarshal([]byte(got), &dryRun); err != nil { t.Fatalf("decode dry-run: %v\n%s", err, got) } - if len(dryRun.API) != 1 || dryRun.API[0].Method != "DELETE" || dryRun.API[0].URL != "/open-apis/spark/v1/apps/app_x/env_vars" { + if len(dryRun.API) != 1 || dryRun.API[0].Method != "POST" || dryRun.API[0].URL != "/open-apis/spark/v1/apps/app_x/delete_env_vars" { t.Fatalf("dry-run api = %#v", dryRun.API) } if dryRun.API[0].Body["env"] != "online" { diff --git a/shortcuts/apps/apps_hints_test.go b/shortcuts/apps/apps_hints_test.go index 50f216a0f..c5ce0e3f3 100644 --- a/shortcuts/apps/apps_hints_test.go +++ b/shortcuts/apps/apps_hints_test.go @@ -17,15 +17,12 @@ import ( func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ - Method: "GET", + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}, OnMatch: func(req *http.Request) { - assertEnvVarQuery(t, req, map[string]string{ - "env": "dev", - "include_values": "true", - }) + assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"}) }, }) diff --git a/shortcuts/apps/apps_observability_common.go b/shortcuts/apps/apps_observability_common.go index 68c0f1bda..5f48539f1 100644 --- a/shortcuts/apps/apps_observability_common.go +++ b/shortcuts/apps/apps_observability_common.go @@ -14,6 +14,10 @@ import ( const ( defaultAppsPageSize = 50 maxAppsPageSize = 100 + + // The CLI exposes the user-facing online environment, while the + // observability backend stores online app runtime telemetry under runtime. + appsObservabilityBackendEnv = "runtime" ) func appScopedPath(appID, suffix string) string { @@ -30,7 +34,7 @@ func validateObservabilityEnv(env string) error { case "", "online": return nil default: - return appsValidationParamError("--env", "observability commands only support --env online (got %q)", env). + return appsValidationParamError("--env", "observability commands only support online (got %q)", env). WithHint("only online is supported; omit --env to use the default online environment") } } @@ -117,7 +121,7 @@ func parseAppsTimeFlag(param, raw string, now time.Time) (time.Time, error) { return t, nil } } - return time.Time{}, appsValidationParamError(param, "invalid %s %q: expected relative duration (30s, 5m, 2h, 3d, 1w), YYYY-MM-DD, local YYYY-MM-DDTHH:mm:ss(.SSS), or RFC3339", param, raw) + return time.Time{}, appsValidationParamError(param, "invalid %s %q: expected relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), YYYY-MM-DD, local YYYY-MM-DDTHH:mm:ss(.SSS), or RFC3339", param, raw) } func parseAppsRelativeDuration(s string) (time.Duration, bool) { @@ -127,12 +131,31 @@ func parseAppsRelativeDuration(s string) (time.Duration, bool) { } unit := s[len(s)-1] number := s[:len(s)-1] + if number == "" { + return 0, false + } + seenDot := false + seenFractionDigit := false for i := 0; i < len(number); i++ { - if number[i] < '0' || number[i] > '9' { + ch := number[i] + if ch == '.' { + if seenDot || i == 0 { + return 0, false + } + seenDot = true + continue + } + if ch < '0' || ch > '9' { return 0, false } + if seenDot { + seenFractionDigit = true + } + } + if seenDot && !seenFractionDigit { + return 0, false } - n, err := strconv.ParseInt(number, 10, 64) + n, err := strconv.ParseFloat(number, 64) if err != nil || n <= 0 { return 0, false } @@ -152,10 +175,14 @@ func parseAppsRelativeDuration(s string) (time.Duration, bool) { return 0, false } const maxDuration = time.Duration(1<<63 - 1) - if n > int64(maxDuration)/int64(unitDuration) { + if n > float64(maxDuration)/float64(unitDuration) { + return 0, false + } + duration := time.Duration(n * float64(unitDuration)) + if duration <= 0 { return 0, false } - return time.Duration(n) * unitDuration, true + return duration, true } func nsNumber(t time.Time) int64 { diff --git a/shortcuts/apps/apps_observability_common_test.go b/shortcuts/apps/apps_observability_common_test.go index 8ba8b9e1e..67f5f4d7c 100644 --- a/shortcuts/apps/apps_observability_common_test.go +++ b/shortcuts/apps/apps_observability_common_test.go @@ -94,6 +94,8 @@ func TestParseAppsTimeAcceptsSupportedInputs(t *testing.T) { {raw: "30s", want: now.Add(-30 * time.Second)}, {raw: "5m", want: now.Add(-5 * time.Minute)}, {raw: "2h", want: now.Add(-2 * time.Hour)}, + {raw: "1.5h", want: now.Add(-90 * time.Minute)}, + {raw: "0.5d", want: now.Add(-12 * time.Hour)}, {raw: "3d", want: now.Add(-72 * time.Hour)}, {raw: "1w", want: now.Add(-7 * 24 * time.Hour)}, {raw: "2026-06-23", want: time.Date(2026, 6, 23, 0, 0, 0, 0, time.Local)}, diff --git a/shortcuts/apps/apps_observability_logs.go b/shortcuts/apps/apps_observability_logs.go index 52ef3fdc0..d3afd92c3 100644 --- a/shortcuts/apps/apps_observability_logs.go +++ b/shortcuts/apps/apps_observability_logs.go @@ -39,8 +39,8 @@ var AppsLogList = common.Shortcut{ Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true}, {Name: "env", Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, - {Name: "since", Desc: "start time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339"}, - {Name: "until", Desc: "end time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, {Name: "level", Type: "string_array", Desc: "log level filter; repeatable, one of DEBUG, INFO, WARN, ERROR (case-insensitive)"}, {Name: "log-id", Type: "string_array", Desc: "log ID filter; repeatable"}, {Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"}, @@ -165,7 +165,7 @@ func buildLogSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, er return nil, err } body := map[string]interface{}{ - "app_env": env, + "app_env": appsObservabilityBackendEnv, "limit": rctx.Int("page-size"), } if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { @@ -186,7 +186,7 @@ func buildLogSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, er func buildLogGetSearchBody(rctx *common.RuntimeContext) map[string]interface{} { return map[string]interface{}{ - "app_env": defaultAppsLogEnv, + "app_env": appsObservabilityBackendEnv, "limit": 1, "filter": map[string]interface{}{ "log_ids": []string{strings.TrimSpace(rctx.Str("log-id"))}, diff --git a/shortcuts/apps/apps_observability_logs_test.go b/shortcuts/apps/apps_observability_logs_test.go index 7f0ddcaef..3c444b325 100644 --- a/shortcuts/apps/apps_observability_logs_test.go +++ b/shortcuts/apps/apps_observability_logs_test.go @@ -37,7 +37,7 @@ func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) { if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/search_logs" { t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) } - if env.API[0].Body["app_env"] != "online" || env.API[0].Body["limit"] != float64(20) { + if env.API[0].Body["app_env"] != "runtime" || env.API[0].Body["limit"] != float64(20) { t.Fatalf("body = %#v", env.API[0].Body) } filter := env.API[0].Body["filter"].(map[string]interface{}) @@ -92,6 +92,9 @@ func TestAppsLogGet_SearchesByLogIDLimitOne(t *testing.T) { if sent["limit"] != float64(1) { t.Fatalf("limit = %v, want 1", sent["limit"]) } + if sent["app_env"] != "runtime" { + t.Fatalf("app_env = %v, want runtime", sent["app_env"]) + } } func TestAppsLogList_NormalizesResponseVariantsAndCanonicalLevel(t *testing.T) { diff --git a/shortcuts/apps/apps_observability_metrics.go b/shortcuts/apps/apps_observability_metrics.go index 7bdafd967..f5c01c947 100644 --- a/shortcuts/apps/apps_observability_metrics.go +++ b/shortcuts/apps/apps_observability_metrics.go @@ -43,8 +43,8 @@ var AppsMetricQuery = common.Shortcut{ {Name: "env", Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"}, {Name: "metric", Desc: "metric family to query", Required: true, Enum: []string{"requests", "latency", "cpu", "memory"}}, {Name: "series", Desc: "metric series within the family, such as total/error or p50/p99"}, - {Name: "since", Desc: "start time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, - {Name: "until", Desc: "end time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, {Name: "page", Type: "string_array", Desc: "frontend page or route filter; repeatable"}, {Name: "api", Type: "string_array", Desc: "API path/name filter; repeatable"}, {Name: "down-sample", Default: defaultAppsMetricDownSample, Desc: "metric down-sample interval", Enum: []string{"1m", "1h", "1d"}}, @@ -102,8 +102,8 @@ var AppsAnalyticsQuery = common.Shortcut{ {Name: "env", Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"}, {Name: "analytics", Desc: "analytics family to query", Required: true, Enum: []string{"users", "page-view"}}, {Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"}, - {Name: "since", Desc: "start time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, - {Name: "until", Desc: "end time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, {Name: "page", Desc: "frontend page or route filter"}, {Name: "device-type", Desc: "device type filter", Enum: []string{"desktop", "mobile"}}, {Name: "granularity", Default: defaultAppsAnalyticsGranular, Desc: "analytics aggregation granularity", Enum: []string{"day", "week", "month"}}, @@ -228,6 +228,7 @@ func buildAnalyticsQueryBody(rctx *common.RuntimeContext) (map[string]interface{ "start_timestamp_ns": nsNumber(since), "end_timestamp_ns": nsNumber(until), "time_aggregation_unit": aggregation, + "need_pack_lack_point": false, } if len(filter) > 0 { body["filter"] = filter diff --git a/shortcuts/apps/apps_observability_metrics_test.go b/shortcuts/apps/apps_observability_metrics_test.go index 500bbf7b8..74cefa5f2 100644 --- a/shortcuts/apps/apps_observability_metrics_test.go +++ b/shortcuts/apps/apps_observability_metrics_test.go @@ -260,6 +260,12 @@ func TestAppsAnalyticsQuery_DryRunUsesNanoseconds(t *testing.T) { if _, ok := body["analytics_types"]; ok { t.Fatalf("analytics OpenAPI body should use metric_types, not analytics_types: %#v", body) } + if body["need_pack_lack_point"] != false { + t.Fatalf("need_pack_lack_point = %#v, want false", body["need_pack_lack_point"]) + } + if _, ok := body["group_by"]; ok { + t.Fatalf("group_by is intentionally unsupported for now: %#v", body) + } if metricTypes, ok := body["metric_types"].([]interface{}); !ok || len(metricTypes) != 3 { t.Fatalf("metric_types = %#v", body["metric_types"]) } diff --git a/shortcuts/apps/apps_observability_traces.go b/shortcuts/apps/apps_observability_traces.go index 6e6b5d825..34fee120f 100644 --- a/shortcuts/apps/apps_observability_traces.go +++ b/shortcuts/apps/apps_observability_traces.go @@ -39,8 +39,8 @@ var AppsTraceList = common.Shortcut{ {Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"}, {Name: "root-span", Desc: "root span keyword filter applied by the trace search backend"}, {Name: "user-id", Desc: "end user ID filter"}, - {Name: "since", Desc: "start time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339"}, - {Name: "until", Desc: "end time, relative duration (30s, 5m, 2h, 3d, 1w), local date/time, or RFC3339"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, {Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", defaultAppsPageSize), Desc: "page size, 1..100"}, {Name: "page-token", Desc: "pagination cursor from a previous trace search response"}, }, @@ -149,7 +149,7 @@ func buildTraceSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, return nil, err } body := map[string]interface{}{ - "app_env": env, + "app_env": appsObservabilityBackendEnv, "limit": rctx.Int("page-size"), } if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { @@ -165,12 +165,8 @@ func buildTraceSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, } func buildTraceGetBody(rctx *common.RuntimeContext) map[string]interface{} { - env := strings.TrimSpace(rctx.Str("env")) - if env == "" { - env = defaultAppsTraceEnv - } return map[string]interface{}{ - "app_env": env, + "app_env": appsObservabilityBackendEnv, "trace_id": strings.TrimSpace(rctx.Str("trace-id")), } } diff --git a/shortcuts/apps/apps_observability_traces_test.go b/shortcuts/apps/apps_observability_traces_test.go index 08a51bc49..71f560069 100644 --- a/shortcuts/apps/apps_observability_traces_test.go +++ b/shortcuts/apps/apps_observability_traces_test.go @@ -35,7 +35,7 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) { if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/search_traces" { t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) } - if env.API[0].Body["app_env"] != "online" || env.API[0].Body["limit"] != float64(10) { + if env.API[0].Body["app_env"] != "runtime" || env.API[0].Body["limit"] != float64(10) { t.Fatalf("body = %#v", env.API[0].Body) } filter := env.API[0].Body["filter"].(map[string]interface{}) @@ -84,7 +84,7 @@ func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) { if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/trace" { t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) } - if env.API[0].Body["app_env"] != "online" || env.API[0].Body["trace_id"] != "trace-1" { + if env.API[0].Body["app_env"] != "runtime" || env.API[0].Body["trace_id"] != "trace-1" { t.Fatalf("body = %#v", env.API[0].Body) } } diff --git a/skills/lark-apps/references/lark-apps-env-pull.md b/skills/lark-apps/references/lark-apps-env-pull.md index 4a7ea49a0..43eb1e3b5 100644 --- a/skills/lark-apps/references/lark-apps-env-pull.md +++ b/skills/lark-apps/references/lark-apps-env-pull.md @@ -4,7 +4,7 @@ 把妙搭应用 dev 启动期环境变量拉取到本地项目根的 `.env.local`。身份固定 `--as user`;scope `spark:app:read`。`--app-id` 必填,目标项目根默认当前工作目录(`--project-path` 可指定)。 -这个命令是 dev-only 的本地恢复工具:内部固定调用 `env_vars`,参数为 `env=dev` 和 `include_values=true`。它没有 `--env` flag,也不管理线上环境变量。 +这个命令是 dev-only 的本地恢复工具:内部固定 `POST env_vars`,body 为 `env=dev`。它没有 `--env` flag,也不管理线上环境变量。 ## 何时别用(核心反模式) diff --git a/skills/lark-apps/references/lark-apps-envvar.md b/skills/lark-apps/references/lark-apps-envvar.md index 0050762c5..73fdba7ec 100644 --- a/skills/lark-apps/references/lark-apps-envvar.md +++ b/skills/lark-apps/references/lark-apps-envvar.md @@ -8,6 +8,8 @@ `+envvar-list` 默认查 dev,且默认不返回 value。只有显式传 `--include-values` 后,响应中才可能出现变量值;不要在公开日志里展示带值输出。 +接口契约:list 使用 `POST env_vars`;set 使用 `POST create_or_update_env_var`;delete 使用 `POST delete_env_vars`。`--include-values` 只控制 CLI 输出是否展示 value,不作为服务端查询参数发送。 + ```bash lark-cli apps +envvar-list --app-id lark-cli apps +envvar-list --app-id --env online diff --git a/skills/lark-apps/references/lark-apps-observability.md b/skills/lark-apps/references/lark-apps-observability.md index b91a0563e..1498fc044 100644 --- a/skills/lark-apps/references/lark-apps-observability.md +++ b/skills/lark-apps/references/lark-apps-observability.md @@ -4,6 +4,10 @@ 查询妙搭应用的线上运行观测和产品访问分析。所有 observability 命令只支持 `--env online`;省略 `--env` 时默认就是 online,传 dev 或其他环境是不支持的。 +日志和 trace 的用户侧环境仍然是 online;但 OpenAPI 请求体里的后端 `app_env` 固定发送 `runtime`,因为线上应用的运行时日志和 trace 存储在 runtime 观测环境下。dry-run 输出会展示这个后端参数。 + +时间过滤支持相对时间(如 `30s`、`5m`、`0.5h`、`2h`、`3d`、`1w`)、本地日期 / 时间和 RFC3339。 + ## 命令选择 - 日志检索:用 `+log-list` 搜索日志,用 `+log-get` 按 log ID 取单条日志。 @@ -11,11 +15,12 @@ - Trace 检索:用 `+trace-list` 搜索 trace,用 `+trace-get` 按 trace ID 取详情。 - 运行时指标:请求数、错误、延迟、CPU、memory 用 `+metric-query`。 - 产品分析:PV、UV、访问量这类业务访问分析用 `+analytics-query`,不要放到 runtime metric 里混查。 +- `+analytics-query` 按最新 OpenAPI 发送 `metric_types`、纳秒时间戳和 `need_pack_lack_point=false`;`group_by` 暂不支持。 ## 示例 ```bash -lark-cli apps +log-list --app-id --level error --keyword timeout --since 1h +lark-cli apps +log-list --app-id --level error --keyword timeout --since 0.5h lark-cli apps +log-get --app-id --log-id lark-cli apps +trace-list --app-id --trace-id lark-cli apps +trace-get --app-id --trace-id diff --git a/tests_e2e/apps/2026_06_23_apps_observability_envvar_test.go b/tests_e2e/apps/2026_06_23_apps_observability_envvar_test.go new file mode 100644 index 000000000..d171b3284 --- /dev/null +++ b/tests_e2e/apps/2026_06_23_apps_observability_envvar_test.go @@ -0,0 +1,632 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +const ( + appsE2EAppID = "app_x" + appsSecretValue = "super-secret-value-for-e2e" +) + +func TestAppsObservabilityDryRunContract(t *testing.T) { + cases := []struct { + name string + args []string + method string + url string + assertBody func(*testing.T, string) + }{ + { + name: "log_list_request_shape", + args: []string{ + "apps", "+log-list", + "--app-id", appsE2EAppID, + "--env", "online", + "--level", "error", + "--since", "2026-06-23T10:00:00Z", + "--until", "2026-06-23T11:00:00Z", + "--log-id", "LOG1", + "--log-id", "LOG2", + "--trace-id", "trace-1", + "--keyword", "timeout", + "--min-duration", "200", + "--page-size", "50", + "--page-token", "next-token", + }, + method: "POST", + url: "/open-apis/spark/v1/apps/app_x/search_logs", + assertBody: func(t *testing.T, stdout string) { + assert.Equal(t, "runtime", gjson.Get(stdout, "api.0.body.app_env").String(), "stdout:\n%s", stdout) + assert.Equal(t, "1782208800000000000", gjson.Get(stdout, "api.0.body.start_timestamp_ns").String(), "stdout:\n%s", stdout) + assert.Equal(t, "1782212400000000000", gjson.Get(stdout, "api.0.body.end_timestamp_ns").String(), "stdout:\n%s", stdout) + assert.Equal(t, int64(50), gjson.Get(stdout, "api.0.body.limit").Int(), "stdout:\n%s", stdout) + assert.Equal(t, "next-token", gjson.Get(stdout, "api.0.body.page_token").String(), "stdout:\n%s", stdout) + requireStringArray(t, stdout, "api.0.body.filter.levels", []string{"ERROR"}) + requireStringArray(t, stdout, "api.0.body.filter.log_ids", []string{"LOG1", "LOG2"}) + requireStringArray(t, stdout, "api.0.body.filter.trace_ids", []string{"trace-1"}) + assert.Equal(t, "timeout", gjson.Get(stdout, "api.0.body.filter.keyword").String(), "stdout:\n%s", stdout) + assert.Equal(t, int64(200), gjson.Get(stdout, "api.0.body.filter.min_duration_ms").Int(), "stdout:\n%s", stdout) + }, + }, + { + name: "log_get_uses_search_logs_with_limit_one", + args: []string{ + "apps", "+log-get", + "--app-id", appsE2EAppID, + "--env", "online", + "--log-id", "LOG763372528845174288", + }, + method: "POST", + url: "/open-apis/spark/v1/apps/app_x/search_logs", + assertBody: func(t *testing.T, stdout string) { + assert.Equal(t, "runtime", gjson.Get(stdout, "api.0.body.app_env").String(), "stdout:\n%s", stdout) + assert.Equal(t, int64(1), gjson.Get(stdout, "api.0.body.limit").Int(), "stdout:\n%s", stdout) + requireStringArray(t, stdout, "api.0.body.filter.log_ids", []string{"LOG763372528845174288"}) + }, + }, + { + name: "trace_list_request_shape", + args: []string{ + "apps", "+trace-list", + "--app-id", appsE2EAppID, + "--env", "online", + "--trace-id", "trace-1", + "--root-span", "api-gateway", + "--user-id", "ou_user", + "--since", "2026-06-23T10:00:00Z", + "--until", "2026-06-23T11:00:00Z", + "--page-size", "25", + "--page-token", "next-token", + }, + method: "POST", + url: "/open-apis/spark/v1/apps/app_x/search_traces", + assertBody: func(t *testing.T, stdout string) { + assert.Equal(t, "runtime", gjson.Get(stdout, "api.0.body.app_env").String(), "stdout:\n%s", stdout) + assert.Equal(t, "1782208800000000000", gjson.Get(stdout, "api.0.body.start_timestamp_ns").String(), "stdout:\n%s", stdout) + assert.Equal(t, "1782212400000000000", gjson.Get(stdout, "api.0.body.end_timestamp_ns").String(), "stdout:\n%s", stdout) + assert.Equal(t, int64(25), gjson.Get(stdout, "api.0.body.limit").Int(), "stdout:\n%s", stdout) + assert.Equal(t, "next-token", gjson.Get(stdout, "api.0.body.page_token").String(), "stdout:\n%s", stdout) + requireStringArray(t, stdout, "api.0.body.filter.trace_ids", []string{"trace-1"}) + assert.Equal(t, "api-gateway", gjson.Get(stdout, "api.0.body.filter.keyword").String(), "stdout:\n%s", stdout) + requireStringArray(t, stdout, "api.0.body.filter.user_ids", []string{"ou_user"}) + }, + }, + { + name: "trace_get_request_shape", + args: []string{ + "apps", "+trace-get", + "--app-id", appsE2EAppID, + "--env", "online", + "--trace-id", "359d7ab1d9e222b43ee56619a55f937a", + }, + method: "POST", + url: "/open-apis/spark/v1/apps/app_x/trace", + assertBody: func(t *testing.T, stdout string) { + assert.Equal(t, "runtime", gjson.Get(stdout, "api.0.body.app_env").String(), "stdout:\n%s", stdout) + assert.Equal(t, "359d7ab1d9e222b43ee56619a55f937a", gjson.Get(stdout, "api.0.body.trace_id").String(), "stdout:\n%s", stdout) + }, + }, + { + name: "metric_query_request_shape", + args: []string{ + "apps", "+metric-query", + "--app-id", appsE2EAppID, + "--env", "online", + "--metric", "requests", + "--series", "total", + "--since", "2026-06-23T10:00:00Z", + "--until", "2026-06-23T11:00:00Z", + "--page", "/home", + "--api", "/api/orders", + "--down-sample", "1m", + }, + method: "POST", + url: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + assertBody: func(t *testing.T, stdout string) { + assert.False(t, gjson.Get(stdout, "api.0.body.app_env").Exists(), "metric OpenAPI body should not include app_env, stdout:\n%s", stdout) + assert.Equal(t, "1782208800", gjson.Get(stdout, "api.0.body.start_timestamp").String(), "stdout:\n%s", stdout) + assert.Equal(t, "1782212400", gjson.Get(stdout, "api.0.body.end_timestamp").String(), "stdout:\n%s", stdout) + requireStringArray(t, stdout, "api.0.body.metric_names", []string{"client_api_request_count"}) + assert.Equal(t, "/home", gjson.Get(stdout, "api.0.body.filter.pages.0").String(), "stdout:\n%s", stdout) + assert.Equal(t, "/api/orders", gjson.Get(stdout, "api.0.body.filter.apis.0").String(), "stdout:\n%s", stdout) + assert.Equal(t, "1m", gjson.Get(stdout, "api.0.body.down_sample").String(), "stdout:\n%s", stdout) + assert.True(t, gjson.Get(stdout, "api.0.body.need_pack_lack_point").Exists(), "stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.need_pack_lack_point").Bool(), "stdout:\n%s", stdout) + }, + }, + { + name: "analytics_query_request_shape", + args: []string{ + "apps", "+analytics-query", + "--app-id", appsE2EAppID, + "--env", "online", + "--analytics", "users", + "--series", "active-users", + "--since", "2026-06-23T10:00:00Z", + "--until", "2026-06-23T11:00:00Z", + "--page", "/home", + "--device-type", "desktop", + "--granularity", "week", + }, + method: "POST", + url: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + assertBody: func(t *testing.T, stdout string) { + assert.False(t, gjson.Get(stdout, "api.0.body.app_env").Exists(), "analytics OpenAPI body should not include app_env, stdout:\n%s", stdout) + assert.Equal(t, "1782208800000000000", gjson.Get(stdout, "api.0.body.start_timestamp_ns").String(), "stdout:\n%s", stdout) + assert.Equal(t, "1782212400000000000", gjson.Get(stdout, "api.0.body.end_timestamp_ns").String(), "stdout:\n%s", stdout) + requireStringArray(t, stdout, "api.0.body.metric_types", []string{"ACTIVE_USER"}) + assert.Equal(t, "WEEK", gjson.Get(stdout, "api.0.body.time_aggregation_unit").String(), "stdout:\n%s", stdout) + assert.Equal(t, "/home", gjson.Get(stdout, "api.0.body.filter.page").String(), "stdout:\n%s", stdout) + assert.Equal(t, "desktop", gjson.Get(stdout, "api.0.body.filter.device_types.0").String(), "stdout:\n%s", stdout) + assert.True(t, gjson.Get(stdout, "api.0.body.need_pack_lack_point").Exists(), "stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.need_pack_lack_point").Bool(), "stdout:\n%s", stdout) + assert.False(t, gjson.Get(stdout, "api.0.body.group_by").Exists(), "group_by is intentionally unsupported for now, stdout:\n%s", stdout) + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result := runAppsDryRunCommand(t, ctx, tc.args, false) + result.AssertExitCode(t, 0) + assert.Equal(t, tc.method, gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, tc.url, gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) + tc.assertBody(t, result.Stdout) + }) + } +} + +func TestAppsObservabilityRejectsNonOnlineEnv(t *testing.T) { + cases := []struct { + name string + args []string + }{ + { + name: "log_list", + args: []string{"apps", "+log-list", "--app-id", appsE2EAppID, "--env", "dev"}, + }, + { + name: "log_get", + args: []string{"apps", "+log-get", "--app-id", appsE2EAppID, "--env", "dev", "--log-id", "LOG763372528845174288"}, + }, + { + name: "trace_list", + args: []string{"apps", "+trace-list", "--app-id", appsE2EAppID, "--env", "dev"}, + }, + { + name: "trace_get", + args: []string{"apps", "+trace-get", "--app-id", appsE2EAppID, "--env", "dev", "--trace-id", "359d7ab1d9e222b43ee56619a55f937a"}, + }, + { + name: "metric_query", + args: []string{"apps", "+metric-query", "--app-id", appsE2EAppID, "--env", "dev", "--metric", "requests"}, + }, + { + name: "analytics_query", + args: []string{"apps", "+analytics-query", "--app-id", appsE2EAppID, "--env", "dev", "--analytics", "users"}, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result := runAppsDryRunCommand(t, ctx, tc.args, false) + result.AssertExitCode(t, 2) + raw := errorEnvelope(t, result) + assert.Equal(t, "validation", gjson.Get(raw, "error.type").String(), "error envelope:\n%s", raw) + assert.Equal(t, "invalid_argument", gjson.Get(raw, "error.subtype").String(), "error envelope:\n%s", raw) + assert.Equal(t, "--env", gjson.Get(raw, "error.param").String(), "error envelope:\n%s", raw) + assert.Contains(t, gjson.Get(raw, "error.message").String(), "observability commands only support online", "error envelope:\n%s", raw) + }) + } +} + +func TestAppsEnvVarDryRunAndSafety(t *testing.T) { + t.Run("env_pull_uses_dev_post_body_contract", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + projectDir := filepath.Join(t.TempDir(), "demo") + + result := runAppsDryRunCommand(t, ctx, []string{ + "apps", "+env-pull", + "--app-id", appsE2EAppID, + "--project-path", projectDir, + }, false) + result.AssertExitCode(t, 0) + assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) + assert.False(t, gjson.Get(result.Stdout, "api.0.params.include_values").Exists(), "stdout:\n%s", result.Stdout) + assert.Equal(t, filepath.Join(projectDir, ".env.local"), gjson.Get(result.Stdout, "env_file").String(), "stdout:\n%s", result.Stdout) + assert.False(t, gjson.Get(result.Stdout, "env_keys").Exists(), "env-pull dry-run must not expose key list, stdout:\n%s", result.Stdout) + assert.NotContains(t, result.Stdout, appsSecretValue, "env-pull dry-run must not leak env values, stdout:\n%s", result.Stdout) + }) + + t.Run("envvar_list_defaults_to_dev_without_values", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result := runAppsDryRunCommand(t, ctx, []string{ + "apps", "+envvar-list", + "--app-id", appsE2EAppID, + }, false) + result.AssertExitCode(t, 0) + assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) + assert.False(t, gjson.Get(result.Stdout, "api.0.params.include_values").Exists(), "stdout:\n%s", result.Stdout) + assert.False(t, gjson.Get(result.Stdout, "api.0.body.value").Exists(), "list dry-run must not send values, stdout:\n%s", result.Stdout) + }) + + t.Run("envvar_set_dev_post_redacts_value", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result := runAppsDryRunCommand(t, ctx, []string{ + "apps", "+envvar-set", + "--app-id", appsE2EAppID, + "--env", "dev", + "--key", "API_HOST", + "--value", appsSecretValue, + }, false) + result.AssertExitCode(t, 0) + assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/create_or_update_env_var", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "API_HOST", gjson.Get(result.Stdout, "api.0.body.key").String(), "stdout:\n%s", result.Stdout) + assert.NotContains(t, result.Stdout, appsSecretValue, "envvar-set dry-run must not leak raw value in stdout:\n%s", result.Stdout) + assert.NotContains(t, result.Stderr, appsSecretValue, "envvar-set dry-run must not leak raw value in stderr:\n%s", result.Stderr) + }) + + t.Run("envvar_set_online_dry_run_does_not_require_yes", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result := runAppsDryRunCommand(t, ctx, []string{ + "apps", "+envvar-set", + "--app-id", appsE2EAppID, + "--env", "online", + "--key", "API_HOST", + "--value", appsSecretValue, + }, false) + result.AssertExitCode(t, 0) + assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/create_or_update_env_var", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "API_HOST", gjson.Get(result.Stdout, "api.0.body.key").String(), "stdout:\n%s", result.Stdout) + assert.NotContains(t, result.Stdout, appsSecretValue, "online dry-run must not leak raw value in stdout:\n%s", result.Stdout) + }) + + t.Run("envvar_set_online_requires_yes_without_dry_run", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result := runAppsCommand(t, ctx, []string{ + "apps", "+envvar-set", + "--app-id", appsE2EAppID, + "--env", "online", + "--key", "API_HOST", + "--value", appsSecretValue, + }, false) + result.AssertExitCode(t, 10) + raw := errorEnvelope(t, result) + assert.Equal(t, "confirmation", gjson.Get(raw, "error.type").String(), "error envelope:\n%s", raw) + assert.Equal(t, "confirmation_required", gjson.Get(raw, "error.subtype").String(), "error envelope:\n%s", raw) + assert.Contains(t, gjson.Get(raw, "error.hint").String(), "add --yes to confirm", "error envelope:\n%s", raw) + assert.NotContains(t, raw, appsSecretValue, "confirmation error must not leak raw value:\n%s", raw) + }) + + t.Run("envvar_delete_dry_run_body", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result := runAppsDryRunCommand(t, ctx, []string{ + "apps", "+envvar-delete", + "--app-id", appsE2EAppID, + "--env", "dev", + "--key", "API_HOST", + "--key", "API_TOKEN", + }, true) + result.AssertExitCode(t, 0) + assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/delete_env_vars", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) + requireStringArray(t, result.Stdout, "api.0.body.keys", []string{"API_HOST", "API_TOKEN"}) + assert.False(t, gjson.Get(result.Stdout, "api.0.body.value").Exists(), "delete body must not contain values, stdout:\n%s", result.Stdout) + }) + + t.Run("envvar_delete_requires_yes_without_dry_run", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result := runAppsCommand(t, ctx, []string{ + "apps", "+envvar-delete", + "--app-id", appsE2EAppID, + "--env", "dev", + "--key", "API_HOST", + }, false) + result.AssertExitCode(t, 10) + raw := errorEnvelope(t, result) + assert.Equal(t, "confirmation", gjson.Get(raw, "error.type").String(), "error envelope:\n%s", raw) + assert.Equal(t, "confirmation_required", gjson.Get(raw, "error.subtype").String(), "error envelope:\n%s", raw) + }) +} + +func TestAppsObservabilityLiveFixtureOutputs(t *testing.T) { + appID := os.Getenv("LARK_CLI_E2E_APPS_OBSERVABILITY_APP_ID") + logID := os.Getenv("LARK_CLI_E2E_APPS_LOG_ID") + traceID := os.Getenv("LARK_CLI_E2E_APPS_TRACE_ID") + if appID == "" || logID == "" || traceID == "" { + t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_OBSERVABILITY_APP_ID, LARK_CLI_E2E_APPS_LOG_ID, and LARK_CLI_E2E_APPS_TRACE_ID to an online app with visible log, trace, metric, and analytics data") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + t.Run("log_get_returns_fixture_log", func(t *testing.T) { + result := runAppsLiveCommand(t, ctx, []string{ + "apps", "+log-get", + "--app-id", appID, + "--env", "online", + "--log-id", logID, + }, false) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Equal(t, logID, gjson.Get(result.Stdout, "data.log_id").String(), "stdout:\n%s", result.Stdout) + }) + + t.Run("trace_get_returns_fixture_trace", func(t *testing.T) { + result := runAppsLiveCommand(t, ctx, []string{ + "apps", "+trace-get", + "--app-id", appID, + "--env", "online", + "--trace-id", traceID, + }, false) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Equal(t, traceID, gjson.Get(result.Stdout, "data.trace_id").String(), "stdout:\n%s", result.Stdout) + require.NotEmpty(t, gjson.Get(result.Stdout, "data.spans").Array(), "trace should include spans, stdout:\n%s", result.Stdout) + }) + + t.Run("metric_query_returns_request_series", func(t *testing.T) { + result := runAppsLiveCommand(t, ctx, []string{ + "apps", "+metric-query", + "--app-id", appID, + "--env", "online", + "--metric", "requests", + "--series", "total", + }, false) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + items := gjson.Get(result.Stdout, "data.items").Array() + require.NotEmpty(t, items, "fixture app should have request metric points, stdout:\n%s", result.Stdout) + assert.True(t, items[0].Get("values.total").Exists(), "request metric should expose total values, stdout:\n%s", result.Stdout) + }) + + t.Run("analytics_query_returns_active_users", func(t *testing.T) { + result := runAppsLiveCommand(t, ctx, []string{ + "apps", "+analytics-query", + "--app-id", appID, + "--env", "online", + "--analytics", "users", + "--series", "active-users", + }, false) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + items := gjson.Get(result.Stdout, "data.items").Array() + require.NotEmpty(t, items, "fixture app should have analytics points, stdout:\n%s", result.Stdout) + assert.True(t, items[0].Get("values.active-users").Exists(), "analytics should expose active-users values, stdout:\n%s", result.Stdout) + }) +} + +func TestAppsEnvVarLiveWorkflow(t *testing.T) { + appID := os.Getenv("LARK_CLI_E2E_APPS_ENVVAR_APP_ID") + if appID == "" { + t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ENVVAR_APP_ID to an app where the user identity may create, list, and delete online env vars") + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := strings.NewReplacer("-", "_").Replace(clie2e.GenerateSuffix()) + key := "LARK_CLI_E2E_" + suffix + value := "secret-value-" + suffix + created := false + + t.Cleanup(func() { + if !created { + return + } + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{ + "apps", "+envvar-delete", + "--app-id", appID, + "--env", "online", + "--key", key, + }, + DefaultAs: "user", + Env: appsNoNoticeEnv(), + Yes: true, + }) + clie2e.ReportCleanupFailure(t, "delete apps envvar "+key, deleteResult, deleteErr) + }) + + t.Run("set_online_redacts_value", func(t *testing.T) { + result := runAppsLiveCommand(t, ctx, []string{ + "apps", "+envvar-set", + "--app-id", appID, + "--env", "online", + "--key", key, + "--value", value, + }, true) + if result.ExitCode == 0 { + created = true + } + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + assert.Equal(t, key, gjson.Get(result.Stdout, "data.key").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, "online", gjson.Get(result.Stdout, "data.env").String(), "stdout:\n%s", result.Stdout) + assert.Contains(t, []string{"set", "created", "updated"}, gjson.Get(result.Stdout, "data.action").String(), "stdout:\n%s", result.Stdout) + assert.NotContains(t, result.Stdout, value, "set output must not leak raw value, stdout:\n%s", result.Stdout) + assert.NotContains(t, result.Stderr, value, "set output must not leak raw value, stderr:\n%s", result.Stderr) + }) + + t.Run("list_include_values_observes_created_key", func(t *testing.T) { + result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "apps", "+envvar-list", + "--app-id", appID, + "--env", "online", + "--include-values", + }, + DefaultAs: "user", + Env: appsNoNoticeEnv(), + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + return result == nil || result.ExitCode != 0 || !envVarKeyExists(result.Stdout, key) + }, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + result.AssertStdoutStatus(t, true) + item, found := envVarItem(result.Stdout, key) + require.True(t, found, "list should include created key %q, stdout:\n%s", key, result.Stdout) + assert.Equal(t, "online", item.Get("env").String(), "stdout:\n%s", result.Stdout) + assert.Equal(t, value, item.Get("value").String(), "include-values should expose the explicitly requested test value, stdout:\n%s", result.Stdout) + }) + + t.Run("delete_removes_key", func(t *testing.T) { + deleteResult := runAppsLiveCommand(t, ctx, []string{ + "apps", "+envvar-delete", + "--app-id", appID, + "--env", "online", + "--key", key, + }, true) + if deleteResult.ExitCode == 0 { + created = false + } + deleteResult.AssertExitCode(t, 0) + deleteResult.AssertStdoutStatus(t, true) + requireStringArray(t, deleteResult.Stdout, "data.deleted_keys", []string{key}) + assert.Equal(t, "online", gjson.Get(deleteResult.Stdout, "data.env").String(), "stdout:\n%s", deleteResult.Stdout) + + listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ + Args: []string{ + "apps", "+envvar-list", + "--app-id", appID, + "--env", "online", + "--include-values", + }, + DefaultAs: "user", + Env: appsNoNoticeEnv(), + }, clie2e.RetryOptions{ + ShouldRetry: func(result *clie2e.Result) bool { + return result == nil || result.ExitCode != 0 || envVarKeyExists(result.Stdout, key) + }, + }) + require.NoError(t, err) + listResult.AssertExitCode(t, 0) + listResult.AssertStdoutStatus(t, true) + assert.False(t, envVarKeyExists(listResult.Stdout, key), "deleted key should be absent, stdout:\n%s", listResult.Stdout) + }) +} + +func runAppsDryRunCommand(t *testing.T, ctx context.Context, args []string, yes bool) *clie2e.Result { + t.Helper() + dryRunArgs := append([]string{}, args...) + dryRunArgs = append(dryRunArgs, "--dry-run") + return runAppsCommandWithEnv(t, ctx, dryRunArgs, yes, appsDryRunEnv()) +} + +func runAppsCommand(t *testing.T, ctx context.Context, args []string, yes bool) *clie2e.Result { + t.Helper() + return runAppsCommandWithEnv(t, ctx, args, yes, appsDryRunEnv()) +} + +func runAppsLiveCommand(t *testing.T, ctx context.Context, args []string, yes bool) *clie2e.Result { + t.Helper() + return runAppsCommandWithEnv(t, ctx, args, yes, appsNoNoticeEnv()) +} + +func runAppsCommandWithEnv(t *testing.T, ctx context.Context, args []string, yes bool, env map[string]string) *clie2e.Result { + t.Helper() + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: args, + DefaultAs: "user", + Env: env, + Yes: yes, + }) + require.NoError(t, err) + return result +} + +func appsDryRunEnv() map[string]string { + env := appsNoNoticeEnv() + env["LARKSUITE_CLI_APP_ID"] = "cli-e2e-app-id" + env["LARKSUITE_CLI_APP_SECRET"] = "cli-e2e-app-secret" + env["LARKSUITE_CLI_BRAND"] = "feishu" + return env +} + +func appsNoNoticeEnv() map[string]string { + return map[string]string{ + "LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1", + "LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1", + } +} + +func requireStringArray(t *testing.T, stdout string, path string, want []string) { + t.Helper() + got := gjson.Get(stdout, path).Array() + require.Len(t, got, len(want), "path %s should contain %d items, stdout:\n%s", path, len(want), stdout) + for i, value := range want { + assert.Equal(t, value, got[i].String(), "path %s[%d], stdout:\n%s", path, i, stdout) + } +} + +func errorEnvelope(t *testing.T, result *clie2e.Result) string { + t.Helper() + raw := strings.TrimSpace(result.Stdout) + if raw == "" { + raw = strings.TrimSpace(result.Stderr) + } + require.NotEmpty(t, raw, "expected structured error output, stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + return raw +} + +func envVarKeyExists(stdout string, key string) bool { + _, found := envVarItem(stdout, key) + return found +} + +func envVarItem(stdout string, key string) (gjson.Result, bool) { + for _, item := range gjson.Get(stdout, "data.items").Array() { + if item.Get("key").String() == key { + return item, true + } + } + return gjson.Result{}, false +} From d2452b7f9c2b8cba743466c414b439bebc599936 Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Thu, 25 Jun 2026 00:11:16 +0800 Subject: [PATCH 12/34] fix: refine apps observability output --- shortcuts/apps/apps_observability_common.go | 15 +- .../apps/apps_observability_common_test.go | 8 +- shortcuts/apps/apps_observability_logs.go | 34 +- .../apps/apps_observability_logs_test.go | 100 ++++- shortcuts/apps/apps_observability_metrics.go | 100 ++++- .../apps/apps_observability_metrics_test.go | 226 ++++++++++- shortcuts/apps/apps_observability_traces.go | 53 ++- .../apps/apps_observability_traces_test.go | 154 +++++++- shortcuts/apps/apps_output_schema.go | 351 ++++++++++++++++++ shortcuts/apps/apps_output_schema_test.go | 56 +++ 10 files changed, 1063 insertions(+), 34 deletions(-) create mode 100644 shortcuts/apps/apps_output_schema.go create mode 100644 shortcuts/apps/apps_output_schema_test.go diff --git a/shortcuts/apps/apps_observability_common.go b/shortcuts/apps/apps_observability_common.go index 5f48539f1..c2fcef335 100644 --- a/shortcuts/apps/apps_observability_common.go +++ b/shortcuts/apps/apps_observability_common.go @@ -75,6 +75,13 @@ func cleanRepeatedStrings(values []string) []string { return out } +func normalizeObservabilityAttributes(item map[string]interface{}) { + kv := observabilityKVList(item["attributes"]) + if len(kv) > 0 { + item["attributes"] = kv + } +} + func parseAppsTimeRange(sinceName, sinceRaw, untilName, untilRaw string) (time.Time, time.Time, bool, bool, error) { var since, until time.Time var hasSince, hasUntil bool @@ -185,10 +192,10 @@ func parseAppsRelativeDuration(s string) (time.Duration, bool) { return duration, true } -func nsNumber(t time.Time) int64 { - return t.UnixNano() +func nsNumber(t time.Time) string { + return strconv.FormatInt(t.UnixNano(), 10) } -func secNumber(t time.Time) int64 { - return t.Unix() +func secNumber(t time.Time) string { + return strconv.FormatInt(t.Unix(), 10) } diff --git a/shortcuts/apps/apps_observability_common_test.go b/shortcuts/apps/apps_observability_common_test.go index 67f5f4d7c..ced8140f1 100644 --- a/shortcuts/apps/apps_observability_common_test.go +++ b/shortcuts/apps/apps_observability_common_test.go @@ -76,11 +76,11 @@ func TestAppsObservabilityCommonHelpers(t *testing.T) { } } ts := time.Date(2026, 6, 23, 10, 11, 12, 123456789, time.UTC) - if got := nsNumber(ts); got != int64(1782209472123456789) { - t.Fatalf("nsNumber = %d", got) + if got := nsNumber(ts); got != "1782209472123456789" { + t.Fatalf("nsNumber = %q", got) } - if got := secNumber(ts); got != int64(1782209472) { - t.Fatalf("secNumber = %d", got) + if got := secNumber(ts); got != "1782209472" { + t.Fatalf("secNumber = %q", got) } } diff --git a/shortcuts/apps/apps_observability_logs.go b/shortcuts/apps/apps_observability_logs.go index d3afd92c3..a60afc97f 100644 --- a/shortcuts/apps/apps_observability_logs.go +++ b/shortcuts/apps/apps_observability_logs.go @@ -11,7 +11,6 @@ import ( "strings" "github.com/larksuite/cli/errs" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -42,7 +41,6 @@ var AppsLogList = common.Shortcut{ {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, {Name: "level", Type: "string_array", Desc: "log level filter; repeatable, one of DEBUG, INFO, WARN, ERROR (case-insensitive)"}, - {Name: "log-id", Type: "string_array", Desc: "log ID filter; repeatable"}, {Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"}, {Name: "keyword", Desc: "keyword filter applied by the log search backend"}, {Name: "module", Desc: "module name filter"}, @@ -80,7 +78,7 @@ var AppsLogList = common.Shortcut{ } out := normalizeLogSearchResponse(data) rctx.OutFormat(out, nil, func(w io.Writer) { - output.PrintTable(w, logListRows(out.Items)) + appsPrintSchemaTable(w, appsProjectRows(logListRows(out.Items), logSummarySchema), logSummarySchema) }) return nil }, @@ -133,7 +131,7 @@ var AppsLogGet = common.Shortcut{ log := out.Items[0] enrichLogSourceStack(rctx, appID, log) rctx.OutFormat(log, nil, func(w io.Writer) { - output.PrintTable(w, []map[string]interface{}{logSummaryRow(log)}) + appsPrintSchemaTable(w, appsProjectRows([]map[string]interface{}{logSummaryRow(log)}, logSummarySchema), logSummarySchema) }) return nil }, @@ -217,9 +215,6 @@ func buildLogSearchFilter(rctx *common.RuntimeContext) (map[string]interface{}, if len(levels) > 0 { filter["levels"] = levels } - if logIDs := cleanRepeatedStrings(rctx.StrArray("log-id")); len(logIDs) > 0 { - filter["log_ids"] = logIDs - } if traceIDs := cleanRepeatedStrings(rctx.StrArray("trace-id")); len(traceIDs) > 0 { filter["trace_ids"] = traceIDs } @@ -302,6 +297,7 @@ func normalizeLogSearchResponse(data map[string]interface{}) logSearchOutput { func normalizeLogItem(item map[string]interface{}) map[string]interface{} { out := cloneMap(item) + normalizeObservabilityAttributes(out) copyFirstAlias(out, item, "log_id", "log_id", "id", "logID", "logId") copyFirstAlias(out, item, "trace_id", "trace_id", "traceID", "traceId") copyFirstAlias(out, item, "timestamp_ns", "timestamp_ns", "timestampNs") @@ -377,16 +373,40 @@ func logListRows(items []map[string]interface{}) []map[string]interface{} { return rows } +var logSummarySchema = appsOutputSchema{ + Columns: []appsOutputColumn{ + {Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05.000")}, + {Key: "level"}, + {Key: "module"}, + {Key: "user_id"}, + {Key: "duration_ms", Format: appsFormatDurationMS}, + {Key: "trace_id"}, + {Key: "log_id"}, + {Key: "message"}, + }, + Strict: true, +} + func logSummaryRow(item map[string]interface{}) map[string]interface{} { return map[string]interface{}{ "log_id": item["log_id"], "level": firstItemString(item, "level", "severity_text"), "trace_id": item["trace_id"], "timestamp_ns": item["timestamp_ns"], + "module": firstLogDetailValue(item, "module"), + "user_id": firstLogDetailValue(item, "user_id"), + "duration_ms": firstLogDetailValue(item, "duration_ms"), "message": firstItemString(item, "message", "body"), } } +func firstLogDetailValue(item map[string]interface{}, key string) interface{} { + if value, ok := item[key]; ok { + return value + } + return appsAttributeValue(item["attributes"], key) +} + func firstItemString(item map[string]interface{}, keys ...string) string { for _, key := range keys { if s, ok := item[key].(string); ok && strings.TrimSpace(s) != "" { diff --git a/shortcuts/apps/apps_observability_logs_test.go b/shortcuts/apps/apps_observability_logs_test.go index 3c444b325..c1082da20 100644 --- a/shortcuts/apps/apps_observability_logs_test.go +++ b/shortcuts/apps/apps_observability_logs_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "strings" "testing" + "time" "github.com/larksuite/cli/internal/httpmock" ) @@ -15,7 +16,7 @@ func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsLogList, []string{ "+log-list", "--app-id", "app_x", "--level", "error", - "--log-id", "LOG1", "--log-id", "LOG2", "--trace-id", "trace-1", + "--trace-id", "trace-1", "--keyword", "timeout", "--module", "frontend", "--user-id", "ou_1", "--page", "/home", "--api", "/api/orders", "--min-duration", "200", "--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z", @@ -55,12 +56,22 @@ func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) { t.Fatalf("filter.%s = %#v, want [%q]", key, filter[key], want) } } - if env.API[0].Body["start_timestamp_ns"] != float64(1782208800000000000) || - env.API[0].Body["end_timestamp_ns"] != float64(1782208860000000000) { + if env.API[0].Body["start_timestamp_ns"] != "1782208800000000000" || + env.API[0].Body["end_timestamp_ns"] != "1782208860000000000" { t.Fatalf("timestamps = %#v %#v", env.API[0].Body["start_timestamp_ns"], env.API[0].Body["end_timestamp_ns"]) } } +func TestAppsLogList_DoesNotAcceptLogIDFlag(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsLogList, []string{ + "+log-list", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "unknown flag: --log-id") { + t.Fatalf("expected unknown --log-id flag, got %v", err) + } +} + func TestAppsLogList_RejectsDevEnv(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, factory, stdout) @@ -145,6 +156,89 @@ func TestAppsLogList_NormalizesResponseVariantsAndCanonicalLevel(t *testing.T) { } } +func TestAppsLogList_NormalizesKVAttributesToObject(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG1", + "attributes": []interface{}{ + map[string]interface{}{"key": "app_env", "value": "runtime"}, + map[string]interface{}{"key": "duration_ms", "value": "8263"}, + map[string]interface{}{"key": "module", "value": "gateway"}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + attrs, ok := env.Data.Items[0]["attributes"].(map[string]interface{}) + if !ok { + t.Fatalf("attributes = %#v, want object", env.Data.Items[0]["attributes"]) + } + if attrs["app_env"] != "runtime" || attrs["duration_ms"] != "8263" || attrs["module"] != "gateway" { + t.Fatalf("attributes = %#v", attrs) + } +} + +func TestAppsLogGet_PrettyFormatsTimestamp(t *testing.T) { + const rawNS = int64(1782209472123456789) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG1", + "level": "ERROR", + "trace_id": "trace-1", + "timestamp_ns": rawNS, + "message": "boom", + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsLogGet, []string{ + "+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + wantTime := time.Unix(0, rawNS).Local().Format("2006-01-02 15:04:05.000") + if !strings.HasPrefix(got, "time") { + t.Fatalf("pretty output should start with time column, got:\n%s", got) + } + if !strings.Contains(got, wantTime) { + t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got) + } + if strings.Contains(got, "timestamp_ns") || strings.Contains(got, "1782209472123456789") { + t.Fatalf("pretty output should hide raw timestamp_ns, got:\n%s", got) + } +} + func TestAppsLogGet_ResolvesSourceStackWhenFieldsPresent(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) search := &httpmock.Stub{ diff --git a/shortcuts/apps/apps_observability_metrics.go b/shortcuts/apps/apps_observability_metrics.go index f5c01c947..8cb65c95d 100644 --- a/shortcuts/apps/apps_observability_metrics.go +++ b/shortcuts/apps/apps_observability_metrics.go @@ -8,10 +8,10 @@ import ( "encoding/json" "fmt" "io" + "sort" "strings" "time" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -78,7 +78,10 @@ var AppsMetricQuery = common.Shortcut{ HasMore: false, } rctx.OutFormat(out, nil, func(w io.Writer) { - output.PrintTable(w, observabilitySeriesRows(out.Items)) + rows := observabilitySeriesRows(out.Items) + sortObservabilityRowsDesc(rows, "timestamp") + rows = filterObservabilityRowsWithTime(rows, "timestamp") + appsPrintSchemaTable(w, rows, metricSeriesSchema(labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "latency")) }) return nil }, @@ -137,7 +140,10 @@ var AppsAnalyticsQuery = common.Shortcut{ HasMore: false, } rctx.OutFormat(out, nil, func(w io.Writer) { - output.PrintTable(w, observabilitySeriesRows(out.Items)) + rows := observabilitySeriesRows(out.Items) + sortObservabilityRowsDesc(rows, "timestamp_ns") + rows = filterObservabilityRowsWithTime(rows, "timestamp_ns") + appsPrintSchemaTable(w, rows, analyticsSeriesSchema(labels)) }) return nil }, @@ -173,7 +179,9 @@ func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, return nil, nil, nil, false, err } downSample := strings.TrimSpace(rctx.Str("down-sample")) - if downSample == "" { + if !rctx.Changed("down-sample") { + downSample = appsMetricDownSampleForRange(since, until) + } else if downSample == "" { downSample = defaultAppsMetricDownSample } body := map[string]interface{}{ @@ -189,6 +197,18 @@ func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, return body, names, labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "requests", nil } +func appsMetricDownSampleForRange(since, until time.Time) string { + d := until.Sub(since) + switch { + case d <= 6*time.Hour: + return "1m" + case d <= 7*24*time.Hour: + return "1h" + default: + return "1d" + } +} + func buildMetricQueryFilter(rctx *common.RuntimeContext) map[string]interface{} { filter := make(map[string]interface{}) if pages := cleanRepeatedStrings(rctx.StrArray("page")); len(pages) > 0 { @@ -370,7 +390,9 @@ func normalizeMetricSeries(data map[string]interface{}, names, labels []string, } func normalizeAnalyticsSeries(data map[string]interface{}, names, labels []string) []map[string]interface{} { - return normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), false, "timestamp_ns") + items := normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), false, "timestamp_ns") + fillObservabilityZeroesWhenPartiallyPresent(items, labels) + return items } func normalizeObservabilitySeries(data map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} { @@ -464,6 +486,29 @@ func fillObservabilityZeroes(items []map[string]interface{}, labels []string) { } } +func fillObservabilityZeroesWhenPartiallyPresent(items []map[string]interface{}, labels []string) { + for _, item := range items { + values, ok := item["values"].(map[string]interface{}) + if !ok || !observabilityHasAnyNonNullValue(values) { + continue + } + for _, label := range labels { + if value, ok := values[label]; !ok || value == nil { + values[label] = 0 + } + } + } +} + +func observabilityHasAnyNonNullValue(values map[string]interface{}) bool { + for _, value := range values { + if value != nil { + return true + } + } + return false +} + func observabilityPointValues(point map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool) map[string]interface{} { values := make(map[string]interface{}, len(labels)) switch raw := firstObservabilityValue(point, "values", "value_map", "valueMap"); v := raw.(type) { @@ -687,3 +732,48 @@ func observabilitySeriesRows(items []map[string]interface{}) []map[string]interf } return rows } + +func metricSeriesSchema(labels []string, durationValues bool) appsOutputSchema { + columns := []appsOutputColumn{ + {Key: "timestamp", Label: "time", Format: appsFormatSec("2006-01-02 15:04:05")}, + } + for _, label := range labels { + col := appsOutputColumn{Key: label} + if durationValues { + col.Format = appsFormatDurationMS + } + columns = append(columns, col) + } + return appsOutputSchema{Columns: columns, Strict: true} +} + +func analyticsSeriesSchema(labels []string) appsOutputSchema { + columns := []appsOutputColumn{ + {Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05")}, + } + for _, label := range labels { + columns = append(columns, appsOutputColumn{Key: label}) + } + return appsOutputSchema{Columns: columns, Strict: true} +} + +func sortObservabilityRowsDesc(rows []map[string]interface{}, key string) { + sort.SliceStable(rows, func(i, j int) bool { + left, leftOK := appsInt64Value(rows[i][key]) + right, rightOK := appsInt64Value(rows[j][key]) + if !leftOK || !rightOK { + return false + } + return left > right + }) +} + +func filterObservabilityRowsWithTime(rows []map[string]interface{}, key string) []map[string]interface{} { + out := rows[:0] + for _, row := range rows { + if _, ok := appsInt64Value(row[key]); ok { + out = append(out, row) + } + } + return out +} diff --git a/shortcuts/apps/apps_observability_metrics_test.go b/shortcuts/apps/apps_observability_metrics_test.go index 74cefa5f2..d64ee386c 100644 --- a/shortcuts/apps/apps_observability_metrics_test.go +++ b/shortcuts/apps/apps_observability_metrics_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "strings" "testing" + "time" "github.com/larksuite/cli/internal/httpmock" ) @@ -61,7 +62,7 @@ func TestAppsMetricQuery_DryRunUsesSeconds(t *testing.T) { if _, ok := body["app_env"]; ok { t.Fatalf("metric OpenAPI body should not include app_env: %#v", body) } - if body["start_timestamp"] != float64(1782208800) || body["end_timestamp"] != float64(1782208860) { + if body["start_timestamp"] != "1782208800" || body["end_timestamp"] != "1782208860" { t.Fatalf("metric timestamps = %v %v", body["start_timestamp"], body["end_timestamp"]) } if body["down_sample"] != "1m" { @@ -69,6 +70,41 @@ func TestAppsMetricQuery_DryRunUsesSeconds(t *testing.T) { } } +func TestAppsMetricQuery_AutoDownSampleByRange(t *testing.T) { + for _, tc := range []struct { + name string + since string + until string + want string + }{ + {name: "short", since: "2026-06-23T10:00:00Z", until: "2026-06-23T12:00:00Z", want: "1m"}, + {name: "medium", since: "2026-06-21T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1h"}, + {name: "long", since: "2026-06-01T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1d"}, + } { + t.Run(tc.name, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsMetricQuery, []string{ + "+metric-query", "--app-id", "app_x", "--metric", "requests", + "--since", tc.since, "--until", tc.until, "--dry-run", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if got := env.API[0].Body["down_sample"]; got != tc.want { + t.Fatalf("down_sample = %#v, want %q; stdout:\n%s", got, tc.want, stdout.String()) + } + }) + } +} + func TestAppsMetricQuery_RejectsDevEnv(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsMetricQuery, []string{ @@ -136,6 +172,46 @@ func TestAppsMetricQuery_FillsMissingRequestValuesWithZero(t *testing.T) { } } +func TestAppsMetricQuery_PrettyFormatsTimeFirst(t *testing.T) { + const rawSec = int64(1782208800) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "points": []interface{}{ + map[string]interface{}{ + "timestamp": float64(rawSec), + "values": []interface{}{ + map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)}, + map[string]interface{}{"metric_name": "client_api_request_error_count", "value": float64(1)}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsMetricQuery, []string{ + "+metric-query", "--app-id", "app_x", "--metric", "requests", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + wantTime := time.Unix(rawSec, 0).Local().Format("2006-01-02 15:04:05") + if !strings.HasPrefix(got, "time") { + t.Fatalf("pretty output should start with time column, got:\n%s", got) + } + if !strings.Contains(got, wantTime) { + t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got) + } + if strings.Contains(got, "timestamp") || strings.Contains(got, "1782208800") { + t.Fatalf("pretty output should hide raw timestamp, got:\n%s", got) + } +} + func TestAppsMetricQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -269,8 +345,8 @@ func TestAppsAnalyticsQuery_DryRunUsesNanoseconds(t *testing.T) { if metricTypes, ok := body["metric_types"].([]interface{}); !ok || len(metricTypes) != 3 { t.Fatalf("metric_types = %#v", body["metric_types"]) } - if body["start_timestamp_ns"] != float64(1782208800000000000) || - body["end_timestamp_ns"] != float64(1782208860000000000) { + if body["start_timestamp_ns"] != "1782208800000000000" || + body["end_timestamp_ns"] != "1782208860000000000" { t.Fatalf("analytics timestamps = %#v %#v", body["start_timestamp_ns"], body["end_timestamp_ns"]) } } @@ -371,6 +447,61 @@ func TestAppsAnalyticsQuery_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { } } +func TestAppsAnalyticsQuery_PrettyFormatsTimeFirst(t *testing.T) { + const rawNS = int64(1782208800000000000) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "series": []interface{}{ + map[string]interface{}{ + "metric_type": "ACTIVE_USER", + "points": []interface{}{ + map[string]interface{}{"timestamp_ns": float64(rawNS), "value": float64(7)}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ + "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--series", "active", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + wantTime := time.Unix(0, rawNS).Local().Format("2006-01-02 15:04:05") + if !strings.HasPrefix(got, "time") { + t.Fatalf("pretty output should start with time column, got:\n%s", got) + } + if !strings.Contains(got, wantTime) { + t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got) + } + if strings.Contains(got, "timestamp_ns") || strings.Contains(got, "1782208800000000000") { + t.Fatalf("pretty output should hide raw timestamp_ns, got:\n%s", got) + } +} + +func TestAppsAnalyticsQuery_PrettySkipsRowsWithoutTime(t *testing.T) { + const rawNS = int64(1782208800000000000) + rows := []map[string]interface{}{ + {"timestamp_ns": rawNS, "active-users": float64(7)}, + {"active-users": float64(0)}, + } + sortObservabilityRowsDesc(rows, "timestamp_ns") + rows = filterObservabilityRowsWithTime(rows, "timestamp_ns") + if len(rows) != 1 { + t.Fatalf("rows len = %d, want 1: %#v", len(rows), rows) + } + if rows[0]["timestamp_ns"] != rawNS { + t.Fatalf("remaining row = %#v", rows[0]) + } +} + func TestAppsAnalyticsQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -428,6 +559,95 @@ func TestAppsAnalyticsQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) } } +func TestAppsAnalyticsQuery_FillsMissingAndNullValuesWhenAnyValuePresent(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "timestamp_ns": "1782208800000000000", + "values": map[string]interface{}{ + "total-users": float64(4), + "active-users": nil, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ + "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + values := env.Data.Items[0].Values + if values["total-users"] != float64(4) || values["active-users"] != float64(0) || values["new-users"] != float64(0) { + t.Fatalf("values = %#v, want total-users=4 active-users=0 new-users=0", values) + } +} + +func TestAppsAnalyticsQuery_DoesNotFillAllNullValues(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "timestamp_ns": "1782208800000000000", + "values": map[string]interface{}{ + "total-users": nil, + "active-users": nil, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ + "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + values := env.Data.Items[0].Values + if values["total-users"] != nil || values["active-users"] != nil { + t.Fatalf("values = %#v, want existing nulls preserved", values) + } + if _, ok := values["new-users"]; ok { + t.Fatalf("values should not fill missing labels when all present values are null: %#v", values) + } +} + func TestAppsAnalyticsQuery_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/apps/apps_observability_traces.go b/shortcuts/apps/apps_observability_traces.go index 34fee120f..27aed3287 100644 --- a/shortcuts/apps/apps_observability_traces.go +++ b/shortcuts/apps/apps_observability_traces.go @@ -10,7 +10,6 @@ import ( "strconv" "strings" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -70,7 +69,7 @@ var AppsTraceList = common.Shortcut{ } out := normalizeTraceSearchResponse(data) rctx.OutFormat(out, nil, func(w io.Writer) { - output.PrintTable(w, traceListRows(out.Items)) + appsPrintSchemaTable(w, appsProjectRows(traceListRows(out.Items), traceSummarySchema), traceSummarySchema) }) return nil }, @@ -117,7 +116,7 @@ var AppsTraceGet = common.Shortcut{ } trace := normalizeTraceDetail(data) rctx.OutFormat(trace, nil, func(w io.Writer) { - output.PrintTable(w, []map[string]interface{}{traceSummaryRow(trace)}) + appsPrintSchemaTable(w, appsProjectRows([]map[string]interface{}{traceDetailSummary(trace)}, traceSummarySchema), traceSummarySchema) }) return nil }, @@ -303,6 +302,7 @@ func aggregateTraceSpanSummaries(spans []map[string]interface{}) []map[string]in indexByTraceID := make(map[string]int, len(spans)) ungrouped := make([]map[string]interface{}, 0) for _, span := range spans { + span = normalizeTraceSpan(span) traceID := firstTraceString(span, "trace_id", "traceID", "traceId") if traceID == "" { ungrouped = append(ungrouped, normalizeTraceSummary(span)) @@ -550,12 +550,18 @@ func normalizeTraceDetail(data map[string]interface{}) map[string]interface{} { normalized = append(normalized, normalizeTraceSpan(span)) } out["spans"] = normalized + if firstTraceString(out, "trace_id") == "" { + if traceID := firstTraceString(normalized[0], "trace_id"); traceID != "" { + out["trace_id"] = traceID + } + } } return out } func normalizeTraceObject(trace map[string]interface{}) map[string]interface{} { out := cloneMap(trace) + normalizeObservabilityAttributes(out) copyFirstAlias(out, trace, "trace_id", "trace_id", "traceID", "traceId") copyFirstAlias(out, trace, "is_break", "is_break", "isBreak") return out @@ -563,13 +569,21 @@ func normalizeTraceObject(trace map[string]interface{}) map[string]interface{} { func normalizeTraceSpan(span map[string]interface{}) map[string]interface{} { out := cloneMap(span) + normalizeObservabilityAttributes(out) copyFirstAlias(out, span, "trace_id", "trace_id", "traceID", "traceId") copyFirstAlias(out, span, "span_id", "span_id", "spanID", "spanId") copyFirstAlias(out, span, "parent_span_id", "parent_span_id", "parentSpanID", "parentSpanId") - copyFirstAlias(out, span, "start_time_ns", "start_time_ns", "startTimeNs") - copyFirstAlias(out, span, "end_time_ns", "end_time_ns", "endTimeNs") + copyFirstAlias(out, span, "start_time_ns", "start_time_ns", "startTimeNs", "start_time_unix_nano", "startTimeUnixNano") + copyFirstAlias(out, span, "end_time_ns", "end_time_ns", "endTimeNs", "end_time_unix_nano", "endTimeUnixNano") copyFirstAlias(out, span, "duration_ms", "duration_ms", "durationMs") copyFirstAlias(out, span, "is_break", "is_break", "isBreak") + for _, key := range []string{"duration_ms", "user_id", "status", "module"} { + if _, ok := out[key]; !ok { + if value := appsAttributeValue(span["attributes"], key); value != nil { + out[key] = value + } + } + } return out } @@ -581,11 +595,38 @@ func traceListRows(items []map[string]interface{}) []map[string]interface{} { return rows } +var traceSummarySchema = appsOutputSchema{ + Columns: []appsOutputColumn{ + {Key: "start_time_ns", Label: "start-time", Format: appsFormatNS("2006-01-02 15:04:05.000")}, + {Key: "root_span", Label: "root-span"}, + {Key: "user_id", Label: "user-id"}, + {Key: "duration_ms", Label: "duration", Format: appsFormatDurationMS}, + {Key: "trace_id", Label: "trace-id"}, + }, + Strict: true, +} + +func traceDetailSummary(trace map[string]interface{}) map[string]interface{} { + if spans := traceMapSlice(trace["spans"]); len(spans) > 0 { + summaries := aggregateTraceSpanSummaries(spans) + if len(summaries) > 0 { + summary := summaries[0] + for _, key := range []string{"trace_id", "is_break"} { + if value, ok := trace[key]; ok { + summary[key] = value + } + } + return summary + } + } + return traceSummaryRow(trace) +} + func traceSummaryRow(item map[string]interface{}) map[string]interface{} { return map[string]interface{}{ "trace_id": item["trace_id"], "start_time_ns": item["start_time_ns"], - "root_span": item["root_span"], + "root_span": firstItemString(item, "root_span", "name", "span_name"), "user_id": item["user_id"], "duration_ms": item["duration_ms"], "status": item["status"], diff --git a/shortcuts/apps/apps_observability_traces_test.go b/shortcuts/apps/apps_observability_traces_test.go index 71f560069..8d8d11faf 100644 --- a/shortcuts/apps/apps_observability_traces_test.go +++ b/shortcuts/apps/apps_observability_traces_test.go @@ -5,7 +5,9 @@ package apps import ( "encoding/json" + "strings" "testing" + "time" "github.com/larksuite/cli/internal/httpmock" ) @@ -50,8 +52,8 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) { if len(userIDs) != 1 || userIDs[0] != "ou_1" { t.Fatalf("filter.user_ids = %#v", userIDs) } - if env.API[0].Body["start_timestamp_ns"] != float64(1782208800000000000) || - env.API[0].Body["end_timestamp_ns"] != float64(1782208860000000000) { + if env.API[0].Body["start_timestamp_ns"] != "1782208800000000000" || + env.API[0].Body["end_timestamp_ns"] != "1782208860000000000" { t.Fatalf("timestamps = %#v %#v", env.API[0].Body["start_timestamp_ns"], env.API[0].Body["end_timestamp_ns"]) } } @@ -255,6 +257,110 @@ func TestAppsTraceList_AggregatesSpansSourceWithSingleSpanPerTrace(t *testing.T) } } +func TestAppsTraceList_PrettyUsesTraceSummaryColumns(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_traces", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "traceItems": []interface{}{ + map[string]interface{}{ + "traceID": "trace-1", + "startTimeNs": "1782232472381701316", + "rootSpan": "GET /app/app_x/api/note-records", + "userID": "1846640196867498", + "durationMs": float64(414), + "status": "OK", + "spanCount": float64(4), + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsTraceList, []string{ + "+trace-list", "--app-id", "app_x", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.HasPrefix(got, "start-time") { + t.Fatalf("pretty output should start with start-time column, got:\n%s", got) + } + for _, want := range []string{"root-span", "user-id", "duration", "trace-id", "GET /app/app_x/api/note-records", "414ms"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q:\n%s", want, got) + } + } + for _, banned := range []string{"span_count", "span-count", "status", "duration_ms", "root_span", "trace_id"} { + if strings.Contains(got, banned) { + t.Fatalf("pretty output should not include %q:\n%s", banned, got) + } + } +} + +func TestAppsTraceGet_PrettySummarizesSpans(t *testing.T) { + const rawNS = int64(1782232472381701316) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "is_break": false, + "spans": []interface{}{ + map[string]interface{}{ + "trace_id": "trace-1", + "name": "GET /app/app_x", + "span_id": "root", + "parent_span_id": "", + "start_time_unix_nano": "1782232472381701316", + "end_time_unix_nano": "1782232480645457992", + "attributes": []interface{}{ + map[string]interface{}{"key": "duration_ms", "value": "8263.76"}, + map[string]interface{}{"key": "user_id", "value": "1826968659245100"}, + }, + }, + map[string]interface{}{ + "trace_id": "trace-1", + "name": "child", + "span_id": "child", + "parent_span_id": "root", + "start_time_unix_nano": "1782232480448000000", + "attributes": []interface{}{ + map[string]interface{}{"key": "duration_ms", "value": "184.89"}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsTraceGet, []string{ + "+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + wantTime := time.Unix(0, rawNS).Local().Format("2006-01-02 15:04:05.000") + if !strings.HasPrefix(got, "start-time") { + t.Fatalf("pretty output should start with start-time columns, got:\n%s", got) + } + for _, want := range []string{"root-span", "user-id", "duration", "trace-id", "trace-1", "GET /app/app_x", "1826968659245100", wantTime} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q:\n%s", want, got) + } + } + for _, banned := range []string{"start_time_ns", "1782232472381701316", "span_count", "span-count", "status", "duration_ms", "root_span", "trace_id"} { + if strings.Contains(got, banned) { + t.Fatalf("pretty output should not include %q:\n%s", banned, got) + } + } +} + func TestAppsTraceGet_NormalizesTraceDetailCamelFields(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -301,3 +407,47 @@ func TestAppsTraceGet_NormalizesTraceDetailCamelFields(t *testing.T) { t.Fatalf("span aliases = %#v", span) } } + +func TestAppsTraceGet_NormalizesKVAttributesToObject(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "spans": []interface{}{ + map[string]interface{}{ + "trace_id": "trace-1", + "span_id": "span-1", + "attributes": []interface{}{ + map[string]interface{}{"key": "app_env", "value": "runtime"}, + map[string]interface{}{"key": "duration_ms", "value": "8263"}, + map[string]interface{}{"key": "module", "value": "gateway"}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsTraceGet, []string{"+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Spans []map[string]interface{} `json:"spans"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + attrs, ok := env.Data.Spans[0]["attributes"].(map[string]interface{}) + if !ok { + t.Fatalf("attributes = %#v, want object", env.Data.Spans[0]["attributes"]) + } + if attrs["app_env"] != "runtime" || attrs["duration_ms"] != "8263" || attrs["module"] != "gateway" { + t.Fatalf("attributes = %#v", attrs) + } +} diff --git a/shortcuts/apps/apps_output_schema.go b/shortcuts/apps/apps_output_schema.go new file mode 100644 index 000000000..19cebbd78 --- /dev/null +++ b/shortcuts/apps/apps_output_schema.go @@ -0,0 +1,351 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "fmt" + "io" + "math" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +type appsCellFormatter func(interface{}) string + +type appsOutputColumn struct { + Key string + Label string + Value func(map[string]interface{}) interface{} + Format appsCellFormatter +} + +type appsOutputSchema struct { + Columns []appsOutputColumn + Strict bool +} + +func appsProjectRows(rows []map[string]interface{}, schema appsOutputSchema) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(rows)) + for _, row := range rows { + out = append(out, appsProjectRow(row, schema)) + } + return out +} + +func appsProjectRow(row map[string]interface{}, schema appsOutputSchema) map[string]interface{} { + out := make(map[string]interface{}, len(schema.Columns)) + declared := make(map[string]struct{}, len(schema.Columns)) + for _, col := range schema.Columns { + if col.Key == "" { + continue + } + declared[col.Key] = struct{}{} + value := row[col.Key] + if col.Value != nil { + value = col.Value(row) + } + if value != nil { + out[col.Key] = value + } + } + if !schema.Strict { + for key, value := range row { + if _, ok := declared[key]; !ok { + out[key] = value + } + } + } + return out +} + +func appsPrintSchemaTable(w io.Writer, rows []map[string]interface{}, schema appsOutputSchema) { + if len(rows) == 0 { + fmt.Fprintln(w, "(no data)") + return + } + headers := make([]string, 0, len(schema.Columns)) + for _, col := range schema.Columns { + if col.Key == "" { + continue + } + headers = append(headers, appsColumnLabel(col)) + } + if len(headers) == 0 { + fmt.Fprintln(w, "(no data)") + return + } + matrix := make([][]string, 0, len(rows)+1) + matrix = append(matrix, headers) + for _, row := range rows { + line := make([]string, 0, len(schema.Columns)) + for _, col := range schema.Columns { + if col.Key == "" { + continue + } + value := row[col.Key] + if col.Value != nil { + value = col.Value(row) + } + line = append(line, appsFormatCell(value, col.Format)) + } + matrix = append(matrix, line) + } + widths := appsColumnWidths(matrix) + for i, row := range matrix { + cells := make([]string, len(row)) + for j, cell := range row { + cells[j] = appsPad(cell, widths[j]) + } + fmt.Fprintln(w, strings.TrimRight(strings.Join(cells, " "), " ")) + if i == 0 { + sep := make([]string, len(widths)) + for j, width := range widths { + sep[j] = strings.Repeat("─", width) + } + fmt.Fprintln(w, strings.Join(sep, " ")) + } + } +} + +func appsColumnLabel(col appsOutputColumn) string { + if col.Label != "" { + return col.Label + } + return col.Key +} + +func appsFormatCell(value interface{}, formatter appsCellFormatter) string { + if formatter != nil { + return formatter(value) + } + return appsDefaultCell(value) +} + +func appsDefaultCell(value interface{}) string { + if value == nil { + return "" + } + switch v := value.(type) { + case string: + return v + case json.Number: + return v.String() + case bool: + return strconv.FormatBool(v) + case int: + return strconv.Itoa(v) + case int8, int16, int32, int64: + return fmt.Sprintf("%d", v) + case uint, uint8, uint16, uint32, uint64: + return fmt.Sprintf("%d", v) + case float32: + return appsFormatFloat(float64(v)) + case float64: + return appsFormatFloat(v) + default: + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + return string(b) + } +} + +func appsFormatFloat(value float64) string { + if math.Trunc(value) == value { + return strconv.FormatInt(int64(value), 10) + } + return strconv.FormatFloat(value, 'f', -1, 64) +} + +func appsColumnWidths(matrix [][]string) []int { + if len(matrix) == 0 { + return nil + } + widths := make([]int, len(matrix[0])) + for _, row := range matrix { + for i, cell := range row { + if width := utf8.RuneCountInString(cell); width > widths[i] { + widths[i] = width + } + } + } + return widths +} + +func appsPad(s string, width int) string { + delta := width - utf8.RuneCountInString(s) + if delta <= 0 { + return s + } + return s + strings.Repeat(" ", delta) +} + +func appsFormatNS(layout string) appsCellFormatter { + return func(value interface{}) string { + ns, ok := appsInt64Value(value) + if !ok || ns <= 0 { + return appsDefaultCell(value) + } + return time.Unix(0, ns).Local().Format(layout) + } +} + +func appsFormatSec(layout string) appsCellFormatter { + return func(value interface{}) string { + sec, ok := appsInt64Value(value) + if !ok || sec <= 0 { + return appsDefaultCell(value) + } + return time.Unix(sec, 0).Local().Format(layout) + } +} + +func appsFormatDurationMS(value interface{}) string { + ms, ok := appsFloat64Value(value) + if !ok || ms < 0 { + return appsDefaultCell(value) + } + switch { + case ms < 1: + return fmt.Sprintf("%.2fms", ms) + case ms < 1000: + return fmt.Sprintf("%.0fms", ms) + case ms < 60000: + return fmt.Sprintf("%.2fs", ms/1000) + case ms < 3600000: + return fmt.Sprintf("%.1fm", ms/60000) + default: + return fmt.Sprintf("%.1fh", ms/3600000) + } +} + +func appsInt64Value(value interface{}) (int64, bool) { + switch v := value.(type) { + case int: + return int64(v), true + case int8: + return int64(v), true + case int16: + return int64(v), true + case int32: + return int64(v), true + case int64: + return v, true + case uint: + return appsUint64ToInt64(uint64(v)) + case uint8: + return int64(v), true + case uint16: + return int64(v), true + case uint32: + return int64(v), true + case uint64: + return appsUint64ToInt64(v) + case float32: + f := float64(v) + if math.Trunc(f) == f && f <= float64(math.MaxInt64) && f >= float64(math.MinInt64) { + return int64(f), true + } + case float64: + if math.Trunc(v) == v && v <= float64(math.MaxInt64) && v >= float64(math.MinInt64) { + return int64(v), true + } + case json.Number: + if n, err := v.Int64(); err == nil { + return n, true + } + if f, err := v.Float64(); err == nil && math.Trunc(f) == f { + return int64(f), true + } + case string: + raw := strings.TrimSpace(v) + if n, err := strconv.ParseInt(raw, 10, 64); err == nil { + return n, true + } + if f, err := strconv.ParseFloat(raw, 64); err == nil && math.Trunc(f) == f { + return int64(f), true + } + } + return 0, false +} + +func appsFloat64Value(value interface{}) (float64, bool) { + switch v := value.(type) { + case int: + return float64(v), true + case int8: + return float64(v), true + case int16: + return float64(v), true + case int32: + return float64(v), true + case int64: + return float64(v), true + case uint: + return float64(v), true + case uint8: + return float64(v), true + case uint16: + return float64(v), true + case uint32: + return float64(v), true + case uint64: + return float64(v), true + case float32: + return float64(v), true + case float64: + return v, true + case json.Number: + f, err := v.Float64() + return f, err == nil + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + return f, err == nil + default: + return 0, false + } +} + +func appsUint64ToInt64(value uint64) (int64, bool) { + if value > uint64(math.MaxInt64) { + return 0, false + } + return int64(value), true +} + +func appsAttrValue(key string) func(map[string]interface{}) interface{} { + return func(row map[string]interface{}) interface{} { + return appsAttributeValue(row["attributes"], key) + } +} + +func appsAttributeValue(raw interface{}, key string) interface{} { + switch attrs := raw.(type) { + case map[string]interface{}: + return attrs[key] + case []interface{}: + for _, rawItem := range attrs { + item, ok := rawItem.(map[string]interface{}) + if !ok { + continue + } + itemKey := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "key", "name"))) + if itemKey == key { + return firstObservabilityValue(item, "value") + } + } + case []map[string]interface{}: + for _, item := range attrs { + itemKey := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "key", "name"))) + if itemKey == key { + return firstObservabilityValue(item, "value") + } + } + } + return nil +} diff --git a/shortcuts/apps/apps_output_schema_test.go b/shortcuts/apps/apps_output_schema_test.go new file mode 100644 index 000000000..9a5ff7441 --- /dev/null +++ b/shortcuts/apps/apps_output_schema_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" + "time" +) + +func TestAppsOutputSchemaProjectsAndFormats(t *testing.T) { + row := map[string]interface{}{ + "timestamp_ns": "1782209472123456789", + "level": "ERROR", + "extra": "ignored", + "attributes": map[string]interface{}{ + "module": "frontend", + "duration_ms": "1234.5", + }, + } + schema := appsOutputSchema{ + Columns: []appsOutputColumn{ + {Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05.000")}, + {Key: "module", Value: appsAttrValue("module")}, + {Key: "duration_ms", Value: appsAttrValue("duration_ms"), Format: appsFormatDurationMS}, + {Key: "level"}, + }, + Strict: true, + } + + projected := appsProjectRow(row, schema) + if len(projected) != 4 { + t.Fatalf("projected field count = %d, want 4: %#v", len(projected), projected) + } + if projected["module"] != "frontend" || projected["duration_ms"] != "1234.5" { + t.Fatalf("projected derived fields = %#v", projected) + } + if _, ok := projected["extra"]; ok { + t.Fatalf("strict projection should drop extra field: %#v", projected) + } + + var b strings.Builder + appsPrintSchemaTable(&b, []map[string]interface{}{projected}, schema) + out := b.String() + wantTime := time.Unix(0, 1782209472123456789).Local().Format("2006-01-02 15:04:05.000") + if !strings.HasPrefix(out, "time") { + t.Fatalf("pretty output should start with schema label time, got:\n%s", out) + } + if !strings.Contains(out, wantTime) { + t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, out) + } + if strings.Contains(out, "1782209472123456789") { + t.Fatalf("pretty output should not contain raw timestamp:\n%s", out) + } +} From f334cc9b3445f8492d038d352ebcbcee6ce82a5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=85=B4=E7=82=80?= Date: Thu, 25 Jun 2026 14:48:58 +0800 Subject: [PATCH 13/34] feat(apps): integrate miaoda db/file CLI commands into apps-spark integration Bring in the refined miaoda Spark db/file command set from the feat/miaoda-db-file-openapi work: db execute (typed errs + per-SQL-type JSON shaping), env diff/migrate, PITR recovery, changelog/audit, data import/export, db/file quota, and the 7 file-storage commands; plus the stderr spinner for slow ops and the aligned lark-apps skill references. Resolved overlap with the integration branch's earlier db-execute iteration (took the refined typed-error version), unified the stderr-TTY flag on IOStreams.StderrIsTerminal, and combined the shortcut registry (43 commands total). --- internal/output/spinner.go | 80 ++++ internal/output/spinner_test.go | 54 +++ shortcuts/apps/apps_db_audit_list.go | 300 ++++++++++++++ shortcuts/apps/apps_db_audit_set.go | 142 +++++++ shortcuts/apps/apps_db_audit_status.go | 139 +++++++ shortcuts/apps/apps_db_audit_test.go | 316 +++++++++++++++ shortcuts/apps/apps_db_changelog_list.go | 150 +++++++ shortcuts/apps/apps_db_changelog_list_test.go | 143 +++++++ shortcuts/apps/apps_db_data_export.go | 189 +++++++++ shortcuts/apps/apps_db_data_export_test.go | 193 +++++++++ shortcuts/apps/apps_db_data_import.go | 142 +++++++ shortcuts/apps/apps_db_data_import_test.go | 161 ++++++++ shortcuts/apps/apps_db_env_migrate.go | 191 +++++++++ .../apps/apps_db_env_recovery_quota_test.go | 369 ++++++++++++++++++ shortcuts/apps/apps_db_execute.go | 191 ++++++--- shortcuts/apps/apps_db_execute_test.go | 358 +++++++++++------ shortcuts/apps/apps_db_quota_get.go | 100 +++++ shortcuts/apps/apps_db_recovery.go | 267 +++++++++++++ shortcuts/apps/apps_file_delete.go | 148 +++++++ shortcuts/apps/apps_file_delete_test.go | 132 +++++++ shortcuts/apps/apps_file_download.go | 122 ++++++ shortcuts/apps/apps_file_download_test.go | 122 ++++++ shortcuts/apps/apps_file_get.go | 87 +++++ shortcuts/apps/apps_file_get_test.go | 89 +++++ shortcuts/apps/apps_file_list.go | 145 +++++++ shortcuts/apps/apps_file_list_test.go | 252 ++++++++++++ shortcuts/apps/apps_file_quota_get.go | 93 +++++ shortcuts/apps/apps_file_quota_get_test.go | 96 +++++ shortcuts/apps/apps_file_sign.go | 82 ++++ shortcuts/apps/apps_file_sign_test.go | 74 ++++ shortcuts/apps/apps_file_upload.go | 206 ++++++++++ shortcuts/apps/apps_file_upload_test.go | 179 +++++++++ shortcuts/apps/db_common.go | 196 +++++++++- shortcuts/apps/file_common.go | 228 +++++++++++ shortcuts/apps/shortcuts.go | 19 + shortcuts/apps/shortcuts_test.go | 19 +- shortcuts/common/runner.go | 14 + skills/lark-apps/SKILL.md | 4 +- .../references/lark-apps-db-env-create.md | 31 -- .../references/lark-apps-db-execute.md | 10 +- .../references/lark-apps-db-table-get.md | 29 -- .../references/lark-apps-db-table-list.md | 31 -- skills/lark-apps/references/lark-apps-db.md | 160 ++++++++ skills/lark-apps/references/lark-apps-file.md | 94 +++++ 44 files changed, 5880 insertions(+), 267 deletions(-) create mode 100644 internal/output/spinner.go create mode 100644 internal/output/spinner_test.go create mode 100644 shortcuts/apps/apps_db_audit_list.go create mode 100644 shortcuts/apps/apps_db_audit_set.go create mode 100644 shortcuts/apps/apps_db_audit_status.go create mode 100644 shortcuts/apps/apps_db_audit_test.go create mode 100644 shortcuts/apps/apps_db_changelog_list.go create mode 100644 shortcuts/apps/apps_db_changelog_list_test.go create mode 100644 shortcuts/apps/apps_db_data_export.go create mode 100644 shortcuts/apps/apps_db_data_export_test.go create mode 100644 shortcuts/apps/apps_db_data_import.go create mode 100644 shortcuts/apps/apps_db_data_import_test.go create mode 100644 shortcuts/apps/apps_db_env_migrate.go create mode 100644 shortcuts/apps/apps_db_env_recovery_quota_test.go create mode 100644 shortcuts/apps/apps_db_quota_get.go create mode 100644 shortcuts/apps/apps_db_recovery.go create mode 100644 shortcuts/apps/apps_file_delete.go create mode 100644 shortcuts/apps/apps_file_delete_test.go create mode 100644 shortcuts/apps/apps_file_download.go create mode 100644 shortcuts/apps/apps_file_download_test.go create mode 100644 shortcuts/apps/apps_file_get.go create mode 100644 shortcuts/apps/apps_file_get_test.go create mode 100644 shortcuts/apps/apps_file_list.go create mode 100644 shortcuts/apps/apps_file_list_test.go create mode 100644 shortcuts/apps/apps_file_quota_get.go create mode 100644 shortcuts/apps/apps_file_quota_get_test.go create mode 100644 shortcuts/apps/apps_file_sign.go create mode 100644 shortcuts/apps/apps_file_sign_test.go create mode 100644 shortcuts/apps/apps_file_upload.go create mode 100644 shortcuts/apps/apps_file_upload_test.go create mode 100644 shortcuts/apps/file_common.go delete mode 100644 skills/lark-apps/references/lark-apps-db-env-create.md delete mode 100644 skills/lark-apps/references/lark-apps-db-table-get.md delete mode 100644 skills/lark-apps/references/lark-apps-db-table-list.md create mode 100644 skills/lark-apps/references/lark-apps-db.md create mode 100644 skills/lark-apps/references/lark-apps-file.md diff --git a/internal/output/spinner.go b/internal/output/spinner.go new file mode 100644 index 000000000..1ea7d4ad9 --- /dev/null +++ b/internal/output/spinner.go @@ -0,0 +1,80 @@ +// 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.StderrIsTerminal), 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 + }) + } +} diff --git a/internal/output/spinner_test.go b/internal/output/spinner_test.go new file mode 100644 index 000000000..c4e683fae --- /dev/null +++ b/internal/output/spinner_test.go @@ -0,0 +1,54 @@ +// 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) + } +} diff --git a/shortcuts/apps/apps_db_audit_list.go b/shortcuts/apps/apps_db_audit_list.go new file mode 100644 index 000000000..f0047fe27 --- /dev/null +++ b/shortcuts/apps/apps_db_audit_list.go @@ -0,0 +1,300 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsDBAuditList 列出数据表的行级审计事件(INSERT/UPDATE/DELETE 的变更追溯)。 +// +// GET /apps/{app_id}/db/audit_list(cursor 分页)。--table 可重复传多张表;--since/--until 多格式时间。 +// operator 透传 {id,name}(json 还原对象、pretty 取 name);before/after 是条件出现的 JSON +// (INSERT 无 before、DELETE 无 after),json 还原成对象。 +// +// 多表查询时,CLI 先用 schema(表是否存在)+ status(审计是否开启)在本地过滤,把不存在 / +// 未开启审计的表剔除后再查 audit_list,被剔除的表及原因放进 skipped(服务端不再返该字段)。 +var AppsDBAuditList = common.Shortcut{ + Service: appsService, + Command: "+db-audit-list", + Description: "List row-change audit events for one or more tables (cursor pagination)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-audit-list --app-id --table orders", + "Multiple tables: repeat --table; filter time with --since 7d / --until 2026-04-15.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, + {Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"}, + {Name: "until", Desc: "filter: event at or before; same formats as --since"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, + {Name: "page-token", Desc: "pagination cursor from previous response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if len(auditListTables(rctx)) == 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required (at least one table)").WithParam("--table") + } + return normalizeTimeFlags(rctx, "since", "until") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appAuditListPath(appID)). + Desc("List Miaoda app table audit events"). + Params(buildAuditListParams(rctx, auditListTables(rctx))) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + requested := auditListTables(rctx) + env := rctx.Str("env") + + // 多表查询:CLI 侧先用 schema(表是否存在)+ status(审计是否开启)过滤, + // 不存在 / 未开启审计的表不进 audit_list 查询,单独在 skipped 里给出原因。 + // 单表查询直接打 audit_list,由后端就 table-not-found / audit-not-enabled 报错。 + queryTables := requested + var skipped []auditSkippedEntry + if len(requested) > 1 { + queryTables, skipped, err = filterAuditTables(rctx, appID, env, requested) + if err != nil { + return withAppsHint(err, dbChangelogHint) + } + // 所有请求表都被过滤掉 → 无可查询表,直接返回空 + skipped 提示,不调 audit_list。 + if len(queryTables) == 0 { + out := map[string]interface{}{"items": []auditLogItem{}, "has_more": false, "skipped": skipped} + rctx.OutFormat(out, nil, func(w io.Writer) { + io.WriteString(w, "No audit events found.\n") + writeAuditSkipped(w, skipped, len(requested)) + }) + return nil + } + } + + data, err := rctx.CallAPITyped("GET", appAuditListPath(appID), buildAuditListParams(rctx, queryTables), nil) + if err != nil { + return withAppsHint(err, dbChangelogHint) + } + items := projectAuditLogItems(data["items"]) + data["items"] = items + // 服务端不再返 skipped;改由 CLI 算出的 skipped 写回输出。 + if len(skipped) > 0 { + data["skipped"] = skipped + } else { + delete(data, "skipped") + } + multi := len(requested) > 1 + rctx.OutFormat(data, nil, func(w io.Writer) { + renderAuditListPretty(w, items, skipped, len(requested), multi) + }) + return nil + }, +} + +// auditSkippedEntry 是被 CLI 预过滤掉的表及原因(替代已删除的服务端 skipped 字段)。 +type auditSkippedEntry struct { + Table string `json:"table"` + Reason string `json:"reason"` +} + +// filterAuditTables 用 schema(存在性)+ status(审计开关)把请求表分成「可查询」与「跳过」两组。 +func filterAuditTables(rctx *common.RuntimeContext, appID, env string, requested []string) ([]string, []auditSkippedEntry, error) { + existing, err := fetchExistingTables(rctx, appID, env) + if err != nil { + return nil, nil, err + } + enabled, err := fetchAuditEnabledTables(rctx, appID, env) + if err != nil { + return nil, nil, err + } + valid := make([]string, 0, len(requested)) + var skipped []auditSkippedEntry + for _, t := range requested { + switch { + case !existing[t]: + skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "table not found"}) + case !enabled[t]: + skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "audit not enabled"}) + default: + valid = append(valid, t) + } + } + return valid, skipped, nil +} + +// fetchExistingTables 翻页拉全量表清单,返回存在表名集合(schema 命令同源接口)。 +func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) { + existing := map[string]bool{} + token := "" + for { + params := map[string]interface{}{"env": env, "page_size": 100} + if token != "" { + params["page_token"] = token + } + data, err := rctx.CallAPITyped("GET", appTablesPath(appID), params, nil) + if err != nil { + return nil, err + } + for _, it := range asMapSlice(data["items"]) { + if name := common.GetString(it, "name"); name != "" { + existing[name] = true + } + } + token = common.GetString(data, "page_token") + if data["has_more"] != true || token == "" { + break + } + } + return existing, nil +} + +// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。 +func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) { + data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil) + if err != nil { + return nil, err + } + enabled := map[string]bool{} + for _, it := range asMapSlice(data["items"]) { + if it["enabled"] == true { + if name := common.GetString(it, "table"); name != "" { + enabled[name] = true + } + } + } + return enabled, nil +} + +// asMapSlice 把 interface{}([]interface{})里的每个 map 元素取出,非 map 丢弃。 +func asMapSlice(raw interface{}) []map[string]interface{} { + arr, _ := raw.([]interface{}) + out := make([]map[string]interface{}, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out +} + +// auditListTables 取 --table 切片,trim 去空。 +func auditListTables(rctx *common.RuntimeContext) []string { + out := make([]string, 0) + for _, t := range rctx.StrSlice("table") { + if v := strings.TrimSpace(t); v != "" { + out = append(out, v) + } + } + return out +} + +// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。 +func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} { + params := map[string]interface{}{ + "env": rctx.Str("env"), + "tables": strings.Join(tables, ","), + "page_size": rctx.Int("page-size"), + } + addStr := func(flag, key string) { + if v := strings.TrimSpace(rctx.Str(flag)); v != "" { + params[key] = v + } + } + addStr("since", "since") + addStr("until", "until") + addStr("page-token", "page_token") + return params +} + +type auditLogItem struct { + EventID string `json:"event_id"` + EventTime string `json:"event_time"` + TargetTable string `json:"target_table"` + Type string `json:"type"` + Operator *operatorRef `json:"operator,omitempty"` + Summary string `json:"summary"` + Before interface{} `json:"before,omitempty"` + After interface{} `json:"after,omitempty"` +} + +// projectAuditLogItems 把服务端原始审计事件投影为白名单 auditLogItem(operator 解析、before/after 还原成对象)。 +func projectAuditLogItems(raw interface{}) []auditLogItem { + arr, _ := raw.([]interface{}) + out := make([]auditLogItem, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + row := auditLogItem{ + EventID: common.GetString(m, "event_id"), + EventTime: common.GetString(m, "event_time"), + TargetTable: common.GetString(m, "target_table"), + Type: common.GetString(m, "type"), + Operator: parseOperator(common.GetString(m, "operator")), + Summary: common.GetString(m, "summary"), + } + // before/after 条件出现:INSERT 无 before、DELETE 无 after。JSON 字符串 → 还原对象。 + if b := common.GetString(m, "before"); b != "" { + row.Before = safeParseJSON(b) + } + if a := common.GetString(m, "after"); a != "" { + row.After = safeParseJSON(a) + } + out = append(out, row) + } + return out +} + +// renderAuditListPretty 单表 5 列 / 多表 6 列(首列 target_table);末尾列出 skipped 表。 +func renderAuditListPretty(w io.Writer, items []auditLogItem, skipped []auditSkippedEntry, totalRequested int, multi bool) { + if len(items) == 0 { + io.WriteString(w, "No audit events found.\n") + writeAuditSkipped(w, skipped, totalRequested) + return + } + var headers []string + if multi { + headers = []string{"target_table", "event_time", "type", "event_id", "operator", "summary"} + } else { + headers = []string{"event_time", "type", "event_id", "operator", "summary"} + } + rows := make([][]string, 0, len(items)) + for _, it := range items { + cells := []string{dashIfEmpty(it.EventTime), it.Type, it.EventID, operatorName(it.Operator), dashIfEmpty(it.Summary)} + if multi { + cells = append([]string{dashIfEmpty(it.TargetTable)}, cells...) + } + rows = append(rows, cells) + } + renderAlignedTable(w, headers, rows) + writeAuditSkipped(w, skipped, totalRequested) +} + +// writeAuditSkipped 打 "— Skipped N of M tables: orders (audit not enabled), foo (table not found)"。 +func writeAuditSkipped(w io.Writer, skipped []auditSkippedEntry, totalRequested int) { + if len(skipped) == 0 { + return + } + parts := make([]string, 0, len(skipped)) + for _, s := range skipped { + parts = append(parts, fmt.Sprintf("%s (%s)", s.Table, s.Reason)) + } + fmt.Fprintf(w, "— Skipped %d of %d tables: %s\n", len(skipped), totalRequested, strings.Join(parts, ", ")) +} diff --git a/shortcuts/apps/apps_db_audit_set.go b/shortcuts/apps/apps_db_audit_set.go new file mode 100644 index 000000000..1d29d95b3 --- /dev/null +++ b/shortcuts/apps/apps_db_audit_set.go @@ -0,0 +1,142 @@ +// 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 `" + +// AppsDBAuditEnable 为某张表开启行级审计(变更追溯)。 +// +// POST /apps/{app_id}/db/audit_set,body {table, enabled:true, retention}。--retention 默认 7d。 +var AppsDBAuditEnable = common.Shortcut{ + Service: appsService, + Command: "+db-audit-enable", + Description: "Enable row-change audit logging for a table", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +db-audit-enable --app-id --table orders --retention 30d", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Desc: "table to enable audit for", Required: true}, + {Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + _, err := requireAppID(rctx.Str("app-id")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appAuditSetPath(appID)). + Desc("Enable table audit"). + Params(map[string]interface{}{"env": rctx.Str("env")}). + Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + table := strings.TrimSpace(rctx.Str("table")) + retention := rctx.Str("retention") + stop := rctx.StartSpinner("Enabling audit logging for " + table) + defer stop() + data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID), + map[string]interface{}{"env": rctx.Str("env")}, + map[string]interface{}{"table": table, "enabled": true, "retention": retention}) + stop() + if err != nil { + return withAppsHint(err, dbAuditSetHint) + } + st := auditSetStatus(data, table) + ret := common.GetString(st, "retention") + if ret == "" { + ret = retention + } + out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": true, "retention": ret} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Audit enabled for table '%s' (retention: %s)\n", common.GetString(out, "table"), ret) + }) + return nil + }, +} + +// AppsDBAuditDisable 关闭某张表的行级审计。 +// +// POST /apps/{app_id}/db/audit_set,body {table, enabled:false}。 +var AppsDBAuditDisable = common.Shortcut{ + Service: appsService, + Command: "+db-audit-disable", + Description: "Disable row-change audit logging for a table", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +db-audit-disable --app-id --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} +} diff --git a/shortcuts/apps/apps_db_audit_status.go b/shortcuts/apps/apps_db_audit_status.go new file mode 100644 index 000000000..e059f5081 --- /dev/null +++ b/shortcuts/apps/apps_db_audit_status.go @@ -0,0 +1,139 @@ +// 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 ", + "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) +} diff --git a/shortcuts/apps/apps_db_audit_test.go b/shortcuts/apps/apps_db_audit_test.go new file mode 100644 index 000000000..becf9b86f --- /dev/null +++ b/shortcuts/apps/apps_db_audit_test.go @@ -0,0 +1,316 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const ( + dbAuditStatusURL = "/open-apis/spark/v1/apps/app_x/db/audit_status" + dbAuditSetURL = "/open-apis/spark/v1/apps/app_x/db/audit_set" + dbAuditListURL = "/open-apis/spark/v1/apps/app_x/db/audit_list" + dbTablesListURL = "/open-apis/spark/v1/apps/app_x/tables" +) + +// ── audit-status ── + +// TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder 验证单表查询无记录时返回 enabled:false 的占位对象(非数组)。 +func TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsDBAuditStatus, + []string{"+db-audit-status", "--app-id", "app_x", "--table", "orders", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + // 单表无记录 → 占位对象 enabled:false(不是数组)。 + var env struct { + Data struct { + Table string `json:"table"` + Enabled bool `json:"enabled"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) + } + if env.Data.Table != "orders" || env.Data.Enabled { + t.Fatalf("expected placeholder {orders,false}, got %+v", env.Data) + } +} + +// TestAppsDBAuditStatus_MultiTablePrettyTable 验证多表 pretty 输出含 enabled/yes/no 列与 retention 值。 +func TestAppsDBAuditStatus_MultiTablePrettyTable(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{ + map[string]interface{}{"table": "orders", "enabled": true, "enabled_at": "2026-04-15T10:30:00Z", "retention": "30d"}, + map[string]interface{}{"table": "users", "enabled": false}, + }}}, + }) + if err := runAppsShortcut(t, AppsDBAuditStatus, + []string{"+db-audit-status", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "enabled") || !strings.Contains(got, "yes") || !strings.Contains(got, "no") || !strings.Contains(got, "30d") { + t.Fatalf("pretty table malformed:\n%s", got) + } +} + +// ── audit-enable / disable ── + +// TestAppsDBAuditEnable_RequiresTableAndValidRetention 验证缺 --table 报必填错、非法 --retention 报 ValidationError。 +func TestAppsDBAuditEnable_RequiresTableAndValidRetention(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // 缺 --table → cobra required, exit 1 + if err := runAppsShortcut(t, AppsDBAuditEnable, + []string{"+db-audit-enable", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected required --table error") + } + // 非法 retention → enum 校验 (validation) + factory2, stdout2, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBAuditEnable, + []string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "99d", "--as", "user"}, factory2, stdout2) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--retention" { + t.Fatalf("Param = %q, want --retention", ve.Param) + } +} + +// TestAppsDBAuditEnable_DryRunAndSuccess 验证 dry-run 发出 enabled:true+retention 的 POST,成功时打印 pretty 确认行。 +func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) { + // dry-run body {table, enabled:true, retention} + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditEnable, + []string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" { + t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body) + } + + // success + factory2, stdout2, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbAuditSetURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": true, "retention": "30d"}}}, + }) + if err := runAppsShortcut(t, AppsDBAuditEnable, + []string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout2.String(), "✓ Audit enabled for table 'orders' (retention: 30d)") { + t.Fatalf("pretty: %s", stdout2.String()) + } +} + +// TestAppsDBAuditDisable_DryRunAndSuccess 验证 dry-run 发出 enabled:false 的 POST,成功时打印 pretty 确认行。 +func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditDisable, + []string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" { + t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body) + } + + factory2, stdout2, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbAuditSetURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": false}}}, + }) + if err := runAppsShortcut(t, AppsDBAuditDisable, + []string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout2.String(), "✓ Audit disabled for table 'orders'") { + t.Fatalf("pretty: %s", stdout2.String()) + } +} + +// ── audit-list ── + +// TestAppsDBAuditList_RequiresTable 验证缺 --table 时报必填错误。 +func TestAppsDBAuditList_RequiresTable(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected required --table error") + } +} + +// TestAppsDBAuditList_DryRunJoinsTables 验证 dry-run 将多个 --table 合并为 tables=orders,users 且归一化 since。 +func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--since", "7d", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" { + t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"]) + } + if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") { + t.Fatalf("since not normalized: %v", a.Params["since"]) + } +} + +// 单表查询:不预过滤、直接打 audit_list(后端就 not-found/not-enabled 报错),无 skipped。 +// TestAppsDBAuditList_SingleTableNoPreflight 验证单表查询不预过滤、operator/before/after 还原为对象、无 skipped。 +func TestAppsDBAuditList_SingleTableNoPreflight(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "has_more": false, "page_token": "", + "items": []interface{}{map[string]interface{}{ + "event_id": "01525", "event_time": "2026-04-16T10:30:00Z", "target_table": "users", + "type": "UPDATE", "operator": `{"id":"7311","name":"alice"}`, "summary": "UPDATE 1 field", + "before": `{"amount":100}`, "after": `{"amount":999}`, + }}, + }}, + }) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "users", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // operator → 对象;before/after → 还原成对象(非字符串)。 + for _, want := range []string{`"name": "alice"`, `"before"`, `"amount": 100`, `"after"`, `"amount": 999`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } + if strings.Contains(got, `"skipped"`) { + t.Errorf("single-table query must not emit skipped:\n%s", got) + } + if strings.Contains(got, `"before": "{`) { + t.Errorf("before should be an object, not a JSON string:\n%s", got) + } +} + +// TestAppsDBAuditList_SingleTableEmptyPretty 验证单表无事件时不报错、pretty 打印 "No audit events found." 且无 Skipped。 +func TestAppsDBAuditList_SingleTableEmptyPretty(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("empty audit list should NOT error (ok read), got %v", err) + } + got := stdout.String() + if !strings.Contains(got, "No audit events found.") || strings.Contains(got, "Skipped") { + t.Fatalf("expected empty, no skipped for single table:\n%s", got) + } +} + +// 多表查询:CLI 用 schema(存在性)+ status(审计开关)预过滤,只把有效表传给 audit_list, +// 不存在 / 未开启审计的表进 skipped。 +// TestAppsDBAuditList_MultiTablePreflightFilters 验证多表查询用 schema+status 预过滤,仅传有效表,不存在/未开审计的表进 skipped。 +func TestAppsDBAuditList_MultiTablePreflightFilters(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + // schema:orders/users/carts 存在,ghost 不存在。 + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbTablesListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{ + map[string]interface{}{"name": "orders"}, map[string]interface{}{"name": "users"}, map[string]interface{}{"name": "carts"}, + }}}, + }) + // status:orders/users 开启审计,carts 未开启。 + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{ + map[string]interface{}{"table": "orders", "enabled": true}, map[string]interface{}{"table": "users", "enabled": true}, + map[string]interface{}{"table": "carts", "enabled": false}, + }}}, + }) + // audit_list 只应被传入有效表 orders,users。 + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditListURL, + OnMatch: func(req *http.Request) { + if got := req.URL.Query().Get("tables"); got != "orders,users" { + t.Errorf("audit_list tables = %q, want orders,users (filtered)", got) + } + }, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{ + map[string]interface{}{"event_id": "e1", "event_time": "2026-04-16T10:30:00Z", "target_table": "orders", "type": "INSERT", "summary": "INSERT"}, + }}}, + }) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--table", "carts", "--table", "ghost", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // skipped:carts(audit not enabled) + ghost(table not found),结构化 {table,reason}。 + for _, want := range []string{`"skipped"`, `"table": "carts"`, `"reason": "audit not enabled"`, `"table": "ghost"`, `"reason": "table not found"`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } +} + +// 多表查询且全部被过滤掉 → 不调 audit_list,直接空 + skipped 提示。 +// TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery 验证多表全部被过滤时跳过 audit_list 调用,直接输出空结果加 Skipped 提示。 +func TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbTablesListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{ + map[string]interface{}{"name": "orders"}, + }}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + // 不注册 audit_list:若被调用会命中未注册请求而报错。 + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "ghost1", "--table", "ghost2", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("all-filtered should still succeed (empty), got %v", err) + } + got := stdout.String() + if !strings.Contains(got, "No audit events found.") || !strings.Contains(got, "Skipped 2 of 2 tables") { + t.Fatalf("expected empty + 'Skipped 2 of 2 tables':\n%s", got) + } +} diff --git a/shortcuts/apps/apps_db_changelog_list.go b/shortcuts/apps/apps_db_changelog_list.go new file mode 100644 index 000000000..03048407c --- /dev/null +++ b/shortcuts/apps/apps_db_changelog_list.go @@ -0,0 +1,150 @@ +// 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 --env dev`" + +// AppsDBChangelogList 列出应用数据库的 DDL 变更记录(建表/改表/索引等结构变更追溯)。 +// +// GET /apps/{app_id}/db/changelog_list(cursor 分页)。过滤:--table、--since/--until(多格式时间)。 +// --change-id 精确查单条(命中返单条、否则空)。operator 后端以 JSON 字符串透传 {id,name}, +// json 还原成对象、pretty 只展示 name。 +var AppsDBChangelogList = common.Shortcut{ + Service: appsService, + Command: "+db-changelog-list", + Description: "List a Miaoda app database's DDL change history (cursor pagination)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-changelog-list --app-id ", + "Pin a single change with --change-id; filter time with --since 7d / --until 2026-04-15.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, + {Name: "table", Desc: "filter by target table"}, + {Name: "change-id", Desc: "look up a single change by id (returns that one record only)"}, + {Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"}, + {Name: "until", Desc: "filter: changed at or before; same formats as --since"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, + {Name: "page-token", Desc: "pagination cursor from previous response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return normalizeTimeFlags(rctx, "since", "until") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appChangelogListPath(appID)). + Desc("List Miaoda app DDL changelog"). + Params(buildChangelogParams(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("GET", appChangelogListPath(appID), buildChangelogParams(rctx), nil) + if err != nil { + return withAppsHint(err, dbChangelogHint) + } + items := projectChangelogItems(data["items"]) + data["items"] = items + changeID := strings.TrimSpace(rctx.Str("change-id")) + rctx.OutFormat(data, nil, func(w io.Writer) { + renderChangelogPretty(w, items, changeID) + }) + return nil + }, +} + +// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。 +func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{ + "env": rctx.Str("env"), + "page_size": rctx.Int("page-size"), + } + addStr := func(flag, key string) { + if v := strings.TrimSpace(rctx.Str(flag)); v != "" { + params[key] = v + } + } + addStr("table", "table") + addStr("change-id", "change_id") + addStr("since", "since") + addStr("until", "until") + addStr("page-token", "page_token") + return params +} + +type changelogItem struct { + ChangeID string `json:"change_id"` + ChangedAt string `json:"changed_at"` + Operator *operatorRef `json:"operator,omitempty"` + TargetTable string `json:"target_table"` + ChangeType string `json:"change_type"` + Summary string `json:"summary"` + Statement string `json:"statement,omitempty"` +} + +// projectChangelogItems 把服务端原始 DDL 变更记录投影为白名单 changelogItem(operator 解析成对象)。 +func projectChangelogItems(raw interface{}) []changelogItem { + arr, _ := raw.([]interface{}) + out := make([]changelogItem, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + out = append(out, changelogItem{ + ChangeID: common.GetString(m, "change_id"), + ChangedAt: common.GetString(m, "changed_at"), + Operator: parseOperator(common.GetString(m, "operator")), + TargetTable: common.GetString(m, "target_table"), + ChangeType: common.GetString(m, "change_type"), + Summary: common.GetString(m, "summary"), + Statement: common.GetString(m, "statement"), + }) + } + return out +} + +// renderChangelogPretty 6 列:change_id / changed_at / operator(name) / target_table / change_type / summary。 +func renderChangelogPretty(w io.Writer, items []changelogItem, changeID string) { + if len(items) == 0 { + if changeID != "" { + fmt.Fprintf(w, "No DDL change with id=%s found.\n", changeID) + } else { + io.WriteString(w, "No DDL changes found.\n") + } + return + } + headers := []string{"change_id", "changed_at", "operator", "target_table", "change_type", "summary"} + rows := make([][]string, 0, len(items)) + for _, it := range items { + rows = append(rows, []string{ + it.ChangeID, + dashIfEmpty(it.ChangedAt), + operatorName(it.Operator), + dashIfEmpty(it.TargetTable), + it.ChangeType, + dashIfEmpty(it.Summary), + }) + } + renderAlignedTable(w, headers, rows) +} diff --git a/shortcuts/apps/apps_db_changelog_list_test.go b/shortcuts/apps/apps_db_changelog_list_test.go new file mode 100644 index 000000000..af56ac51e --- /dev/null +++ b/shortcuts/apps/apps_db_changelog_list_test.go @@ -0,0 +1,143 @@ +// 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) + } +} diff --git a/shortcuts/apps/apps_db_data_export.go b/shortcuts/apps/apps_db_data_export.go new file mode 100644 index 000000000..5e4cb7b47 --- /dev/null +++ b/shortcuts/apps/apps_db_data_export.go @@ -0,0 +1,189 @@ +// 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,缺省输出 .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 --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:
.csv)"}, + {Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "source db environment"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("table")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required").WithParam("--table") + } + if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit") + } + if _, _, err := exportFormatAndOutput(rctx); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + format, _, _ := exportFormatAndOutput(rctx) + return common.NewDryRunAPI(). + GET(appDataExportPath(appID)). + Desc("Export Miaoda app table data (raw bytes)"). + Params(map[string]interface{}{ + "env": rctx.Str("env"), "table": strings.TrimSpace(rctx.Str("table")), + "format": format, "limit": rctx.Int("limit"), + }) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + table := strings.TrimSpace(rctx.Str("table")) + format, out, err := exportFormatAndOutput(rctx) + if err != nil { + return err + } + + // 原子编排第 1 步:先查总行数(records 列表的 total),再导出文件。 + // total 查询失败不阻断导出——回退到按导出文件内容数行。 + total, totalErr := queryExportTotal(rctx, appID, rctx.Str("env"), table) + + resp, err := rctx.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: appDataExportPath(appID), + QueryParams: larkcore.QueryParams{ + "env": []string{rctx.Str("env")}, + "table": []string{table}, + "format": []string{format}, + "limit": []string{strconv.Itoa(rctx.Int("limit"))}, + }, + }) + if err != nil { + return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint) + } + // 成功是原始字节;业务错误网关以 JSON 信封 {code,msg} 返回(以 '{' 开头)。 + if b := bytes.TrimSpace(resp.RawBody); len(b) > 0 && b[0] == '{' { + if _, cerr := rctx.ClassifyAPIResponse(resp); cerr != nil { + return withAppsHint(cerr, dbDataExportHint) + } + } + if resp.StatusCode >= 400 { + return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkServer, "export failed: HTTP %d", resp.StatusCode).WithRetryable(), dbDataExportHint) + } + body := resp.RawBody + if len(body) > dbDataExportMaxBytes { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "export exceeds 1 MB limit (%d bytes); filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets", len(body)) + } + + saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: int64(len(body)), + }, bytes.NewReader(body)) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output") + } + // 行数取自预查的 total(导出最多 limit 行,故取 min);total 查询失败时按导出内容数行兜底。 + rows := 0 + if totalErr == nil { + rows = total + if lim := rctx.Int("limit"); rows > lim { + rows = lim + } + } else { + rows = countDataRows(body, format) + } + resolved, perr := rctx.FileIO().ResolvePath(out) + if perr != nil || resolved == "" { + resolved = out + } + result := map[string]interface{}{ + "table": table, "output": resolved, "format": format, + "rows": rows, "size_bytes": saved.Size(), + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Exported %s → %s (%d rows)\n", table, resolved, rows) + }) + return nil + }, +} + +// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。 +// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。 +func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) { + raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), + map[string]interface{}{"env": env, "page_size": 1}, nil) + if err != nil { + return 0, err + } + return totalAsInt(raw["total"]), nil +} + +// totalAsInt 把 total 解析成 int,兼容 JSON number 与 i64-as-string 两种 wire 形态。 +func totalAsInt(v interface{}) int { + if f, ok := numericAsFloat(v); ok { + return int(f) + } + if s, ok := v.(string); ok { + if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { + return n + } + } + return 0 +} + +// exportFormatAndOutput 由 --output 推断数据格式与落盘路径: +// 给了 --output → 取其扩展名定 format(csv/json/sql);未给 → 默认 csv、输出
.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 +} diff --git a/shortcuts/apps/apps_db_data_export_test.go b/shortcuts/apps/apps_db_data_export_test.go new file mode 100644 index 000000000..f2c9121ac --- /dev/null +++ b/shortcuts/apps/apps_db_data_export_test.go @@ -0,0 +1,193 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const dbDataExportURL = "/open-apis/spark/v1/apps/app_x/db/data_export" +const dbOrdersRecordsURL = "/open-apis/spark/v1/apps/app_x/tables/orders/records" + +// TestAppsDBDataExport_RequiresTable 验证缺 --table 时报必填错误。 +func TestAppsDBDataExport_RequiresTable(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // 缺 --table → cobra required-flag, exit 1 + err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected required-flag error for missing --table") + } +} + +// TestAppsDBDataExport_RejectsBadLimit 验证越界 --limit(0/-1/5001)均报 --limit 的 ValidationError。 +func TestAppsDBDataExport_RejectsBadLimit(t *testing.T) { + for _, lim := range []string{"0", "-1", "5001"} { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--limit", lim, "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("limit=%s err = %T %v, want *errs.ValidationError", lim, err, err) + } + if ve.Param != "--limit" { + t.Fatalf("limit=%s Param = %q, want --limit", lim, ve.Param) + } + } +} + +// TestAppsDBDataExport_RejectsBadOutputExtension 验证不支持的 --output 扩展名(.xml)报校验错误。 +func TestAppsDBDataExport_RejectsBadOutputExtension(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "dump.xml", "--as", "user"}, factory, stdout) + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected unsupported-format validation for .xml, got %v", err) + } +} + +// dry-run:format 跟随 --output 扩展名;缺省 csv。 +// TestAppsDBDataExport_DryRunFormatFromOutput 验证 dry-run 的 format 参数跟随 --output 扩展名、缺省为 csv,并带 limit。 +func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) { + cases := []struct{ output, wantFmt string }{ + {"", "csv"}, {"orders.csv", "csv"}, {"orders.json", "json"}, {"dump.sql", "sql"}, + } + for _, c := range cases { + factory, stdout, _ := newAppsExecuteFactory(t) + args := []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"} + if c.output != "" { + args = append(args, "--output", c.output) + } + if err := runAppsShortcut(t, AppsDBDataExport, args, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "GET" || a.URL != dbDataExportURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Params["format"] != c.wantFmt || a.Params["table"] != "orders" { + t.Errorf("output=%q params.format=%v want %q", c.output, a.Params["format"], c.wantFmt) + } + if _, ok := a.Params["limit"]; !ok { + t.Errorf("dry-run missing limit param") + } + } +} + +// 成功:先查 records 列表 total 计行,再把原始字节落盘。 +// TestAppsDBDataExport_SuccessWritesFile 验证成功路径先查 records total 计行、再将导出原始字节落盘并输出 rows/format/table。 +func TestAppsDBDataExport_SuccessWritesFile(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + // 第 1 步:records 列表 total=2(行数来源)。 + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbOrdersRecordsURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 2, "has_more": false, "items": "[]"}}, + }) + // 第 2 步:导出原始字节。 + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: dbDataExportURL, + RawBody: []byte("id,name\n1,a\n2,b\n"), + Headers: http.Header{"Content-Type": []string{"text/csv"}}, + }) + if err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + b, err := os.ReadFile(dir + "/orders.csv") + if err != nil || string(b) != "id,name\n1,a\n2,b\n" { + t.Fatalf("output file wrong: %q err=%v", string(b), err) + } + got := stdout.String() + if !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"format": "csv"`) || !strings.Contains(got, `"table": "orders"`) { + t.Fatalf("output json missing fields:\n%s", got) + } +} + +// 行数取自 records total,且按 --limit 截顶(min(total, limit))。 +// TestAppsDBDataExport_RowsFromTotalCappedByLimit 验证行数取 records total 并按 --limit 截顶(total=10000、limit=100 → rows=100)。 +func TestAppsDBDataExport_RowsFromTotalCappedByLimit(t *testing.T) { + chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbOrdersRecordsURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 10000, "has_more": true, "items": "[]"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbDataExportURL, + RawBody: []byte("id\n1\n2\n3\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}}, + }) + if err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--limit", "100", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), `"rows": 100`) { + t.Fatalf("expected rows capped to limit 100 from total=10000:\n%s", stdout.String()) + } +} + +// total 查询失败(records 列表报错)→ 回退按导出文件内容数行,不阻断导出。 +// TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable 验证 records total 查询失败时回退按导出文件内容数行,不阻断落盘。 +func TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbOrdersRecordsURL, + Body: map[string]interface{}{"code": 1254000, "msg": "records unavailable"}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbDataExportURL, + RawBody: []byte("id,name\n1,a\n2,b\n3,c\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}}, + }) + if err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("export should still succeed via fallback, got %v", err) + } + b, _ := os.ReadFile(dir + "/orders.csv") + if string(b) != "id,name\n1,a\n2,b\n3,c\n" { + t.Fatalf("file not written on fallback path: %q", string(b)) + } + if !strings.Contains(stdout.String(), `"rows": 3`) { + t.Fatalf("expected fallback file-count rows:3:\n%s", stdout.String()) + } +} + +// 业务错误:网关回 JSON 信封 {code,msg}(非原始字节)→ typed error,不落盘。 +// TestAppsDBDataExport_BusinessErrorEnvelope 验证响应为 JSON 错误信封(非原始字节)时返回 typed error 且不落盘。 +func TestAppsDBDataExport_BusinessErrorEnvelope(t *testing.T) { + chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: dbDataExportURL, + RawBody: []byte(`{"code":1254043,"msg":"table not found"}`), + Headers: http.Header{"Content-Type": []string{"application/json"}}, + }) + err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "nope", "--output", "nope.csv", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String()) + } + if _, statErr := os.Stat("nope.csv"); statErr == nil { + t.Fatalf("error path must not write the output file") + } +} diff --git a/shortcuts/apps/apps_db_data_import.go b/shortcuts/apps/apps_db_data_import.go new file mode 100644 index 000000000..986bf113b --- /dev/null +++ b/shortcuts/apps/apps_db_data_import.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/shortcuts/common" +) + +const dbDataImportMaxBytes = 1 * 1024 * 1024 // 1 MB + +const dbDataImportHint = "verify --app-id and --table; data file must be .csv/.json and ≤1 MB — split larger files and import in batches" + +// AppsDBDataImport 把本地 csv/json 文件直传到应用数据表(high-risk-write)。 +// +// POST /apps/{app_id}/db/data_import,multipart 表单:file_name + 可选 table + 文件本体(与 +// +file-upload / UploadFileForOpenAPI 一致)。文件的格式解析与转换在服务端 integration 层完成 +// (按 file_name 扩展名推断 csv/json),CLI 不再本地解析。表名缺省取文件名(去扩展名)。上限 1 MB。 +var AppsDBDataImport = common.Shortcut{ + Service: appsService, + Command: "+db-data-import", + Description: "Import rows from a local csv/json file into a Miaoda app table", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +db-data-import --app-id --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": ""}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + file := strings.TrimSpace(rctx.Str("file")) + content, err := cmdutil.ReadInputFile(rctx.FileIO(), file) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file") + } + if len(content) > dbDataImportMaxBytes { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", len(content)).WithParam("--file") + } + fileName := filepath.Base(file) + table := importTableName(rctx) + + // multipart:file_name 走表单字段、文件本体走 form-files;env / table 走 query。 + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddFile("file", bytes.NewReader(content)) + + resp, err := rctx.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: appDataImportPath(appID), + QueryParams: larkcore.QueryParams{"env": []string{rctx.Str("env")}, "table": []string{table}}, + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "import request failed").WithCause(err).WithRetryable(), dbDataImportHint) + } + data, err := rctx.ClassifyAPIResponse(resp) + if err != nil { + return withAppsHint(err, dbDataImportHint) + } + + outTable := common.GetString(data, "table") + if outTable == "" { + outTable = table + } + rows := int64(0) + if f, ok := numericAsFloat(data["rows"]); ok { + rows = int64(f) + } + out := map[string]interface{}{"file": file, "table": outTable, "rows": rows} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Imported %s → table '%s' (%d rows)\n", file, outTable, rows) + }) + return nil + }, +} + +// importTableName 取目标表名:--table 优先,否则文件名去扩展名。 +func importTableName(rctx *common.RuntimeContext) string { + if t := strings.TrimSpace(rctx.Str("table")); t != "" { + return t + } + f := strings.TrimSpace(rctx.Str("file")) + if f == "" { + return "" + } + base := filepath.Base(f) + return strings.TrimSuffix(base, filepath.Ext(base)) +} diff --git a/shortcuts/apps/apps_db_data_import_test.go b/shortcuts/apps/apps_db_data_import_test.go new file mode 100644 index 000000000..14d29290b --- /dev/null +++ b/shortcuts/apps/apps_db_data_import_test.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const dbDataImportURL = "/open-apis/spark/v1/apps/app_x/db/data_import" + +// chdirTemp 切到临时工作目录(--file 走 cwd 内相对路径),返回该目录。 +func chdirTemp(t *testing.T) string { + t.Helper() + dir := t.TempDir() + old, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) + return dir +} + +// TestAppsDBDataImport_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。 +func TestAppsDBDataImport_RequiresAppID(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", " ", "--file", "orders.csv", "--yes", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--app-id" { + t.Fatalf("Param = %q, want --app-id", ve.Param) + } +} + +// TestAppsDBDataImport_RejectsUnsupportedFormat 验证非 csv/json 文件(.txt)报不支持格式的校验错误。 +func TestAppsDBDataImport_RejectsUnsupportedFormat(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("data.txt", []byte("x\n"), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "data.txt", "--yes", "--as", "user"}, factory, stdout) + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected unsupported-format validation, got %v", err) + } +} + +// TestAppsDBDataImport_RequiresConfirmation 验证缺 --yes 时报 requires confirmation 错误。 +func TestAppsDBDataImport_RequiresConfirmation(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "requires confirmation") { + t.Fatalf("expected confirmation_required, got %v", err) + } +} + +// TestAppsDBDataImport_RejectsOversizeFile 验证超过 1MB 上限的文件报 --file 的 ValidationError。 +func TestAppsDBDataImport_RejectsOversizeFile(t *testing.T) { + chdirTemp(t) + // >1MB → size 校验 + big := append([]byte("id\n"), make([]byte, dbDataImportMaxBytes+1)...) + _ = os.WriteFile("big.csv", big, 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "big.csv", "--yes", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected 1MB limit error, got %T %v", err, err) + } + if ve.Param != "--file" { + t.Fatalf("Param = %q, want --file", ve.Param) + } +} + +// dry-run:multipart 上传——file_name + file 走 body,env + table 走 query(table 缺省取文件名)。 +// TestAppsDBDataImport_DryRunMultipartShape 验证 dry-run 的 multipart 形态:file_name+file 走 body、env+table 走 query 且不再发 format。 +func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--env", "dev", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != dbDataImportURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Body["file_name"] != "orders.csv" || a.Body["file"] == nil { + t.Fatalf("dry-run body should carry file_name + file: %v", a.Body) + } + if _, ok := a.Body["format"]; ok { + t.Fatalf("format must no longer be sent: %v", a.Body) + } + if a.Params["env"] != "dev" || a.Params["table"] != "orders" { + t.Fatalf("dry-run params (env+table) = %v", a.Params) + } +} + +// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。 +func TestAppsDBDataImport_Success(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("orders.csv", []byte("id,name\n1,a\n2,b\n"), 0o600) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbDataImportURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"table": "orders", "rows": 2}}, + }) + if err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--table", "orders", "--yes", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"table": "orders"`) || !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"file": "orders.csv"`) { + t.Fatalf("output missing fields:\n%s", got) + } +} + +// TestAppsDBDataImport_TableDefaultsToFileBasename 验证未传 --table 时表名缺省取文件名去扩展名(customers.json→customers)。 +func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("customers.json", []byte(`[{"id":1}]`), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Params["table"] != "customers" { + t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params) + } +} diff --git a/shortcuts/apps/apps_db_env_migrate.go b/shortcuts/apps/apps_db_env_migrate.go new file mode 100644 index 000000000..3f54df890 --- /dev/null +++ b/shortcuts/apps/apps_db_env_migrate.go @@ -0,0 +1,191 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +const dbEnvMigrateHint = "ensure the app is multi-env (`+db-env-create`) and has pending dev changes; preview with `+db-env-diff`" + +// AppsDBEnvDiff 预览 dev→online 待发布的结构变更(不落地)。 +// +// POST /apps/{app_id}/db/env_migrate,body {dry_run:true},同步返 {from,to,changes[]}。 +// 与 +db-env-migrate 同端点、dry_run 区分;预览也需 spark:app:write scope。 +var AppsDBEnvDiff = common.Shortcut{ + Service: appsService, + Command: "+db-env-diff", + Description: "Preview pending dev→online schema changes (no apply)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-env-diff --app-id ", + "Apply the previewed changes with +db-env-migrate --yes.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + _, err := requireAppID(rctx.Str("app-id")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Preview dev→online migration").Body(map[string]interface{}{"dry_run": true}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + stop := rctx.StartSpinner("Previewing migration diff (dev → online)") + defer stop() + data, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}) + stop() + if err != nil { + return withAppsHint(err, dbEnvMigrateHint) + } + from, to := common.GetString(data, "from"), common.GetString(data, "to") + changes := projectMigrationChanges(data["changes"]) + out := map[string]interface{}{"from": from, "to": to, "changes": changes} + rctx.OutFormat(out, nil, func(w io.Writer) { + renderMigrationDiff(w, from, to, changes) + }) + return nil + }, +} + +// AppsDBEnvMigrate 把 dev 的待发布结构变更发布到 online(异步,CLI 轮询至完成)。 +// +// POST /apps/{app_id}/db/env_migrate,body {dry_run:false} → task_id,轮询 env_migrate_status +// 至 success;后端 status:applied,CLI 对外统一呈现 migrated。high-risk-write。 +var AppsDBEnvMigrate = common.Shortcut{ + Service: appsService, + Command: "+db-env-migrate", + Description: "Publish pending dev→online schema changes (irreversible)", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +db-env-migrate --app-id --yes", + "Preview first with +db-env-diff.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + _, err := requireAppID(rctx.Str("app-id")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Apply dev→online migration").Body(map[string]interface{}{"dry_run": false}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + stop := rctx.StartSpinner("Applying migration (dev → online)") + defer stop() + submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false}) + if err != nil { + return withAppsHint(err, dbEnvMigrateHint) + } + from, to := common.GetString(submit, "from"), common.GetString(submit, "to") + taskID := common.GetString(submit, "task_id") + applied := intFromAny(submit["changes_applied"]) + if applied == 0 { + applied = len(projectMigrationChanges(submit["changes"])) + } + // 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。 + if taskID != "" { + final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute, + func() (map[string]interface{}, error) { + return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil) + }, + func(d map[string]interface{}) (bool, error) { + switch strings.ToLower(common.GetString(d, "status")) { + case "success", "applied", "migrated": + return true, nil + case "failed": + return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", migrateFailMsg(d, taskID)), dbEnvMigrateHint) + } + return false, nil + }) + if perr != nil { + return perr + } + if n := intFromAny(final["changes_applied"]); n > 0 { + applied = n + } + } + stop() // clear spinner before printing the result + out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Migrated %s → %s (%d changes)\n", from, to, applied) + }) + return nil + }, +} + +type migrationChange struct { + Type string `json:"type"` + Table string `json:"table"` + Statement string `json:"statement"` +} + +// projectMigrationChanges 把服务端原始变更项投影为白名单 migrationChange(type/table/statement)。 +func projectMigrationChanges(raw interface{}) []migrationChange { + arr, _ := raw.([]interface{}) + out := make([]migrationChange, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]interface{}); ok { + out = append(out, migrationChange{ + Type: common.GetString(m, "type"), + Table: common.GetString(m, "table"), + Statement: common.GetString(m, "statement"), + }) + } + } + return out +} + +// renderMigrationDiff 渲染 dev→online 待发布变更:无变更打提示,否则逐条打 statement。 +func renderMigrationDiff(w io.Writer, from, to string, changes []migrationChange) { + if len(changes) == 0 { + fmt.Fprintf(w, "No pending changes from %s to %s.\n", from, to) + return + } + fmt.Fprintf(w, "%s → %s (%d changes):\n\n", from, to, len(changes)) + for _, c := range changes { + fmt.Fprintf(w, " %s\n", c.Statement) + } +} + +// migrateFailMsg 取发布失败信息:优先服务端 error_message,缺失则用带 task_id 的兜底文案。 +func migrateFailMsg(d map[string]interface{}, taskID string) string { + if m := common.GetString(d, "error_message"); m != "" { + return m + } + return fmt.Sprintf("migration apply failed (task_id=%s)", taskID) +} + +// intFromAny 把 JSON number / json.Number 转 int(计数用)。 +func intFromAny(v interface{}) int { + if f, ok := numericAsFloat(v); ok { + return int(f) + } + return 0 +} diff --git a/shortcuts/apps/apps_db_env_recovery_quota_test.go b/shortcuts/apps/apps_db_env_recovery_quota_test.go new file mode 100644 index 000000000..a2dee10b1 --- /dev/null +++ b/shortcuts/apps/apps_db_env_recovery_quota_test.go @@ -0,0 +1,369 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const ( + dbEnvMigrateURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate" + dbEnvMigrateStatusURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate_status" + dbRecoveryURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery" + dbRecoveryDiffURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_diff_status" + dbRecoveryApplyURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_apply_status" + dbQuotaURL = "/open-apis/spark/v1/apps/app_x/db/quota" +) + +// ── env-diff ── + +// TestAppsDBEnvDiff_DryRunBody 校验 dry-run 请求体:POST env_migrate 且 dry_run=true。 +func TestAppsDBEnvDiff_DryRunBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBEnvDiff, + []string{"+db-env-diff", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true { + t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body) + } +} + +// TestAppsDBEnvDiff_SuccessRendersChanges 验证 pretty 输出渲染出 dev → online 变更摘要及 DDL 语句。 +func TestAppsDBEnvDiff_SuccessRendersChanges(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbEnvMigrateURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "from": "dev", "to": "online", + "changes": []interface{}{ + map[string]interface{}{"type": "ALTER_TABLE", "table": "orders", "statement": "ALTER TABLE orders ADD COLUMN note text"}, + }, + }}, + }) + if err := runAppsShortcut(t, AppsDBEnvDiff, + []string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "dev → online (1 changes)") || !strings.Contains(got, "ALTER TABLE orders ADD COLUMN note text") { + t.Fatalf("pretty diff malformed:\n%s", got) + } +} + +// TestAppsDBEnvDiff_EmptyChanges 验证无变更时 pretty 输出"无待发布变更"提示。 +func TestAppsDBEnvDiff_EmptyChanges(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbEnvMigrateURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "changes": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsDBEnvDiff, + []string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "No pending changes from dev to online.") { + t.Fatalf("expected empty message, got: %s", stdout.String()) + } +} + +// ── env-migrate ── + +// TestAppsDBEnvMigrate_DryRunBody 校验 migrate 的 dry-run 请求体里 dry_run=false(真实迁移)。 +func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBEnvMigrate, + []string{"+db-env-migrate", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Body["dry_run"] != false { + t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body) + } +} + +// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。 +func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbEnvMigrateURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbEnvMigrateStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "applied", "changes_applied": 3}}, + }) + if err := runAppsShortcut(t, AppsDBEnvMigrate, + []string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "✓ Migrated dev → online (3 changes)") { + t.Fatalf("pretty: %s", got) + } +} + +// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。 +func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbEnvMigrateURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbEnvMigrateStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "failed", "error_message": "lock timeout"}}, + }) + err := runAppsShortcut(t, AppsDBEnvMigrate, + []string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--as", "user"}, factory, stdout) + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError { + t.Fatalf("got %T %v, want API/server_error typed error", err, err) + } + if !strings.Contains(p.Message, "lock timeout") { + t.Fatalf("Message = %q, want it to contain 'lock timeout'", p.Message) + } + if !strings.Contains(p.Hint, "+db-env-diff") { + t.Fatalf("Hint = %q, want the db-env-migrate recovery hint", p.Hint) + } +} + +// TestAppsDBEnvMigrate_RequiresConfirmation 验证 high-risk-write 无 --yes 时被确认门拦截。 +func TestAppsDBEnvMigrate_RequiresConfirmation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // high-risk-write 无 --yes → 应被确认门拦截(非 0 退出)。 + if err := runAppsShortcut(t, AppsDBEnvMigrate, + []string{"+db-env-migrate", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected confirmation gate without --yes") + } +} + +// ── recovery-diff ── + +// TestAppsDBRecoveryDiff_RequiresTarget 验证缺少 --target 时报必填错误。 +func TestAppsDBRecoveryDiff_RequiresTarget(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBRecoveryDiff, + []string{"+db-recovery-diff", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected required --target error") + } +} + +// TestAppsDBRecoveryDiff_DryRunNormalizesTarget 验证 dry-run 走 POST env_recovery 且 --target 被归一化为 RFC3339 UTC。 +func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBRecoveryDiff, + []string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true { + t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body) + } + if s, _ := a.Body["target"].(string); !strings.HasSuffix(s, "Z") { + t.Fatalf("target not normalized to RFC3339 UTC: %v", a.Body["target"]) + } +} + +// TestAppsDBRecoveryDiff_SuccessRendersChanges 验证 preview 成功后 pretty 渲染受影响表数、行增删与预估耗时。 +func TestAppsDBRecoveryDiff_SuccessRendersChanges(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbRecoveryURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbRecoveryDiffURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "preview_status": "success", "tables_affected": 2, "estimated_seconds": 12, + "changes": []interface{}{ + map[string]interface{}{"table": "orders", "inserted": 5, "deleted": 2}, + map[string]interface{}{"table": "carts", "action": "restore_table"}, + }, + }}, + }) + if err := runAppsShortcut(t, AppsDBRecoveryDiff, + []string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"tables affected: 2", "orders: +5 rows, -2 rows", "carts: table will be restored", "estimated time: ~12s"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } +} + +// TestAppsDBRecoveryDiff_PreviewFailed 验证 preview_status=failed 时返回 API/server_error,携带 message 与 PITR window hint。 +func TestAppsDBRecoveryDiff_PreviewFailed(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbRecoveryURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbRecoveryDiffURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_status": "failed", "error_message": "snapshot expired"}}, + }) + err := runAppsShortcut(t, AppsDBRecoveryDiff, + []string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout) + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError { + t.Fatalf("got %T %v, want API/server_error typed error", err, err) + } + if !strings.Contains(p.Message, "snapshot expired") { + t.Fatalf("Message = %q, want it to contain 'snapshot expired'", p.Message) + } + if !strings.Contains(p.Hint, "PITR window") { + t.Fatalf("Hint = %q, want the db-recovery recovery hint", p.Hint) + } +} + +// ── recovery-apply ── + +// TestAppsDBRecoveryApply_NoChangesShortCircuits 验证 status=no_changes 时短路输出"已是该状态",不再轮询。 +func TestAppsDBRecoveryApply_NoChangesShortCircuits(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbRecoveryURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "no_changes"}}, + }) + if err := runAppsShortcut(t, AppsDBRecoveryApply, + []string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "No changes — database is already at this state.") { + t.Fatalf("expected no-changes short-circuit, got: %s", stdout.String()) + } +} + +// TestAppsDBRecoveryApply_AsyncPollSuccess 验证 running → 轮询 success 后 pretty 输出恢复完成及耗时。 +func TestAppsDBRecoveryApply_AsyncPollSuccess(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbRecoveryURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "running"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbRecoveryApplyURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "success", "restore_time_sec": 8}}, + }) + if err := runAppsShortcut(t, AppsDBRecoveryApply, + []string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "✓ Database restored to") || !strings.Contains(stdout.String(), "(8s elapsed)") { + t.Fatalf("pretty: %s", stdout.String()) + } +} + +// TestAppsDBRecoveryApply_RequiresConfirmation 验证无 --yes 时被确认门拦截。 +func TestAppsDBRecoveryApply_RequiresConfirmation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBRecoveryApply, + []string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected confirmation gate without --yes") + } +} + +// ── quota-get ── + +// TestAppsDBQuotaGet_WithQuotaPretty 验证已对接配额时 pretty 渲染存储用量、百分比及 tables/views 数。 +func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbQuotaURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "storage_used_bytes": 1048576, "storage_quota_bytes": 10485760, "usage_percent": 10.0, + "tables": 4, "views": 1, + }}, + }) + if err := runAppsShortcut(t, AppsDBQuotaGet, + []string{"+db-quota-get", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"Storage", "(10.0%)", "Tables", "4", "Views", "1"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } +} + +// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。 +func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbQuotaURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "storage_used_bytes": 2048, "storage_quota_bytes": 0, "tables": 2, "views": 0, + }}, + }) + if err := runAppsShortcut(t, AppsDBQuotaGet, + []string{"+db-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if strings.Contains(got, "storage_quota_bytes") || strings.Contains(got, "usage_percent") { + t.Fatalf("quota fields should be omitted when not provisioned:\n%s", got) + } + if !strings.Contains(got, "storage_used_bytes") || !strings.Contains(got, "\"tables\"") { + t.Fatalf("expected used + tables retained:\n%s", got) + } +} + +// TestProjectDbQuota_WhitelistsFields 验证 projectDbQuota 白名单投影:只保留 used/tables/views(及配额已对接时的 +// quota/usage_percent),后端额外字段不透传。 +func TestProjectDbQuota_WhitelistsFields(t *testing.T) { + out := projectDbQuota(map[string]interface{}{ + "storage_used_bytes": 2048, "storage_quota_bytes": float64(0), "usage_percent": float64(0), + "tables": 2, "views": 1, "tenant_key": "leak", "internal_shard": "s1", + }) + if _, ok := out["storage_quota_bytes"]; ok { + t.Errorf("zero quota should be omitted: %v", out) + } + if out["storage_used_bytes"] != 2048 || out["tables"] != 2 || out["views"] != 1 { + t.Errorf("whitelisted fields should be kept: %v", out) + } + for _, leaked := range []string{"tenant_key", "internal_shard"} { + if _, ok := out[leaked]; ok { + t.Errorf("non-whitelisted field %q must be dropped: %v", leaked, out) + } + } + + out2 := projectDbQuota(map[string]interface{}{"storage_used_bytes": 2048, "storage_quota_bytes": float64(4096), "usage_percent": float64(50), "tables": 2}) + if _, ok := out2["storage_quota_bytes"]; !ok { + t.Errorf("non-zero quota should be kept: %v", out2) + } + if _, ok := out2["usage_percent"]; !ok { + t.Errorf("usage_percent should be kept when quota>0: %v", out2) + } +} diff --git a/shortcuts/apps/apps_db_execute.go b/shortcuts/apps/apps_db_execute.go index 4405cccf9..f67ab534f 100644 --- a/shortcuts/apps/apps_db_execute.go +++ b/shortcuts/apps/apps_db_execute.go @@ -12,12 +12,12 @@ 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" ) -// AppsDBExecute executes SQL against an app database. +// AppsDBExecute executes SQL against a Miaoda app database. // // POST /apps/{app_id}/sql_commands,CLI 永远带 ?transactional=false 进入 DBA 模式 // (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON)。 @@ -31,12 +31,18 @@ import ( // - 多语句部分失败:`Statement K: ✗ []` + 末尾「前序语句已落地」提示 // // 失败语义:server 多语句失败仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵 -// 后按 partial failure 上报(exit 非 0):stdout 输出 ok:false 数据,带 results / -// statement_index / error_code / error_message / rolled_back / note,避免 agent 误判 -// ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit -// 落地,故 rolled_back=false(真机 boe 实证)。 +// 后升级成 typed errs.APIError(CategoryAPI → exit 1),避免 agent 误判 ok:true 假成功。诊断信息 +// (第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地)写进 message+hint 文案(errs.* 信封扁平、无 +// detail 容器):失败在用户显式 BEGIN…COMMIT 事务内 → 整批回滚、前序未落库;否则前序语句已逐条 +// commit、未回滚。rolled_back 语义由 inferRolledBack 按 BEGIN/COMMIT 计数推断。 // -// JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。 +// JSON(成功路径)按 SQL 类型归一化 `data`(不透传后端 result 字符串): +// - 单 SELECT → data 是行数组 `[{...}]`(空 → `[]`) +// - 单 DML → data = `{command, rows_affected}` +// - 单 DDL → data = `{command}` +// - 多语句 → data = `[{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]` +// +// 字段裁剪用框架原生 --jq/-q。 // // Risk: high-risk-write —— SQL 可含 DML/DDL,框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。 // @@ -45,18 +51,18 @@ import ( var AppsDBExecute = common.Shortcut{ Service: appsService, Command: "+db-execute", - Description: "Execute SQL (SELECT / DML / DDL) against an app database", + Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database", Risk: "high-risk-write", Tips: []string{ `Example: lark-cli apps +db-execute --app-id --sql "SELECT * FROM orders LIMIT 10" --yes`, `Example: lark-cli apps +db-execute --app-id --env dev --file ./migration.sql --yes`, - "Tip: filter fields with --jq, e.g. -q '.data.results[].sql_type'", + "Tip: single SELECT returns data as a row array — filter with --jq, e.g. -q '.data[].id'", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ - {Name: "app-id", Desc: "app id", Required: true}, + {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file", Input: []string{common.Stdin}}, {Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"}, @@ -69,27 +75,19 @@ var AppsDBExecute = common.Shortcut{ sql := strings.TrimSpace(rctx.Str("sql")) file := strings.TrimSpace(rctx.Str("file")) if sql != "" && file != "" { - return appsValidationError("--sql and --file are mutually exclusive"). - WithParams( - appsInvalidParam("--sql", "mutually exclusive with --file"), - appsInvalidParam("--file", "mutually exclusive with --sql"), - ) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sql and --file are mutually exclusive") } if file != "" { data, err := cmdutil.ReadInputFile(rctx.FileIO(), file) if err != nil { - return appsValidationParamError("--file", "--file: %v", err).WithCause(err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err) } // 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。 rctx.Cmd.Flags().Set("sql", string(data)) sql = strings.TrimSpace(string(data)) } if sql == "" { - 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --sql or --file is required (use --sql - to read stdin)") } return nil }, @@ -97,7 +95,7 @@ var AppsDBExecute = common.Shortcut{ appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). POST(appSQLPath(appID)). - Desc("Execute SQL on app database"). + Desc("Execute SQL on Miaoda app database"). Params(buildDBSQLParams(rctx)). Body(buildDBSQLBody(rctx)) }, @@ -113,24 +111,27 @@ var AppsDBExecute = common.Shortcut{ return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table
`; for day-to-day debugging target the dev database with `--env dev`") } - // server `result: string` 内嵌结构化数组 —— CLI 解出来放进 envelope 的 data.results, + // server `result: string` 内嵌结构化数组 —— CLI 解出来后按 SQL 类型归一化成 PRD 形态, // 让 json/pretty 路径都基于同一份反序列化产物渲染。 stmts := parseSQLResult(common.GetString(raw, "result")) - // 注意:data.results 在 json(默认)路径下原样透出全部行,CLI 侧不再二次截断。 - // 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出会直接 - // 返报错(而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。 - data := map[string]interface{}{"results": stmts} + // 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 )。 + // 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出直接报错 + // (而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。 + data := shapeSQLData(stmts) // 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。 - // 已落地的前序语句 + 失败语句构成 partial failure:逐条结果作为 ok:false 数据 - // 留在 stdout(机器可读)+ 非零退出信号,别让 agent 误判 ok:true 假成功。 - // pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope),仅返回退出信号。 + // 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。 + // pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→stderr)。 if errIdx, errStmt, failed := findErrorSentinel(stmts); failed { if rctx.Format == "pretty" { renderSQLPretty(rctx.IO().Out, stmts) - return output.PartialFailure(output.ExitAPI) } - return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil) + return sqlStatementError(stmts, errIdx, errStmt) } rctx.OutFormat(data, nil, func(w io.Writer) { @@ -140,6 +141,70 @@ var AppsDBExecute = common.Shortcut{ }, } +// shapeSQLData 把解析出的 statements 归一化成 PRD 约定的 JSON `data` 形态: +// - 无语句 → [](空数组) +// - 单条语句 → singleStatementJSON(SELECT 是行数组、DML/DDL 是对象) +// - 多条语句 → []multiStatementElement(每条统一成 {command,...} 对象,SELECT 行放 rows) +// +// 不再透传后端 result 字符串(旧形态 data.results[].data 是 JSON 字符串,对 agent 不友好)。 +func shapeSQLData(stmts []map[string]interface{}) interface{} { + if len(stmts) == 0 { + return []interface{}{} + } + if len(stmts) == 1 { + return singleStatementJSON(stmts[0]) + } + out := make([]interface{}, 0, len(stmts)) + for _, s := range stmts { + out = append(out, multiStatementElement(s)) + } + return out +} + +// singleStatementJSON 单条语句的 PRD JSON 形态: +// - SELECT → 行数组(空 → []) +// - DML → {command, rows_affected} +// - DDL / OK / 其它 → {command} +func singleStatementJSON(s map[string]interface{}) interface{} { + sqlType := common.GetString(s, "sql_type") + switch { + case sqlType == "SELECT": + return selectRows(s) + case isDMLType(sqlType): + return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])} + default: + return map[string]interface{}{"command": sqlType} + } +} + +// multiStatementElement 多语句里单条的 PRD JSON 形态:与单条一致,但 SELECT 包成 +// {command:"SELECT", rows:[...]}(避免数组里直接嵌套数组造成歧义)。 +func multiStatementElement(s map[string]interface{}) map[string]interface{} { + sqlType := common.GetString(s, "sql_type") + switch { + case sqlType == "SELECT": + return map[string]interface{}{"command": "SELECT", "rows": selectRows(s)} + case isDMLType(sqlType): + return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])} + default: + return map[string]interface{}{"command": sqlType} + } +} + +// selectRows 把 SELECT statement 的 data 字段(行 JSON 数组字符串)解析成行数组; +// 空 / 非法一律返回非 nil 的空数组(保证 JSON 序列化成 [] 而非 null)。 +func selectRows(s map[string]interface{}) []map[string]interface{} { + dataJSON := strings.TrimSpace(common.GetString(s, "data")) + if dataJSON == "" || dataJSON == "null" { + return []map[string]interface{}{} + } + var rows []map[string]interface{} + if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil || rows == nil { + return []map[string]interface{}{} + } + return rows +} + // findErrorSentinel 在 statements 里找 ERROR 哨兵(server 失败时追加在失败语句位置)。 // 返回失败语句下标(0-based)、该 ERROR statement、是否命中。 func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interface{}, bool) { @@ -151,28 +216,48 @@ func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interfac return 0, nil, false } -// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据。 +// sqlStatementError 把 ERROR 哨兵升级成 typed errs.APIError(CategoryAPI → exit 1)。 // -// 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{} { +// 多语句失败的诊断信息——第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地——都写进 +// message + hint 的人类可读文案(errs.* 信封是扁平字段、不带结构化 detail 容器)。文案对齐 +// miaoda-cli(src/cli/handlers/db/sql.ts、src/api/db/api.ts): +// - message 末尾 "(at statement N of M)" 给出失败位置; +// - hint 由 inferRolledBack 推断(实测后端把 BEGIN/COMMIT 也作为 statement 返回): +// 失败仍在用户显式事务内 → 服务端整批回滚,用 miaoda 原句 "Transaction rolled back; no changes persisted."; +// 否则前序语句已逐条 commit、未回滚(flat 信封无逐句 breakdown,故 hint 简述前序已落地 + 从失败处续跑)。 +func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error { code, msg := parseErrorSentinel(common.GetString(errStmt, "data")) stmtNo := errIdx + 1 // 1-based 给人看 - 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) + 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." } - 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 errs.NewAPIError(errs.SubtypeServerError, "%s", fullMsg).WithCode(code).WithHint("%s", hint) +} + +// inferRolledBack 推断失败时是否处于用户显式事务内(→ 服务端整批回滚)。 +// 遍历已完成语句的 sql_type:BEGIN/START TRANSACTION +1,COMMIT/ROLLBACK/END -1; +// 结束 depth>0 说明事务还开着、已被服务端回滚。对齐 miaoda-cli inferRolledBack。 +func inferRolledBack(completed []map[string]interface{}) bool { + depth := 0 + for _, s := range completed { + switch strings.ToUpper(strings.TrimSpace(common.GetString(s, "sql_type"))) { + case "BEGIN", "START TRANSACTION", "START_TRANSACTION": + depth++ + case "COMMIT", "ROLLBACK", "END": + if depth > 0 { + depth-- + } + } } + return depth > 0 } // parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。 @@ -354,10 +439,10 @@ func renderMultiStatementPretty(w io.Writer, stmts []map[string]interface{}) { } fmt.Fprintln(w) if failedIdx >= 0 { - // CLI 永远 DBA 模式(transactional=false),失败语句之前的语句已 auto-commit 落地, - // 不存在整批回滚 —— 如实告诉用户,避免整批重跑导致重复写入。 + // CLI 永远传 transactional=false,失败语句之前的语句已逐条 commit 落地、不会整批回滚—— + // 如实告诉用户,避免整批重跑导致重复写入。 if successCount > 0 { - fmt.Fprintf(w, "(statement %d failed; %d statement%s before it already applied — DBA mode auto-commits each)\n", + fmt.Fprintf(w, "(statement %d failed; %d statement%s before it committed and not rolled back)\n", failedIdx+1, successCount, plural(int64(successCount))) } else { fmt.Fprintf(w, "(statement %d failed; no statements applied)\n", failedIdx+1) @@ -461,6 +546,7 @@ 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": @@ -475,6 +561,7 @@ func dmlVerb(sqlType string) string { return "affected" } +// plural 返回英文复数后缀:n==1 时空串,否则 "s"。 func plural(n int64) string { if n == 1 { return "" diff --git a/shortcuts/apps/apps_db_execute_test.go b/shortcuts/apps/apps_db_execute_test.go index cb95d3f94..335e27e38 100644 --- a/shortcuts/apps/apps_db_execute_test.go +++ b/shortcuts/apps/apps_db_execute_test.go @@ -5,17 +5,18 @@ 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" ) -func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(t *testing.T) { +// TestAppsDBExecute_SingleSELECTJSONIsRowArray 断言单条 SELECT 的 JSON data 直接是行数组(不再透传 result 字符串)。 +func TestAppsDBExecute_SingleSELECTJSONIsRowArray(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -33,23 +34,130 @@ func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(t *testing.T) { factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } - // JSON envelope 应该把 result 字符串 parse 之后放进 data.results + // PRD 单 SELECT:data 直接是行数组(不再是 data.results[].data 字符串) + var env struct { + Data []map[string]interface{} `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 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]) + } +} + +// TestAppsDBExecute_SingleDMLJSONShape 断言单条 DML 的 JSON data 形如 {command, rows_affected}。 +func TestAppsDBExecute_SingleDMLJSONShape(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `[{"sql_type":"INSERT","data":"","affected_rows":3}]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "insert", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + // PRD 单 DML:data = {command, rows_affected} var env struct { Data struct { - Results []map[string]interface{} `json:"results"` + Command string `json:"command"` + RowsAffected int `json:"rows_affected"` } `json:"data"` } if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("decode envelope: %v\n%s", err, stdout.String()) + 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) } - if len(env.Data.Results) != 1 { - t.Fatalf("data.results = %d items (want 1)", len(env.Data.Results)) + // PRD 单 DDL:data = {command} + var env struct { + Data struct { + Command string `json:"command"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) } - if env.Data.Results[0]["sql_type"] != "SELECT" { - t.Fatalf("results[0].sql_type = %v", env.Data.Results[0]["sql_type"]) + if env.Data.Command != "CREATE_TABLE" { + t.Fatalf("data.command = %q, want CREATE_TABLE", env.Data.Command) } } +// TestAppsDBExecute_MultiStatementJSONShape 断言多语句的 JSON data 是元素数组,且 SELECT 包成 {command:"SELECT", rows:[...]}。 +func TestAppsDBExecute_MultiStatementJSONShape(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `[` + + `{"sql_type":"INSERT","data":"","affected_rows":1},` + + `{"sql_type":"SELECT","data":"[{\"id\":999}]","record_count":1}` + + `]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + // PRD 多语句:data 是元素数组;SELECT 包成 {command:"SELECT", rows:[...]} + var env struct { + Data []map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) + } + if len(env.Data) != 2 { + t.Fatalf("data = %d elements (want 2)\n%s", len(env.Data), stdout.String()) + } + if env.Data[0]["command"] != "INSERT" || env.Data[0]["rows_affected"] != float64(1) { + t.Fatalf("data[0] = %v, want {command:INSERT, rows_affected:1}", env.Data[0]) + } + if env.Data[1]["command"] != "SELECT" { + t.Fatalf("data[1].command = %v, want SELECT", env.Data[1]["command"]) + } + rows, ok := env.Data[1]["rows"].([]interface{}) + if !ok || len(rows) != 1 { + t.Fatalf("data[1].rows = %v, want 1 row", env.Data[1]["rows"]) + } +} + +// TestAppsDBExecute_DryRunSendsTransactionalFalse 断言 dry-run 发出的请求是 POST、params 带 transactional=false(DBA 模式)且 transactional 不在 body 里。 func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsDBExecute, @@ -85,6 +193,7 @@ 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, @@ -147,6 +256,7 @@ 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) @@ -178,8 +288,9 @@ func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) { } } -func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) { - // 验证 JSON envelope 也把 legacy result 正确归一化进 data.results +// TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray 断言 legacy wire 的 SELECT 同样归一化成 PRD 行数组形态。 +func TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray(t *testing.T) { + // 验证 legacy wire 的 SELECT 也归一化成 PRD 行数组形态(data 直接是行) factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -197,24 +308,20 @@ func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) { t.Fatalf("execute err=%v", err) } var env struct { - Data struct { - Results []map[string]interface{} `json:"results"` - } `json:"data"` + 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.Results) != 1 { - t.Fatalf("results length = %d, want 1; got: %v", len(env.Data.Results), env.Data.Results) - } - if env.Data.Results[0]["sql_type"] != "SELECT" { - t.Fatalf("results[0].sql_type = %v, want SELECT", env.Data.Results[0]["sql_type"]) + if len(env.Data) != 1 { + t.Fatalf("data length = %d, want 1; got: %v", len(env.Data), env.Data) } - if env.Data.Results[0]["record_count"] != float64(1) { - t.Fatalf("results[0].record_count = %v, want 1", env.Data.Results[0]["record_count"]) + if env.Data[0]["x"] != float64(1) { + t.Fatalf("data[0].x = %v, want 1", env.Data[0]["x"]) } } +// 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) @@ -244,6 +351,7 @@ func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) { } } +// TestAppsDBExecute_LegacyWireDDLEmptyResult 断言 result 为空字符串时(legacy DDL)pretty 输出 "(empty result)"。 func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) { // BOE 实测:CREATE TABLE → result: "" (空字符串,无 rows) // 老 wire 不区分 DDL/DML/无返回,统一标 "ok" @@ -270,6 +378,7 @@ 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) @@ -328,6 +437,7 @@ 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{ @@ -350,6 +460,7 @@ func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) { } } +// TestAppsDBExecute_PrettySingleDMLAndDDL 断言单条 DML 渲染 "✓ N row(s) "、各类 DDL(含细粒度动词)渲染 "✓ DDL executed"。 func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) { cases := []struct { name string @@ -386,6 +497,7 @@ 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{ @@ -455,6 +567,7 @@ 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{ @@ -486,19 +599,20 @@ func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *t t.Errorf("missing %q in pretty output\nfull:\n%s", line, 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) + // 非事务(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) } if strings.Contains(got, "statements executed") { t.Errorf("failed run should NOT print success summary; got:\n%s", got) } } -// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → partial failure」: -// 逐条结果 + statement_index / error_code / rolled_back / note 作为 ok:false 数据落 stdout, -// 退出信号是 PartialFailureError(非零 exit)。rolled_back=false 因 CLI 永远 DBA 模式 -// (真机 boe 实证:失败前的语句已落地)。 +// 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")。 func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -518,65 +632,74 @@ 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 partial-failure error; stdout:\n%s", stdout.String()) + t.Fatalf("multi-statement failure must return a typed 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()) } - var pfErr *output.PartialFailureError - if !errors.As(err, &pfErr) { - t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want a typed errs.* error, got %T: %v", err, err) } - if pfErr.Code != output.ExitAPI { - t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) + 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()) - if got := payload["statement_index"]; got != float64(1) { - t.Errorf("statement_index = %v, want 1", got) + if p.Code != 1300002 { + t.Errorf("code = %d, want 1300002", p.Code) } - if got := payload["error_code"]; got != float64(1300002) { - t.Errorf("error_code = %v, want 1300002", got) + if !strings.Contains(p.Message, "(at statement 2 of 2)") { + t.Errorf("message missing statement locator: %q", p.Message) } - msg, _ := payload["error_message"].(string) - if !strings.Contains(msg, "(at statement 2 of 2)") { - t.Errorf("error_message missing statement locator: %q", msg) + // 无 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) } - 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) + if output.ExitCodeOf(err) != output.ExitAPI { + t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI) } } -// 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"` +// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败(server 也返 code:0 + ERROR 哨兵) +// 同样升级成 typed error:statement_index=0、completed 空、message 标注 (at statement 1 of 1)。 +func TestAppsDBExecute_SingleErrorReturnsTypedError(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 at or near 'SELEC'\"}"}]`, + }, + }, + }) + err := runAppsShortcut(t, AppsDBExecute, + []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()) } - if err := json.Unmarshal([]byte(stdoutStr), &envelope); err != nil { - t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, stdoutStr) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want a typed errs.* error, got %T: %v", err, err) } - if envelope.OK { - t.Fatalf("envelope.ok = true, want false on partial failure") + if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError { + t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype) } - if envelope.Data == nil { - t.Fatalf("envelope.data missing; stdout:\n%s", stdoutStr) + if !strings.Contains(p.Message, "(at statement 1 of 1)") { + t.Errorf("message missing locator: %q", p.Message) + } + // 第一条就失败、无落地 的语义写在 hint。 + if !strings.Contains(p.Hint, "No statements were applied") { + t.Errorf("hint should state nothing applied: %q", p.Hint) } - return envelope.Data } -// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败(server 也返 code:0 + ERROR 哨兵) -// 同样走 partial failure:statement_index=0、note 说明无语句落地、message 标注 (at statement 1 of 1)。 -func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) { +// TestAppsDBExecute_TransactionFailureRolledBack 钉死「显式事务内失败 → 整批回滚」: +// 实测后端把 BEGIN 也作为 statement 返回;completed 含未配对 BEGIN → inferRolledBack 判定回滚。 +// 回滚语义现写在 hint(miaoda 原句 "Transaction rolled back; no changes persisted."),失败位置在 message。 +func TestAppsDBExecute_TransactionFailureRolledBack(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -584,7 +707,13 @@ func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) { Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ - "result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error at or near 'SELEC'\"}"}]`, + // BOE 实测 wire:BEGIN; CREATE; INSERT(ok); INSERT(dup→ERROR) + "result": `[` + + `{"sql_type":"BEGIN","data":"[]"},` + + `{"sql_type":"CREATE_TABLE","data":"[]"},` + + `{"sql_type":"INSERT","data":"[{\"rowCount\":1}]","affected_rows":1},` + + `{"sql_type":"ERROR","data":"{\"code\":\"k_dl_1300002\",\"message\":\"duplicate key value violates unique constraint\"}"}` + + `]`, }, }, }) @@ -592,26 +721,49 @@ 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 partial-failure error; stdout:\n%s", stdout.String()) + t.Fatalf("transaction failure must return a typed error; stdout:\n%s", stdout.String()) } - var pfErr *output.PartialFailureError - if !errors.As(err, &pfErr) { - t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want a typed errs.* error, got %T: %v", err, err) } - 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 p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError { + t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype) } - if got := payload["statement_index"]; got != float64(0) { - t.Errorf("statement_index = %v, want 0", got) + if !strings.Contains(p.Message, "(at statement 4 of 4)") { + t.Errorf("message missing statement locator: %q", p.Message) } - note, _ := payload["note"].(string) - if !strings.Contains(note, "no statements were applied") { - t.Errorf("note should say nothing was applied, got %q", note) + // 事务整批回滚 / 前序未落库 的语义写在 hint(miaoda 原句)。 + if !strings.Contains(p.Hint, "Transaction rolled back; no changes persisted.") { + t.Errorf("hint should state transaction rolled back & nothing persisted: %q", p.Hint) } } +// TestInferRolledBack_Cases 断言 inferRolledBack 按 BEGIN/COMMIT/ROLLBACK 计数判定失败时事务是否仍开着(即整批回滚)。 +func TestInferRolledBack_Cases(t *testing.T) { + stmt := func(t string) map[string]interface{} { return map[string]interface{}{"sql_type": t} } + cases := []struct { + name string + completed []map[string]interface{} + want bool + }{ + {"empty", nil, false}, + {"autocommit single", []map[string]interface{}{stmt("INSERT")}, false}, + {"open tx (unmatched BEGIN)", []map[string]interface{}{stmt("BEGIN"), stmt("CREATE_TABLE"), stmt("INSERT")}, true}, + {"closed tx (BEGIN+COMMIT)", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("COMMIT")}, false}, + {"reopened tx", []map[string]interface{}{stmt("BEGIN"), stmt("COMMIT"), stmt("BEGIN"), stmt("INSERT")}, true}, + {"rollback closes tx", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("ROLLBACK")}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := inferRolledBack(c.completed); got != c.want { + t.Errorf("inferRolledBack(%s) = %v, want %v", c.name, got, c.want) + } + }) + } +} + +// TestCellString_AllKinds 断言 cellString 对 nil/string/bool/整数/小数/对象各类型的字符串化结果。 func TestCellString_AllKinds(t *testing.T) { cases := []struct { name string @@ -635,6 +787,7 @@ func TestCellString_AllKinds(t *testing.T) { } } +// TestCodeString_Forms 断言 codeString 处理 nil / "k_dl_xxx" / 纯数字串 / float64 / 不支持类型各形态。 func TestCodeString_Forms(t *testing.T) { cases := []struct { name string @@ -656,6 +809,7 @@ 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", @@ -671,6 +825,7 @@ 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) @@ -683,6 +838,7 @@ 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 @@ -701,6 +857,7 @@ 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 @@ -722,6 +879,7 @@ 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") @@ -734,6 +892,7 @@ 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("") @@ -764,6 +923,7 @@ 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. @@ -772,6 +932,7 @@ 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 @@ -795,6 +956,7 @@ func TestRenderSingleStatementPretty_Branches(t *testing.T) { } } +// TestRenderSelectRowsAsTable_Branches 断言 renderSelectRowsAsTable 对空串 / 空数组 / 非法 JSON 回退 / 正常 rows 各分支的输出。 func TestRenderSelectRowsAsTable_Branches(t *testing.T) { cases := []struct { name string @@ -816,35 +978,3 @@ 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) - } -} diff --git a/shortcuts/apps/apps_db_quota_get.go b/shortcuts/apps/apps_db_quota_get.go new file mode 100644 index 000000000..c03085b03 --- /dev/null +++ b/shortcuts/apps/apps_db_quota_get.go @@ -0,0 +1,100 @@ +// 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 ", + "Example: lark-cli apps +db-quota-get --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) +} diff --git a/shortcuts/apps/apps_db_recovery.go b/shortcuts/apps/apps_db_recovery.go new file mode 100644 index 000000000..e60ec23f1 --- /dev/null +++ b/shortcuts/apps/apps_db_recovery.go @@ -0,0 +1,267 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +const dbRecoveryHint = "PITR window is up to 7 days back, limited by your last `+db-env-migrate`; pass --target as a time (e.g. 2h / 2026-04-15 / 2026-04-15T10:00:00Z)" + +// AppsDBRecoveryDiff 预览把数据库恢复到某个时间点会带来的变更(PITR diff,不落地)。 +// +// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:true} → preview_request_id, +// 轮询 env_recovery_diff_status 至终态,返回受影响表与行数变化。预览也需 spark:app:write scope。 +var AppsDBRecoveryDiff = common.Shortcut{ + Service: appsService, + Command: "+db-recovery-diff", + Description: "Preview restoring the database to a point in time (PITR diff)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-recovery-diff --app-id --target 2h", + "Apply with +db-recovery-apply --target --yes.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return normalizeTimeFlags(rctx, "target") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery"). + Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + target := rctx.Str("target") + preview, err := runRecoveryPreview(rctx, appID, target) + if err != nil { + return err + } + out := recoveryDiffOutput(target, preview) + rctx.OutFormat(out, nil, func(w io.Writer) { + renderRecoveryDiff(w, target, out) + }) + return nil + }, +} + +// AppsDBRecoveryApply 把数据库恢复到某个时间点(覆盖当前数据,异步,CLI 轮询至完成)。 +// +// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:false};目标=当前态时短路 no_changes, +// 否则轮询 env_recovery_apply_status 至 success。high-risk-write。 +var AppsDBRecoveryApply = common.Shortcut{ + Service: appsService, + Command: "+db-recovery-apply", + Description: "Restore the database to a point in time (overwrites current data, irreversible)", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +db-recovery-apply --app-id --target 2026-04-15T10:00:00Z --yes", + "Preview first with +db-recovery-diff.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return normalizeTimeFlags(rctx, "target") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery"). + Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + target := rctx.Str("target") + stop := rctx.StartSpinner("Restoring database (target: " + target + ")") + defer stop() + submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false}) + if err != nil { + return withAppsHint(err, dbRecoveryHint) + } + // 目标=当前态 → 后端短路 no_changes,不轮询。 + if strings.ToLower(common.GetString(submit, "status")) == "no_changes" { + stop() + out := map[string]interface{}{"status": "no_changes", "target": target} + rctx.OutFormat(out, nil, func(w io.Writer) { + io.WriteString(w, "No changes — database is already at this state.\n") + }) + return nil + } + final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 30*time.Minute, + func() (map[string]interface{}, error) { + return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil) + }, + func(d map[string]interface{}) (bool, error) { + switch strings.ToLower(common.GetString(d, "status")) { + case "success", "restored", "ready": + return true, nil + case "failed": + msg := common.GetString(d, "error_message") + if msg == "" { + msg = fmt.Sprintf("recovery to %s failed", target) + } + return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint) + } + return false, nil + }) + if perr != nil { + return perr + } + stop() + out := map[string]interface{}{"status": "restored", "target": target} + if n := intFromAny(final["restore_time_sec"]); n > 0 { + out["restore_time_sec"] = n + } + rctx.OutFormat(out, nil, func(w io.Writer) { + if n, ok := out["restore_time_sec"].(int); ok { + fmt.Fprintf(w, "✓ Database restored to %s (%ds elapsed)\n", target, n) + } else { + fmt.Fprintf(w, "✓ Database restored to %s\n", target) + } + }) + return nil + }, +} + +// runRecoveryPreview 触发 PITR 预览(dry_run=true)拿 preview_request_id,轮询 diff_status 至终态。 +func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) { + stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")") + defer stop() + submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true}) + if err != nil { + return nil, withAppsHint(err, dbRecoveryHint) + } + prid := common.GetString(submit, "preview_request_id") + if prid == "" { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id") + } + return pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute, + func() (map[string]interface{}, error) { + return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil) + }, + func(d map[string]interface{}) (bool, error) { + switch strings.ToLower(common.GetString(d, "preview_status")) { + case "success": + return true, nil + case "failed": + msg := common.GetString(d, "error_message") + if msg == "" { + msg = "recovery preview failed" + } + return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint) + } + return false, nil + }) +} + +type recoveryChange struct { + Table string `json:"table"` + Inserted interface{} `json:"inserted,omitempty"` + Deleted interface{} `json:"deleted,omitempty"` + Action string `json:"action,omitempty"` + DroppedAt string `json:"dropped_at,omitempty"` +} + +// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。 +func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} { + arr, _ := preview["changes"].([]interface{}) + changes := make([]recoveryChange, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + changes = append(changes, recoveryChange{ + Table: common.GetString(m, "table"), + Inserted: m["inserted"], + Deleted: m["deleted"], + Action: common.GetString(m, "action"), + DroppedAt: common.GetString(m, "dropped_at"), + }) + } + tablesAffected := intFromAny(preview["tables_affected"]) + if tablesAffected == 0 { + tablesAffected = len(changes) + } + est := intFromAny(preview["estimated_seconds"]) + if est == 0 { + est = 30 // PRD 兜底 + } + return map[string]interface{}{ + "target": target, "tables_affected": tablesAffected, + "changes": changes, "estimated_seconds": est, + } +} + +// renderRecoveryDiff 渲染 PITR 恢复预览:受影响表数、逐表变化描述及预估耗时;无变更打提示。 +func renderRecoveryDiff(w io.Writer, target string, out map[string]interface{}) { + changes, _ := out["changes"].([]recoveryChange) + if len(changes) == 0 { + io.WriteString(w, "No changes — database is already at this state.\n") + return + } + fmt.Fprintf(w, "Recovery preview (→ %s):\n\n", target) + fmt.Fprintf(w, " tables affected: %d\n", intFromAny(out["tables_affected"])) + for _, c := range changes { + fmt.Fprintf(w, " %s: %s\n", c.Table, describeRecoveryChange(c)) + } + fmt.Fprintf(w, "\n estimated time: ~%ds\n", intFromAny(out["estimated_seconds"])) +} + +// describeRecoveryChange:schema 动作 或 数据行变化二选一(无 modified,对齐设计)。 +func describeRecoveryChange(c recoveryChange) string { + switch c.Action { + case "restore_table": + return "table will be restored" + case "drop_table": + return "table will be dropped" + case "alter_table": + return "table will be altered" + case "unavailable": + if c.DroppedAt != "" { + return "diff unavailable: " + c.DroppedAt + } + return "diff unavailable" + } + parts := make([]string, 0, 2) + if n := intFromAny(c.Inserted); n != 0 { + parts = append(parts, fmt.Sprintf("+%d rows", n)) + } + if n := intFromAny(c.Deleted); n != 0 { + parts = append(parts, fmt.Sprintf("-%d rows", n)) + } + if len(parts) == 0 { + return "no changes" + } + return strings.Join(parts, ", ") +} diff --git a/shortcuts/apps/apps_file_delete.go b/shortcuts/apps/apps_file_delete.go new file mode 100644 index 000000000..153f40a84 --- /dev/null +++ b/shortcuts/apps/apps_file_delete.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsFileDelete batch-deletes files by remote path(high-risk-write,框架自动注入 --yes 确认)。 +// +// POST /apps/{app_id}/storage/file_batch_remove,body {paths:[...]}。网关把该路由注册为 POST +// (DELETE-with-body 不被网关支持,实测 DELETE→404 / POST→200)。后端 results[] 与请求 paths +// 顺序一一对应:成功项带 file,失败项带 error_code(CLI 据下标回填 path)。 +// 部分失败整体仍 ok:true —— 失败项落在 data.results[].error,不翻成非 0 退出码(lark-cli 信封语义)。 +var AppsFileDelete = common.Shortcut{ + Service: appsService, + Command: "+file-delete", + Description: "Delete one or more files by remote path (batch)", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +file-delete --app-id --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)) +} diff --git a/shortcuts/apps/apps_file_delete_test.go b/shortcuts/apps/apps_file_delete_test.go new file mode 100644 index 000000000..edfce9241 --- /dev/null +++ b/shortcuts/apps/apps_file_delete_test.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const fileDeleteURL = "/open-apis/spark/v1/apps/app_x/storage/file_batch_remove" + +// TestAppsFileDelete_RequiresAppIDAndPath 验证仅含空白的 --path 去空后为空时,Validate 报 --path typed 校验错误。 +func TestAppsFileDelete_RequiresAppIDAndPath(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // 传入仅含空白的 --path:满足 cobra 的 Required 检查,但 cleanDeletePaths 去空后为空, + // 触发 Validate 内的 typed --path 校验。 + err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", " ", "--yes", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--path" { + t.Fatalf("Param = %q, want --path", ve.Param) + } +} + +// high-risk-write:无 --yes → confirmation_required(exit 10)。 +func TestAppsFileDelete_RequiresConfirmation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "requires confirmation") { + t.Fatalf("expected confirmation_required, got %v", err) + } +} + +// TestAppsFileDelete_DryRunSendsPaths 验证 dry-run 输出 POST file_batch_remove,body.paths 按序携带多个 --path。 +func TestAppsFileDelete_DryRunSendsPaths(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/b.png", "--yes", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != fileDeleteURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + paths, _ := a.Body["paths"].([]interface{}) + if len(paths) != 2 || paths[0] != "/a.png" || paths[1] != "/b.png" { + t.Fatalf("body.paths = %v", a.Body["paths"]) + } +} + +// 部分失败仍 ok:true;results 按下标 zip 回 path;失败项带 error{code,message}。 +func TestAppsFileDelete_PartialFailureStillOK(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileDeleteURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "results": []interface{}{ + map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png", "path": "/a.png"}}, + map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"}, + }, + }}, + }) + err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--as", "user"}, factory, stdout) + if err != nil { + t.Fatalf("partial failure should NOT error (ok:true semantics), got %v", err) + } + got := stdout.String() + var env struct { + Data struct { + Results []map[string]interface{} `json:"results"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(got), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, got) + } + if len(env.Data.Results) != 2 { + t.Fatalf("want 2 results, got %d: %s", len(env.Data.Results), got) + } + r0, r1 := env.Data.Results[0], env.Data.Results[1] + if r0["status"] != "ok" || r0["path"] != "/a.png" { + t.Errorf("result[0] = %v", r0) + } + if r1["status"] != "error" || r1["path"] != "/missing.png" { + t.Errorf("result[1] = %v (path must be back-filled by index)", r1) + } + if e, ok := r1["error"].(map[string]interface{}); !ok || e["code"] != "FILE_NOT_FOUND" { + t.Errorf("result[1].error = %v (want code FILE_NOT_FOUND)", r1["error"]) + } +} + +// TestAppsFileDelete_PrettySummary 验证 pretty 输出逐项 ✓/✗ 标记并汇总 "1/2 deleted"。 +func TestAppsFileDelete_PrettySummary(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileDeleteURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "results": []interface{}{ + map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png"}}, + map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"}, + }, + }}, + }) + if err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"✓ /a.png", "✗ /missing.png (FILE_NOT_FOUND)", "1/2 deleted"} { + if !strings.Contains(got, want) { + t.Errorf("pretty missing %q:\n%s", want, got) + } + } +} diff --git a/shortcuts/apps/apps_file_download.go b/shortcuts/apps/apps_file_download.go new file mode 100644 index 000000000..15736f854 --- /dev/null +++ b/shortcuts/apps/apps_file_download.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "net/http" + "path" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsFileDownload downloads a file to a local path via a signed URL。 +// +// 两步:POST /apps/{app_id}/storage/file_sign 拿 signed_url(presigned,直连对象存储), +// 再客户端 GET signed_url 落盘到 --output(默认远端 basename)。不单设 download 接口。 +var AppsFileDownload = common.Shortcut{ + Service: appsService, + Command: "+file-download", + Description: "Download a file to a local path (via a signed URL)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-download --app-id --path /1858537546760216.png --output ./logo.png", + "Example (omit --output): lark-cli apps +file-download --app-id --path /1858537546760216.png # saves to ./1858537546760216.png", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "path", Desc: "remote file path", Required: true}, + {Name: "output", Desc: "local output path (default: remote file basename in cwd)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, err := requireFilePath(rctx.Str("path")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + remotePath, _ := requireFilePath(rctx.Str("path")) + return common.NewDryRunAPI(). + POST(appFileSignPath(appID)). + Desc("Sign a download URL, then GET it to --output"). + Body(map[string]interface{}{"path": remotePath}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + remotePath, err := requireFilePath(rctx.Str("path")) + if err != nil { + return err + } + + // 1. 签名拿 presigned signed_url。 + signData, err := rctx.CallAPITyped("POST", appFileSignPath(appID), nil, map[string]interface{}{"path": remotePath}) + if err != nil { + return err + } + signedURL := common.GetString(signData, "signed_url") + if signedURL == "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "sign returned no signed_url") + } + + // 2. 直连 GET signed_url 落盘。 + out := strings.TrimSpace(rctx.Str("output")) + if out == "" { + out = path.Base(strings.TrimPrefix(remotePath, "/")) + if out == "" || out == "." || out == "/" { + out = "download" + } + } + req, err := http.NewRequestWithContext(rctx.Ctx(), http.MethodGet, signedURL, nil) //nolint:forbidigo // GET from a presigned object-storage URL bypasses the Lark gateway; raw HTTP required (not a Lark API call). + if err != nil { + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "build download request").WithCause(err) + } + resp, err := newFileTransferClient().Do(req) //nolint:forbidigo // see above: direct presigned-URL download, RuntimeContext.DoAPI does not apply. + if err != nil { + // dial/transport 失败是典型可重试场景。 + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed").WithCause(err).WithRetryable() + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + // 5xx 是上游瞬时故障,标 retryable;4xx(如签名过期)需重新签名而非盲重试,不标。 + if resp.StatusCode >= 500 { + return errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed: HTTP %d", resp.StatusCode).WithRetryable() + } + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode) + } + saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output").WithCause(err) + } + resolved, perr := rctx.FileIO().ResolvePath(out) + if perr != nil || resolved == "" { + resolved = out + } + result := map[string]interface{}{ + "path": remotePath, + "output": resolved, + "size_bytes": saved.Size(), + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Downloaded %s → %s (%s)\n", remotePath, resolved, humanBytes(saved.Size())) + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_file_download_test.go b/shortcuts/apps/apps_file_download_test.go new file mode 100644 index 000000000..1d4297971 --- /dev/null +++ b/shortcuts/apps/apps_file_download_test.go @@ -0,0 +1,122 @@ +// 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) + } +} diff --git a/shortcuts/apps/apps_file_get.go b/shortcuts/apps/apps_file_get.go new file mode 100644 index 000000000..7fd99af23 --- /dev/null +++ b/shortcuts/apps/apps_file_get.go @@ -0,0 +1,87 @@ +// 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=。file 仅按 path 精确寻址,无按名寻址。 +// pretty 渲染 key/value:file_name / path / size(含 bytes) / type / uploaded_by(只 name) / uploaded_at / +// download_url(条件出现)。server created_at/created_by → uploaded_at/uploaded_by。 +var AppsFileGet = common.Shortcut{ + Service: appsService, + Command: "+file-get", + Description: "Get a single file's metadata by path", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-get --app-id --path /1858537546760216.png", + "Tip: extract a single field with --jq, e.g. -q '.size_bytes' or -q '.download_url'", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "path", Desc: "remote file path", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, err := requireFilePath(rctx.Str("path")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appFileGetPath(appID)). + Desc("Get Miaoda app file metadata"). + Params(buildFileGetParams(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("GET", appFileGetPath(appID), buildFileGetParams(rctx), nil) + if err != nil { + return err + } + info := projectFileInfo(data) + rctx.OutFormat(info, nil, func(w io.Writer) { + renderFileGetPretty(w, info) + }) + return nil + }, +} + +// buildFileGetParams 组装 file_get 查询参数:按 path 精确寻址单文件。 +func buildFileGetParams(rctx *common.RuntimeContext) map[string]interface{} { + path, _ := requireFilePath(rctx.Str("path")) + return map[string]interface{}{"path": path} +} + +// renderFileGetPretty 输出对齐 key/value;uploaded_by 只展示 name(id 仅 json 保留)。 +func renderFileGetPretty(w io.Writer, info fileInfo) { + pairs := [][2]string{ + {"file_name", dashIfEmpty(info.FileName)}, + {"path", info.Path}, + {"size", fileSizeDetail(info.SizeBytes)}, + {"type", dashIfEmpty(info.Type)}, + } + if info.UploadedBy != nil { + pairs = append(pairs, [2]string{"uploaded_by", info.UploadedBy.Name}) + } + pairs = append(pairs, [2]string{"uploaded_at", dashIfEmpty(info.UploadedAt)}) + if info.DownloadURL != "" { + pairs = append(pairs, [2]string{"download_url", info.DownloadURL}) + } + renderKeyValuePairs(w, pairs) +} diff --git a/shortcuts/apps/apps_file_get_test.go b/shortcuts/apps/apps_file_get_test.go new file mode 100644 index 000000000..ec78811a2 --- /dev/null +++ b/shortcuts/apps/apps_file_get_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const fileGetURL = "/open-apis/spark/v1/apps/app_x/storage/file" + +// TestAppsFileGet_RequiresAppIDAndPath 验证空白 --app-id 与空白 --path 分别触发对应的 typed 校验错误。 +func TestAppsFileGet_RequiresAppIDAndPath(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileGet, + []string{"+file-get", "--app-id", " ", "--path", "/x.png", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--app-id" { + t.Fatalf("Param = %q, want --app-id", ve.Param) + } + factory2, stdout2, _ := newAppsExecuteFactory(t) + err2 := runAppsShortcut(t, AppsFileGet, + []string{"+file-get", "--app-id", "app_x", "--path", " ", "--as", "user"}, factory2, stdout2) + var ve2 *errs.ValidationError + if !errors.As(err2, &ve2) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err2, err2) + } + if ve2.Param != "--path" { + t.Fatalf("Param = %q, want --path", ve2.Param) + } +} + +// TestAppsFileGet_DryRunSendsPathQuery 验证 dry-run 输出 GET file,path 作为 query 参数下发。 +func TestAppsFileGet_DryRunSendsPathQuery(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileGet, + []string{"+file-get", "--app-id", "app_x", "--path", "/x.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Method != "GET" || env.API[0].URL != fileGetURL || env.API[0].Params["path"] != "/x.png" { + t.Fatalf("dry-run = %s %s params=%v", env.API[0].Method, env.API[0].URL, env.API[0].Params) + } +} + +// TestAppsFileGet_SuccessAndPrettyKeyValue 验证 pretty key/value 展示 size 含 bytes、uploaded_by 只显示 name 且不泄漏 user id。 +func TestAppsFileGet_SuccessAndPrettyKeyValue(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: fileGetURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "file_name": "logo.png", "path": "/1858537546760216.png", + "size_bytes": 24580, "type": "image/png", + "created_at": "2026-04-15T10:30:00Z", + "created_by": `{"id":"7311","name":"alice"}`, + }}, + }) + if err := runAppsShortcut(t, AppsFileGet, + []string{"+file-get", "--app-id", "app_x", "--path", "/1858537546760216.png", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // pretty key/value:size 含 bytes、uploaded_by 只展示 name。 + for _, want := range []string{"file_name:", "24 KB (24580 bytes)", "uploaded_by: alice", "uploaded_at: 2026-04-15T10:30:00Z"} { + if !strings.Contains(got, want) { + t.Errorf("pretty missing %q:\n%s", want, got) + } + } + // pretty 不该泄漏 user id。 + if strings.Contains(got, "7311") { + t.Errorf("pretty should show name only, not id:\n%s", got) + } +} diff --git a/shortcuts/apps/apps_file_list.go b/shortcuts/apps/apps_file_list.go new file mode 100644 index 000000000..56ab88c53 --- /dev/null +++ b/shortcuts/apps/apps_file_list.go @@ -0,0 +1,145 @@ +// 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 ", + "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 + } + // 设计原则三: 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。 + for _, f := range []string{"uploaded-since", "uploaded-until"} { + if strings.TrimSpace(rctx.Str(f)) == "" { + continue + } + n, err := normalizeTimestamp(rctx.Str(f)) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: %v", f, err).WithParam("--" + f) + } + _ = rctx.Cmd.Flags().Set(f, n) + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appFileListPath(appID)). + Desc("List Miaoda app files"). + Params(buildFileListParams(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("GET", appFileListPath(appID), buildFileListParams(rctx), nil) + if err != nil { + return err + } + // 白名单投影:server created_at/created_by → uploaded_at/uploaded_by,替换原始 items[]。 + items := projectFileItems(data["items"]) + data["items"] = items + rctx.OutFormat(data, nil, func(w io.Writer) { + renderFileListPretty(w, items) + }) + return nil + }, +} + +// projectFileItems 把服务端原始 items 逐项投影为白名单 fileInfo(created_*→uploaded_*)。 +func projectFileItems(raw interface{}) []fileInfo { + arr, _ := raw.([]interface{}) + out := make([]fileInfo, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]interface{}); ok { + out = append(out, projectFileInfo(m)) + } + } + return out +} + +// buildFileListParams 组装 file_list 查询参数:page_size 及可选 name/path/type/size_gt/size_lt/uploaded_since/uploaded_until/page_token。 +func buildFileListParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{ + "page_size": rctx.Int("page-size"), + } + addStr := func(flag, key string) { + if v := strings.TrimSpace(rctx.Str(flag)); v != "" { + params[key] = v + } + } + addStr("name", "name") + addStr("path", "path") + addStr("type", "type") + addStr("uploaded-since", "uploaded_since") + addStr("uploaded-until", "uploaded_until") + addStr("page-token", "page_token") + if v := rctx.Int("size-gt"); v > 0 { + params["size_gt"] = v + } + if v := rctx.Int("size-lt"); v > 0 { + params["size_lt"] = v + } + return params +} + +// renderFileListPretty 5 列对齐表:file_name / path / size / type / uploaded_at。 +func renderFileListPretty(w io.Writer, items []fileInfo) { + if len(items) == 0 { + io.WriteString(w, "No files found.\n") + return + } + headers := []string{"file_name", "path", "size", "type", "uploaded_at"} + rows := make([][]string, 0, len(items)) + for _, it := range items { + rows = append(rows, []string{ + dashIfEmpty(it.FileName), + it.Path, + humanBytes(it.SizeBytes), + dashIfEmpty(it.Type), + dashIfEmpty(it.UploadedAt), + }) + } + renderAlignedTable(w, headers, rows) +} diff --git a/shortcuts/apps/apps_file_list_test.go b/shortcuts/apps/apps_file_list_test.go new file mode 100644 index 000000000..c6616e4ab --- /dev/null +++ b/shortcuts/apps/apps_file_list_test.go @@ -0,0 +1,252 @@ +// 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" +) + +// 设计原则三: 四种格式 → 统一 RFC3339 UTC。 +func TestNormalizeTimestamp_AllFormats(t *testing.T) { + // 空串透传 + if got, err := normalizeTimestamp(" "); err != nil || got != "" { + t.Fatalf("empty → %q,%v want \"\",nil", got, err) + } + + // ISO 8601 带 TZ:Z 原样、显式偏移换算到 UTC + mustEq := func(in, want string) { + got, err := normalizeTimestamp(in) + if err != nil || got != want { + t.Errorf("normalizeTimestamp(%q)=%q,%v want %q", in, got, err, want) + } + } + mustEq("2026-04-15T10:00:00Z", "2026-04-15T10:00:00Z") + mustEq("2026-04-15T10:00:00+08:00", "2026-04-15T02:00:00Z") // +08:00 → UTC -8h + + // date / local datetime:按本地时区解释再转 UTC(与 time.ParseInLocation 对齐) + dExp, _ := time.ParseInLocation("2006-01-02", "2026-04-15", time.Local) + mustEq("2026-04-15", dExp.UTC().Format(time.RFC3339)) + ldExp, _ := time.ParseInLocation("2006-01-02T15:04:05", "2026-04-15T10:00:00", time.Local) + mustEq("2026-04-15T10:00:00", ldExp.UTC().Format(time.RFC3339)) + + // 相对:从现在往前推,结果应 ≈ now-dur(5s 容差) + for _, c := range []struct { + in string + dur time.Duration + }{{"30s", 30 * time.Second}, {"5m", 5 * time.Minute}, {"2h", 2 * time.Hour}, {"3d", 72 * time.Hour}, {"1w", 7 * 24 * time.Hour}} { + got, err := normalizeTimestamp(c.in) + if err != nil { + t.Errorf("normalizeTimestamp(%q) err=%v", c.in, err) + continue + } + ts, perr := time.Parse(time.RFC3339, got) + if perr != nil { + t.Errorf("normalizeTimestamp(%q)=%q not RFC3339", c.in, got) + continue + } + want := time.Now().Add(-c.dur) + if diff := want.Sub(ts); diff > 5*time.Second || diff < -5*time.Second { + t.Errorf("normalizeTimestamp(%q)=%q off by %v from now-%v", c.in, got, diff, c.dur) + } + } + + // 非法格式 → error + for _, bad := range []string{"notatime", "7x", "2026/04/15", "2026-13-99"} { + if _, err := normalizeTimestamp(bad); err == nil { + t.Errorf("normalizeTimestamp(%q) expected error", bad) + } + } +} + +const fileListURL = "/open-apis/spark/v1/apps/app_x/storage/file_list" + +// TestAppsFileList_RequiresAppID 验证空白 --app-id 触发 --app-id typed 校验错误。 +func TestAppsFileList_RequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", " ", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--app-id" { + t.Fatalf("Param = %q, want --app-id", ve.Param) + } +} + +// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_since/until 原样)。 +func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", + "--name", "logo.png", "--path", "/x.png", "--type", "image/png", + "--size-gt", "100", "--size-lt", "9000", + "--uploaded-since", "2026-01-01", "--uploaded-until", "2026-02-01", + "--page-size", "5", "--page-token", "cur-1", + "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + a := env.API[0] + if a.Method != "GET" || a.URL != fileListURL { + t.Fatalf("method/url = %s %s", a.Method, a.URL) + } + // 设计原则三:date 入参会被归一化为 RFC3339 UTC,期望值用 normalizeTimestamp 计算(避开本地时区脆弱断言)。 + sinceN, _ := normalizeTimestamp("2026-01-01") + untilN, _ := normalizeTimestamp("2026-02-01") + wantStr := map[string]string{ + "name": "logo.png", "path": "/x.png", "type": "image/png", + "uploaded_since": sinceN, "uploaded_until": untilN, "page_token": "cur-1", + } + for k, v := range wantStr { + if a.Params[k] != v { + t.Errorf("params.%s = %v, want %v", k, a.Params[k], v) + } + } + // 且确实归一化成了 UTC(以 Z 结尾),不是原样透传。 + if s, _ := a.Params["uploaded_since"].(string); !strings.HasSuffix(s, "Z") { + t.Errorf("uploaded_since not normalized to RFC3339 UTC: %v", a.Params["uploaded_since"]) + } + for _, k := range []string{"size_gt", "size_lt", "page_size"} { + if _, ok := a.Params[k]; !ok { + t.Errorf("params missing %s: %v", k, a.Params) + } + } +} + +// 0 值过滤器不下发(size-gt/lt 缺省 0、空字符串过滤器)。 +func TestAppsFileList_DryRunOmitsEmptyFilters(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + for _, banned := range []string{"name", "path", "type", "size_gt", "size_lt", "uploaded_since", "uploaded_until", "page_token"} { + if _, ok := env.API[0].Params[banned]; ok { + t.Errorf("params should omit empty %s: %v", banned, env.API[0].Params) + } + } + if _, ok := env.API[0].Params["page_size"]; !ok { + t.Errorf("params should always carry page_size: %v", env.API[0].Params) + } +} + +// created_at/created_by → uploaded_at/uploaded_by;created_by 是 JSON 字符串 → parse 成对象。 +func TestAppsFileList_SuccessProjectsCreatedToUploaded(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: fileListURL, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{ + map[string]interface{}{ + "file_name": "logo.png", + "path": "/1858537546760216.png", + "size_bytes": 24580, + "type": "image/png", + "created_at": "2026-04-15T10:30:00Z", + "created_by": `{"id":"7311","name":"alice"}`, + "download_url": "/spark/app/x/1858537546760216.png", + }, + }, + }, + }, + }) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{`"uploaded_at": "2026-04-15T10:30:00Z"`, `"uploaded_by"`, `"name": "alice"`, `"id": "7311"`} { + if !strings.Contains(got, want) { + t.Errorf("stdout missing %q:\n%s", want, got) + } + } + // created_* 不应再出现在输出。 + for _, banned := range []string{"created_at", "created_by"} { + if strings.Contains(got, banned) { + t.Errorf("stdout should not contain %q (renamed to uploaded_*):\n%s", banned, got) + } + } +} + +// TestAppsFileList_PrettyTableAndEmpty 验证 pretty 非空时渲染表头与人类可读 size,空结果时输出 "No files found."。 +func TestAppsFileList_PrettyTableAndEmpty(t *testing.T) { + // 非空:5 列表头。 + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: fileListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{ + "file_name": "logo.png", "path": "/x.png", "size_bytes": 24576, "type": "image/png", + "created_at": "2026-04-15T10:30:00Z", + }}, + }}, + }) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "file_name") || !strings.Contains(got, "uploaded_at") || !strings.Contains(got, "24 KB") { + t.Fatalf("pretty table malformed:\n%s", got) + } + + // 空:No files found. + factory2, stdout2, reg2 := newAppsExecuteFactory(t) + reg2.Register(&httpmock.Stub{ + Method: "GET", URL: fileListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout2.String(), "No files found.") { + t.Fatalf("empty pretty should say 'No files found.', got: %s", stdout2.String()) + } +} + +// TestParseFileUser_Cases 验证 parseFileUser:合法 JSON 解析成对象,空串/非法/全空字段均返回 nil。 +func TestParseFileUser_Cases(t *testing.T) { + if u := parseFileUser(`{"id":"1","name":"a"}`); u == nil || u.ID != "1" || u.Name != "a" { + t.Fatalf("valid parse failed: %#v", u) + } + if u := parseFileUser(""); u != nil { + t.Errorf("empty → nil, got %#v", u) + } + if u := parseFileUser("not json"); u != nil { + t.Errorf("invalid → nil, got %#v", u) + } + if u := parseFileUser(`{"id":"","name":""}`); u != nil { + t.Errorf("all-empty → nil, got %#v", u) + } +} diff --git a/shortcuts/apps/apps_file_quota_get.go b/shortcuts/apps/apps_file_quota_get.go new file mode 100644 index 000000000..bc3c2f7c8 --- /dev/null +++ b/shortcuts/apps/apps_file_quota_get.go @@ -0,0 +1,93 @@ +// 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 ", + "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) +} diff --git a/shortcuts/apps/apps_file_quota_get_test.go b/shortcuts/apps/apps_file_quota_get_test.go new file mode 100644 index 000000000..6924c0df4 --- /dev/null +++ b/shortcuts/apps/apps_file_quota_get_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +const fileQuotaURL = "/open-apis/spark/v1/apps/app_x/storage/file_quota" + +// TestAppsFileQuotaGet_QuotaConnectedShowsAllFields 验证配额已对接时输出 storage_quota_bytes/usage_percent/files 全字段。 +func TestAppsFileQuotaGet_QuotaConnectedShowsAllFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: fileQuotaURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "storage_used_bytes": 157286400, + "storage_quota_bytes": 1073741824, + "usage_percent": 14.6, + "files": 42, + }}, + }) + if err := runAppsShortcut(t, AppsFileQuotaGet, + []string{"+file-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{`"storage_quota_bytes"`, `"usage_percent"`, `"files"`} { + if !strings.Contains(got, want) { + t.Errorf("quota json missing %q:\n%s", want, got) + } + } +} + +// 配额未对接(=0):storage_quota_bytes / usage_percent 不输出。 +func TestAppsFileQuotaGet_UnconnectedOmitsQuotaFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: fileQuotaURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "storage_used_bytes": 157286400, + "storage_quota_bytes": 0, + "usage_percent": 0, + "files": 42, + }}, + }) + if err := runAppsShortcut(t, AppsFileQuotaGet, + []string{"+file-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, banned := range []string{"storage_quota_bytes", "usage_percent"} { + if strings.Contains(got, banned) { + t.Errorf("unconnected quota should omit %q:\n%s", banned, got) + } + } + if !strings.Contains(got, `"storage_used_bytes"`) || !strings.Contains(got, `"files"`) { + t.Errorf("should still show used/files:\n%s", got) + } +} + +// TestProjectFileQuota_OmitsZeroQuotaAndDropsUnknownFields 验证 projectFileQuota 白名单投影: +// quota=0 时不输出 storage_quota_bytes/usage_percent,非零时保留;后端额外字段不透传。 +func TestProjectFileQuota_OmitsZeroQuotaAndDropsUnknownFields(t *testing.T) { + out := projectFileQuota(map[string]interface{}{ + "storage_used_bytes": 100, "storage_quota_bytes": float64(0), "usage_percent": float64(0), + "files": 3, "tenant_key": "leak", "request_id": "rid", + }) + if _, ok := out["storage_quota_bytes"]; ok { + t.Errorf("zero quota should be omitted: %v", out) + } + if _, ok := out["usage_percent"]; ok { + t.Errorf("usage_percent should be omitted when quota=0: %v", out) + } + if out["storage_used_bytes"] != 100 || out["files"] != 3 { + t.Errorf("whitelisted fields should be kept: %v", out) + } + // 白名单外的字段必须被丢弃,避免无用字段消耗 agent 上下文。 + for _, leaked := range []string{"tenant_key", "request_id"} { + if _, ok := out[leaked]; ok { + t.Errorf("non-whitelisted field %q must be dropped: %v", leaked, out) + } + } + + out2 := projectFileQuota(map[string]interface{}{"storage_used_bytes": 100, "storage_quota_bytes": float64(1024), "usage_percent": float64(9.8), "files": 3}) + if _, ok := out2["storage_quota_bytes"]; !ok { + t.Errorf("non-zero quota should be kept: %v", out2) + } + if _, ok := out2["usage_percent"]; !ok { + t.Errorf("usage_percent should be kept when quota>0: %v", out2) + } +} diff --git a/shortcuts/apps/apps_file_sign.go b/shortcuts/apps/apps_file_sign.go new file mode 100644 index 000000000..df5a19b44 --- /dev/null +++ b/shortcuts/apps/apps_file_sign.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// fileSignMaxExpiresSeconds 是签名链接最长有效期(30 天)。超出 → 校验失败。 +const fileSignMaxExpiresSeconds = 30 * 24 * 60 * 60 + +// AppsFileSign generates a temporary signed download URL for a file。 +// +// POST /apps/{app_id}/storage/file_sign,body {path, expires_in}。 +// pretty 模式只打 signed_url(便于直接管道 / curl);json 返 {file_name,path,signed_url,expires_at}。 +var AppsFileSign = common.Shortcut{ + Service: appsService, + Command: "+file-sign", + Description: "Generate a temporary signed download URL for a file", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-sign --app-id --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 +} diff --git a/shortcuts/apps/apps_file_sign_test.go b/shortcuts/apps/apps_file_sign_test.go new file mode 100644 index 000000000..84ebbaa79 --- /dev/null +++ b/shortcuts/apps/apps_file_sign_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const fileSignURL = "/open-apis/spark/v1/apps/app_x/storage/file_sign" + +// TestAppsFileSign_DryRunBody 验证 dry-run 输出 POST file_sign,body 携带 path 与 expires_in。 +func TestAppsFileSign_DryRunBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileSign, + []string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "3600", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != fileSignURL || a.Body["path"] != "/x.png" { + t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body) + } + if ei, _ := a.Body["expires_in"].(float64); int(ei) != 3600 { + t.Fatalf("body.expires_in = %v, want 3600", a.Body["expires_in"]) + } +} + +// TestAppsFileSign_RejectsDurationOverMax 验证 --expires-in 超过上限时触发 --expires-in typed 校验错误。 +func TestAppsFileSign_RejectsDurationOverMax(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileSign, + []string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "9999999", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--expires-in" { + t.Fatalf("Param = %q, want --expires-in", ve.Param) + } +} + +// TestAppsFileSign_PrettyPrintsSignedURL 验证 pretty 只输出 signed_url 本身。 +func TestAppsFileSign_PrettyPrintsSignedURL(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileSignURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "file_name": "x.png", "path": "/x.png", + "signed_url": "https://tos.example/x.png?sig=abc", "expires_at": "2026-04-16T10:30:00Z", + }}, + }) + if err := runAppsShortcut(t, AppsFileSign, + []string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := strings.TrimSpace(stdout.String()) + if got != "https://tos.example/x.png?sig=abc" { + t.Fatalf("pretty should print only signed_url, got: %q", got) + } +} diff --git a/shortcuts/apps/apps_file_upload.go b/shortcuts/apps/apps_file_upload.go new file mode 100644 index 000000000..6f3cae4ee --- /dev/null +++ b/shortcuts/apps/apps_file_upload.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/shortcuts/common" +) + +// fileUploadMaxBytes 是单文件上传上限(100 MB,对齐 miaoda)。 +const fileUploadMaxBytes = 100 * 1024 * 1024 + +// AppsFileUpload uploads a local file to an app's storage(三步直传)。 +// +// 1. POST /apps/{app_id}/storage/file_pre_upload {file_name,file_size,content_type} → {upload_url,upload_id} +// 2. 客户端 PUT 文件字节到 presigned upload_url,取响应 ETag +// 3. POST /apps/{app_id}/storage/file_upload_callback {upload_id,etag} → 文件元数据 +// file_name 取本地 basename;path 由平台生成 16 位 ID(不可指定)。仅收 --file。 +var AppsFileUpload = common.Shortcut{ + Service: appsService, + Command: "+file-upload", + Description: "Upload a local file to an app's storage", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +file-upload --app-id --file ./logo.png", + "Example: lark-cli apps +file-upload --app-id --file ./report.pdf -q '.path' # print the platform-generated file path", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "file", Desc: "local file to upload (file_name = basename)", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + f := strings.TrimSpace(rctx.Str("file")) + if f == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file") + } + st, err := rctx.FileIO().Stat(f) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err) + } + if st.IsDir() { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file") + } + if st.Size() > fileUploadMaxBytes { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appFilePreUploadPath(appID)). + Desc("Pre-upload → client PUT bytes → callback (3-step)"). + Body(map[string]interface{}{"file_name": filepath.Base(strings.TrimSpace(rctx.Str("file")))}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + localPath := strings.TrimSpace(rctx.Str("file")) + content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err) + } + fileName := filepath.Base(localPath) + contentType := mimeByExt(fileName) + + // 1. pre-upload + pre, err := rctx.CallAPITyped("POST", appFilePreUploadPath(appID), nil, map[string]interface{}{ + "file_name": fileName, + "file_size": len(content), + "content_type": contentType, + }) + if err != nil { + return err + } + uploadURL := common.GetString(pre, "upload_url") + uploadID := common.GetString(pre, "upload_id") + if uploadURL == "" || uploadID == "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "pre-upload returned no upload_url / upload_id") + } + + // 2. PUT 文件字节到 presigned URL,取 ETag(带 Content-Disposition 透传原始文件名) + etag, err := putFileBytes(rctx.Ctx(), uploadURL, content, contentType, fileName) + if err != nil { + return err + } + + // 3. callback + result, err := rctx.CallAPITyped("POST", appFileUploadCallbackPath(appID), nil, map[string]interface{}{ + "upload_id": uploadID, + "etag": etag, + }) + if err != nil { + return err + } + info := projectFileInfo(result) + rctx.OutFormat(info, nil, func(w io.Writer) { + renderFileUploadPretty(w, fileName, info) + }) + return nil + }, +} + +// putFileBytes 直连 PUT 文件字节到 presigned URL,返回响应的 ETag。 +// +// Content-Disposition 透传原始文件名:TOS 把它存成对象 metadata,callback 阶段后端 +// HeadObject 读回解析出 filename 写入 DB 的 display name。不传则后端兜底用 storage key +// (平台 16 位 ID)当文件名 —— 即「上传后文件名变成 ID」的根因。 +// +//nolint:forbidigo // direct PUT to a presigned object-storage URL bypasses the Lark gateway — raw HTTP is required (no Lark auth/gateway); RuntimeContext.DoAPI cannot target a presigned URL. +func putFileBytes(ctx context.Context, url string, content []byte, contentType, fileName string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(content)) + if err != nil { + return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "build upload request").WithCause(err) + } + req.ContentLength = int64(len(content)) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + req.Header.Set("Content-Disposition", "attachment; filename=\""+sanitizeUploadFileName(fileName)+"\"") + resp, err := newFileTransferClient().Do(req) + if err != nil { + // dial/transport 失败是典型可重试场景。 + return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed").WithCause(err).WithRetryable() + } + defer resp.Body.Close() + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 400 { + // 5xx 是上游瞬时故障,标 retryable;4xx(如签名过期)需重新签名而非盲重试,不标。 + if resp.StatusCode >= 500 { + return "", errs.NewNetworkError(errs.SubtypeNetworkServer, "upload failed: HTTP %d", resp.StatusCode).WithRetryable() + } + return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed: HTTP %d", resp.StatusCode) + } + return resp.Header.Get("ETag"), nil +} + +// sanitizeUploadFileName 对齐 miaoda:先去掉 TOS 非法字符 [:"\/*?<>|,;],再 encodeURIComponent +// (UTF-8 百分号编码,兼容中文等非 ASCII,且让 Content-Disposition header 合法),空则兜底 download_file。 +func sanitizeUploadFileName(name string) string { + var b strings.Builder + for _, r := range name { + switch r { + case ':', '"', '\\', '/', '*', '?', '<', '>', '|', ',', ';': + continue + default: + b.WriteRune(r) + } + } + enc := encodeURIComponent(b.String()) + if enc == "" { + return "download_file" + } + return enc +} + +// encodeURIComponent 复刻 JS encodeURIComponent:除 A-Za-z0-9-_.!~*'() 外按 UTF-8 字节 %XX 编码。 +func encodeURIComponent(s string) string { + const keep = "-_.!~*'()" + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || strings.IndexByte(keep, c) >= 0 { + b.WriteByte(c) + } else { + b.WriteString(fmt.Sprintf("%%%02X", c)) + } + } + return b.String() +} + +// mimeByExt 按扩展名推断 Content-Type,未知回退 application/octet-stream。 +func mimeByExt(name string) string { + if t := mime.TypeByExtension(filepath.Ext(name)); t != "" { + return t + } + return "application/octet-stream" +} + +// renderFileUploadPretty 打 ✓ Uploaded + 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) + } +} diff --git a/shortcuts/apps/apps_file_upload_test.go b/shortcuts/apps/apps_file_upload_test.go new file mode 100644 index 000000000..3182afee6 --- /dev/null +++ b/shortcuts/apps/apps_file_upload_test.go @@ -0,0 +1,179 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +// TestAppsFileUpload_RequiresAppIDAndFile 验证仅含空白的 --file 经 Validate 去空后触发 --file typed 校验错误。 +func TestAppsFileUpload_RequiresAppIDAndFile(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // --file is a cobra-required flag; pass whitespace so cobra's required check + // passes and our Validate (which trims) rejects it with a typed error. + err := runAppsShortcut(t, AppsFileUpload, + []string{"+file-upload", "--app-id", "app_x", "--file", " ", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--file" { + t.Fatalf("Param = %q, want --file", ve.Param) + } +} + +// TestAppsFileUpload_RejectsDirectory 验证 --file 指向目录时触发 --file typed 校验错误。 +func TestAppsFileUpload_RejectsDirectory(t *testing.T) { + dir := t.TempDir() + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileUpload, + []string{"+file-upload", "--app-id", "app_x", "--file", "sub", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--file" { + t.Fatalf("Param = %q, want --file", ve.Param) + } +} + +// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_upload,body.file_name 取文件 basename。 +func TestAppsFileUpload_DryRunPreUpload(t *testing.T) { + // Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。 + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileUpload, + []string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload" { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Body["file_name"] != "logo.png" { + t.Fatalf("dry-run body.file_name = %v, want logo.png (basename)", a.Body["file_name"]) + } +} + +// 三步直传:pre-upload → 客户端 PUT 字节 → callback。 +func TestAppsFileUpload_EndToEnd(t *testing.T) { + var putBody []byte + var putContentType, putCD string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + putBody, _ = io.ReadAll(r.Body) + putContentType = r.Header.Get("Content-Type") + putCD = r.Header.Get("Content-Disposition") + w.Header().Set("ETag", `"etag-123"`) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("PNGBYTES"), 0o600); err != nil { + t.Fatal(err) + } + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "file_name": "logo.png", "path": "/1858537546760216.png", "size_bytes": 8, "type": "image/png", + "download_url": "/spark/app/x/1858537546760216.png", + }}, + }) + + if err := runAppsShortcut(t, AppsFileUpload, + []string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if string(putBody) != "PNGBYTES" { + t.Fatalf("PUT body = %q, want file bytes", putBody) + } + if putContentType != "image/png" { + t.Errorf("PUT Content-Type = %q, want image/png", putContentType) + } + // 原始文件名必须经 Content-Disposition 透传给 TOS(否则后端用 storage key 当文件名)。 + if putCD != `attachment; filename="logo.png"` { + t.Errorf("PUT Content-Disposition = %q, want attachment; filename=\"logo.png\"", putCD) + } + got := stdout.String() + if !strings.Contains(got, `"path": "/1858537546760216.png"`) { + t.Errorf("output missing uploaded path:\n%s", got) + } +} + +// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName:空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。 +func TestSanitizeUploadFileName_Cases(t *testing.T) { + cases := []struct{ in, want string }{ + {"logo.png", "logo.png"}, + {"a b.png", "a%20b.png"}, // 空格 → %20(encodeURIComponent) + {`a:b/c*d?.png`, "abcd.png"}, // 去掉 TOS 非法字符 + {"///", "download_file"}, // 全非法 → 兜底 + {"中.txt", "%E4%B8%AD.txt"}, // 非 ASCII → UTF-8 百分号编码 + } + for _, c := range cases { + if got := sanitizeUploadFileName(c.in); got != c.want { + t.Errorf("sanitizeUploadFileName(%q)=%q want %q", c.in, got, c.want) + } + } +} + +// TestMimeByExt_Cases 验证 mimeByExt:按扩展名识别 image/png,未知扩展名兜底 application/octet-stream。 +func TestMimeByExt_Cases(t *testing.T) { + if got := mimeByExt("a.png"); !strings.HasPrefix(got, "image/png") { + t.Errorf("mimeByExt(a.png)=%q want image/png", got) + } + if got := mimeByExt("data.unknownext"); got != "application/octet-stream" { + t.Errorf("mimeByExt(unknown)=%q want application/octet-stream", got) + } +} diff --git a/shortcuts/apps/db_common.go b/shortcuts/apps/db_common.go index 9223e46e5..36ad75217 100644 --- a/shortcuts/apps/db_common.go +++ b/shortcuts/apps/db_common.go @@ -4,12 +4,50 @@ package apps import ( + "context" + "encoding/json" "fmt" "strings" + "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" ) +// pollUntil 轮询异步任务直到 check 判定终态。async migrate/recovery 用:dataloom 立即返 +// task_id/preview_request_id,CLI 自己 poll(避免单连接长挂被网关/SDK 30s 中断)。 +// 首次立即 fetch(不睡);check 返 done→返回;返 err→透传(失败终态);否则按 interval 间隔重试至 maxWait。 +func pollUntil(ctx context.Context, interval, maxWait time.Duration, + fetch func() (map[string]interface{}, error), + check func(map[string]interface{}) (done bool, err error)) (map[string]interface{}, error) { + maxAttempts := int(maxWait / interval) + if maxAttempts < 1 { + maxAttempts = 1 + } + for i := 0; ; i++ { + data, err := fetch() + if err != nil { + return nil, err + } + done, cerr := check(data) + if cerr != nil { + return nil, cerr + } + if done { + return data, nil + } + if i+1 >= maxAttempts { + // async 任务多半还在服务端推进,poll 超时是可重试的——标 retryable 让 agent 重新轮询而非放弃。 + return nil, errs.NewNetworkError(errs.SubtypeNetworkTimeout, "timed out waiting for completion after %s", maxWait).WithRetryable() + } + select { + case <-ctx.Done(): + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "cancelled while waiting").WithCause(ctx.Err()) + case <-time.After(interval): + } + } +} + // URL helpers for the db CLI commands. // appTablesPath 返回 app db 表列表 URL(复用存量「获取数据表列表」接口)。 @@ -32,11 +70,167 @@ func appDbEnvCreatePath(appID string) string { return fmt.Sprintf("%s/apps/%s/db_dev_init", apiBasePath, validate.EncodePathSegment(appID)) } +// ── 多环境发布(env diff/migrate)/ 数据恢复(recovery)/ 配额 路由 ── + +// appEnvMigratePath 返回 dev→online 发布(预览/落地共用)URL:db/env_migrate。 +func appEnvMigratePath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_migrate", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appEnvMigrateStatusPath 返回发布异步任务状态查询 URL:db/env_migrate_status。 +func appEnvMigrateStatusPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_migrate_status", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appRecoveryPath 返回 PITR 数据恢复(预览/落地共用)URL:db/env_recovery。 +func appRecoveryPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_recovery", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appRecoveryDiffStatusPath 返回恢复预览(diff)异步状态查询 URL:db/env_recovery_diff_status。 +func appRecoveryDiffStatusPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_recovery_diff_status", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appRecoveryApplyStatusPath 返回恢复落地异步状态查询 URL:db/env_recovery_apply_status。 +func appRecoveryApplyStatusPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_recovery_apply_status", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appDbQuotaPath 返回 db 配额查询 URL:db/quota。 +func appDbQuotaPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/quota", apiBasePath, validate.EncodePathSegment(appID)) +} + +// ── 变更追溯(changelog / audit)路由 ── + +// appChangelogListPath 返回 DDL 变更记录列表 URL:db/changelog_list。 +func appChangelogListPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/changelog_list", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appAuditStatusPath 返回表审计开关状态查询 URL:db/audit_status。 +func appAuditStatusPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/audit_status", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appAuditSetPath 返回表审计开关设置 URL:db/audit_set。 +func appAuditSetPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/audit_set", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appAuditListPath 返回行级审计事件列表 URL:db/audit_list。 +func appAuditListPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/audit_list", apiBasePath, validate.EncodePathSegment(appID)) +} + +// operatorRef 是 operator 的 {id,name}。后端用 JSON 字符串内嵌透传,CLI parse: +// json 输出还原成对象(下游能区分同名用户),pretty 只取 name。 +type operatorRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// parseOperator 解析 operator 字符串:空→nil;非 JSON→{raw,raw};JSON→{id,name}(name 空兜底 id)。 +func parseOperator(raw string) *operatorRef { + s := strings.TrimSpace(raw) + if s == "" { + return nil + } + if !strings.HasPrefix(s, "{") { + return &operatorRef{ID: s, Name: s} + } + var o operatorRef + if json.Unmarshal([]byte(s), &o) != nil { + return &operatorRef{ID: s, Name: s} + } + if o.Name == "" { + o.Name = o.ID + } + return &o +} + +// operatorName 取 operator 的展示名(pretty),空用 "—"。 +func operatorName(op *operatorRef) string { + if op == nil || op.Name == "" { + return "—" + } + return op.Name +} + +// safeParseJSON 把 before/after 的 JSON 字符串还原成结构化对象供下游消费;失败时透传原始串。 +func safeParseJSON(s string) interface{} { + var v interface{} + if json.Unmarshal([]byte(s), &v) == nil { + return v + } + return s +} + +// appDataImportPath 返回 db 数据导入 URL(新增 db/ 域段路由)。 +func appDataImportPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/data_import", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appDataExportPath 返回 db 数据导出 URL(返原始字节)。 +func appDataExportPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/data_export", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appTableRecordsPath 返回数据表记录列表 URL(复用 GetAppTableRecordList,其 total 即符合条件的记录总数)。 +func appTableRecordsPath(appID, table string) string { + return appTablePath(appID, table) + "/records" +} + +// resolveDataFormat 由文件扩展名推断数据格式。lark-cli 的 --format 已被框架占用(输出渲染), +// 故数据格式从文件名推断:import 接受 csv/json,export 还接受 sql。 +func resolveDataFormat(ext string, allowSQL bool) (string, error) { + raw := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".") + switch raw { + case "csv", "json": + return raw, nil + case "sql": + if allowSQL { + return "sql", nil + } + } + if allowSQL { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported data format %q (file must end in .csv, .json or .sql)", raw) + } + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported data format %q (file must end in .csv or .json)", raw) +} + +// countDataRows 粗估数据行数(用于导入上限校验、导出兜底计数)。 +// csv:非空行数 - 1(表头);json:顶层数组长度,非数组算 1,解析失败算 0。 +func countDataRows(body []byte, format string) int { + if format == "csv" { + lines := 0 + for _, ln := range strings.Split(string(body), "\n") { + if strings.TrimRight(ln, "\r") != "" { + lines++ + } + } + if lines > 0 { + return lines - 1 + } + return 0 + } + var arr []json.RawMessage + if err := json.Unmarshal(body, &arr); err == nil { + return len(arr) + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(body, &obj); err == nil { + return 1 + } + return 0 +} + // requireAppID trims --app-id and rejects blank, returning a uniform validation error. func requireAppID(raw string) (string, error) { id := strings.TrimSpace(raw) if id == "" { - return "", appsValidationParamError("--app-id", "--app-id is required") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id") } return id, nil } diff --git a/shortcuts/apps/file_common.go b/shortcuts/apps/file_common.go new file mode 100644 index 000000000..b9849d16f --- /dev/null +++ b/shortcuts/apps/file_common.go @@ -0,0 +1,228 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var ( + reTsRelative = regexp.MustCompile(`^([0-9]+)([smhdw])$`) + reTsDate = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}$`) + reTsLocalDateTime = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$`) +) + +// normalizeTimestamp 实现设计原则三的 多格式输入,统一归一化为 RFC3339 UTC: +// - 相对:30s / 5m / 2h / 3d / 1w(从现在往前推) +// - date:2026-04-15(本地时区 00:00:00) +// - local datetime:2026-04-15T10:00:00(本地时区,T 分隔) +// - ISO 8601 带 TZ:...Z(UTC)/ ...+08:00(显式偏移) +// +// 归一化到 UTC 是必须的:服务端对无 TZ 的串按 UTC 裸解析,故 date / local datetime 的「本地」 +// 语义只能在 CLI 端换算;相对时间服务端也不认。空串原样返回(调用方据此跳过该过滤)。 +func normalizeTimestamp(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", nil + } + if m := reTsRelative.FindStringSubmatch(s); m != nil { + n, _ := strconv.Atoi(m[1]) + var unit time.Duration + switch m[2] { + case "s": + unit = time.Second + case "m": + unit = time.Minute + case "h": + unit = time.Hour + case "d": + unit = 24 * time.Hour + case "w": + unit = 7 * 24 * time.Hour + } + return time.Now().Add(-time.Duration(n) * unit).UTC().Format(time.RFC3339), nil + } + if reTsDate.MatchString(s) { + t, err := time.ParseInLocation("2006-01-02", s, time.Local) + if err != nil { + return "", fmt.Errorf("invalid date %q", s) + } + return t.UTC().Format(time.RFC3339), nil + } + if reTsLocalDateTime.MatchString(s) { + t, err := time.ParseInLocation("2006-01-02T15:04:05", s, time.Local) + if err != nil { + return "", fmt.Errorf("invalid local datetime %q", s) + } + return t.UTC().Format(time.RFC3339), nil + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t.UTC().Format(time.RFC3339), nil + } + return "", fmt.Errorf("invalid timestamp %q (want relative 7d/2h/30s, date 2026-04-15, datetime 2026-04-15T10:00:00, or ISO 8601 with TZ)", s) +} + +// newFileTransferClient 直传 / 直下对象存储 presigned URL 用(绕开 Lark 网关,无需 auth、无超时以容纳大文件)。 +// +//nolint:forbidigo // presigned object-storage transfer bypasses the Lark gateway — raw http.Client is required (no Lark auth, no gateway routing); not a Lark API call, so RuntimeContext.DoAPI does not apply. +func newFileTransferClient() *http.Client { + return &http.Client{Transport: http.DefaultTransport} +} + +// URL helpers for the file (storage) CLI commands. +// +// 全部走 spark OpenAPI,path 形如 /open-apis/spark/v1/apps/{app_id}/storage/。 +// 路由段不含 HTTP 方法名(file_get→file、file_delete→file_batch_remove、file_quota_get→file_quota)。 + +// appFileListPath 返回文件列表 URL:storage/file_list。 +func appFileListPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_list", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appFileGetPath 返回单文件元数据 URL:storage/file(file_get→file,路由不含方法名)。 +func appFileGetPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appFileSignPath 返回临时签名下载 URL 生成接口:storage/file_sign。 +func appFileSignPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_sign", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appFilePreUploadPath 返回上传预处理(取 presigned 直传地址)URL:storage/file_pre_upload。 +func appFilePreUploadPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_pre_upload", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appFileUploadCallbackPath 返回直传完成回调(登记文件)URL:storage/file_upload_callback。 +func appFileUploadCallbackPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_upload_callback", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appFileBatchRemovePath 返回批量删除文件 URL:storage/file_batch_remove(file_delete→file_batch_remove)。 +func appFileBatchRemovePath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_batch_remove", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appFileQuotaPath 返回存储配额查询 URL:storage/file_quota(file_quota_get→file_quota)。 +func appFileQuotaPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_quota", apiBasePath, validate.EncodePathSegment(appID)) +} + +// requireFilePath trims --path and rejects blank, returning a uniform validation error. +func requireFilePath(raw string) (string, error) { + p := strings.TrimSpace(raw) + if p == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--path is required").WithParam("--path") + } + return p, nil +} + +// fileUser 是 uploaded_by 的 {id,name}。OpenAPI 以 created_by 的 JSON 字符串透传,CLI parse。 +type fileUser struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// fileInfo 是 file 命令对外输出的白名单字段。 +// OpenAPI 字段 created_at / created_by → CLI 产品语义 uploaded_at / uploaded_by。 +type fileInfo struct { + FileName string `json:"file_name"` + Path string `json:"path"` + SizeBytes interface{} `json:"size_bytes,omitempty"` + Type string `json:"type,omitempty"` + UploadedBy *fileUser `json:"uploaded_by,omitempty"` + UploadedAt string `json:"uploaded_at,omitempty"` + DownloadURL string `json:"download_url,omitempty"` +} + +// projectFileInfo 把 server 原始 file map 投影为 CLI fileInfo(created_*→uploaded_*)。 +func projectFileInfo(m map[string]interface{}) fileInfo { + return fileInfo{ + FileName: common.GetString(m, "file_name"), + Path: common.GetString(m, "path"), + SizeBytes: m["size_bytes"], + Type: common.GetString(m, "type"), + UploadedBy: parseFileUser(common.GetString(m, "created_by")), + UploadedAt: common.GetString(m, "created_at"), + DownloadURL: common.GetString(m, "download_url"), + } +} + +// parseFileUser 解析 created_by 的 JSON 字符串 {id,name};空 / 非法 / 全空 → nil。 +func parseFileUser(raw string) *fileUser { + s := strings.TrimSpace(raw) + if s == "" { + return nil + } + var u fileUser + if err := json.Unmarshal([]byte(s), &u); err != nil { + return nil + } + if u.ID == "" && u.Name == "" { + return nil + } + return &u +} + +// normalizeTimeFlags 把若干时间 flag(如 --since/--until/--uploaded-since)就地归一化为 RFC3339 UTC +// 并回写,供 build*Params 透传。空 flag 跳过;非法格式 → validation 错误。复用 normalizeTimestamp。 +func normalizeTimeFlags(rctx *common.RuntimeContext, flags ...string) error { + for _, f := range flags { + 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 +} + +// dashIfEmpty 空白串用 "—" 占位(pretty 列对齐)。 +func dashIfEmpty(s string) string { + if strings.TrimSpace(s) == "" { + return "—" + } + return s +} + +// fileSizeDetail 把 size_bytes 渲染成 "24 KB (24580 bytes)"(pretty 单文件详情用)。 +func fileSizeDetail(raw interface{}) string { + n, ok := numericAsFloat(raw) + if !ok { + return "—" + } + return fmt.Sprintf("%s (%d bytes)", humanBytes(raw), int64(n)) +} + +// renderKeyValuePairs 输出对齐的 key: value(key 列按最长 key 右填充)。 +func renderKeyValuePairs(w io.Writer, pairs [][2]string) { + width := 0 + for _, p := range pairs { + if dw := displayWidth(p[0]); dw > width { + width = dw + } + } + for _, p := range pairs { + io.WriteString(w, p[0]+":") + if pad := width - displayWidth(p[0]); pad > 0 { + io.WriteString(w, strings.Repeat(" ", pad)) + } + io.WriteString(w, " "+p[1]+"\n") + } +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index e15489fa1..3f4ae793c 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -23,6 +23,25 @@ func Shortcuts() []common.Shortcut { AppsDBTableGet, AppsDBExecute, AppsDBEnvCreate, + AppsDBDataImport, + AppsDBDataExport, + AppsDBChangelogList, + AppsDBAuditStatus, + AppsDBAuditEnable, + AppsDBAuditDisable, + AppsDBAuditList, + AppsDBEnvDiff, + AppsDBEnvMigrate, + AppsDBRecoveryDiff, + AppsDBRecoveryApply, + AppsDBQuotaGet, + AppsFileList, + AppsFileGet, + AppsFileSign, + AppsFileDownload, + AppsFileUpload, + AppsFileDelete, + AppsFileQuotaGet, AppsGitCredentialInit, AppsGitCredentialList, AppsGitCredentialRemove, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 264c7ed4f..cb5d3565f 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -10,12 +10,17 @@ import ( ) // 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 -// 6 基础 + 1 init + 3 publish + 1 env-pull + 4 db(table-list/table-schema/sql/dev-init) -// + 3 git-credential + 5 session(create/list/get/stop/chat)+ 1 session-messages-list = 24。 -func TestAppsShortcuts_Returns24(t *testing.T) { +// 6 基础 + 1 init + 3 publish + 1 env-pull +// - 16 db(table-list/table-schema/sql/dev-init/data-import/data-export/changelog-list/ +// audit-status/audit-enable/audit-disable/audit-list/ +// env-diff/env-migrate/recovery-diff/recovery-apply/quota-get) +// - 7 file(list/get/sign/download/upload/delete/quota-get) +// - 3 git-credential +// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list = 43。 +func TestAppsShortcuts_Returns43(t *testing.T) { got := Shortcuts() - if len(got) != 24 { - t.Fatalf("Shortcuts() returned %d entries, want 24", len(got)) + if len(got) != 43 { + t.Fatalf("Shortcuts() returned %d entries, want 43", len(got)) } } @@ -40,6 +45,7 @@ func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) { } } +// TestAppsGitCredentialHelper_IsNotAShortcut 确认 git credential helper 不作为 shortcut 暴露。 func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) { for _, shortcut := range Shortcuts() { if shortcut.Command == "git-credential-helper" { @@ -48,18 +54,21 @@ func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) { } } +// TestAppsGitCredentialRemove_IsLocalCleanupWithoutScopes 确认 git credential remove 是本地清理、不带任何 scope。 func TestAppsGitCredentialRemove_IsLocalCleanupWithoutScopes(t *testing.T) { if len(AppsGitCredentialRemove.Scopes) != 0 { t.Fatalf("git credential remove scopes = %#v, want none for local cleanup", AppsGitCredentialRemove.Scopes) } } +// TestAppsGitCredentialList_IsLocalReadWithoutScopes 确认 git credential list 是本地读取、不带任何 scope。 func TestAppsGitCredentialList_IsLocalReadWithoutScopes(t *testing.T) { if len(AppsGitCredentialList.Scopes) != 0 { t.Fatalf("git credential list scopes = %#v, want none for local read", AppsGitCredentialList.Scopes) } } +// TestInstallOnApps_AddsHiddenGitCredentialHelper 验证 InstallOnApps 挂载一个隐藏、带 RunE 且独立于 shortcut 管线的 git-credential-helper 命令。 func TestInstallOnApps_AddsHiddenGitCredentialHelper(t *testing.T) { parent := &cobra.Command{Use: "apps"} InstallOnApps(parent, nil) diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index f375714c0..468148c2c 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -534,6 +534,20 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams { return ctx.Factory.IOStreams } +// StartSpinner shows a braille spinner with elapsed time on stderr for a slow +// operation, until the returned stop() runs. It is a no-op unless stderr is an +// interactive terminal, so pipes / CI / captured output emit nothing and stdout +// (JSON/pretty) is never polluted — hence it is shown in JSON mode too. Call +// stop() before printing the result; stop() is safe to call multiple times +// (e.g. `defer stop()` plus an explicit call on the success path). +func (ctx *RuntimeContext) StartSpinner(label string) func() { + io := ctx.IO() + if io == nil { + return func() {} + } + return output.StartSpinner(io.ErrOut, io.StderrIsTerminal, label) +} + // FileIO resolves the FileIO using the current execution context. // Falls back to the globally registered provider when Factory or its // FileIOProvider is nil (e.g. in lightweight test helpers). diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 86b660926..45eb2f0f2 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -24,7 +24,9 @@ metadata: | 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) | | 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | -| 看表、看 schema、跑 SQL、初始化 dev/online 多环境 DB | `+db-table-list`, `+db-table-get`, `+db-execute`, `+db-env-create` | 对应 `lark-apps-db-*.md` | +| 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / dev→online 发布 / 时间点恢复 / 查 DB 用量 | `+db-table-list`、`+db-table-get`、`+db-env-create`、`+db-data-export`/`+db-data-import`、`+db-changelog-list`、`+db-audit-status`/`+db-audit-enable`/`+db-audit-disable`/`+db-audit-list`、`+db-env-diff`/`+db-env-migrate`、`+db-recovery-diff`/`+db-recovery-apply`、`+db-quota-get` | [`lark-apps-db.md`](references/lark-apps-db.md) | +| 逐条执行 SQL(SELECT / DML / DDL) | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md) | +| 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) | | **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) | | 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference | | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | diff --git a/skills/lark-apps/references/lark-apps-db-env-create.md b/skills/lark-apps/references/lark-apps-db-env-create.md deleted file mode 100644 index 6dd933a2f..000000000 --- a/skills/lark-apps/references/lark-apps-db-env-create.md +++ /dev/null @@ -1,31 +0,0 @@ -# apps +db-env-create - -把存量单库应用初始化为 `dev` / `online` 多环境数据库。运行时命令事实以 `lark-cli apps +db-env-create --help` 为准。 - -## 何时用 - -仅用于存量单库应用需要拆成 `dev` / `online` 两套数据库的场景。普通查看表、查 schema、执行 SQL 不需要先初始化。注意:通过 `+create --app-type full_stack` 新建的应用通常已自带多环境,无需再初始化(重复初始化会返回「已初始化」错误)。 - -## 命令骨架 - -- 必填:`--app-id`。 -- `--env`:要创建的环境,由调用方传入,目前只支持 `dev`(默认 `dev`)。 -- `--sync-data`:bool 开关,传 `--sync-data` 则把现有 online 数据复制到新环境;不传则不复制(默认)。 -- risk 是 `high-risk-write`;单库拆成 dev/online 后不可逆。 - -## 示例 - -```bash -lark-cli apps +db-env-create --app-id app_xxx --env dev --dry-run -lark-cli apps +db-env-create --app-id app_xxx --env dev --sync-data --yes -``` - -## 输出契约 - -- 成功读取 `data.status`、`data.environments`、`data.data_synced`;pretty 会提示是否初始化、多环境列表、是否同步数据。 -- 未确认时返回 `confirmation_required` / exit 10;按 lark-shared 询问用户后再补 `--yes` 重试。 -- 如果服务端提示已启用多环境(`Multi-env is already initialized`),转述状态即可,不要重复初始化。 - -## Agent 规则 - -不要静默追加 `--yes`。遇到 confirmation_required 时,按 `lark-shared` 的 exit-10 协议向用户确认不可逆风险;用户明确同意后才在原 argv 末尾追加 `--yes` 重试。 diff --git a/skills/lark-apps/references/lark-apps-db-execute.md b/skills/lark-apps/references/lark-apps-db-execute.md index f7d78819b..8a879b256 100644 --- a/skills/lark-apps/references/lark-apps-db-execute.md +++ b/skills/lark-apps/references/lark-apps-db-execute.md @@ -26,15 +26,19 @@ lark-cli apps +db-execute --app-id app_xxx --env dev --sql - --yes < /Users/.../ ## 输出契约 -- 成功默认 JSON 读取 `data.results[]`;每个元素对应一条 SQL,常见字段有 `sql_type`、`data`、`record_count`、`affected_rows`。 +- 成功默认 JSON 的 `data` 按 SQL 类型自适应(不透传后端原始串): + - 单 SELECT → `data` 是行数组 `[{...}]`(空 → `[]`),直接 `-q '.data[].col'` 取字段。 + - 单 DML → `data = {command, rows_affected}`(如 `{"command":"INSERT","rows_affected":1}`)。 + - 单 DDL → `data = {command}`(如 `{"command":"CREATE_TABLE"}`)。 + - 多语句 → `data` 是元素数组:SELECT 为 `{command:"SELECT", rows:[...]}`,DML 为 `{command, rows_affected}`,DDL 为 `{command}`。 - pretty 会按 SELECT/DML/DDL 自适应渲染;多语句会逐条显示 Statement 摘要。 -- 失败可能仍有前序语句已执行;此时 stdout 输出 `ok:false` 的 envelope(exit 非 0),从 `data` 读 `results[]`(全部逐条结果,失败语句 `sql_type` 为 `ERROR`)、`statement_index`、`error_code`、`error_message`、`rolled_back` 和 `note`,决定从哪条继续。 +- 失败返回 typed `error`(`type:"api"`、`subtype:"server_error"`、`code`、`message`、`hint`):失败位置在 `message` 的「(at statement N of M)」;前序是否落地 / 是否整批回滚写在 `hint`——事务内失败「Transaction rolled back; no changes persisted.」;非事务多语句前序已落地「Earlier statements were committed and not rolled back; fix statement N and re-run the remaining statements.」;首句即失败(无前序落地)「No statements were applied; fix the SQL and re-run.」。据此决定整段重跑还是只跑剩余语句。 ## Agent 规则 - 该命令为 high-risk-write,执行一律需 `--yes`;无 `--yes` 会返回 `confirmation_required` / exit 10。 - **只读查询、以及不删除/不丢失既有数据且可撤回的语句**:已授权时可直接带 `--yes` 执行。 - **会删除或丢失既有数据、或难以撤回的语句**:先 `--dry-run` 预览(无需 `--yes`),向用户确认后再带 `--yes` 执行;不要在用户不知情时自动补 `--yes`。 -- 多语句失败时,失败前的语句可能已经 auto-commit。不要整批重跑;按错误 detail/hint 修失败语句,并从剩余语句继续。 +- 多语句失败时,失败前的语句可能已经 commit 落地。不要整批重跑;按错误 message/hint 修失败语句,并从剩余语句继续。 - 如果需要原子性,让用户在 SQL 内显式写 `BEGIN` / `COMMIT`,不要假设 CLI 会包事务。 - 不要把数据库连接串从 env 中取出来裸连。 diff --git a/skills/lark-apps/references/lark-apps-db-table-get.md b/skills/lark-apps/references/lark-apps-db-table-get.md deleted file mode 100644 index 301aea685..000000000 --- a/skills/lark-apps/references/lark-apps-db-table-get.md +++ /dev/null @@ -1,29 +0,0 @@ -# apps +db-table-get - -查看妙搭应用数据库某张表的结构。运行时命令事实以 `lark-cli apps +db-table-get --help` 为准。 - -## 何时用 - -用于查看已知表的字段、索引、约束,或给 SQL/迁移生成提供依据。只想知道有哪些表时先 `+db-table-list`。 - -## 命令骨架 - -- 必填:`--app-id`、`--table`。 -- `--env` 枚举:`dev` / `online`,默认 `online`。 -- `--format pretty` 会向服务端请求 DDL,并直接输出 DDL 文本;默认 JSON 返回结构化 columns/indexes/constraints/stats。 - -## 示例 - -```bash -lark-cli apps +db-table-get --app-id app_xxx --table orders -lark-cli apps +db-table-get --app-id app_xxx --table orders --env dev --format pretty -``` - -## 输出契约 - -- 默认 JSON 读取 `data.name`、`columns`、`indexes`、`constraints`、`estimated_row_count`、`size_bytes`。 -- `--format pretty` stdout 是服务端返回的 DDL 文本,不是 JSON envelope;需要建表语句时可原样给用户。 - -## Agent 规则 - -需要给用户看建表语句或迁移参照时用 `--format pretty`;需要程序化分析字段/索引/约束时保留默认 JSON。 diff --git a/skills/lark-apps/references/lark-apps-db-table-list.md b/skills/lark-apps/references/lark-apps-db-table-list.md deleted file mode 100644 index 9a08a093a..000000000 --- a/skills/lark-apps/references/lark-apps-db-table-list.md +++ /dev/null @@ -1,31 +0,0 @@ -# apps +db-table-list - -列出妙搭应用某个数据库环境的数据表。运行时命令事实以 `lark-cli apps +db-table-list --help` 为准。 - -## 何时用 - -用于先摸清应用数据库里有哪些表,或在用户只给业务对象名时定位可能的表名。已知表名且要字段/索引时直接用 `+db-table-get`。 - -## 命令骨架 - -- 必填:`--app-id`。 -- `--env` 枚举:`dev` / `online`,默认 `online`。 -- 分页:`--page-size` 默认 20,`--page-token` 使用上一页 cursor。 -- pretty 输出列包含 `name`、`description`、`estimated_row_count`、`size`、`columns`(列数)。 - -## 示例 - -```bash -lark-cli apps +db-table-list --app-id app_xxx -lark-cli apps +db-table-list --app-id app_xxx --env dev --page-size 50 -``` - -## 输出契约 - -- 成功读取 `data.items[]`;每项字段是 `name`、`description`、`estimated_row_count`、`size_bytes`、`column_count`(列数)。CLI 默认不透出每表完整 `columns[]`(与 `+db-table-get` 重复且放大 token),只给 `column_count`;要完整列定义/索引/约束用 `+db-table-get`。 -- pretty 输出是 5 列扫描表:`name`、`description`、`estimated_row_count`、`size`、`columns`(即列数)。 -- 若响应带 `has_more=true`,用返回的 `page_token` / `next_page_token` 翻页。 - -## Agent 规则 - -用户说“本地/开发库/调试库”时优先 `--env dev`;线上问题排查用 `--env online`。如果 dev 返回服务端错误提示未初始化,多环境入口是 [`+db-env-create`](lark-apps-db-env-create.md)。 diff --git a/skills/lark-apps/references/lark-apps-db.md b/skills/lark-apps/references/lark-apps-db.md new file mode 100644 index 000000000..bd96f5403 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-db.md @@ -0,0 +1,160 @@ +# apps db 域命令 + +管理妙搭应用数据库:看表与结构、初始化与发布多环境、数据搬运、变更治理、时间点恢复、用量。逐条跑 SQL(SELECT/DML/DDL)走 [`+db-execute`](lark-apps-db-execute.md)(单独一篇)。运行时命令事实以 `lark-cli apps + --help` 为准;认证、`--as user`、exit 码、`_notice` 等通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 与本域 [`SKILL.md`](../SKILL.md)。 + +## 何时用 + +用户要看应用里有哪些表 / 某张表的结构、把单库应用拆成 dev/online 多环境、把数据导进导出表、查谁在什么时候改了表结构或表数据、开关行级审计、把开发环境的库结构发布到线上、把库恢复到过去某个时间点、或看数据库用量时。逐条执行 SQL 走 [`+db-execute`](lark-apps-db-execute.md);文件存储(上传/下载文件)走 [`lark-apps-file.md`](lark-apps-file.md)。 + +## 命令一览 + +| 命令 | 做什么 | 关键参数 | +|---|---|---| +| `+db-table-list` | 列出某环境的数据表 | `--env`、`--page-size`/`--page-token` | +| `+db-table-get` | 看单张表的结构(字段/索引/约束/DDL) | `--table`、`--env`、`--format` | +| `+db-env-create` | 把单库应用初始化为 dev/online 多环境(高危) | `--env`、`--sync-data`、`--yes` | +| `+db-data-export` | 把一张表的数据导出到本地文件 | `--table`、`--output`、`--limit`、`--env` | +| `+db-data-import` | 把本地 csv/json 文件导进一张表(高危) | `--file`、`--table`、`--env`、`--yes` | +| `+db-changelog-list` | 查表结构变更(DDL)历史 | `--table`、`--change-id`、`--since`/`--until`、`--env` | +| `+db-audit-status` | 看哪些表开了行级审计、保留期 | `--table`、`--env` | +| `+db-audit-enable` | 给某表开启行级变更审计 | `--table`、`--retention`、`--env` | +| `+db-audit-disable` | 关闭某表的行级审计 | `--table`、`--env` | +| `+db-audit-list` | 列出表的行级变更事件(增删改追溯) | `--table`(可重复)、`--since`/`--until`、`--env` | +| `+db-env-diff` | 预览开发环境待发布到线上的结构变更 | `--app-id` | +| `+db-env-migrate` | 把开发环境的结构变更发布到线上(高危) | `--app-id`、`--yes` | +| `+db-recovery-diff` | 预览把库恢复到某时间点会带来的变更 | `--target` | +| `+db-recovery-apply` | 把库恢复到某个时间点、覆盖当前数据(高危) | `--target`、`--yes` | +| `+db-quota-get` | 查数据库存储用量 | `--env` | + +## 约定(先读) + +- **环境 `--env dev|online`(默认 online)**:看表、看结构、数据导入导出、变更追溯、审计、配额、初始化都按环境区分,写操作建议先在 `dev` 验。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--env`。 +- **本地文件用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;路径在别处先 `cd` 过去或改成相对路径。 +- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。 +- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。 + +## 各命令 + +### 表与结构 + +**`+db-table-list`**:列出某环境的数据表。分页 `--page-size`(默认 20)/ `--page-token`(上一页 cursor)。每项给表名、描述、估算行数、大小、列数;要完整列定义 / 索引 / 约束用 `+db-table-get`。只知道业务对象名时,先用它定位可能的表名。 + +```bash +lark-cli apps +db-table-list --app-id app_xxx +lark-cli apps +db-table-list --app-id app_xxx --env dev --page-size 50 +``` + +**`+db-table-get`**:看单张表的结构。默认 JSON 给结构化的字段 / 索引 / 约束 / 估算行数 / 大小;`--format pretty` 直接输出建表 DDL 文本(给用户看建表语句或做迁移参照时用)。 + +```bash +lark-cli apps +db-table-get --app-id app_xxx --table orders +lark-cli apps +db-table-get --app-id app_xxx --table orders --env dev --format pretty +``` + +### 多环境数据库(初始化 + 发布) + +**`+db-env-create`(高危)**:把存量单库应用初始化为 dev/online 两套库,不可逆,必须带 `--yes`。`--env` 目前只支持 `dev`(默认 `dev`);`--sync-data` 把现有 online 数据复制到新环境(不传则不复制)。注意:`+create --app-type full_stack` 新建的应用通常已自带多环境,重复初始化会返回冲突错误(应用已是多环境)——按 `error.hint` 转述状态即可,别重复初始化。 + +```bash +lark-cli apps +db-env-create --app-id app_xxx --env dev --dry-run +lark-cli apps +db-env-create --app-id app_xxx --env dev --sync-data --yes +``` + +**`+db-env-diff`**:预览开发环境里待发布到线上的表结构变更,不落地。发布前先看这个。无待发布变更时明确返回「无变更」。 + +**`+db-env-migrate`(高危)**:把开发环境的结构变更正式发布到线上,不可逆,必须带 `--yes`,返回实际发布的变更条数。发布是异步的,命令会等到完成再返回结果。 + +> 预览与发布同一端点,故 `+db-env-diff` 也需 `spark:app:write` scope(不是纯只读权限)。 + +```bash +lark-cli apps +db-env-diff --app-id app_xxx +lark-cli apps +db-env-migrate --app-id app_xxx --yes +``` + +### 数据导入导出 + +**`+db-data-export`**:把一张表导出到本地文件。导出格式**只由 `--output` 的扩展名决定**——`.csv` / `.json` / `.sql`,缺省按 `<表名>.csv` 落在当前目录。注意:全局 `--format json|pretty` 只控制**命令自身输出**(成功摘要 / 错误信封)的渲染,**不影响导出文件的格式**;`--output` 后缀必须是 `.csv/.json/.sql` 之一,否则报 validation 错误(exit 2),且不支持导出到 stdout。两道体量约束: + +- `--limit`(1..5000,默认 5000)是**行数上限守卫**:表的行数超过它会被整体拒掉(不是「只导前 N 行」); +- 导出产物 >1 MB 也会被拒。 + +超大表别硬导:先用 `+db-execute` 加 `WHERE` / `LIMIT` 缩小范围、分批导。 + +```bash +lark-cli apps +db-data-export --app-id app_xxx --table orders --output ./orders.csv +lark-cli apps +db-data-export --app-id app_xxx --table orders --output ./orders.json --env dev +``` + +**`+db-data-import`(高危)**:把本地 csv/json 文件的数据导进表。文件需是 `.csv`/`.json`、≤1 MB,必须带 `--yes`。目标表缺省取文件名去掉**最后一个**扩展名(如 `orders.csv`→`orders`,`orders.2026.csv`→`orders.2026`);文件名带点号时建议显式传 `--table` 以免落到意外的表名。 + +```bash +lark-cli apps +db-data-import --app-id app_xxx --table orders --file ./orders.csv --env dev --yes +``` + +**导入/导出限额**:体积 ≤ **1 MB**、行数 ≤ **5000**,导入导出都一样,超限会被拒。超限就分批——导入拆成 ≤1 MB / ≤5000 行的多个文件,导出用 `WHERE` / `LIMIT` 缩小范围。 + +### 变更追溯与审计 + +**`+db-changelog-list`**:查表结构变更(DDL)历史——谁、什么时候、改了哪张表、做了什么。可按 `--table` 过滤、按 `--change-id` 精确定位某条、用 `--since`/`--until` 圈时间区间,分页 `--page-size`/`--page-token`。 + +```bash +lark-cli apps +db-changelog-list --app-id app_xxx --table orders --since 7d +``` + +**`+db-audit-status`**:看审计开关状态。给 `--table` 看单表,不给则列出所有已配置的表(开没开、保留期)。 + +**`+db-audit-enable` / `+db-audit-disable`**:开 / 关某张表的行级变更审计。`--retention` 设保留期,取值 `7d`/`30d`/`180d`/`360d`/`forever`(默认 `7d`)。不要对已经开启审计的表重复 enable——不确定就先用 `+db-audit-status` 查。 + +```bash +lark-cli apps +db-audit-enable --app-id app_xxx --table orders --retention 30d +lark-cli apps +db-audit-disable --app-id app_xxx --table orders +``` + +**`+db-audit-list`**:列出表的行级变更事件(INSERT/UPDATE/DELETE 的前后值与操作人)。`--table` 必填、可重复传多张表;`--since`/`--until` 圈时间。 +- **多表查询**:会先帮用户把不存在、或没开审计的表过滤掉再查,被过滤的表及原因列在结果的 `skipped` 里——据此告诉用户哪些表没纳入及为什么。 +- **单表查询**:不预过滤,表不存在 / 未开审计会直接报错(按 `error.hint` 转述给用户,引导先 `+db-audit-enable`)。 + +```bash +lark-cli apps +db-audit-list --app-id app_xxx --table orders --since 24h +lark-cli apps +db-audit-list --app-id app_xxx --table orders --table users +``` + +### 时间点恢复(PITR) + +**`+db-recovery-diff`**:预览把库恢复到 `--target` 时间点会带来哪些变更(受影响的表、行数、预计耗时),不落地。同样需 `spark:app:write` scope。 + +**`+db-recovery-apply`(高危)**:把库恢复到某个时间点,**会覆盖当前数据**,不可逆,必须带 `--yes`。 + +- 可恢复窗口最长 **7 天**,且不早于**最近一次 `+db-env-migrate`**;超出窗口的目标会被拒。 +- 目标时间点与当前库一致时返回 `no_changes`(空操作),不算失败。 +- 动手前务必先 `+db-recovery-diff` 给用户确认。 + +```bash +lark-cli apps +db-recovery-diff --app-id app_xxx --target 2h +lark-cli apps +db-recovery-apply --app-id app_xxx --target 2026-04-15T10:00:00Z --yes +``` + +### 配额 + +**`+db-quota-get`**:查数据库存储用量(已用量、表数、视图数;配额接入后还会给总配额与使用率)。 + +```bash +lark-cli apps +db-quota-get --app-id app_xxx --env dev +``` + +## 时间格式(`--since` / `--until` / `--target`) + +按用户口语自然传入即可,支持: +- 相对时间 `7d` / `2h` / `30s`(从现在往前推) +- 日期 `2026-04-15` +- 日期时间 `2026-04-15T10:00:00` +- 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00` + +## Agent 规则 + +- 用户说「本地 / 开发库 / 调试库」优先 `--env dev`,线上排查用 `--env online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。 +- 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty`);`+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。 +- 四个高危命令(`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--env dev` 验;不要静默追加 `--yes`,遇 confirmation_required(exit 10)按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。 +- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。 +- `+db-audit-list` 多表查询时,把结果里 `skipped` 的表(不存在 / 未开审计)连同原因一并向用户说明,不要让用户以为这些表「没有变更」。 +- 恢复是覆盖式且不可逆:`+db-recovery-apply` 前必须先 `+db-recovery-diff`,并明确告知用户会覆盖当前数据。 diff --git a/skills/lark-apps/references/lark-apps-file.md b/skills/lark-apps/references/lark-apps-file.md new file mode 100644 index 000000000..aef156bfc --- /dev/null +++ b/skills/lark-apps/references/lark-apps-file.md @@ -0,0 +1,94 @@ +# apps file 域命令(应用存储) + +管理妙搭应用的文件存储:上传 / 下载本地文件、列出与查看已存文件、生成临时分享链接、批量删除、查看用量。运行时命令事实以 `lark-cli apps + --help` 为准;认证、`--as user`、exit 码、`_notice` 等通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 与本域 [`SKILL.md`](../SKILL.md)。 + +## 何时用 + +用户要在某个妙搭应用里上传 / 下载 / 列出 / 删除文件、拿文件的临时分享链接、或看存储用量时。普通飞书云盘走 [`lark-drive`](../../lark-drive/SKILL.md);数据库里的表数据走 `+db-*`。 + +## 命令一览 + +| 命令 | 做什么 | 关键参数 | +|---|---|---| +| `+file-list` | 列出文件,可按名/路径/类型/大小/上传时间过滤 | `--app-id`、过滤器、`--page-size`/`--page-token` | +| `+file-get` | 查单个文件的元数据 | `--app-id`、`--path` | +| `+file-sign` | 生成有时效的下载链接(用于分享 / 直接下载) | `--app-id`、`--path`、`--expires-in` | +| `+file-download` | 把远端文件保存到本地 | `--app-id`、`--path`、`--output` | +| `+file-upload` | 上传本地文件到应用存储 | `--app-id`、`--file` | +| `+file-delete` | 按路径批量删除文件 | `--app-id`、`--path`(可重复)、`--yes` | +| `+file-quota-get` | 查应用的文件存储用量 | `--app-id` | + +## 寻址与约定(先读) + +- **远端文件统一用 `--path` 精确寻址**(远端路径,带前导 `/`)。只知道文件名时,先用 `+file-list --name <名>` 定位拿到 `path`,再做后续操作。 +- **本地文件 / 输出路径用工作目录内的相对路径**(如 `--file ./report.pdf`、`--output ./out.png`);路径在别处时先 `cd` 过去或改成相对路径。 +- 上传只接收本地 `--file`:文件名沿用本地文件名,远端路径由平台分配、全局唯一(无需也无法手填)。 +- file 域不区分环境,没有 `--env`。 + +## 各命令 + +### +file-list +列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`(MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20)/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间(pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`。 + +```bash +lark-cli apps +file-list --app-id app_xxx +lark-cli apps +file-list --app-id app_xxx --type image/png --uploaded-since 7d +``` + +### +file-get +按 `--path` 查单个文件的元数据。路径不存在时返回明确的「文件不存在」错误。 + +```bash +lark-cli apps +file-get --app-id app_xxx --path /1858537546760216.png +``` + +### +file-sign +为指定文件生成一个**有时效的下载链接**——适合发给用户分享、或直接下载。`--expires-in` 设有效期秒数(默认 1 天,最长 30 天)。`pretty` 模式只输出链接本身,便于复制 / 管道;要把到期时间一并告诉用户时用默认 JSON 输出(含到期时间)。 + +```bash +lark-cli apps +file-sign --app-id app_xxx --path /1858537546760216.png --expires-in 3600 +``` + +### +file-download +把远端文件保存到本地。`--output` 指定保存路径,缺省时按远端文件名保存到当前目录。 + +```bash +lark-cli apps +file-download --app-id app_xxx --path /1858537546760216.png --output ./logo.png +``` + +### +file-upload +上传一个本地文件。文件名沿用本地文件名,远端路径由平台分配。单文件上限 100 MB。 + +```bash +lark-cli apps +file-upload --app-id app_xxx --file ./report.pdf +``` + +### +file-delete(高危) +按路径批量删除,`--path` 可重复传多个。删除是高危操作,必须带 `--yes`;缺省会被确认关卡拦下。**逐项返回结果**:部分文件删除失败(如某个路径不存在)不影响其余文件,整体仍算成功,失败项在结果里单独标出原因。 + +```bash +lark-cli apps +file-delete --app-id app_xxx --path /1858537546760216.png --yes +lark-cli apps +file-delete --app-id app_xxx --path /a.png --path /b.png --yes +``` + +### +file-quota-get +查应用的文件存储用量(已用量、文件数;配额接入后还会给总配额与使用率)。 + +```bash +lark-cli apps +file-quota-get --app-id app_xxx +``` + +## 时间格式(`--uploaded-since` / `--uploaded-until`) + +按用户口语自然传入即可,支持: +- 相对时间 `7d` / `2h` / `30s`(从现在往前推) +- 日期 `2026-04-15` +- 日期时间 `2026-04-15T10:00:00` +- 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00` + +## Agent 规则 + +- 寻址一律用 `--path`;用户只给文件名时先 `+file-list --name <名>` 定位,多个同名再让用户确认。 +- 上传 / 下载的本地路径用工作目录内相对路径;不在当前目录就 `cd` 过去或改相对路径。 +- 用户要「分享链接 / 临时下载地址」时用 `+file-sign`,把返回的链接转述给用户。 +- 删除前判断意图:已明确要删且授权时可直接带 `--yes`;不确定删哪些时先 `+file-list` 给用户确认。批量删除部分失败不报错,按逐项结果向用户说明哪些成功、哪些没删掉及原因。 From 6cbb9d68b81a76325f2ba77e40e5135c4f853982 Mon Sep 17 00:00:00 2001 From: raistlin042 Date: Thu, 25 Jun 2026 17:03:04 +0800 Subject: [PATCH 14/34] feat(apps): add openapi-key shortcuts for open API key management (#1576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(apps): add openapi-key common helpers (mask/redact/config) * feat(apps): add +openapi-key-list (redacted) * feat(apps): add +openapi-key-get (redacted) * feat(apps): add +openapi-key-create (one-time raw secret) * feat(apps): add +openapi-key-update * feat(apps): add +openapi-key-enable / +openapi-key-disable * feat(apps): add +openapi-key-delete (high-risk-write) * feat(apps): add +openapi-key-reset (rotate, one-time new secret) * test(apps): assert reset surfaces raw key exactly once * feat(apps): register openapi-key shortcuts * docs(lark-apps): add openapi-key reference and routing * test(apps): update shortcut count for openapi-key commands * fix(apps): trim openapi-key update name and correct shortcut-count comment * fix(apps): use camelCase config and add scope-all/scope-api flags Replace snake_case wire keys (request_scope, is_allow_access_preview) with camelCase (requestScope, isAllowAccessPreview, allowAll, httpInfos, httpMethod, httpPath). Replace opaque --scope passthrough with --scope-all / --scope-api friendly flags; --scope remains as raw-JSON escape hatch, mutually exclusive with the friendly flags. Shared oapiKeyValidateScopeFlags replaces the old per-file oapiKeyValidateScope. * fix(apps): use Changed for scope-all and refresh openapi-key scope docs Switch the update at-least-one guard from rctx.Bool to rctx.Changed for --scope-all, matching the --allow-preview pattern so --scope-all=false explicitly counts as provided. Rewrite lark-apps-openapi-key.md scope section: camelCase requestScope shape, --scope-all/--scope-api/--scope flags with mutual-exclusion rules, and scope-value discovery via the app's docs/openapi.json. * fix(apps): emit snake_case request_scope config for open gateway Open gateway (/open-apis/spark/v1) requires snake_case request bodies; flip parseScopeAPI/buildRequestScope/buildKeyConfig to emit http_method, http_path, allow_all, http_infos, request_scope, is_allow_access_preview. Update unit tests to assert snake_case and reject camelCase keys. * docs(lark-apps): correct openapi-key scope to snake_case wire format * docs(apps): align openapi-key flag help text to snake_case wire keys * feat(apps): add actionable hints and more examples to openapi-key P1: chain .WithHint(...) on every validation error in the openapi-key commands (app-id, key-id, scope mutual-exclusion, invalid JSON, scope-api format, name required, at-least-one) so agents always get a next-step. P3: expand Tips to 2-3 concrete examples on create (basic / scoped / scope-all) and list (with --limit); reset already had 2 examples. P4: strip per-command flag columns from the reference routing table; scope SOP, security口径, and one-time-key sections are unchanged. --- shortcuts/apps/apps_openapi_key_common.go | 129 +++++++++ .../apps/apps_openapi_key_common_test.go | 254 ++++++++++++++++++ shortcuts/apps/apps_openapi_key_create.go | 110 ++++++++ .../apps/apps_openapi_key_create_test.go | 86 ++++++ shortcuts/apps/apps_openapi_key_delete.go | 47 ++++ .../apps/apps_openapi_key_delete_test.go | 35 +++ shortcuts/apps/apps_openapi_key_disable.go | 33 +++ shortcuts/apps/apps_openapi_key_enable.go | 53 ++++ shortcuts/apps/apps_openapi_key_get.go | 72 +++++ shortcuts/apps/apps_openapi_key_get_test.go | 49 ++++ shortcuts/apps/apps_openapi_key_list.go | 104 +++++++ shortcuts/apps/apps_openapi_key_list_test.go | 91 +++++++ shortcuts/apps/apps_openapi_key_reset.go | 50 ++++ shortcuts/apps/apps_openapi_key_reset_test.go | 48 ++++ .../apps/apps_openapi_key_status_test.go | 43 +++ shortcuts/apps/apps_openapi_key_update.go | 82 ++++++ .../apps/apps_openapi_key_update_test.go | 63 +++++ shortcuts/apps/shortcuts.go | 9 + shortcuts/apps/shortcuts_test.go | 9 +- skills/lark-apps/SKILL.md | 1 + .../references/lark-apps-openapi-key.md | 79 ++++++ 21 files changed, 1443 insertions(+), 4 deletions(-) create mode 100644 shortcuts/apps/apps_openapi_key_common.go create mode 100644 shortcuts/apps/apps_openapi_key_common_test.go create mode 100644 shortcuts/apps/apps_openapi_key_create.go create mode 100644 shortcuts/apps/apps_openapi_key_create_test.go create mode 100644 shortcuts/apps/apps_openapi_key_delete.go create mode 100644 shortcuts/apps/apps_openapi_key_delete_test.go create mode 100644 shortcuts/apps/apps_openapi_key_disable.go create mode 100644 shortcuts/apps/apps_openapi_key_enable.go create mode 100644 shortcuts/apps/apps_openapi_key_get.go create mode 100644 shortcuts/apps/apps_openapi_key_get_test.go create mode 100644 shortcuts/apps/apps_openapi_key_list.go create mode 100644 shortcuts/apps/apps_openapi_key_list_test.go create mode 100644 shortcuts/apps/apps_openapi_key_reset.go create mode 100644 shortcuts/apps/apps_openapi_key_reset_test.go create mode 100644 shortcuts/apps/apps_openapi_key_status_test.go create mode 100644 shortcuts/apps/apps_openapi_key_update.go create mode 100644 shortcuts/apps/apps_openapi_key_update_test.go create mode 100644 skills/lark-apps/references/lark-apps-openapi-key.md diff --git a/shortcuts/apps/apps_openapi_key_common.go b/shortcuts/apps/apps_openapi_key_common.go new file mode 100644 index 000000000..60bb32d9d --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_common.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// API Key 端点 path 模板。前缀复用 apiBasePath = "/open-apis/spark/v1"(同包)。 +const ( + oapiKeyListPath = apiBasePath + "/apps/%s/oapi_apikeys" // GET(list) / POST(create) + oapiKeyItemPath = apiBasePath + "/apps/%s/oapi_apikeys/%s" // GET / PATCH / DELETE + oapiKeyRefreshPath = apiBasePath + "/apps/%s/oapi_apikeys/%s/refresh" // POST(reset) +) + +// maskAPIKey 把原始 api_key 收敛为非敏感预览:末 4 位前缀 "****"。 +// 空串或 <=4 位统一返回 "****"。 +func maskAPIKey(s string) string { + if len(s) <= 4 { + return "****" + } + return "****" + s[len(s)-4:] +} + +// redactKeyInfo 返回 app_open_api_key_info 的副本,剥离原始 api_key 并补 masked +// key_preview。非颁发命令(list/get/update/enable/disable)一律经此处理,确保原始 +// 密钥不从这些路径泄露。不修改入参。 +func redactKeyInfo(info map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(info)+1) + for k, v := range info { + if k == "api_key" { + continue + } + out[k] = v + } + if raw, ok := info["api_key"].(string); ok { + out["key_preview"] = maskAPIKey(raw) + } else { + out["key_preview"] = "****" + } + return out +} + +// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case httpInfo. +func parseScopeAPI(s string) (map[string]interface{}, error) { + fields := strings.Fields(strings.TrimSpace(s)) + if len(fields) != 2 { + return nil, fmt.Errorf("expected 'METHOD /path', got %q", s) + } + return map[string]interface{}{"http_method": strings.ToUpper(fields[0]), "http_path": fields[1]}, nil +} + +// buildRequestScope assembles config.request_scope (snake_case) from the scope flags. +// Returns (nil, nil) when no scope flag is set. Raw --scope is the escape hatch and +// is mutually exclusive with --scope-all / --scope-api. +func buildRequestScope(scopeAll bool, scopeAPIs []string, scopeRaw string) (interface{}, error) { + scopeRaw = strings.TrimSpace(scopeRaw) + hasFriendly := scopeAll || len(scopeAPIs) > 0 + if scopeRaw != "" { + if hasFriendly { + return nil, fmt.Errorf("--scope cannot be combined with --scope-all / --scope-api") + } + var rs interface{} + if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil { + return nil, err + } + return rs, nil + } + if !hasFriendly { + return nil, nil + } + rs := map[string]interface{}{"allow_all": scopeAll} + if len(scopeAPIs) > 0 { + infos := make([]interface{}, 0, len(scopeAPIs)) + for _, a := range scopeAPIs { + info, err := parseScopeAPI(a) + if err != nil { + return nil, err + } + infos = append(infos, info) + } + rs["http_infos"] = infos + } + return rs, nil +} + +// buildKeyConfig assembles the snake_case config object. Returns nil when nothing is set. +func buildKeyConfig(scopeAll bool, scopeAPIs []string, scopeRaw string, hasAllowPreview, allowPreview bool) (map[string]interface{}, error) { + rs, err := buildRequestScope(scopeAll, scopeAPIs, scopeRaw) + if err != nil { + return nil, err + } + if rs == nil && !hasAllowPreview { + return nil, nil + } + cfg := map[string]interface{}{} + if rs != nil { + cfg["request_scope"] = rs + } + if hasAllowPreview { + cfg["is_allow_access_preview"] = allowPreview + } + return cfg, nil +} + +// oapiKeyValidateScopeFlags validates the scope flag combination (shared by create/update). +func oapiKeyValidateScopeFlags(rctx *common.RuntimeContext) error { + scopeRaw := strings.TrimSpace(rctx.Str("scope")) + if scopeRaw != "" && (rctx.Bool("scope-all") || len(rctx.StrArray("scope-api")) > 0) { + return appsValidationParamError("--scope", "--scope cannot be combined with --scope-all / --scope-api"). + WithHint("use either --scope (raw JSON) OR --scope-all/--scope-api, not both") + } + if scopeRaw != "" && !json.Valid([]byte(scopeRaw)) { + return appsValidationParamError("--scope", "--scope must be valid JSON"). + WithHint("--scope takes raw JSON for config.request_scope; or use --scope-all / --scope-api 'METHOD /openapi/path'") + } + for _, a := range rctx.StrArray("scope-api") { + if len(strings.Fields(strings.TrimSpace(a))) != 2 { + return appsValidationParamError("--scope-api", "--scope-api must be 'METHOD /path', got %q", a). + WithHint("format: --scope-api 'METHOD /openapi/path' (routes come from the app's docs/openapi.json), e.g. --scope-api 'GET /openapi/orders'") + } + } + return nil +} diff --git a/shortcuts/apps/apps_openapi_key_common_test.go b/shortcuts/apps/apps_openapi_key_common_test.go new file mode 100644 index 000000000..15c2ed16f --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_common_test.go @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "reflect" + "testing" +) + +func TestMaskAPIKey(t *testing.T) { + cases := map[string]string{ + "": "****", + "abcd": "****", + "xxxxxxxxxxxx": "****5f4a", + } + for in, want := range cases { + if got := maskAPIKey(in); got != want { + t.Errorf("maskAPIKey(%q) = %q, want %q", in, got, want) + } + } +} + +func TestRedactKeyInfo_StripsRawKey(t *testing.T) { + in := map[string]interface{}{ + "api_key_id": "1", + "api_key": "xxxxxxxxxxxx", + "name": "partner-test", + "status": float64(1), + } + out := redactKeyInfo(in) + if _, ok := out["api_key"]; ok { + t.Fatalf("redactKeyInfo must strip api_key, got %v", out) + } + if out["key_preview"] != "****5f4a" { + t.Errorf("key_preview = %v, want ****5f4a", out["key_preview"]) + } + if out["name"] != "partner-test" || out["api_key_id"] != "1" { + t.Errorf("non-secret fields must be preserved, got %v", out) + } + // input not mutated + if _, ok := in["api_key"]; !ok { + t.Errorf("redactKeyInfo must not mutate input") + } +} + +func TestParseScopeAPI(t *testing.T) { + t.Run("valid", func(t *testing.T) { + info, err := parseScopeAPI("GET /openapi/v1/orders") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info["http_method"] != "GET" { + t.Errorf("http_method = %v, want GET", info["http_method"]) + } + if info["http_path"] != "/openapi/v1/orders" { + t.Errorf("http_path = %v, want /openapi/v1/orders", info["http_path"]) + } + }) + t.Run("lowercase method uppercased", func(t *testing.T) { + info, err := parseScopeAPI("post /openapi/x") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info["http_method"] != "POST" { + t.Errorf("http_method = %v, want POST", info["http_method"]) + } + }) + t.Run("too few fields", func(t *testing.T) { + if _, err := parseScopeAPI("GET"); err == nil { + t.Errorf("one-word input must error") + } + }) + t.Run("too many fields", func(t *testing.T) { + if _, err := parseScopeAPI("GET /openapi/x extra"); err == nil { + t.Errorf("three-word input must error") + } + }) +} + +func TestBuildRequestScope(t *testing.T) { + t.Run("nothing set -> nil", func(t *testing.T) { + rs, err := buildRequestScope(false, nil, "") + if err != nil || rs != nil { + t.Fatalf("expected nil,nil got rs=%v err=%v", rs, err) + } + }) + t.Run("scope-all only", func(t *testing.T) { + rs, err := buildRequestScope(true, nil, "") + if err != nil { + t.Fatalf("err = %v", err) + } + m := rs.(map[string]interface{}) + if m["allow_all"] != true { + t.Errorf("allow_all = %v, want true", m["allow_all"]) + } + if _, ok := m["http_infos"]; ok { + t.Errorf("http_infos should not appear when no scope-api provided") + } + }) + t.Run("scope-api adds http_infos", func(t *testing.T) { + rs, err := buildRequestScope(false, []string{"GET /openapi/x"}, "") + if err != nil { + t.Fatalf("err = %v", err) + } + m := rs.(map[string]interface{}) + if m["allow_all"] != false { + t.Errorf("allow_all = %v, want false", m["allow_all"]) + } + infos := m["http_infos"].([]interface{}) + if len(infos) != 1 { + t.Fatalf("http_infos len = %d, want 1", len(infos)) + } + info := infos[0].(map[string]interface{}) + if info["http_method"] != "GET" || info["http_path"] != "/openapi/x" { + t.Errorf("info = %v", info) + } + }) + t.Run("raw scope passthrough", func(t *testing.T) { + rs, err := buildRequestScope(false, nil, `{"allow_all":true}`) + if err != nil { + t.Fatalf("err = %v", err) + } + m := rs.(map[string]interface{}) + if m["allow_all"] != true { + t.Errorf("allow_all = %v, want true", m["allow_all"]) + } + }) + t.Run("raw + scope-all -> error", func(t *testing.T) { + if _, err := buildRequestScope(true, nil, `{"allow_all":true}`); err == nil { + t.Errorf("raw + scope-all must error") + } + }) + t.Run("raw + scope-api -> error", func(t *testing.T) { + if _, err := buildRequestScope(false, []string{"GET /openapi/x"}, `{"allow_all":true}`); err == nil { + t.Errorf("raw + scope-api must error") + } + }) + t.Run("invalid raw json -> error", func(t *testing.T) { + if _, err := buildRequestScope(false, nil, "{bad"); err == nil { + t.Errorf("invalid json must error") + } + }) +} + +func TestBuildKeyConfig(t *testing.T) { + t.Run("nothing set -> nil", func(t *testing.T) { + cfg, err := buildKeyConfig(false, nil, "", false, false) + if err != nil || cfg != nil { + t.Fatalf("empty -> nil, got cfg=%v err=%v", cfg, err) + } + }) + t.Run("scope-all -> snake_case request_scope", func(t *testing.T) { + cfg, err := buildKeyConfig(true, nil, "", false, false) + if err != nil { + t.Fatalf("err = %v", err) + } + rs := cfg["request_scope"].(map[string]interface{}) + if rs["allow_all"] != true { + t.Errorf("allow_all = %v, want true", rs["allow_all"]) + } + if _, ok := cfg["is_allow_access_preview"]; ok { + t.Errorf("is_allow_access_preview should not appear") + } + }) + t.Run("scope-api -> snake_case http_infos", func(t *testing.T) { + cfg, err := buildKeyConfig(false, []string{"GET /openapi/x"}, "", false, false) + if err != nil { + t.Fatalf("err = %v", err) + } + rs := cfg["request_scope"].(map[string]interface{}) + if rs["allow_all"] != false { + t.Errorf("allow_all = %v, want false", rs["allow_all"]) + } + infos := rs["http_infos"].([]interface{}) + if len(infos) != 1 { + t.Fatalf("http_infos len = %d, want 1", len(infos)) + } + info := infos[0].(map[string]interface{}) + if info["http_method"] != "GET" || info["http_path"] != "/openapi/x" { + t.Errorf("info = %v", info) + } + }) + t.Run("raw scope passthrough", func(t *testing.T) { + cfg, err := buildKeyConfig(false, nil, `{"allow_all":true}`, false, false) + if err != nil { + t.Fatalf("err = %v", err) + } + rs := cfg["request_scope"].(map[string]interface{}) + if rs["allow_all"] != true { + t.Errorf("allow_all = %v", rs["allow_all"]) + } + }) + t.Run("allow-preview only -> is_allow_access_preview", func(t *testing.T) { + cfg, err := buildKeyConfig(false, nil, "", true, true) + if err != nil { + t.Fatalf("err = %v", err) + } + if _, ok := cfg["request_scope"]; ok { + t.Errorf("request_scope should not appear when not set") + } + if cfg["is_allow_access_preview"] != true { + t.Errorf("is_allow_access_preview = %v, want true", cfg["is_allow_access_preview"]) + } + }) + t.Run("scope-all + allow-preview -> both snake_case keys", func(t *testing.T) { + cfg, err := buildKeyConfig(true, nil, "", true, false) + if err != nil { + t.Fatalf("err = %v", err) + } + if _, ok := cfg["request_scope"]; !ok { + t.Errorf("request_scope missing") + } + if cfg["is_allow_access_preview"] != false { + t.Errorf("is_allow_access_preview = %v, want false", cfg["is_allow_access_preview"]) + } + // ensure no camelCase keys + if _, ok := cfg["requestScope"]; ok { + t.Errorf("found camelCase key requestScope — must use snake_case") + } + if _, ok := cfg["isAllowAccessPreview"]; ok { + t.Errorf("found camelCase key isAllowAccessPreview — must use snake_case") + } + }) + t.Run("raw + scope-all -> error", func(t *testing.T) { + if _, err := buildKeyConfig(true, nil, `{"allow_all":true}`, false, false); err == nil { + t.Errorf("raw + scope-all must error") + } + }) + t.Run("invalid json -> error", func(t *testing.T) { + if _, err := buildKeyConfig(false, nil, "{bad", false, false); err == nil { + t.Errorf("invalid json must error") + } + }) + t.Run("no camelCase keys emitted", func(t *testing.T) { + cfg, err := buildKeyConfig(false, []string{"GET /openapi/x"}, "", true, true) + if err != nil { + t.Fatalf("err = %v", err) + } + if _, ok := cfg["requestScope"]; ok { + t.Errorf("camelCase requestScope must not appear") + } + if _, ok := cfg["isAllowAccessPreview"]; ok { + t.Errorf("camelCase isAllowAccessPreview must not appear") + } + rs := cfg["request_scope"].(map[string]interface{}) + infos := rs["http_infos"].([]interface{}) + info := infos[0].(map[string]interface{}) + wantInfo := map[string]interface{}{"http_method": "GET", "http_path": "/openapi/x"} + if !reflect.DeepEqual(info, wantInfo) { + t.Errorf("info = %v, want %v", info, wantInfo) + } + }) +} diff --git a/shortcuts/apps/apps_openapi_key_create.go b/shortcuts/apps/apps_openapi_key_create.go new file mode 100644 index 000000000..17627badb --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_create.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsOpenAPIKeyCreate creates an open API key. The raw secret is returned ONCE. +var AppsOpenAPIKeyCreate = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-create", + Description: "Create an open API key (returns the raw secret once)", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +openapi-key-create --app-id --name partner-test", + "Example: lark-cli apps +openapi-key-create --app-id --name orders-readonly --scope-api 'GET /openapi/orders'", + "Example: lark-cli apps +openapi-key-create --app-id --name full-access --scope-all", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "name", Desc: "API key name", Required: true}, + {Name: "scope-all", Type: "bool", Desc: "grant access to all /openapi/** routes (request_scope.allow_all)"}, + {Name: "scope-api", Type: "string_array", Desc: "grant one route, repeatable: 'METHOD /openapi/path' (from the app's docs/openapi.json)"}, + {Name: "scope", Desc: "advanced: raw JSON for config.request_scope (mutually exclusive with --scope-all/--scope-api)"}, + {Name: "allow-preview", Type: "bool", Desc: "allow preview-env access (config.is_allow_access_preview)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := oapiKeyValidateAppID(rctx); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("name")) == "" { + return appsValidationParamError("--name", "--name is required"). + WithHint("provide a human-readable key name, e.g. --name partner-readonly") + } + return oapiKeyValidateScopeFlags(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + body, _ := buildOpenAPIKeyCreateBody(rctx) + return common.NewDryRunAPI(). + POST(fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID))). + Desc("Create open API key"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + body, err := buildOpenAPIKeyCreateBody(rctx) + if err != nil { + return appsValidationParamError("--scope", "invalid scope: %v", err). + WithHint("--scope must be valid JSON for config.request_scope; or use --scope-all / --scope-api") + } + path := fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID)) + data, err := rctx.CallAPITyped("POST", path, nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + return outputIssuedKey(rctx, data) + }, +} + +// buildOpenAPIKeyCreateBody builds {name, config?}. +func buildOpenAPIKeyCreateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { + body := map[string]interface{}{"name": strings.TrimSpace(rctx.Str("name"))} + cfg, err := buildKeyConfig(rctx.Bool("scope-all"), rctx.StrArray("scope-api"), rctx.Str("scope"), rctx.Changed("allow-preview"), rctx.Bool("allow-preview")) + if err != nil { + return nil, err + } + if cfg != nil { + body["config"] = cfg + } + return body, nil +} + +// outputIssuedKey emits {api_key_id, api_key(raw, once), info(redacted)} for +// create/reset, plus a one-time stderr warning. The raw secret is NEVER persisted. +func outputIssuedKey(rctx *common.RuntimeContext, data map[string]interface{}) error { + info := common.GetMap(data, "info") + raw := common.GetString(info, "api_key") + if raw == "" { + raw = common.GetString(data, "api_key") // reset returns top-level api_key + } + out := map[string]interface{}{ + "api_key_id": firstNonEmpty(common.GetString(data, "api_key_id"), common.GetString(info, "api_key_id")), + "api_key": raw, + "info": redactKeyInfo(info), + } + fmt.Fprintln(rctx.IO().ErrOut, "warning: this api_key is shown only once and is NOT stored by lark-cli — copy it now and store it in your own secret manager.") + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "api_key_id: %v\napi_key: %v (shown once)\n", out["api_key_id"], raw) + }) + return nil +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/shortcuts/apps/apps_openapi_key_create_test.go b/shortcuts/apps/apps_openapi_key_create_test.go new file mode 100644 index 000000000..076fcf4db --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_create_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// createFlagDefs returns the flag type map for +openapi-key-create tests. +func createFlagDefs() map[string]string { + return map[string]string{ + "app-id": "string", + "name": "string", + "scope-all": "bool", + "scope-api": "string_array", + "scope": "string", + "allow-preview": "bool", + } +} + +func TestOpenAPIKeyCreateExecute_ReturnsRawOnce(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + createFlagDefs(), + map[string]string{"app-id": "app_x", "name": "partner-test"}) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "api_key_id": "1", + "info": map[string]interface{}{ + "api_key_id": "1", "name": "partner-test", + "api_key": "xxxxxxxxxxxx", "status": float64(1), + }, + }, + }, + }) + if err := AppsOpenAPIKeyCreate.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + out := stdoutBuf.String() + // create surfaces the raw secret ONCE at top-level api_key + if !strings.Contains(out, "xxxxxxxxxxxx") { + t.Fatalf("create must surface raw api_key once: %s", out) + } + // nested info must be redacted — raw key appears exactly once (top-level only) + if strings.Count(out, "xxxxxxxxxxxx") != 1 { + t.Errorf("raw key must appear exactly once (top-level only): %s", out) + } + if !strings.Contains(out, "****5f4a") { + t.Errorf("redacted info must carry key_preview: %s", out) + } +} + +func TestOpenAPIKeyCreate_MissingName(t *testing.T) { + rctx, _, _ := newOpenAPIKeyRCtx(t, + createFlagDefs(), + map[string]string{"app-id": "app_x"}) + if err := AppsOpenAPIKeyCreate.Validate(context.Background(), rctx); err == nil { + t.Errorf("missing --name must fail validation") + } +} + +func TestOpenAPIKeyCreate_InvalidScope(t *testing.T) { + rctx, _, _ := newOpenAPIKeyRCtx(t, + createFlagDefs(), + map[string]string{"app-id": "app_x", "name": "n", "scope": "{bad"}) + if err := AppsOpenAPIKeyCreate.Validate(context.Background(), rctx); err == nil { + t.Errorf("invalid --scope json must fail validation") + } +} + +func TestOpenAPIKeyCreate_ScopeRawAndFriendlyMutuallyExclusive(t *testing.T) { + rctx, _, _ := newOpenAPIKeyRCtx(t, + createFlagDefs(), + map[string]string{"app-id": "app_x", "name": "n", "scope": `{"allowAll":true}`, "scope-all": "true"}) + if err := AppsOpenAPIKeyCreate.Validate(context.Background(), rctx); err == nil { + t.Errorf("--scope + --scope-all must fail validation") + } +} diff --git a/shortcuts/apps/apps_openapi_key_delete.go b/shortcuts/apps/apps_openapi_key_delete.go new file mode 100644 index 000000000..caec4cafe --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_delete.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsOpenAPIKeyDelete permanently deletes an open API key (irreversible). +var AppsOpenAPIKeyDelete = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-delete", + Description: "Delete an open API key (irreversible; prefer +openapi-key-disable)", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +openapi-key-delete --app-id --key-id --yes", + "Preview: add --dry-run to see the request without deleting", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "key-id", Desc: "API key ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI().DELETE(oapiKeyItemURL(rctx)).Desc("Delete open API key") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + keyID := strings.TrimSpace(rctx.Str("key-id")) + if _, err := rctx.CallAPITyped("DELETE", oapiKeyItemURL(rctx), nil, nil); err != nil { + return withAppsHint(err, oapiKeyNotFoundHint(rctx)) + } + out := map[string]interface{}{"api_key_id": keyID, "deleted": true} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "deleted api_key_id: %s\n", keyID) + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_openapi_key_delete_test.go b/shortcuts/apps/apps_openapi_key_delete_test.go new file mode 100644 index 000000000..81a427cb3 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_delete_test.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestOpenAPIKeyDeleteMeta_HighRisk(t *testing.T) { + if AppsOpenAPIKeyDelete.Risk != "high-risk-write" { + t.Errorf("delete must be high-risk-write, got %q", AppsOpenAPIKeyDelete.Risk) + } +} + +func TestOpenAPIKeyDeleteExecute(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + map[string]string{"app-id": "string", "key-id": "string", "yes": "bool"}, + map[string]string{"app-id": "app_x", "key-id": "1", "yes": "true"}) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1", + Body: map[string]interface{}{"code": 0, "msg": "", "data": map[string]interface{}{}}, + }) + if err := AppsOpenAPIKeyDelete.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if !strings.Contains(stdoutBuf.String(), "\"deleted\"") && !strings.Contains(stdoutBuf.String(), "deleted") { + t.Errorf("expected deleted marker: %s", stdoutBuf.String()) + } +} diff --git a/shortcuts/apps/apps_openapi_key_disable.go b/shortcuts/apps/apps_openapi_key_disable.go new file mode 100644 index 000000000..35dc9d50f --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_disable.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsOpenAPIKeyDisable disables (status=0) an open API key — the minimal safety brake. +var AppsOpenAPIKeyDisable = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-disable", + Description: "Disable an open API key (minimal safety brake)", + Risk: "write", + Tips: []string{"Example: lark-cli apps +openapi-key-disable --app-id --key-id "}, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "key-id", Desc: "API key ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI().PATCH(oapiKeyItemURL(rctx)).Desc("Disable open API key").Body(openAPIKeyStatusBody(oapiKeyStatusDisable)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + return execOpenAPIKeyStatus(rctx, oapiKeyStatusDisable) + }, +} diff --git a/shortcuts/apps/apps_openapi_key_enable.go b/shortcuts/apps/apps_openapi_key_enable.go new file mode 100644 index 000000000..b32653e08 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_enable.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +// app_open_api_key_status enum: 0=DISABLE, 1=ENABLE. +const ( + oapiKeyStatusDisable = 0 + oapiKeyStatusEnable = 1 +) + +// AppsOpenAPIKeyEnable enables (status=1) an open API key. +var AppsOpenAPIKeyEnable = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-enable", + Description: "Enable an open API key", + Risk: "write", + Tips: []string{"Example: lark-cli apps +openapi-key-enable --app-id --key-id "}, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "key-id", Desc: "API key ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI().PATCH(oapiKeyItemURL(rctx)).Desc("Enable open API key").Body(openAPIKeyStatusBody(oapiKeyStatusEnable)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + return execOpenAPIKeyStatus(rctx, oapiKeyStatusEnable) + }, +} + +// openAPIKeyStatusBody builds the PATCH body for a status change. +func openAPIKeyStatusBody(status int) map[string]interface{} { + return map[string]interface{}{"status": status} +} + +// execOpenAPIKeyStatus PATCHes status and prints the redacted info. +func execOpenAPIKeyStatus(rctx *common.RuntimeContext, status int) error { + data, err := rctx.CallAPITyped("PATCH", oapiKeyItemURL(rctx), nil, openAPIKeyStatusBody(status)) + if err != nil { + return withAppsHint(err, oapiKeyNotFoundHint(rctx)) + } + return outputRedactedInfo(rctx, data) +} diff --git a/shortcuts/apps/apps_openapi_key_get.go b/shortcuts/apps/apps_openapi_key_get.go new file mode 100644 index 000000000..b3d6f0757 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_get.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsOpenAPIKeyGet returns one open API key's detail (redacted). +var AppsOpenAPIKeyGet = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-get", + Description: "Get an open API key detail (secret redacted)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +openapi-key-get --app-id --key-id ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "key-id", Desc: "API key ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + return oapiKeyValidateKeyID(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET(oapiKeyItemURL(rctx)). + Desc("Get open API key detail") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("GET", oapiKeyItemURL(rctx), nil, nil) + if err != nil { + return withAppsHint(err, oapiKeyNotFoundHint(rctx)) + } + return outputRedactedInfo(rctx, data) + }, +} + +// oapiKeyItemURL builds the per-key item path from --app-id / --key-id. +func oapiKeyItemURL(rctx *common.RuntimeContext) string { + return fmt.Sprintf(oapiKeyItemPath, + validate.EncodePathSegment(strings.TrimSpace(rctx.Str("app-id"))), + validate.EncodePathSegment(strings.TrimSpace(rctx.Str("key-id")))) +} + +// oapiKeyNotFoundHint points a failed per-key call at +openapi-key-list. +func oapiKeyNotFoundHint(rctx *common.RuntimeContext) string { + return "verify --key-id; list keys with `lark-cli apps +openapi-key-list --app-id " + + strings.TrimSpace(rctx.Str("app-id")) + "`" +} + +// outputRedactedInfo emits {info: } for get/update/enable/disable. +func outputRedactedInfo(rctx *common.RuntimeContext, data map[string]interface{}) error { + info := common.GetMap(data, "info") + red := redactKeyInfo(info) + out := map[string]interface{}{"info": red} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "api_key_id: %v\nname: %v\nstatus: %v\nkey_preview: %v\n", + red["api_key_id"], red["name"], red["status"], red["key_preview"]) + }) + return nil +} diff --git a/shortcuts/apps/apps_openapi_key_get_test.go b/shortcuts/apps/apps_openapi_key_get_test.go new file mode 100644 index 000000000..28a504bb3 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_get_test.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestOpenAPIKeyGetExecute_Redacts(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + map[string]string{"app-id": "string", "key-id": "string"}, + map[string]string{"app-id": "app_x", "key-id": "1"}) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "info": map[string]interface{}{ + "api_key_id": "1", "name": "partner-test", + "api_key": "xxxxxxxxxxxx", "status": float64(1), + }, + }, + }, + }) + if err := AppsOpenAPIKeyGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if strings.Contains(stdoutBuf.String(), "xxxxxxxxxxxx") { + t.Fatalf("get output leaked raw api_key: %s", stdoutBuf.String()) + } + if !strings.Contains(stdoutBuf.String(), "****5f4a") { + t.Errorf("expected key_preview: %s", stdoutBuf.String()) + } +} + +func TestOpenAPIKeyGetExecute_MissingKeyID(t *testing.T) { + rctx, _, _ := newOpenAPIKeyRCtx(t, + map[string]string{"app-id": "string", "key-id": "string"}, + map[string]string{"app-id": "app_x"}) + if err := AppsOpenAPIKeyGet.Validate(context.Background(), rctx); err == nil { + t.Errorf("missing --key-id must fail validation") + } +} diff --git a/shortcuts/apps/apps_openapi_key_list.go b/shortcuts/apps/apps_openapi_key_list.go new file mode 100644 index 000000000..f61f6987c --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_list.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsOpenAPIKeyList lists an app's open API keys (redacted; raw secret never shown). +var AppsOpenAPIKeyList = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-list", + Description: "List an app's open API keys (secrets redacted)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +openapi-key-list --app-id ", + "Example: lark-cli apps +openapi-key-list --app-id --limit 10", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "limit", Type: "int", Desc: "page size (server default if omitted)"}, + {Name: "offset", Type: "int", Desc: "page offset"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + return oapiKeyValidateAppID(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID))). + Desc("List open API keys"). + Params(buildOpenAPIKeyListParams(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + path := fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID)) + data, err := rctx.CallAPITyped("GET", path, buildOpenAPIKeyListParams(rctx), nil) + if err != nil { + return withAppsHint(err, appIDListHint) + } + infos := common.GetSlice(data, "infos") + redacted := make([]interface{}, 0, len(infos)) + for _, it := range infos { + if m, ok := it.(map[string]interface{}); ok { + redacted = append(redacted, redactKeyInfo(m)) + } else { + redacted = append(redacted, it) + } + } + out := map[string]interface{}{"infos": redacted} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "%d key(s)\n", len(redacted)) + for _, it := range redacted { + if m, ok := it.(map[string]interface{}); ok { + fmt.Fprintf(w, "- %v %v %v\n", m["api_key_id"], m["name"], m["key_preview"]) + } + } + }) + return nil + }, +} + +// buildOpenAPIKeyListParams builds the optional limit/offset query params. +func buildOpenAPIKeyListParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{} + if rctx.Changed("limit") { + params["limit"] = rctx.Int("limit") + } + if rctx.Changed("offset") { + params["offset"] = rctx.Int("offset") + } + return params +} + +// oapiKeyValidateAppID validates --app-id presence. Shared by all openapi-key commands. +func oapiKeyValidateAppID(rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return appsValidationParamError("--app-id", "--app-id is required"). + WithHint("list your apps with `lark-cli apps +list`") + } + return nil +} + +// oapiKeyValidateKeyID validates --app-id and --key-id presence. +func oapiKeyValidateKeyID(rctx *common.RuntimeContext) error { + if err := oapiKeyValidateAppID(rctx); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("key-id")) == "" { + return appsValidationParamError("--key-id", "--key-id is required"). + WithHint("find key ids with `lark-cli apps +openapi-key-list --app-id `") + } + return nil +} diff --git a/shortcuts/apps/apps_openapi_key_list_test.go b/shortcuts/apps/apps_openapi_key_list_test.go new file mode 100644 index 000000000..8d41ca5dd --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_list_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +// newOpenAPIKeyRCtx 构造带指定 flag 的 RuntimeContext。flags 是 name->value, +// bool flag 传 "true"/"false"。被本组所有命令测试复用。 +func newOpenAPIKeyRCtx(t *testing.T, flagDefs map[string]string, flags map[string]string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + cfg := &core.CliConfig{ + AppID: "test-app-" + strings.ToLower(t.Name()), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_test", + } + factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg) + cmd := &cobra.Command{Use: "test-openapi-key"} + cmd.SetContext(context.Background()) + for name, typ := range flagDefs { + switch typ { + case "bool": + cmd.Flags().Bool(name, false, "") + case "int": + cmd.Flags().Int(name, 0, "") + case "string_array": + cmd.Flags().StringArray(name, nil, "") + default: + cmd.Flags().String(name, "", "") + } + } + for name, val := range flags { + _ = cmd.Flags().Set(name, val) + } + rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + return rctx, stdoutBuf, reg +} + +func TestOpenAPIKeyListMeta(t *testing.T) { + if AppsOpenAPIKeyList.Command != "+openapi-key-list" || AppsOpenAPIKeyList.Risk != "read" { + t.Errorf("meta mismatch: %+v", AppsOpenAPIKeyList) + } + if len(AppsOpenAPIKeyList.Scopes) != 1 || AppsOpenAPIKeyList.Scopes[0] != "spark:app:read" { + t.Errorf("scopes = %v", AppsOpenAPIKeyList.Scopes) + } +} + +func TestOpenAPIKeyListExecute_Redacts(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + map[string]string{"app-id": "string", "limit": "int", "offset": "int"}, + map[string]string{"app-id": "app_x"}) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "infos": []interface{}{ + map[string]interface{}{ + "api_key_id": "1", "name": "partner-test", + "api_key": "xxxxxxxxxxxx", "status": float64(1), + }, + }, + }, + }, + }) + if err := AppsOpenAPIKeyList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + out := stdoutBuf.String() + if strings.Contains(out, "xxxxxxxxxxxx") { + t.Fatalf("list output leaked raw api_key: %s", out) + } + if !strings.Contains(out, "****5f4a") { + t.Errorf("expected masked key_preview in output: %s", out) + } + _ = json.Valid +} diff --git a/shortcuts/apps/apps_openapi_key_reset.go b/shortcuts/apps/apps_openapi_key_reset.go new file mode 100644 index 000000000..7013d84e9 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_reset.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsOpenAPIKeyReset rotates (refreshes) an open API key, returning a new raw secret ONCE. +var AppsOpenAPIKeyReset = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-reset", + Description: "Reset (rotate) an open API key; returns a new raw secret once", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +openapi-key-reset --app-id --key-id --yes", + "Preview: add --dry-run to see the request without rotating", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "key-id", Desc: "API key ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI().POST(oapiKeyRefreshURL(rctx)).Desc("Reset (rotate) open API key") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("POST", oapiKeyRefreshURL(rctx), nil, nil) + if err != nil { + return withAppsHint(err, oapiKeyNotFoundHint(rctx)) + } + return outputIssuedKey(rctx, data) + }, +} + +// oapiKeyRefreshURL builds the refresh path from --app-id / --key-id. +func oapiKeyRefreshURL(rctx *common.RuntimeContext) string { + return fmt.Sprintf(oapiKeyRefreshPath, + validate.EncodePathSegment(strings.TrimSpace(rctx.Str("app-id"))), + validate.EncodePathSegment(strings.TrimSpace(rctx.Str("key-id")))) +} diff --git a/shortcuts/apps/apps_openapi_key_reset_test.go b/shortcuts/apps/apps_openapi_key_reset_test.go new file mode 100644 index 000000000..4eefaf26d --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_reset_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestOpenAPIKeyResetMeta_HighRisk(t *testing.T) { + if AppsOpenAPIKeyReset.Risk != "high-risk-write" { + t.Errorf("reset must be high-risk-write, got %q", AppsOpenAPIKeyReset.Risk) + } +} + +func TestOpenAPIKeyResetExecute_ReturnsNewRaw(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + map[string]string{"app-id": "string", "key-id": "string", "yes": "bool"}, + map[string]string{"app-id": "app_x", "key-id": "1", "yes": "true"}) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1/refresh", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "api_key": "xxxxxxxxxxxx", + "info": map[string]interface{}{"api_key_id": "1", "name": "k", "api_key": "xxxxxxxxxxxx", "status": float64(1)}, + }, + }, + }) + if err := AppsOpenAPIKeyReset.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + out := stdoutBuf.String() + if !strings.Contains(out, "xxxxxxxxxxxx") { + t.Fatalf("reset must surface the new raw secret once: %s", out) + } + if strings.Count(out, "xxxxxxxxxxxx") != 1 { + t.Errorf("raw key must appear exactly once (top-level only, info must be redacted): %s", out) + } + if !strings.Contains(out, "****9999") { + t.Errorf("redacted info must carry key_preview: %s", out) + } +} diff --git a/shortcuts/apps/apps_openapi_key_status_test.go b/shortcuts/apps/apps_openapi_key_status_test.go new file mode 100644 index 000000000..1d991db2a --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_status_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestOpenAPIKeyEnableExecute_StatusOne(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + map[string]string{"app-id": "string", "key-id": "string"}, + map[string]string{"app-id": "app_x", "key-id": "1"}) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "info": map[string]interface{}{"api_key_id": "1", "name": "k", "api_key": "xxxxxxxxxxxx", "status": float64(1)}, + }, + }, + }) + if err := AppsOpenAPIKeyEnable.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if strings.Contains(stdoutBuf.String(), "xxxxxxxxxxxx") { + t.Fatalf("enable leaked raw api_key") + } +} + +func TestOpenAPIKeyStatusBody(t *testing.T) { + if b := openAPIKeyStatusBody(1); b["status"] != 1 { + t.Errorf("enable body = %v", b) + } + if b := openAPIKeyStatusBody(0); b["status"] != 0 { + t.Errorf("disable body = %v", b) + } +} diff --git a/shortcuts/apps/apps_openapi_key_update.go b/shortcuts/apps/apps_openapi_key_update.go new file mode 100644 index 000000000..e6ea7f0d0 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_update.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsOpenAPIKeyUpdate updates an open API key's name and/or config (not status). +var AppsOpenAPIKeyUpdate = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-update", + Description: "Update an open API key's name and/or scope", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +openapi-key-update --app-id --key-id --name partner-prod", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "key-id", Desc: "API key ID", Required: true}, + {Name: "name", Desc: "new name"}, + {Name: "scope-all", Type: "bool", Desc: "grant access to all /openapi/** routes (request_scope.allow_all)"}, + {Name: "scope-api", Type: "string_array", Desc: "grant one route, repeatable: 'METHOD /openapi/path' (from the app's docs/openapi.json)"}, + {Name: "scope", Desc: "advanced: raw JSON for config.request_scope (mutually exclusive with --scope-all/--scope-api)"}, + {Name: "allow-preview", Type: "bool", Desc: "allow preview-env access (config.is_allow_access_preview)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := oapiKeyValidateKeyID(rctx); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("name")) == "" && + !rctx.Changed("scope-all") && + len(rctx.StrArray("scope-api")) == 0 && + strings.TrimSpace(rctx.Str("scope")) == "" && + !rctx.Changed("allow-preview") { + return appsValidationParamError("--name", "at least one of --name / --scope-all / --scope-api / --scope / --allow-preview is required"). + WithHint("pass at least one of --name / --scope-all / --scope-api / --scope / --allow-preview") + } + return oapiKeyValidateScopeFlags(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildOpenAPIKeyUpdateBody(rctx) + return common.NewDryRunAPI(). + PATCH(oapiKeyItemURL(rctx)). + Desc("Update open API key"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + body, err := buildOpenAPIKeyUpdateBody(rctx) + if err != nil { + return appsValidationParamError("--scope", "invalid scope: %v", err) + } + data, err := rctx.CallAPITyped("PATCH", oapiKeyItemURL(rctx), nil, body) + if err != nil { + return withAppsHint(err, oapiKeyNotFoundHint(rctx)) + } + return outputRedactedInfo(rctx, data) + }, +} + +// buildOpenAPIKeyUpdateBody builds {name?, config?} with only provided fields. +func buildOpenAPIKeyUpdateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { + body := map[string]interface{}{} + if name := strings.TrimSpace(rctx.Str("name")); name != "" { + body["name"] = name + } + cfg, err := buildKeyConfig(rctx.Bool("scope-all"), rctx.StrArray("scope-api"), rctx.Str("scope"), rctx.Changed("allow-preview"), rctx.Bool("allow-preview")) + if err != nil { + return nil, err + } + if cfg != nil { + body["config"] = cfg + } + return body, nil +} diff --git a/shortcuts/apps/apps_openapi_key_update_test.go b/shortcuts/apps/apps_openapi_key_update_test.go new file mode 100644 index 000000000..e9f0f9b3e --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_update_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// updateFlagDefs returns the flag type map for +openapi-key-update tests. +func updateFlagDefs() map[string]string { + return map[string]string{ + "app-id": "string", + "key-id": "string", + "name": "string", + "scope-all": "bool", + "scope-api": "string_array", + "scope": "string", + "allow-preview": "bool", + } +} + +func TestOpenAPIKeyUpdate_RequiresOneField(t *testing.T) { + rctx, _, _ := newOpenAPIKeyRCtx(t, + updateFlagDefs(), + map[string]string{"app-id": "app_x", "key-id": "1"}) + err := AppsOpenAPIKeyUpdate.Validate(context.Background(), rctx) + if err == nil { + t.Errorf("update with no changeable field must fail validation") + } + if err != nil && !strings.Contains(err.Error(), "at least one of --name / --scope-all / --scope-api / --scope / --allow-preview is required") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestOpenAPIKeyUpdateExecute_Redacts(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + updateFlagDefs(), + map[string]string{"app-id": "app_x", "key-id": "1", "name": "partner-prod"}) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "info": map[string]interface{}{ + "api_key_id": "1", "name": "partner-prod", + "api_key": "xxxxxxxxxxxx", "status": float64(1), + }, + }, + }, + }) + if err := AppsOpenAPIKeyUpdate.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if strings.Contains(stdoutBuf.String(), "xxxxxxxxxxxx") { + t.Fatalf("update leaked raw api_key: %s", stdoutBuf.String()) + } +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index 3f4ae793c..e5a7e6899 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -51,5 +51,14 @@ func Shortcuts() []common.Shortcut { AppsSessionStop, AppsSessionMessagesList, AppsChat, + // open API key management + AppsOpenAPIKeyList, + AppsOpenAPIKeyGet, + AppsOpenAPIKeyCreate, + AppsOpenAPIKeyUpdate, + AppsOpenAPIKeyEnable, + AppsOpenAPIKeyDisable, + AppsOpenAPIKeyDelete, + AppsOpenAPIKeyReset, } } diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index cb5d3565f..66ccbca41 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -16,11 +16,12 @@ import ( // env-diff/env-migrate/recovery-diff/recovery-apply/quota-get) // - 7 file(list/get/sign/download/upload/delete/quota-get) // - 3 git-credential -// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list = 43。 -func TestAppsShortcuts_Returns43(t *testing.T) { +// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list +// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)= 51。 +func TestAppsShortcuts_Returns51(t *testing.T) { got := Shortcuts() - if len(got) != 43 { - t.Fatalf("Shortcuts() returned %d entries, want 43", len(got)) + if len(got) != 51 { + t.Fatalf("Shortcuts() returned %d entries, want 51", len(got)) } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 45eb2f0f2..f884515cb 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -30,6 +30,7 @@ metadata: | **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) | | 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference | | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | +| 管理妙搭应用开放 API Key(创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | ## 选择开发路径(进意图路由前先判这步) diff --git a/skills/lark-apps/references/lark-apps-openapi-key.md b/skills/lark-apps/references/lark-apps-openapi-key.md new file mode 100644 index 000000000..37cb7d37e --- /dev/null +++ b/skills/lark-apps/references/lark-apps-openapi-key.md @@ -0,0 +1,79 @@ +# apps openapi-key 命令族 SOP + +管理妙搭应用对外暴露的 HTTP API Key(`/openapi/**` 鉴权凭证)。全部操作需 `--as user`(AuthType: user)。`--help` 是参数细节的完整来源;本文件只记录 Agent 不看就会做错的领域规则。 + +## 命令路由 + +| 命令 | 用途 | +|---|---| +| `+openapi-key-list` | 列出应用所有 API Key(脱敏) | +| `+openapi-key-get` | 查看单个 Key 详情(脱敏) | +| `+openapi-key-create` | 创建新 Key,**原始密钥一次性可见** | +| `+openapi-key-update` | 改名或改 config(不改 status) | +| `+openapi-key-enable` | 启用 Key(status→1) | +| `+openapi-key-disable` | 停用 Key(status→0),**泄露/疑似泄露优先用这个而非 delete** | +| `+openapi-key-delete` | 永久删除 Key(不可逆) | +| `+openapi-key-reset` | 轮换密钥(刷新原始 Key),**一次性可见** | + +## 脱敏口径(安全关键) + +- `list` / `get` / `update` / `enable` / `disable`:返回结构里 **无** `api_key` 字段,只有 `key_preview`(格式:`****` + 原始密钥末 4 位,如 `****5f4a`)。 +- `create` / `reset`:**仅** 在 `data.api_key`(顶层)返回原始密钥一次;同时在 stderr 打印一次性提示: + ``` + warning: this api_key is shown only once and is NOT stored by lark-cli — copy it now and store it in your own secret manager. + ``` +- 原始密钥绝不写入 cache / config / recent / debug log / 错误信息。 + +## 一次性密钥语义 + +CLI 不保存原始密钥。密钥在 `create` / `reset` 时仅随响应返回一次。**密钥丢失不能用 `get` 找回**——唯一恢复方式是 `+openapi-key-reset` 重新生成新密钥(旧密钥同时失效)。 + +## scope 结构与 CLI 表达 + +后端 `config.request_scope` 的真实结构(**snake_case**——Lark 开放网关 `/open-apis/` 对外契约约定;`api_key.thrift` 的 camelCase go.tag 是内部表示,OGW 已转成 snake_case): + +```json +{ + "allow_all": true, + "http_infos": [ + { "http_method": "GET", "http_path": "/openapi/some-path" } + ] +} +``` + +- `allow_all=true`:放开该应用所有 `/openapi/**` 路由;`http_infos` 此时忽略。 +- `allow_all=false`:按 `http_infos` 逐条授权,每条需 `http_method`(大写)+ `http_path`(`/openapi/` 开头)。 + +CLI 提供三种互斥的 scope 表达方式: + +| flag | 用途 | 备注 | +|---|---|---| +| `--scope-all` | `allow_all=true`,放开所有路由 | bool flag,显式传 `--scope-all=false` 也算"已设置" | +| `--scope-api 'METHOD /openapi/path'` | 逐条授权一个路由,可重复 | 路由从应用 `docs/openapi.json` 取 | +| `--scope ''` | 高级逃生口,直传 request_scope JSON(snake_case) | CLI 只校验合法 JSON;`--scope` 与 `--scope-all`/`--scope-api` 互斥 | + +### scope 值来源 + +妙搭应用的 `/openapi/**` 路由定义在应用仓库,并同步维护在 `docs/openapi.json`(`paths` 下每个 `"/openapi/..."` 条目 + HTTP 方法)。要授权哪些路由,读目标应用自己的 `docs/openapi.json`,取 `(method, path)` 对。CLI 本身不提供 API 路由发现功能(P1 规划中)。 + +## 高风险操作 + +`delete` 和 `reset` 是高风险(`high-risk-write`),有以下约束: + +- 需显式传 `--yes`(框架 `cmdutil.RequireConfirmation`);缺少时退出码 10,**不要自动补 `--yes`**(遵循 lark-shared 安全红线)。 +- 支持 `--dry-run` 查看将要执行的 HTTP 请求(不含密钥);不确定时先 dry-run。 +- **泄露场景**:应优先 `+openapi-key-disable` 立即停用,而非 `+openapi-key-delete`——停用可随时 enable 恢复,delete 不可逆。 + +## 典型决策场景 + +| 用户意图 | 正确操作 | +|---|---| +| "key 泄露了,先停掉" | `+openapi-key-disable`(不是 delete) | +| "key 丢了/忘了,再给我一个" | `+openapi-key-reset`(不是 create 新 key;reset 轮换密钥、保留原 key 配置) | +| "我的 key 密钥是什么" | 解释:list/get 不回显原始密钥,只能用 `+openapi-key-reset` 轮换 | +| "给应用创建一个有权限限制的 key" | `+openapi-key-create --name ... --scope-api 'GET /openapi/...'`(路由取自应用 `docs/openapi.json`) | + +## 不在本 skill 范围 + +- OpenAPI spec 全量导出、实时日志 tail、Webhook 消费、多鉴权方式:本期不支持。 +- 身份选择、权限不足处理(`permission_violations`→`console_url`)、exit-10 审批、通用"禁输出密钥"红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。 From 81c3736da231ddad777f813e5b0a8d19daf17dfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=85=B4=E7=82=80?= Date: Thu, 25 Jun 2026 20:14:03 +0800 Subject: [PATCH 15/34] refactor(apps): rename db --env to --environment (hard rename) Make --environment the only accepted db environment flag across the db commands (execute, table-list/get, env-create, data export/import, changelog, audit status/enable/disable/list, quota). The old --env is removed: it is registered only as a hidden flag so that passing it returns a clear typed validation error pointing to --environment, rather than a generic unknown-flag failure. Update the lark-apps db references accordingly. --- shortcuts/apps/apps_db_audit_list.go | 12 ++--- shortcuts/apps/apps_db_audit_set.go | 30 +++++++------ shortcuts/apps/apps_db_audit_status.go | 13 +++--- shortcuts/apps/apps_db_changelog_list.go | 12 ++--- shortcuts/apps/apps_db_changelog_list_test.go | 2 +- shortcuts/apps/apps_db_data_export.go | 14 +++--- shortcuts/apps/apps_db_data_import.go | 12 ++--- shortcuts/apps/apps_db_data_import_test.go | 2 +- shortcuts/apps/apps_db_env_create.go | 19 ++++---- shortcuts/apps/apps_db_env_create_test.go | 10 ++--- shortcuts/apps/apps_db_execute.go | 14 +++--- shortcuts/apps/apps_db_execute_test.go | 21 ++++++++- shortcuts/apps/apps_db_quota_get.go | 17 +++---- shortcuts/apps/apps_db_table_get.go | 12 ++--- shortcuts/apps/apps_db_table_list.go | 15 ++++--- shortcuts/apps/apps_db_table_list_test.go | 6 +-- shortcuts/apps/apps_hints_more_test.go | 4 +- shortcuts/apps/db_common.go | 29 ++++++++++++ .../references/lark-apps-db-execute.md | 8 ++-- skills/lark-apps/references/lark-apps-db.md | 44 +++++++++---------- 20 files changed, 180 insertions(+), 116 deletions(-) diff --git a/shortcuts/apps/apps_db_audit_list.go b/shortcuts/apps/apps_db_audit_list.go index f0047fe27..c3f40f459 100644 --- a/shortcuts/apps/apps_db_audit_list.go +++ b/shortcuts/apps/apps_db_audit_list.go @@ -33,19 +33,21 @@ var AppsDBAuditList = common.Shortcut{ Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]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"}, - }, + }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } + if err := rejectLegacyEnvFlag(rctx); err != nil { + return err + } if len(auditListTables(rctx)) == 0 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required (at least one table)").WithParam("--table") } @@ -64,7 +66,7 @@ var AppsDBAuditList = common.Shortcut{ return err } requested := auditListTables(rctx) - env := rctx.Str("env") + env := dbEnv(rctx) // 多表查询:CLI 侧先用 schema(表是否存在)+ status(审计是否开启)过滤, // 不存在 / 未开启审计的表不进 audit_list 查询,单独在 skipped 里给出原因。 @@ -207,7 +209,7 @@ func auditListTables(rctx *common.RuntimeContext) []string { // 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"), + "env": dbEnv(rctx), "tables": strings.Join(tables, ","), "page_size": rctx.Int("page-size"), } diff --git a/shortcuts/apps/apps_db_audit_set.go b/shortcuts/apps/apps_db_audit_set.go index 1d29d95b3..287640eb8 100644 --- a/shortcuts/apps/apps_db_audit_set.go +++ b/shortcuts/apps/apps_db_audit_set.go @@ -31,22 +31,23 @@ var AppsDBAuditEnable = common.Shortcut{ Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]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"}, - }, + }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - _, err := requireAppID(rctx.Str("app-id")) - return err + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return rejectLegacyEnvFlag(rctx) }, 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")}). + Params(map[string]interface{}{"env": dbEnv(rctx)}). 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 { @@ -59,7 +60,7 @@ var AppsDBAuditEnable = common.Shortcut{ 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{}{"env": dbEnv(rctx)}, map[string]interface{}{"table": table, "enabled": true, "retention": retention}) stop() if err != nil { @@ -92,21 +93,22 @@ var AppsDBAuditDisable = common.Shortcut{ Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]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"}, - }, + }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - _, err := requireAppID(rctx.Str("app-id")) - return err + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return rejectLegacyEnvFlag(rctx) }, 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")}). + Params(map[string]interface{}{"env": dbEnv(rctx)}). Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -116,7 +118,7 @@ var AppsDBAuditDisable = common.Shortcut{ } table := strings.TrimSpace(rctx.Str("table")) data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID), - map[string]interface{}{"env": rctx.Str("env")}, + map[string]interface{}{"env": dbEnv(rctx)}, map[string]interface{}{"table": table, "enabled": false}) if err != nil { return withAppsHint(err, dbAuditSetHint) diff --git a/shortcuts/apps/apps_db_audit_status.go b/shortcuts/apps/apps_db_audit_status.go index e059f5081..855100683 100644 --- a/shortcuts/apps/apps_db_audit_status.go +++ b/shortcuts/apps/apps_db_audit_status.go @@ -27,14 +27,15 @@ var AppsDBAuditStatus = common.Shortcut{ Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]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)"}, - }, + }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - _, err := requireAppID(rctx.Str("app-id")) - return err + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return rejectLegacyEnvFlag(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) @@ -74,7 +75,7 @@ var AppsDBAuditStatus = common.Shortcut{ // buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。 func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} { - params := map[string]interface{}{"env": rctx.Str("env")} + params := map[string]interface{}{"env": dbEnv(rctx)} if t := strings.TrimSpace(rctx.Str("table")); t != "" { params["table"] = t } diff --git a/shortcuts/apps/apps_db_changelog_list.go b/shortcuts/apps/apps_db_changelog_list.go index 03048407c..1f013daa6 100644 --- a/shortcuts/apps/apps_db_changelog_list.go +++ b/shortcuts/apps/apps_db_changelog_list.go @@ -12,7 +12,7 @@ import ( "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 --env dev`" +const dbChangelogHint = "verify --app-id is correct; if targeting --environment dev, create it first with `lark-cli apps +db-env-create --app-id --environment dev`" // AppsDBChangelogList 列出应用数据库的 DDL 变更记录(建表/改表/索引等结构变更追溯)。 // @@ -31,20 +31,22 @@ var AppsDBChangelogList = common.Shortcut{ Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]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"}, - }, + }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } + if err := rejectLegacyEnvFlag(rctx); err != nil { + return err + } return normalizeTimeFlags(rctx, "since", "until") }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { @@ -76,7 +78,7 @@ var AppsDBChangelogList = common.Shortcut{ // 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"), + "env": dbEnv(rctx), "page_size": rctx.Int("page-size"), } addStr := func(flag, key string) { diff --git a/shortcuts/apps/apps_db_changelog_list_test.go b/shortcuts/apps/apps_db_changelog_list_test.go index af56ac51e..a179b14e1 100644 --- a/shortcuts/apps/apps_db_changelog_list_test.go +++ b/shortcuts/apps/apps_db_changelog_list_test.go @@ -33,7 +33,7 @@ func TestAppsDBChangelogList_RequiresAppID(t *testing.T) { 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", + []string{"+db-changelog-list", "--app-id", "app_x", "--environment", "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) } diff --git a/shortcuts/apps/apps_db_data_export.go b/shortcuts/apps/apps_db_data_export.go index 5e4cb7b47..e4d98294b 100644 --- a/shortcuts/apps/apps_db_data_export.go +++ b/shortcuts/apps/apps_db_data_export.go @@ -42,17 +42,19 @@ var AppsDBDataExport = common.Shortcut{ Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]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:
.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"}, - }, + }, dbEnvFlags("online", []string{"dev", "online"}, "source db environment")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } + if err := rejectLegacyEnvFlag(rctx); err != nil { + return err + } if strings.TrimSpace(rctx.Str("table")) == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required").WithParam("--table") } @@ -71,7 +73,7 @@ var AppsDBDataExport = common.Shortcut{ 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")), + "env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")), "format": format, "limit": rctx.Int("limit"), }) }, @@ -88,13 +90,13 @@ var AppsDBDataExport = common.Shortcut{ // 原子编排第 1 步:先查总行数(records 列表的 total),再导出文件。 // total 查询失败不阻断导出——回退到按导出文件内容数行。 - total, totalErr := queryExportTotal(rctx, appID, rctx.Str("env"), table) + total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table) resp, err := rctx.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodGet, ApiPath: appDataExportPath(appID), QueryParams: larkcore.QueryParams{ - "env": []string{rctx.Str("env")}, + "env": []string{dbEnv(rctx)}, "table": []string{table}, "format": []string{format}, "limit": []string{strconv.Itoa(rctx.Int("limit"))}, diff --git a/shortcuts/apps/apps_db_data_import.go b/shortcuts/apps/apps_db_data_import.go index 986bf113b..1fe5cb18c 100644 --- a/shortcuts/apps/apps_db_data_import.go +++ b/shortcuts/apps/apps_db_data_import.go @@ -40,16 +40,18 @@ var AppsDBDataImport = common.Shortcut{ Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]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"}, - }, + }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } + if err := rejectLegacyEnvFlag(rctx); err != nil { + return err + } if strings.TrimSpace(rctx.Str("file")) == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file") } @@ -74,7 +76,7 @@ var AppsDBDataImport = common.Shortcut{ 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)}). + Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}). Body(map[string]interface{}{"file_name": fileName, "file": ""}) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -101,7 +103,7 @@ var AppsDBDataImport = common.Shortcut{ resp, err := rctx.DoAPI(&larkcore.ApiReq{ HttpMethod: http.MethodPost, ApiPath: appDataImportPath(appID), - QueryParams: larkcore.QueryParams{"env": []string{rctx.Str("env")}, "table": []string{table}}, + QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}}, Body: fd, }, larkcore.WithFileUpload()) if err != nil { diff --git a/shortcuts/apps/apps_db_data_import_test.go b/shortcuts/apps/apps_db_data_import_test.go index 14d29290b..0902e2cf1 100644 --- a/shortcuts/apps/apps_db_data_import_test.go +++ b/shortcuts/apps/apps_db_data_import_test.go @@ -94,7 +94,7 @@ func TestAppsDBDataImport_DryRunMultipartShape(t *testing.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 { + []string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--environment", "dev", "--dry-run", "--yes", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } var env struct { diff --git a/shortcuts/apps/apps_db_env_create.go b/shortcuts/apps/apps_db_env_create.go index 5fe3034ed..9e0830dbd 100644 --- a/shortcuts/apps/apps_db_env_create.go +++ b/shortcuts/apps/apps_db_env_create.go @@ -12,11 +12,11 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id --env dev`" +const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id --environment dev`" // AppsDBEnvCreate creates a DB environment for an app(拆分单库为 dev/online 多环境)。 // -// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。 +// 调 POST /apps/{app_id}/db_dev_init。--environment 指定要创建的环境,由调用方传入,目前只支持 dev。 // 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。 var AppsDBEnvCreate = common.Shortcut{ Service: appsService, @@ -24,19 +24,20 @@ var AppsDBEnvCreate = common.Shortcut{ Description: "Create a DB environment (split single-env DB into dev/online, irreversible)", Risk: "high-risk-write", Tips: []string{ - "Example: lark-cli apps +db-env-create --env dev --sync-data --app-id --yes", + "Example: lark-cli apps +db-env-create --environment dev --sync-data --app-id --yes", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]common.Flag{ {Name: "app-id", Desc: "app id", Required: true}, - {Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"}, {Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"}, - }, + }, dbEnvFlags("dev", []string{"dev"}, "environment to create (only dev supported for now)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - _, err := requireAppID(rctx.Str("app-id")) - return err + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return rejectLegacyEnvFlag(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) @@ -62,7 +63,7 @@ var AppsDBEnvCreate = common.Shortcut{ } // buildDBEnvCreateBody 构造 db 环境创建 body:sync_data(bool)。 -// --env 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。 +// --environment 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。 func buildDBEnvCreateBody(rctx *common.RuntimeContext) map[string]interface{} { return map[string]interface{}{ "sync_data": rctx.Bool("sync-data"), diff --git a/shortcuts/apps/apps_db_env_create_test.go b/shortcuts/apps/apps_db_env_create_test.go index 0b29bd452..e72af95d7 100644 --- a/shortcuts/apps/apps_db_env_create_test.go +++ b/shortcuts/apps/apps_db_env_create_test.go @@ -27,7 +27,7 @@ func TestAppsDBEnvCreate_WithYesPostsSyncData(t *testing.T) { } reg.Register(stub) if err := runAppsShortcut(t, AppsDBEnvCreate, - []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--as", "user"}, + []string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--yes", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -54,7 +54,7 @@ func TestAppsDBEnvCreate_SyncDataFalseByDefault(t *testing.T) { } reg.Register(stub) if err := runAppsShortcut(t, AppsDBEnvCreate, - []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"}, + []string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--yes", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -82,7 +82,7 @@ func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) { }, }) if err := runAppsShortcut(t, AppsDBEnvCreate, - []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"}, + []string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -103,7 +103,7 @@ func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) { func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsDBEnvCreate, - []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run", "--as", "user"}, + []string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } @@ -116,7 +116,7 @@ func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) { func TestAppsDBEnvCreate_RejectsNonDevEnv(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDBEnvCreate, - []string{"+db-env-create", "--app-id", "app_x", "--env", "online", "--yes", "--as", "user"}, + []string{"+db-env-create", "--app-id", "app_x", "--environment", "online", "--yes", "--as", "user"}, factory, stdout) if err == nil || !strings.Contains(err.Error(), "env") { t.Fatalf("expected env enum rejection, got %v", err) diff --git a/shortcuts/apps/apps_db_execute.go b/shortcuts/apps/apps_db_execute.go index f67ab534f..f20acdd7a 100644 --- a/shortcuts/apps/apps_db_execute.go +++ b/shortcuts/apps/apps_db_execute.go @@ -55,23 +55,25 @@ var AppsDBExecute = common.Shortcut{ Risk: "high-risk-write", Tips: []string{ `Example: lark-cli apps +db-execute --app-id --sql "SELECT * FROM orders LIMIT 10" --yes`, - `Example: lark-cli apps +db-execute --app-id --env dev --file ./migration.sql --yes`, + `Example: lark-cli apps +db-execute --app-id --environment dev --file ./migration.sql --yes`, "Tip: single SELECT returns data as a row array — filter with --jq, e.g. -q '.data[].id'", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file", Input: []string{common.Stdin}}, {Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"}, - {Name: "env", Default: "dev", Enum: []string{"dev", "online"}, Desc: "target db environment (default dev; use --env online for the online environment)"}, - }, + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use --environment online for the online environment)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } + if err := rejectLegacyEnvFlag(rctx); err != nil { + return err + } sql := strings.TrimSpace(rctx.Str("sql")) file := strings.TrimSpace(rctx.Str("file")) if sql != "" && file != "" { @@ -108,7 +110,7 @@ var AppsDBExecute = common.Shortcut{ buildDBSQLParams(rctx), buildDBSQLBody(rctx)) if err != nil { - return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table
`; for day-to-day debugging target the dev database with `--env dev`") + return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table
`; for day-to-day debugging target the dev database with `--environment dev`") } // server `result: string` 内嵌结构化数组 —— CLI 解出来后按 SQL 类型归一化成 PRD 形态, @@ -290,7 +292,7 @@ func parseErrorSentinel(data string) (int, string) { // CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。 func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} { return map[string]interface{}{ - "env": rctx.Str("env"), + "env": dbEnv(rctx), "transactional": false, } } diff --git a/shortcuts/apps/apps_db_execute_test.go b/shortcuts/apps/apps_db_execute_test.go index 335e27e38..7bb277e43 100644 --- a/shortcuts/apps/apps_db_execute_test.go +++ b/shortcuts/apps/apps_db_execute_test.go @@ -161,7 +161,7 @@ func TestAppsDBExecute_MultiStatementJSONShape(t *testing.T) { func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsDBExecute, - []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--env", "dev", "--dry-run", "--as", "user"}, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } @@ -203,6 +203,23 @@ func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) { } } +// TestAppsDBExecute_LegacyEnvFlagRejected 钉死:旧名 --env 已移除,显式传入报 validation 错并指向 --environment。 +func TestAppsDBExecute_LegacyEnvFlagRejected(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--env", "dev", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("--env should be rejected; stdout:\n%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryValidation { + t.Fatalf("want a typed validation error, got %T: %v", err, err) + } + if !strings.Contains(p.Message, "--environment") { + t.Errorf("message should point to --environment: %q", p.Message) + } +} + // --sql 与 --file 互斥 func TestAppsDBExecute_RejectsSQLAndFileTogether(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) @@ -233,7 +250,7 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsDBExecute, - []string{"+db-execute", "--app-id", "app_x", "--env", "dev", "--file", "m.sql", "--dry-run", "--as", "user"}, + []string{"+db-execute", "--app-id", "app_x", "--environment", "dev", "--file", "m.sql", "--dry-run", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } diff --git a/shortcuts/apps/apps_db_quota_get.go b/shortcuts/apps/apps_db_quota_get.go index c03085b03..c2e767f06 100644 --- a/shortcuts/apps/apps_db_quota_get.go +++ b/shortcuts/apps/apps_db_quota_get.go @@ -22,32 +22,33 @@ var AppsDBQuotaGet = common.Shortcut{ Risk: "read", Tips: []string{ "Example: lark-cli apps +db-quota-get --app-id ", - "Example: lark-cli apps +db-quota-get --app-id --env dev", + "Example: lark-cli apps +db-quota-get --app-id --environment dev", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, - {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, - }, + }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - _, err := requireAppID(rctx.Str("app-id")) - return err + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return rejectLegacyEnvFlag(rctx) }, 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")}) + Params(map[string]interface{}{"env": dbEnv(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", appDbQuotaPath(appID), map[string]interface{}{"env": rctx.Str("env")}, nil) + data, err := rctx.CallAPITyped("GET", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil) if err != nil { return withAppsHint(err, appIDListHint) } diff --git a/shortcuts/apps/apps_db_table_get.go b/shortcuts/apps/apps_db_table_get.go index af0e63f00..617d08b72 100644 --- a/shortcuts/apps/apps_db_table_get.go +++ b/shortcuts/apps/apps_db_table_get.go @@ -11,7 +11,7 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -const dbTableGetHint = "verify --app-id and --table are correct; list tables with `lark-cli apps +db-table-list --app-id `; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id --env dev`" +const dbTableGetHint = "verify --app-id and --table are correct; list tables with `lark-cli apps +db-table-list --app-id `; if targeting --environment dev, create it first with `lark-cli apps +db-env-create --app-id --environment dev`" // AppsDBTableGet gets one table's structure (动词对齐 +db-table-list)。 // @@ -34,15 +34,17 @@ var AppsDBTableGet = common.Shortcut{ Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]common.Flag{ {Name: "app-id", Desc: "app id", Required: true}, {Name: "table", Desc: "table name", Required: true}, - {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, - }, + }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } + if err := rejectLegacyEnvFlag(rctx); err != nil { + return err + } if strings.TrimSpace(rctx.Str("table")) == "" { return appsValidationParamError("--table", "--table is required") } @@ -78,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{ // CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本; // 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。 func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} { - params := map[string]interface{}{"env": rctx.Str("env")} + params := map[string]interface{}{"env": dbEnv(rctx)} if rctx.Format == "pretty" { params["format"] = "ddl" } diff --git a/shortcuts/apps/apps_db_table_list.go b/shortcuts/apps/apps_db_table_list.go index d905531ea..68654928b 100644 --- a/shortcuts/apps/apps_db_table_list.go +++ b/shortcuts/apps/apps_db_table_list.go @@ -13,7 +13,7 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -const dbTableListHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id --env dev`" +const dbTableListHint = "verify --app-id is correct; if targeting --environment dev, create it first with `lark-cli apps +db-env-create --app-id --environment dev`" // AppsDBTableList lists tables in an app's database. // @@ -38,15 +38,16 @@ var AppsDBTableList = common.Shortcut{ Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]common.Flag{ {Name: "app-id", Desc: "app id", Required: true}, - {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, {Name: "page-token", Desc: "pagination cursor from previous response"}, - }, + }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - _, err := requireAppID(rctx.Str("app-id")) - return err + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return rejectLegacyEnvFlag(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) @@ -110,7 +111,7 @@ func projectTableListItems(raw interface{}) []dbTableListItem { func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} { params := map[string]interface{}{ - "env": rctx.Str("env"), + "env": dbEnv(rctx), "page_size": rctx.Int("page-size"), } if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { diff --git a/shortcuts/apps/apps_db_table_list_test.go b/shortcuts/apps/apps_db_table_list_test.go index b9c5a352b..85e1dd4c1 100644 --- a/shortcuts/apps/apps_db_table_list_test.go +++ b/shortcuts/apps/apps_db_table_list_test.go @@ -31,7 +31,7 @@ func TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope(t *testing.T) { }) err := runAppsShortcut(t, AppsDBTableList, - []string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, + []string{"+db-table-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout) if err == nil { t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String()) @@ -159,7 +159,7 @@ func TestAppsDBTableList_RequiresAppID(t *testing.T) { func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsDBTableList, - []string{"+db-table-list", "--app-id", "app_x", "--env", "dev", + []string{"+db-table-list", "--app-id", "app_x", "--environment", "dev", "--page-size", "50", "--page-token", "cursor-abc", "--dry-run", "--as", "user"}, factory, stdout); err != nil { @@ -212,7 +212,7 @@ func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) { func TestAppsDBTableList_RejectsBadEnv(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDBTableList, - []string{"+db-table-list", "--app-id", "app_x", "--env", "prod", "--as", "user"}, factory, stdout) + []string{"+db-table-list", "--app-id", "app_x", "--environment", "prod", "--as", "user"}, factory, stdout) if err == nil || !strings.Contains(err.Error(), "env") { t.Fatalf("expected env enum rejection, got %v", err) } diff --git a/shortcuts/apps/apps_hints_more_test.go b/shortcuts/apps/apps_hints_more_test.go index 2fed3cd68..6275cc7bc 100644 --- a/shortcuts/apps/apps_hints_more_test.go +++ b/shortcuts/apps/apps_hints_more_test.go @@ -80,7 +80,7 @@ func TestAppsCreate_4xxFailureCarriesTypeHint(t *testing.T) { func TestAppsDBEnvCreate_4xxFailureCarriesHint(t *testing.T) { assertHintContains(t, AppsDBEnvCreate, - []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"}, + []string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--yes", "--as", "user"}, &httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/db_dev_init", Status: http.StatusConflict, Body: map[string]interface{}{"msg": "already multi-env"}}, "+db-table-list") @@ -96,7 +96,7 @@ func TestAppsDBTableGet_4xxFailureCarriesHint(t *testing.T) { func TestAppsDBTableList_4xxFailureCarriesHint(t *testing.T) { assertHintContains(t, AppsDBTableList, - []string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, + []string{"+db-table-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, &httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables", Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "dev env not found"}}, "+db-env-create") diff --git a/shortcuts/apps/db_common.go b/shortcuts/apps/db_common.go index 36ad75217..982c9bee2 100644 --- a/shortcuts/apps/db_common.go +++ b/shortcuts/apps/db_common.go @@ -12,8 +12,37 @@ import ( "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" ) +// ── db 环境 flag:--environment 是唯一受理名;旧名 --env 已移除 ── +// +// 硬改名:标准名 --environment(带默认/枚举)正常注册并受理;旧名 --env 仅注册为隐藏 flag, +// 目的是「传了能被识别并给出清晰报错」而非继续受理——一旦显式传 --env,在 Validate 阶段直接 +// 返回 validation 错、指向 --environment。所有 DryRun/Execute 经 dbEnv() 只读 --environment。 + +// dbEnvFlags 返回环境 flag 对,供各 db 命令 append 进自己的 Flags。 +func dbEnvFlags(def string, enum []string, desc string) []common.Flag { + return []common.Flag{ + {Name: "environment", Default: def, Enum: enum, Desc: desc}, + {Name: "env", Hidden: true, Desc: "removed: use --environment"}, + } +} + +// dbEnv 取环境值:只认标准 --environment(含其默认值);旧名 --env 不再受理(见 rejectLegacyEnvFlag)。 +func dbEnv(rctx *common.RuntimeContext) string { + return rctx.Str("environment") +} + +// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env:显式传了就报清晰的 validation 错,指向 --environment。 +func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error { + if rctx.Changed("env") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--env is no longer supported; use --environment instead").WithParam("--env") + } + return nil +} + // pollUntil 轮询异步任务直到 check 判定终态。async migrate/recovery 用:dataloom 立即返 // task_id/preview_request_id,CLI 自己 poll(避免单连接长挂被网关/SDK 30s 中断)。 // 首次立即 fetch(不睡);check 返 done→返回;返 err→透传(失败终态);否则按 interval 间隔重试至 maxWait。 diff --git a/skills/lark-apps/references/lark-apps-db-execute.md b/skills/lark-apps/references/lark-apps-db-execute.md index 8a879b256..601110922 100644 --- a/skills/lark-apps/references/lark-apps-db-execute.md +++ b/skills/lark-apps/references/lark-apps-db-execute.md @@ -11,17 +11,17 @@ - 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。 - `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < `(shell 解析路径,CLI 仅接收内容)。 - `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。 -- `--env` 枚举:`dev` / `online`,**默认 `dev`**;需要操作线上环境数据库时,显式指定 `--env online`。 +- `--environment` 枚举:`dev` / `online`,**默认 `dev`**;需要操作线上环境数据库时,显式指定 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。 - risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。 - CLI 永远传 `transactional=false`;不默认包事务。 ## 示例 ```bash -lark-cli apps +db-execute --app-id app_xxx --env dev --sql "select * from orders limit 5" --yes -lark-cli apps +db-execute --app-id app_xxx --env dev --file ./migration.sql --dry-run +lark-cli apps +db-execute --app-id app_xxx --environment dev --sql "select * from orders limit 5" --yes +lark-cli apps +db-execute --app-id app_xxx --environment dev --file ./migration.sql --dry-run # 绝对路径文件 / cwd 不固定:经 stdin 传入 -lark-cli apps +db-execute --app-id app_xxx --env dev --sql - --yes < /Users/.../migrations/0001_init.sql +lark-cli apps +db-execute --app-id app_xxx --environment dev --sql - --yes < /Users/.../migrations/0001_init.sql ``` ## 输出契约 diff --git a/skills/lark-apps/references/lark-apps-db.md b/skills/lark-apps/references/lark-apps-db.md index bd96f5403..abefd598f 100644 --- a/skills/lark-apps/references/lark-apps-db.md +++ b/skills/lark-apps/references/lark-apps-db.md @@ -10,25 +10,25 @@ | 命令 | 做什么 | 关键参数 | |---|---|---| -| `+db-table-list` | 列出某环境的数据表 | `--env`、`--page-size`/`--page-token` | -| `+db-table-get` | 看单张表的结构(字段/索引/约束/DDL) | `--table`、`--env`、`--format` | -| `+db-env-create` | 把单库应用初始化为 dev/online 多环境(高危) | `--env`、`--sync-data`、`--yes` | -| `+db-data-export` | 把一张表的数据导出到本地文件 | `--table`、`--output`、`--limit`、`--env` | -| `+db-data-import` | 把本地 csv/json 文件导进一张表(高危) | `--file`、`--table`、`--env`、`--yes` | -| `+db-changelog-list` | 查表结构变更(DDL)历史 | `--table`、`--change-id`、`--since`/`--until`、`--env` | -| `+db-audit-status` | 看哪些表开了行级审计、保留期 | `--table`、`--env` | -| `+db-audit-enable` | 给某表开启行级变更审计 | `--table`、`--retention`、`--env` | -| `+db-audit-disable` | 关闭某表的行级审计 | `--table`、`--env` | -| `+db-audit-list` | 列出表的行级变更事件(增删改追溯) | `--table`(可重复)、`--since`/`--until`、`--env` | +| `+db-table-list` | 列出某环境的数据表 | `--environment`、`--page-size`/`--page-token` | +| `+db-table-get` | 看单张表的结构(字段/索引/约束/DDL) | `--table`、`--environment`、`--format` | +| `+db-env-create` | 把单库应用初始化为 dev/online 多环境(高危) | `--environment`、`--sync-data`、`--yes` | +| `+db-data-export` | 把一张表的数据导出到本地文件 | `--table`、`--output`、`--limit`、`--environment` | +| `+db-data-import` | 把本地 csv/json 文件导进一张表(高危) | `--file`、`--table`、`--environment`、`--yes` | +| `+db-changelog-list` | 查表结构变更(DDL)历史 | `--table`、`--change-id`、`--since`/`--until`、`--environment` | +| `+db-audit-status` | 看哪些表开了行级审计、保留期 | `--table`、`--environment` | +| `+db-audit-enable` | 给某表开启行级变更审计 | `--table`、`--retention`、`--environment` | +| `+db-audit-disable` | 关闭某表的行级审计 | `--table`、`--environment` | +| `+db-audit-list` | 列出表的行级变更事件(增删改追溯) | `--table`(可重复)、`--since`/`--until`、`--environment` | | `+db-env-diff` | 预览开发环境待发布到线上的结构变更 | `--app-id` | | `+db-env-migrate` | 把开发环境的结构变更发布到线上(高危) | `--app-id`、`--yes` | | `+db-recovery-diff` | 预览把库恢复到某时间点会带来的变更 | `--target` | | `+db-recovery-apply` | 把库恢复到某个时间点、覆盖当前数据(高危) | `--target`、`--yes` | -| `+db-quota-get` | 查数据库存储用量 | `--env` | +| `+db-quota-get` | 查数据库存储用量 | `--environment` | ## 约定(先读) -- **环境 `--env dev|online`(默认 online)**:看表、看结构、数据导入导出、变更追溯、审计、配额、初始化都按环境区分,写操作建议先在 `dev` 验。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--env`。 +- **环境 `--environment dev|online`(默认 online;`+db-execute`/`+db-env-create` 默认 dev)**:看表、看结构、数据导入导出、变更追溯、审计、配额、初始化都按环境区分,写操作建议先在 `dev` 验。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。 - **本地文件用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;路径在别处先 `cd` 过去或改成相对路径。 - **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。 - **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。 @@ -41,23 +41,23 @@ ```bash lark-cli apps +db-table-list --app-id app_xxx -lark-cli apps +db-table-list --app-id app_xxx --env dev --page-size 50 +lark-cli apps +db-table-list --app-id app_xxx --environment dev --page-size 50 ``` **`+db-table-get`**:看单张表的结构。默认 JSON 给结构化的字段 / 索引 / 约束 / 估算行数 / 大小;`--format pretty` 直接输出建表 DDL 文本(给用户看建表语句或做迁移参照时用)。 ```bash lark-cli apps +db-table-get --app-id app_xxx --table orders -lark-cli apps +db-table-get --app-id app_xxx --table orders --env dev --format pretty +lark-cli apps +db-table-get --app-id app_xxx --table orders --environment dev --format pretty ``` ### 多环境数据库(初始化 + 发布) -**`+db-env-create`(高危)**:把存量单库应用初始化为 dev/online 两套库,不可逆,必须带 `--yes`。`--env` 目前只支持 `dev`(默认 `dev`);`--sync-data` 把现有 online 数据复制到新环境(不传则不复制)。注意:`+create --app-type full_stack` 新建的应用通常已自带多环境,重复初始化会返回冲突错误(应用已是多环境)——按 `error.hint` 转述状态即可,别重复初始化。 +**`+db-env-create`(高危)**:把存量单库应用初始化为 dev/online 两套库,不可逆,必须带 `--yes`。`--environment` 目前只支持 `dev`(默认 `dev`);`--sync-data` 把现有 online 数据复制到新环境(不传则不复制)。注意:`+create --app-type full_stack` 新建的应用通常已自带多环境,重复初始化会返回冲突错误(应用已是多环境)——按 `error.hint` 转述状态即可,别重复初始化。 ```bash -lark-cli apps +db-env-create --app-id app_xxx --env dev --dry-run -lark-cli apps +db-env-create --app-id app_xxx --env dev --sync-data --yes +lark-cli apps +db-env-create --app-id app_xxx --environment dev --dry-run +lark-cli apps +db-env-create --app-id app_xxx --environment dev --sync-data --yes ``` **`+db-env-diff`**:预览开发环境里待发布到线上的表结构变更,不落地。发布前先看这个。无待发布变更时明确返回「无变更」。 @@ -82,13 +82,13 @@ lark-cli apps +db-env-migrate --app-id app_xxx --yes ```bash lark-cli apps +db-data-export --app-id app_xxx --table orders --output ./orders.csv -lark-cli apps +db-data-export --app-id app_xxx --table orders --output ./orders.json --env dev +lark-cli apps +db-data-export --app-id app_xxx --table orders --output ./orders.json --environment dev ``` **`+db-data-import`(高危)**:把本地 csv/json 文件的数据导进表。文件需是 `.csv`/`.json`、≤1 MB,必须带 `--yes`。目标表缺省取文件名去掉**最后一个**扩展名(如 `orders.csv`→`orders`,`orders.2026.csv`→`orders.2026`);文件名带点号时建议显式传 `--table` 以免落到意外的表名。 ```bash -lark-cli apps +db-data-import --app-id app_xxx --table orders --file ./orders.csv --env dev --yes +lark-cli apps +db-data-import --app-id app_xxx --table orders --file ./orders.csv --environment dev --yes ``` **导入/导出限额**:体积 ≤ **1 MB**、行数 ≤ **5000**,导入导出都一样,超限会被拒。超限就分批——导入拆成 ≤1 MB / ≤5000 行的多个文件,导出用 `WHERE` / `LIMIT` 缩小范围。 @@ -139,7 +139,7 @@ lark-cli apps +db-recovery-apply --app-id app_xxx --target 2026-04-15T10:00:00Z **`+db-quota-get`**:查数据库存储用量(已用量、表数、视图数;配额接入后还会给总配额与使用率)。 ```bash -lark-cli apps +db-quota-get --app-id app_xxx --env dev +lark-cli apps +db-quota-get --app-id app_xxx --environment dev ``` ## 时间格式(`--since` / `--until` / `--target`) @@ -152,9 +152,9 @@ lark-cli apps +db-quota-get --app-id app_xxx --env dev ## Agent 规则 -- 用户说「本地 / 开发库 / 调试库」优先 `--env dev`,线上排查用 `--env online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。 +- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。 - 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty`);`+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。 -- 四个高危命令(`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--env dev` 验;不要静默追加 `--yes`,遇 confirmation_required(exit 10)按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。 +- 四个高危命令(`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--environment dev` 验;不要静默追加 `--yes`,遇 confirmation_required(exit 10)按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。 - 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。 - `+db-audit-list` 多表查询时,把结果里 `skipped` 的表(不存在 / 未开审计)连同原因一并向用户说明,不要让用户以为这些表「没有变更」。 - 恢复是覆盖式且不可逆:`+db-recovery-apply` 前必须先 `+db-recovery-diff`,并明确告知用户会覆盖当前数据。 From 9efa8b3b696d8cab63f4f225b112e566ea6261a4 Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Thu, 25 Jun 2026 21:09:44 +0800 Subject: [PATCH 16/34] fix: upgrade observability and env --- .../apps/{apps_envvar.go => apps_env.go} | 68 +++- shortcuts/apps/apps_env_pull.go | 17 +- shortcuts/apps/apps_env_pull_test.go | 37 +- .../{apps_envvar_test.go => apps_env_test.go} | 133 ++++++- shortcuts/apps/apps_examples_test.go | 19 +- shortcuts/apps/apps_hints_test.go | 2 +- shortcuts/apps/apps_observability_common.go | 7 +- .../apps/apps_observability_common_test.go | 8 +- shortcuts/apps/apps_observability_logs.go | 363 ++++++++++++++++-- .../apps/apps_observability_logs_test.go | 256 +++++++++++- shortcuts/apps/apps_observability_metrics.go | 8 +- .../apps/apps_observability_metrics_test.go | 4 +- shortcuts/apps/apps_observability_traces.go | 8 +- .../apps/apps_observability_traces_test.go | 4 +- shortcuts/apps/shortcuts.go | 19 +- shortcuts/apps/shortcuts_test.go | 30 +- skills/lark-apps/SKILL.md | 12 +- .../references/lark-apps-env-pull.md | 2 +- skills/lark-apps/references/lark-apps-env.md | 48 +++ .../lark-apps/references/lark-apps-envvar.md | 44 --- .../references/lark-apps-observability.md | 11 +- .../cli_e2e/apps/apps_env_pull_dryrun_test.go | 3 + 22 files changed, 941 insertions(+), 162 deletions(-) rename shortcuts/apps/{apps_envvar.go => apps_env.go} (83%) rename shortcuts/apps/{apps_envvar_test.go => apps_env_test.go} (66%) create mode 100644 skills/lark-apps/references/lark-apps-env.md delete mode 100644 skills/lark-apps/references/lark-apps-envvar.md diff --git a/shortcuts/apps/apps_envvar.go b/shortcuts/apps/apps_env.go similarity index 83% rename from shortcuts/apps/apps_envvar.go rename to shortcuts/apps/apps_env.go index 1661b4ffe..57c05b5e0 100644 --- a/shortcuts/apps/apps_envvar.go +++ b/shortcuts/apps/apps_env.go @@ -5,6 +5,7 @@ package apps import ( "context" + "io" "sort" "strings" @@ -12,23 +13,26 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -const defaultAppsEnvVarEnv = "dev" +const ( + defaultAppsEnvVarEnv = "dev" + defaultAppsEnvVarScene = 2 +) // AppsEnvVarList lists app environment variables without values by default. var AppsEnvVarList = common.Shortcut{ Service: appsService, - Command: "+envvar-list", + Command: "+env-list", Description: "List app environment variables", Risk: "read", Tips: []string{ - "Example: lark-cli apps +envvar-list --app-id ", + "Example: lark-cli apps +env-list --app-id ", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, - {Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, + {Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, {Name: "include-values", Type: "bool", Desc: "include environment variable values"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -57,7 +61,10 @@ var AppsEnvVarList = common.Shortcut{ if err != nil { return withAppsHint(err, appIDListHint) } - rctx.OutFormat(normalizeEnvVarListOutput(data, includeValues), nil, nil) + out := normalizeEnvVarListOutput(data, includeValues) + rctx.OutFormat(out, nil, func(w io.Writer) { + appsPrintSchemaTable(w, out.Items, envVarListSchema(includeValues)) + }) return nil }, } @@ -65,18 +72,18 @@ var AppsEnvVarList = common.Shortcut{ // AppsEnvVarSet sets one app environment variable. Values are never printed. var AppsEnvVarSet = common.Shortcut{ Service: appsService, - Command: "+envvar-set", + Command: "+env-set", Description: "Set an app environment variable", Risk: "write", Tips: []string{ - "Example: lark-cli apps +envvar-set --app-id --key FOO --value bar", + "Example: lark-cli apps +env-set --app-id --key FOO --value bar", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, - {Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, + {Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, {Name: "key", Desc: "environment variable key", Required: true}, {Name: "value", Desc: "environment variable value", Required: true, Input: []string{common.File, common.Stdin}}, {Name: "yes", Type: "bool", Desc: "confirm setting variables in online"}, @@ -113,8 +120,8 @@ var AppsEnvVarSet = common.Shortcut{ if env == "online" && !rctx.Bool("yes") { return errs.NewConfirmationRequiredError( errs.RiskWrite, - "apps +envvar-set --env online", - "apps +envvar-set --env online requires confirmation", + "apps +env-set --environment online", + "apps +env-set --environment online requires confirmation", ).WithHint("add --yes to confirm") } appID, err := requireAppID(rctx.Str("app-id")) @@ -131,7 +138,7 @@ var AppsEnvVarSet = common.Shortcut{ "value": rctx.Str("value"), }) if err != nil { - return withAppsHint(err, appIDListHint) + return withAppsHint(err, envVarMutationHint(err)) } action := envVarStringAny(data, "action") if action == "" { @@ -149,18 +156,18 @@ var AppsEnvVarSet = common.Shortcut{ // AppsEnvVarDelete deletes one or more app environment variables. var AppsEnvVarDelete = common.Shortcut{ Service: appsService, - Command: "+envvar-delete", + Command: "+env-delete", Description: "Delete app environment variables", Risk: "high-risk-write", Tips: []string{ - "Example: lark-cli apps +envvar-delete --app-id --key FOO --yes", + "Example: lark-cli apps +env-delete --app-id --key FOO --yes", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, - {Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, + {Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, {Name: "key", Type: "string_array", Desc: "environment variable key; repeatable", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -193,7 +200,7 @@ var AppsEnvVarDelete = common.Shortcut{ env := envVarEnv(rctx) data, err := rctx.CallAPITyped("POST", envVarDeletePath(appID), nil, buildEnvVarDeleteBody(env, keys)) if err != nil { - return withAppsHint(err, appIDListHint) + return withAppsHint(err, envVarMutationHint(err)) } deletedKeys := envVarStringSliceAny(data, "deleted_keys", "deletedKeys") if len(deletedKeys) == 0 { @@ -208,7 +215,7 @@ var AppsEnvVarDelete = common.Shortcut{ } func envVarEnv(rctx *common.RuntimeContext) string { - env := strings.TrimSpace(rctx.Str("env")) + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) if env == "" { return defaultAppsEnvVarEnv } @@ -229,7 +236,8 @@ func envVarDeletePath(appID string) string { func buildEnvVarListBody(rctx *common.RuntimeContext) map[string]interface{} { return map[string]interface{}{ - "env": envVarEnv(rctx), + "env": envVarEnv(rctx), + "scene": defaultAppsEnvVarScene, } } @@ -240,6 +248,21 @@ func buildEnvVarDeleteBody(env string, keys []string) map[string]interface{} { } } +func envVarMutationHint(err error) string { + if isEnvVarNotModifiableError(err) { + return "this environment variable is platform-managed and cannot be modified; remove protected keys from --key and retry only with user-defined variables" + } + return appIDListHint +} + +func isEnvVarNotModifiableError(err error) bool { + p, ok := errs.ProblemOf(err) + if !ok { + return false + } + return strings.Contains(strings.ToLower(p.Message), "not modifiable") +} + func requireEnvVarKey(raw string) (string, error) { key := strings.TrimSpace(raw) if key == "" { @@ -339,6 +362,17 @@ func filterEnvVarItem(item map[string]interface{}, includeValues bool) map[strin return out } +func envVarListSchema(includeValues bool) appsOutputSchema { + columns := []appsOutputColumn{ + {Key: "key"}, + {Key: "env"}, + } + if includeValues { + columns = append(columns, appsOutputColumn{Key: "value"}) + } + return appsOutputSchema{Columns: columns, Strict: true} +} + func envVarStringAny(data map[string]interface{}, keys ...string) string { for _, key := range keys { if value, ok := data[key].(string); ok { diff --git a/shortcuts/apps/apps_env_pull.go b/shortcuts/apps/apps_env_pull.go index 211b0b4b5..690b08e26 100644 --- a/shortcuts/apps/apps_env_pull.go +++ b/shortcuts/apps/apps_env_pull.go @@ -62,9 +62,9 @@ var AppsEnvPull = common.Shortcut{ projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path"))) appID := strings.TrimSpace(rctx.Str("app-id")) return common.NewDryRunAPI(). - POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))). + POST(envPullVarsPath(appID)). Desc("Pull app startup env vars into the local .env.local file"). - Body(map[string]interface{}{"env": "dev"}). + Body(envPullVarsBody()). Set("project_path", projectPath). Set("env_file", envFile) }, @@ -81,8 +81,7 @@ var AppsEnvPull = common.Shortcut{ return err } - path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID)) - data, err := rctx.CallAPITyped("POST", path, nil, map[string]interface{}{"env": "dev"}) + data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody()) if err != nil { return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`") } @@ -117,6 +116,16 @@ var AppsEnvPull = common.Shortcut{ }, } +func envPullVarsPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID)) +} + +func envPullVarsBody() map[string]interface{} { + return map[string]interface{}{ + "env": "dev", + } +} + 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. diff --git a/shortcuts/apps/apps_env_pull_test.go b/shortcuts/apps/apps_env_pull_test.go index 4df081645..590d14d31 100644 --- a/shortcuts/apps/apps_env_pull_test.go +++ b/shortcuts/apps/apps_env_pull_test.go @@ -32,6 +32,11 @@ func assertValidationError(t *testing.T, err error, wantSubstr string) { } } +func assertEnvPullBody(t *testing.T, req *http.Request) { + t.Helper() + assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"}) +} + func TestResolveEnvPullTarget_DefaultProjectPathUsesCWD(t *testing.T) { cwd := t.TempDir() oldwd, err := os.Getwd() @@ -288,7 +293,7 @@ func TestAppsEnvPull_PrettyOutput_WithDatabaseLine(t *testing.T) { Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", OnMatch: func(req *http.Request) { - assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"}) + assertEnvPullBody(t, req) }, Body: map[string]interface{}{ "code": 0, @@ -557,6 +562,36 @@ func TestAppsEnvPull_ExecuteUsesNestedDataEnvVars(t *testing.T) { } } +func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + RawBody: []byte("[]"), + OnMatch: func(req *http.Request) { + assertEnvPullBody(t, req) + }, + }) + + err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"}, + factory, stdout, + ) + if err == nil { + t.Fatalf("expected non-object JSON failure, got nil; stdout=%s", stdout.String()) + } + 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) + } + if strings.Contains(p.Hint, "apps +list") || strings.Contains(p.Hint, "--app-id") { + t.Fatalf("hint should not point to app-id/list recovery for malformed upstream JSON: %q", p.Hint) + } +} + func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() diff --git a/shortcuts/apps/apps_envvar_test.go b/shortcuts/apps/apps_env_test.go similarity index 66% rename from shortcuts/apps/apps_envvar_test.go rename to shortcuts/apps/apps_env_test.go index 533a72130..4913bf5c4 100644 --- a/shortcuts/apps/apps_envvar_test.go +++ b/shortcuts/apps/apps_env_test.go @@ -29,6 +29,10 @@ func assertEnvVarBody(t *testing.T, req *http.Request, want map[string]interface } } +func expectedEnvVarSceneJSON() float64 { + return float64(defaultAppsEnvVarScene) +} + func decodeEnvVarEnvelopeData(t *testing.T, stdout string) map[string]interface{} { t.Helper() var envelope struct { @@ -65,7 +69,7 @@ func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) { Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", OnMatch: func(req *http.Request) { - assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"}) + assertEnvVarBody(t, req, map[string]interface{}{"env": "dev", "scene": expectedEnvVarSceneJSON()}) }, Body: map[string]interface{}{ "code": 0, @@ -78,7 +82,7 @@ func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) { }) if err := runAppsShortcut(t, AppsEnvVarList, - []string{"+envvar-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + []string{"+env-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -106,7 +110,7 @@ func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) { Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", OnMatch: func(req *http.Request) { - assertEnvVarBody(t, req, map[string]interface{}{"env": "online"}) + assertEnvVarBody(t, req, map[string]interface{}{"env": "online", "scene": expectedEnvVarSceneJSON()}) }, Body: map[string]interface{}{ "code": 0, @@ -119,7 +123,7 @@ func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) { }) if err := runAppsShortcut(t, AppsEnvVarList, - []string{"+envvar-list", "--app-id", "app_x", "--env", "online", "--include-values", "--as", "user"}, factory, stdout); err != nil { + []string{"+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -129,10 +133,73 @@ func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) { } } +func TestAppsEnvVarList_DoesNotAcceptEnvironmentShorthand(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarList, + []string{"+env-list", "--app-id", "app_x", "-e", "online", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "unknown shorthand flag: 'e'") { + t.Fatalf("expected unknown -e shorthand, got %v", err) + } +} + +func TestAppsEnvVarList_DryRunIncludesScene(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsEnvVarList, []string{ + "+env-list", "--app-id", "app_x", "--include-values", "--dry-run", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var dryRun struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &dryRun); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if got := dryRun.API[0].Body["scene"]; got != expectedEnvVarSceneJSON() { + t.Fatalf("body.scene = %#v, want %v; stdout:\n%s", got, expectedEnvVarSceneJSON(), stdout.String()) + } +} + +func TestAppsEnvVarList_PrettyDisplaysTable(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"key": "API_HOST", "value": "https://example.com", "env": "online"}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvVarList, []string{ + "+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.HasPrefix(got, "key") { + t.Fatalf("pretty output should start with key column, got:\n%s", got) + } + for _, want := range []string{"API_HOST", "online", "https://example.com"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q:\n%s", want, got) + } + } + if strings.Contains(got, `"ok"`) || strings.Contains(got, `"data"`) { + t.Fatalf("pretty output should not fall back to JSON envelope:\n%s", got) + } +} + func TestAppsEnvVarSet_OnlineRequiresYesOutsideDryRun(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsEnvVarSet, - []string{"+envvar-set", "--app-id", "app_x", "--env", "online", + []string{"+env-set", "--app-id", "app_x", "--environment", "online", "--key", "SECRET_TOKEN", "--value", "super-secret", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryConfirmation) @@ -147,7 +214,7 @@ func TestAppsEnvVarSet_OnlineRequiresYesOutsideDryRun(t *testing.T) { func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsEnvVarSet, - []string{"+envvar-set", "--app-id", "app_x", "--env", "online", + []string{"+env-set", "--app-id", "app_x", "--environment", "online", "--key", "SECRET_TOKEN", "--value", "super-secret", "--dry-run", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } @@ -184,7 +251,7 @@ func TestAppsEnvVarSet_ExecutesWithYesAndDoesNotEchoValue(t *testing.T) { reg.Register(stub) if err := runAppsShortcut(t, AppsEnvVarSet, - []string{"+envvar-set", "--app-id", "app_x", "--env", "online", + []string{"+env-set", "--app-id", "app_x", "--environment", "online", "--key", "SECRET_TOKEN", "--value", "super-secret", "--yes", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -223,7 +290,7 @@ func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) { reg.Register(stub) if err := runAppsShortcut(t, AppsEnvVarDelete, - []string{"+envvar-delete", "--app-id", "app_x", "--env", "online", + []string{"+env-delete", "--app-id", "app_x", "--environment", "online", "--key", "SECRET_ONE", "--key", "SECRET_TWO", "--yes", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -247,10 +314,41 @@ func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) { } } +func TestAppsEnvVarDelete_NotModifiableHint(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/delete_env_vars", + Body: map[string]interface{}{ + "code": 400000072, + "msg": "Invalid Request: env var (INTEGRATION_TOKEN) is not modifiable", + }, + }) + + err := runAppsShortcut(t, AppsEnvVarDelete, + []string{"+env-delete", "--app-id", "app_x", "--key", "INTEGRATION_TOKEN", "--yes", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected not modifiable error, got nil; stdout=%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Code != 400000072 { + t.Fatalf("code = %d, want 400000072", p.Code) + } + if !strings.Contains(p.Hint, "platform-managed") || !strings.Contains(p.Hint, "user-defined") { + t.Fatalf("hint = %q, want platform-managed/user-defined guidance", p.Hint) + } + if strings.Contains(p.Hint, "apps +list") { + t.Fatalf("hint should not point at app listing for protected env vars: %q", p.Hint) + } +} + func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsEnvVarDelete, - []string{"+envvar-delete", "--app-id", "app_x", "--env", "online", + []string{"+env-delete", "--app-id", "app_x", "--environment", "online", "--key", "SECRET_ONE", "--key", "SECRET_TWO", "--dry-run", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } @@ -281,14 +379,23 @@ func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) { func TestAppsEnvVarList_InvalidEnvTypedValidation(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsEnvVarList, - []string{"+envvar-list", "--app-id", "app_x", "--env", "prod", "--as", "user"}, factory, stdout) - requireEnvVarValidationProblem(t, err, "--env") + []string{"+env-list", "--app-id", "app_x", "--environment", "prod", "--as", "user"}, factory, stdout) + requireEnvVarValidationProblem(t, err, "--environment") +} + +func TestAppsEnvVarList_OldEnvFlagIsNotAlias(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarList, + []string{"+env-list", "--app-id", "app_x", "--env", "online", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "unknown flag: --env") { + t.Fatalf("expected old --env to be rejected, got %v", err) + } } func TestAppsEnvVarSet_InvalidKeyTypedValidation(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsEnvVarSet, - []string{"+envvar-set", "--app-id", "app_x", "--key", "bad-key", + []string{"+env-set", "--app-id", "app_x", "--key", "bad-key", "--value", "super-secret", "--as", "user"}, factory, stdout) requireEnvVarValidationProblem(t, err, "--key") } @@ -296,7 +403,7 @@ func TestAppsEnvVarSet_InvalidKeyTypedValidation(t *testing.T) { func TestAppsEnvVarDelete_InvalidKeyTypedValidation(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsEnvVarDelete, - []string{"+envvar-delete", "--app-id", "app_x", "--key", "bad-key", + []string{"+env-delete", "--app-id", "app_x", "--key", "bad-key", "--yes", "--as", "user"}, factory, stdout) requireEnvVarValidationProblem(t, err, "--key") } diff --git a/shortcuts/apps/apps_examples_test.go b/shortcuts/apps/apps_examples_test.go index d2c1ed4fc..75576b6c7 100644 --- a/shortcuts/apps/apps_examples_test.go +++ b/shortcuts/apps/apps_examples_test.go @@ -14,6 +14,9 @@ func TestAppsShortcutsHaveExamples(t *testing.T) { email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`) phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`) for _, s := range Shortcuts() { + if s.Hidden { + continue + } hasExample := false for _, tip := range s.Tips { if strings.HasPrefix(tip, "Example: lark-cli apps +") { @@ -51,15 +54,15 @@ func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) { } } -func TestAppsEnvVarTipsCoverConfirmations(t *testing.T) { - envvarSet := requireShortcutForExamples(t, "+envvar-set") - if !tipsContainAll(envvarSet.Tips, "--env online", "--yes") { - t.Fatalf("+envvar-set tips must include an online write example with --env online --yes: %#v", envvarSet.Tips) +func TestAppsEnvTipsCoverConfirmations(t *testing.T) { + envSet := requireShortcutForExamples(t, "+env-set") + if !tipsContainAll(envSet.Tips, "--environment online", "--yes") { + t.Fatalf("+env-set tips must include an online write example with --environment online --yes: %#v", envSet.Tips) } - envvarDelete := requireShortcutForExamples(t, "+envvar-delete") - if !tipsContainAll(envvarDelete.Tips, "--yes") { - t.Fatalf("+envvar-delete tips must include --yes: %#v", envvarDelete.Tips) + envDelete := requireShortcutForExamples(t, "+env-delete") + if !tipsContainAll(envDelete.Tips, "--yes") { + t.Fatalf("+env-delete tips must include --yes: %#v", envDelete.Tips) } } @@ -73,7 +76,7 @@ func TestAppsObservabilityTipsMentionOnlineOnly(t *testing.T) { "+analytics-query", } { shortcut := requireShortcutForExamples(t, cmd) - if !tipsContainAll(shortcut.Tips, "online-only", "--env online") { + if !tipsContainAll(shortcut.Tips, "online-only", "--environment online") { t.Fatalf("%s tips should mention online-only env: %#v", cmd, shortcut.Tips) } } diff --git a/shortcuts/apps/apps_hints_test.go b/shortcuts/apps/apps_hints_test.go index c5ce0e3f3..a4af9254e 100644 --- a/shortcuts/apps/apps_hints_test.go +++ b/shortcuts/apps/apps_hints_test.go @@ -22,7 +22,7 @@ func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) { Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}, OnMatch: func(req *http.Request) { - assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"}) + assertEnvPullBody(t, req) }, }) diff --git a/shortcuts/apps/apps_observability_common.go b/shortcuts/apps/apps_observability_common.go index c2fcef335..22d2b7116 100644 --- a/shortcuts/apps/apps_observability_common.go +++ b/shortcuts/apps/apps_observability_common.go @@ -14,6 +14,7 @@ import ( const ( defaultAppsPageSize = 50 maxAppsPageSize = 100 + appsEnvironmentFlag = "environment" // The CLI exposes the user-facing online environment, while the // observability backend stores online app runtime telemetry under runtime. @@ -34,8 +35,8 @@ func validateObservabilityEnv(env string) error { case "", "online": return nil default: - return appsValidationParamError("--env", "observability commands only support online (got %q)", env). - WithHint("only online is supported; omit --env to use the default online environment") + return appsValidationParamError("--environment", "observability commands only support online (got %q)", env). + WithHint("only online is supported; omit --environment to use the default online environment") } } @@ -44,7 +45,7 @@ func validateEnvVarEnv(env string) error { case "dev", "online": return nil default: - return appsValidationParamError("--env", "env var commands only support --env dev or --env online (got %q)", env) + return appsValidationParamError("--environment", "env var commands only support --environment dev or --environment online (got %q)", env) } } diff --git a/shortcuts/apps/apps_observability_common_test.go b/shortcuts/apps/apps_observability_common_test.go index ced8140f1..9fed5e665 100644 --- a/shortcuts/apps/apps_observability_common_test.go +++ b/shortcuts/apps/apps_observability_common_test.go @@ -33,9 +33,9 @@ func TestAppsObservabilityValidateEnvOnlyOnline(t *testing.T) { t.Fatalf("online should pass: %v", err) } err := validateObservabilityEnv("dev") - p := requireAppsValidationParam(t, err, "--env") + p := requireAppsValidationParam(t, err, "--environment") if p.Subtype != errs.SubtypeInvalidArgument { - t.Fatalf("problem = %#v, want invalid_argument param --env", p) + t.Fatalf("problem = %#v, want invalid_argument param --environment", p) } if !strings.Contains(p.Hint, "only online is supported") { t.Fatalf("hint = %q, want only-online guidance", p.Hint) @@ -63,8 +63,8 @@ func TestAppsObservabilityCommonHelpers(t *testing.T) { t.Fatalf("validateEnvVarEnv(%q) err=%v", env, err) } } - requireAppsValidationParam(t, validateEnvVarEnv(""), "--env") - requireAppsValidationParam(t, validateEnvVarEnv("boe"), "--env") + requireAppsValidationParam(t, validateEnvVarEnv(""), "--environment") + requireAppsValidationParam(t, validateEnvVarEnv("boe"), "--environment") got := cleanRepeatedStrings([]string{" a ", "b", "a", "", "b", "c"}) want := []string{"a", "b", "c"} if len(got) != len(want) { diff --git a/shortcuts/apps/apps_observability_logs.go b/shortcuts/apps/apps_observability_logs.go index a60afc97f..0123ad530 100644 --- a/shortcuts/apps/apps_observability_logs.go +++ b/shortcuts/apps/apps_observability_logs.go @@ -8,18 +8,29 @@ import ( "encoding/json" "fmt" "io" + "regexp" + "strconv" "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) const ( - defaultAppsLogEnv = "online" - logSearchEndpoint = "search_logs" - resolveStackEndpoint = "resolve_stack_trace" - sourceStackStatusOK = "resolved" - sourceStackStatusError = "unresolved" + defaultAppsLogEnv = "online" + logSearchEndpoint = "search_logs" + resolveStackEndpoint = "resolve_stack_trace" + sourceStackStatusOK = "resolved" + sourceStackStatusError = "unresolved" + sourceStackMaxScanDepth = 8 + sourceStackMaxFrames = 2000 + defaultSourceMapPrefix = "client/assets/" +) + +var ( + jsStackFrameParenRe = regexp.MustCompile(`^\s*(?:at\s+(.+?)\s+)?\((.+):(\d+):(\d+)\)\s*$`) + jsStackFrameBareRe = regexp.MustCompile(`^\s*(?:at\s+)?(.+):(\d+):(\d+)\s*$`) ) // AppsLogList searches online app logs with observability filters. @@ -37,7 +48,7 @@ var AppsLogList = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true}, - {Name: "env", Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, + {Name: appsEnvironmentFlag, Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, {Name: "level", Type: "string_array", Desc: "log level filter; repeatable, one of DEBUG, INFO, WARN, ERROR (case-insensitive)"}, @@ -100,7 +111,7 @@ var AppsLogGet = common.Shortcut{ Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true}, {Name: "log-id", Desc: "log ID to fetch", Required: true}, - {Name: "env", Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, + {Name: appsEnvironmentFlag, Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { @@ -109,7 +120,7 @@ var AppsLogGet = common.Shortcut{ if strings.TrimSpace(rctx.Str("log-id")) == "" { return appsValidationParamError("--log-id", "--log-id is required") } - return validateObservabilityEnv(rctx.Str("env")) + return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag)) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). @@ -119,14 +130,14 @@ var AppsLogGet = common.Shortcut{ }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, _ := requireAppID(rctx.Str("app-id")) - data, err := rctx.CallAPITyped("POST", logSearchPath(appID), nil, buildLogGetSearchBody(rctx)) + data, err := callLogGetSearch(rctx, appID, buildLogGetSearchBody(rctx)) if err != nil { return withAppsHint(err, appIDListHint) } out := normalizeLogSearchResponse(data) if len(out.Items) == 0 { return appsFailedPreconditionParamError("--log-id", "log not found"). - WithHint("verify --log-id and --env online") + WithHint("verify --log-id and --environment online") } log := out.Items[0] enrichLogSourceStack(rctx, appID, log) @@ -137,6 +148,25 @@ var AppsLogGet = common.Shortcut{ }, } +func callLogGetSearch(rctx *common.RuntimeContext, appID string, body map[string]interface{}) (map[string]interface{}, error) { + resp, err := rctx.DoAPI(&larkcore.ApiReq{ + HttpMethod: "POST", + ApiPath: logSearchPath(appID), + Body: body, + }) + if err != nil { + return nil, err + } + data, err := rctx.ClassifyAPIResponse(resp) + if err == nil && data != nil { + return data, nil + } + if flex, ok := flexibleLogSearchData(resp.RawBody); ok && (err == nil || isNonObjectInvalidResponse(err)) { + return flex, nil + } + return data, err +} + type logSearchOutput struct { Items []map[string]interface{} `json:"items"` PageToken string `json:"page_token,omitempty"` @@ -152,7 +182,7 @@ func resolveStackPath(appID string) string { } func buildLogSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { - env := strings.TrimSpace(rctx.Str("env")) + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) if env == "" { env = defaultAppsLogEnv } @@ -330,6 +360,40 @@ func firstMapSlice(data map[string]interface{}, keys ...string) []map[string]int return nil } +func flexibleLogSearchData(raw []byte) (map[string]interface{}, bool) { + var result interface{} + if err := json.Unmarshal(raw, &result); err != nil { + return nil, false + } + switch value := result.(type) { + case []interface{}: + return map[string]interface{}{"items": value}, true + case map[string]interface{}: + data, ok := value["data"] + if !ok { + return nil, false + } + items, ok := data.([]interface{}) + if !ok { + return nil, false + } + out := map[string]interface{}{"items": items} + for _, key := range []string{"page_token", "next_page_token", "pageToken", "nextPageToken", "has_more", "hasMore"} { + if v, present := value[key]; present { + out[key] = v + } + } + return out, true + default: + return nil, false + } +} + +func isNonObjectInvalidResponse(err error) bool { + p, ok := errs.ProblemOf(err) + return ok && p.Category == errs.CategoryInternal && p.Subtype == errs.SubtypeInvalidResponse +} + func firstLogString(data map[string]interface{}, keys ...string) string { for _, key := range keys { if s, ok := data[key].(string); ok && strings.TrimSpace(s) != "" { @@ -429,8 +493,7 @@ func enrichLogSourceStack(rctx *common.RuntimeContext, appID string, log map[str data, err := rctx.CallAPITyped("POST", resolveStackPath(appID), nil, body) if err != nil { if _, typed := errs.ProblemOf(err); typed { - log["source_stack_status"] = sourceStackStatusError - log["source_stack_reason"] = "resolve_stack_trace failed" + markSourceStackResolveError(log, err) } return } @@ -442,6 +505,20 @@ func enrichLogSourceStack(rctx *common.RuntimeContext, appID string, log map[str log["source_stack"] = stack } +func markSourceStackResolveError(log map[string]interface{}, err error) { + log["source_stack_status"] = sourceStackStatusError + log["source_stack_reason"] = "resolve_stack_trace failed" + if problem, ok := errs.ProblemOf(err); ok { + if problem.Code != 0 { + log["source_stack_error_code"] = problem.Code + log["source_stack_reason"] = fmt.Sprintf("resolve_stack_trace failed: code %d", problem.Code) + } + if problem.LogID != "" { + log["source_stack_log_id"] = problem.LogID + } + } +} + func shouldResolveSourceStack(log map[string]interface{}) bool { level := strings.ToUpper(firstItemString(log, "level", "severity_text", "severityText")) if level != "ERROR" { @@ -479,24 +556,78 @@ func isSourceMapSignal(value string) bool { } func extractSourceStackResolveBody(log map[string]interface{}) (map[string]interface{}, bool) { - sources := []map[string]interface{}{log} - if attrs, ok := log["attributes"].(map[string]interface{}); ok { - sources = append([]map[string]interface{}{attrs}, sources...) - } - if bodyMap, ok := log["body"].(map[string]interface{}); ok { - sources = append([]map[string]interface{}{bodyMap}, sources...) - } - commitID := firstStringInMaps(sources, "commit_id", "commitID", "commitId") + sources := collectSourceStackMaps(log) + commitID := firstStringInMaps(sources, "commit_id", "commitID", "commitId", "release_commit_id", "releaseCommitID", "releaseCommitId") prefix := firstStringInMaps(sources, "source_map_file_prefix", "sourceMapFilePrefix", "source_map_prefix", "sourceMapPrefix") - frames := firstFramesInMaps(sources, "frames", "stack_frames", "stackFrames", "source_stack_frames", "sourceStackFrames") + if prefix == "" && firstStringInMaps(sources, "release_commit_id", "releaseCommitID", "releaseCommitId") != "" { + prefix = defaultSourceMapPrefix + } + frames := firstFramesInMaps( + sources, + "frames", + "stack_frames", + "stackFrames", + "source_stack_frames", + "sourceStackFrames", + "stack", + "stack_trace", + "stackTrace", + "error_stack", + "errorStack", + "exception_stack", + "exceptionStack", + "message", + "body", + ) if commitID == "" || prefix == "" || len(frames) == 0 { return nil, false } - return map[string]interface{}{ + body := map[string]interface{}{ "commit_id": commitID, "source_map_file_prefix": prefix, "frames": frames, - }, true + } + if tenantID := firstStringInMaps(sources, "tenant_id", "tenantID", "tenantId"); tenantID != "" { + body["tenant_id"] = tenantID + } + return body, true +} + +func collectSourceStackMaps(value interface{}) []map[string]interface{} { + out := make([]map[string]interface{}, 0, 8) + collectSourceStackMapsInto(value, 0, &out) + return out +} + +func collectSourceStackMapsInto(value interface{}, depth int, out *[]map[string]interface{}) { + if depth > sourceStackMaxScanDepth || value == nil { + return + } + switch v := value.(type) { + case map[string]interface{}: + *out = append(*out, v) + for _, nested := range v { + collectSourceStackMapsInto(nested, depth+1, out) + } + case []interface{}: + if attrs := observabilityKVList(v); len(attrs) > 0 { + *out = append(*out, attrs) + for _, nested := range attrs { + collectSourceStackMapsInto(nested, depth+1, out) + } + } + for _, nested := range v { + collectSourceStackMapsInto(nested, depth+1, out) + } + case []map[string]interface{}: + for _, nested := range v { + collectSourceStackMapsInto(nested, depth+1, out) + } + case string: + if parsed := parseJSONObjectString(v); parsed != nil { + collectSourceStackMapsInto(parsed, depth+1, out) + } + } } func firstStringInMaps(sources []map[string]interface{}, keys ...string) string { @@ -509,8 +640,8 @@ func firstStringInMaps(sources []map[string]interface{}, keys ...string) string } func firstFramesInMaps(sources []map[string]interface{}, keys ...string) []interface{} { - for _, source := range sources { - for _, key := range keys { + for _, key := range keys { + for _, source := range sources { frames := normalizeFrames(source[key]) if len(frames) > 0 { return frames @@ -525,16 +656,22 @@ func normalizeFrames(raw interface{}) []interface{} { case []interface{}: out := make([]interface{}, 0, len(frames)) for _, frame := range frames { - if isNonEmptyFrame(frame) { - out = append(out, frame) + if normalized, ok := normalizeFrame(frame); ok { + out = append(out, normalized) + if len(out) >= sourceStackMaxFrames { + return out + } } } return out case []map[string]interface{}: out := make([]interface{}, 0, len(frames)) for _, frame := range frames { - if len(frame) > 0 { - out = append(out, frame) + if normalized, ok := normalizeFrame(frame); ok { + out = append(out, normalized) + if len(out) >= sourceStackMaxFrames { + return out + } } } return out @@ -545,19 +682,106 @@ func normalizeFrames(raw interface{}) []interface{} { } } -func isNonEmptyFrame(frame interface{}) bool { +func normalizeFrame(frame interface{}) (map[string]interface{}, bool) { switch f := frame.(type) { case map[string]interface{}: - return len(f) > 0 + return normalizeFrameMap(f) case map[string]string: - return len(f) > 0 + m := make(map[string]interface{}, len(f)) + for key, value := range f { + m[key] = value + } + return normalizeFrameMap(m) + case string: + parsed := parseJSStackFrameLine(f) + if _, ok := parsed["file_name"]; !ok { + return nil, false + } + return parsed, true + default: + return nil, false + } +} + +func normalizeFrameMap(frame map[string]interface{}) (map[string]interface{}, bool) { + fileName := normalizeSourceFrameFileName(firstLogString(frame, "file_name", "fileName", "filename", "file", "url")) + line, lineOK := firstFrameInt(frame, "line", "line_number", "lineNumber") + column, columnOK := firstFrameInt(frame, "column", "col", "column_number", "columnNumber") + if fileName == "" || !lineOK || !columnOK { + return nil, false + } + out := map[string]interface{}{ + "file_name": fileName, + "line": line, + "column": column, + } + if fn := firstLogString(frame, "function", "function_name", "functionName", "method", "methodName"); fn != "" { + out["function"] = fn + } + return out, true +} + +func normalizeSourceFrameFileName(fileName string) string { + fileName = strings.TrimSpace(fileName) + if fileName == "" { + return "" + } + parts := strings.FieldsFunc(fileName, func(r rune) bool { + return r == '/' || r == '?' || r == '#' + }) + for i := len(parts) - 1; i >= 0; i-- { + if part := strings.TrimSpace(parts[i]); part != "" { + return part + } + } + return fileName +} + +func firstFrameInt(frame map[string]interface{}, keys ...string) (int, bool) { + for _, key := range keys { + if value, ok := frame[key]; ok { + if n, valid := frameInt(value); valid { + return n, true + } + } + } + return 0, false +} + +func frameInt(value interface{}) (int, bool) { + switch v := value.(type) { + case int: + return positiveFrameInt(v) + case int64: + if v > int64(^uint(0)>>1) { + return 0, false + } + return positiveFrameInt(int(v)) + case float64: + if v != float64(int(v)) { + return 0, false + } + return positiveFrameInt(int(v)) + case json.Number: + n, err := strconv.Atoi(v.String()) + if err != nil { + return 0, false + } + return positiveFrameInt(n) case string: - return strings.TrimSpace(f) != "" + return parsePositiveInt(v) default: - return frame != nil + return 0, false } } +func positiveFrameInt(n int) (int, bool) { + if n < 1 { + return 0, false + } + return n, true +} + func parseFrameString(raw string) []interface{} { raw = strings.TrimSpace(raw) if raw == "" { @@ -571,13 +795,78 @@ func parseFrameString(raw string) []interface{} { out := make([]interface{}, 0, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) - if line != "" { - out = append(out, map[string]interface{}{"raw": line}) + if line == "" { + continue + } + if frame, ok := normalizeFrame(parseJSStackFrameLine(line)); ok { + out = append(out, frame) + if len(out) >= sourceStackMaxFrames { + return out + } } } return out } +func parseJSStackFrameLine(line string) map[string]interface{} { + if frame := parseJSStackFrameMatch(line, jsStackFrameParenRe.FindStringSubmatch(line)); frame != nil { + return frame + } + if frame := parseJSStackFrameMatch(line, jsStackFrameBareRe.FindStringSubmatch(line)); frame != nil { + return frame + } + return map[string]interface{}{"raw": line} +} + +func parseJSStackFrameMatch(raw string, match []string) map[string]interface{} { + if match == nil { + return nil + } + switch len(match) { + case 4: + line, lineOK := parsePositiveInt(match[2]) + column, columnOK := parsePositiveInt(match[3]) + if lineOK && columnOK { + return map[string]interface{}{"file_name": normalizeSourceFrameFileName(match[1]), "line": line, "column": column} + } + case 5: + line, lineOK := parsePositiveInt(match[3]) + column, columnOK := parsePositiveInt(match[4]) + if lineOK && columnOK { + out := map[string]interface{}{ + "file_name": normalizeSourceFrameFileName(match[2]), + "line": line, + "column": column, + } + if fn := strings.TrimSpace(match[1]); fn != "" { + out["function"] = fn + } + return out + } + } + return map[string]interface{}{"raw": raw} +} + +func parseJSONObjectString(raw string) map[string]interface{} { + raw = strings.TrimSpace(raw) + if raw == "" || !strings.HasPrefix(raw, "{") { + return nil + } + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return nil + } + return parsed +} + +func parsePositiveInt(raw string) (int, bool) { + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || n < 1 { + return 0, false + } + return n, true +} + func firstLogValue(data map[string]interface{}, keys ...string) interface{} { for _, key := range keys { if value, ok := data[key]; ok { diff --git a/shortcuts/apps/apps_observability_logs_test.go b/shortcuts/apps/apps_observability_logs_test.go index c1082da20..e456aa2f6 100644 --- a/shortcuts/apps/apps_observability_logs_test.go +++ b/shortcuts/apps/apps_observability_logs_test.go @@ -74,8 +74,8 @@ func TestAppsLogList_DoesNotAcceptLogIDFlag(t *testing.T) { func TestAppsLogList_RejectsDevEnv(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, factory, stdout) - requireAppsValidationParam(t, err, "--env") + err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout) + requireAppsValidationParam(t, err, "--environment") } func TestAppsLogGet_SearchesByLogIDLimitOne(t *testing.T) { @@ -108,6 +108,49 @@ func TestAppsLogGet_SearchesByLogIDLimitOne(t *testing.T) { } } +func TestAppsLogGet_AcceptsDataArraySearchResponse(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + RawBody: []byte(`{ + "code": 0, + "data": [ + { + "log_id": "LOG7655249917057764881", + "level": "ERROR", + "attributes": { + "commit_id": "commit_array", + "source_map_file_prefix": "sourcemaps/array", + "frames": [{"file":"main.js","line":10,"column":20}] + } + } + ] + }`), + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/App.tsx", "line": 7, "column": 9}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") { + t.Fatalf("stdout missing resolved source stack from data array response: %s", got) + } +} + func TestAppsLogList_NormalizesResponseVariantsAndCanonicalLevel(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -297,6 +340,212 @@ func TestAppsLogGet_ResolvesSourceStackWhenFieldsPresent(t *testing.T) { } } +func TestAppsLogGet_ResolvesSourceStackFromNestedKVAttributes(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG7655249917057764881", + "severityText": "ERROR", + "attributes": []interface{}{ + map[string]interface{}{"key": "commit_id", "value": "commit_nested"}, + map[string]interface{}{"key": "source_map_file_prefix", "value": "sourcemaps/nested"}, + map[string]interface{}{ + "key": "exception", + "value": map[string]interface{}{ + "stackTrace": strings.Join([]string{ + "TypeError: failed to render", + " at render (https://cdn.example.com/assets/main.js:12:34)", + " at https://cdn.example.com/assets/chunk.js:56:78", + }, "\n"), + }, + }, + }, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/App.tsx", "line": 12, "column": 34}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil { + t.Fatal(err) + } + if sent["commit_id"] != "commit_nested" || sent["source_map_file_prefix"] != "sourcemaps/nested" { + t.Fatalf("resolve body missing nested source map fields: %#v", sent) + } + frames, ok := sent["frames"].([]interface{}) + if !ok || len(frames) != 2 { + t.Fatalf("resolve frames = %#v, want parsed stack frames", sent["frames"]) + } + frame, ok := frames[0].(map[string]interface{}) + if !ok { + t.Fatalf("parsed frame = %#v, want object", frames[0]) + } + if frame["function"] != "render" || frame["file_name"] != "main.js" || frame["line"] != float64(12) || frame["column"] != float64(34) { + t.Fatalf("parsed frame = %#v", frame) + } + bare, ok := frames[1].(map[string]interface{}) + if !ok { + t.Fatalf("bare frame = %#v, want object", frames[1]) + } + if bare["file_name"] != "chunk.js" || bare["line"] != float64(56) || bare["column"] != float64(78) { + t.Fatalf("bare frame = %#v", bare) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") { + t.Fatalf("stdout missing resolved source stack: %s", got) + } +} + +func TestAppsLogGet_ResolvesSourceStackFromReleaseCommitJSONStack(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG7655249917057764881", + "severityText": "ERROR", + "attributes": map[string]interface{}{ + "tenant_id": "110564", + "release_commit_id": "4b393e4e0ca9ca1a855ba4585bc6750a7db2266f", + "stack": `[{"fileName":"main.js","line":3348,"column":540585},` + + `{"fileName":"main.js","line":3107,"column":51935},` + + `{"fileName":"main.js","line":62,"column":12516}]`, + }, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/App.tsx", "line": 42, "column": 7}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil { + t.Fatal(err) + } + if sent["commit_id"] != "4b393e4e0ca9ca1a855ba4585bc6750a7db2266f" || sent["source_map_file_prefix"] != defaultSourceMapPrefix || sent["tenant_id"] != "110564" { + t.Fatalf("resolve body missing release source map fields: %#v", sent) + } + frames, ok := sent["frames"].([]interface{}) + if !ok || len(frames) != 3 { + t.Fatalf("resolve frames = %#v, want all valid generated frames", sent["frames"]) + } + first, ok := frames[0].(map[string]interface{}) + if !ok { + t.Fatalf("first frame = %#v, want object", frames[0]) + } + if first["file_name"] != "main.js" || first["line"] != float64(3348) || first["column"] != float64(540585) { + t.Fatalf("first frame = %#v", first) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") { + t.Fatalf("stdout missing resolved source stack: %s", got) + } +} + +func TestAppsLogGet_ResolvesSourceStackFromJSONBodyStack(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG_BODY_STACK", + "severityText": "ERROR", + "attributes": map[string]interface{}{ + "release_commit_id": "commit_body", + }, + "body": `{"error":{"stack":"AxiosError: failed\n at request (https://cdn.example.com/client/assets/body.js:9:88)"}}`, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/request.ts", "line": 9, "column": 88}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG_BODY_STACK", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil { + t.Fatal(err) + } + if sent["commit_id"] != "commit_body" || sent["source_map_file_prefix"] != defaultSourceMapPrefix { + t.Fatalf("resolve body missing body stack source map fields: %#v", sent) + } + frames, ok := sent["frames"].([]interface{}) + if !ok || len(frames) != 1 { + t.Fatalf("resolve frames = %#v, want parsed JSON body stack frame", sent["frames"]) + } + frame, ok := frames[0].(map[string]interface{}) + if !ok { + t.Fatalf("frame = %#v, want object", frames[0]) + } + if frame["function"] != "request" || frame["file_name"] != "body.js" || frame["line"] != float64(9) || frame["column"] != float64(88) { + t.Fatalf("frame = %#v", frame) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/request.ts") { + t.Fatalf("stdout missing resolved source stack: %s", got) + } +} + func TestAppsLogGet_SourceStackMissingFieldsDoesNotFail(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) search := &httpmock.Stub{ @@ -404,6 +653,9 @@ func TestAppsLogGet_SourceStackResolveFailureIsRedacted(t *testing.T) { if !strings.Contains(got, `"source_stack_status": "unresolved"`) { t.Fatalf("stdout missing unresolved status: %s", got) } + if !strings.Contains(got, `"source_stack_error_code": 999`) { + t.Fatalf("stdout missing resolve error code: %s", got) + } for _, banned := range []string{"secret", "token", "raw request payload"} { if strings.Contains(strings.ToLower(got), banned) { t.Fatalf("stdout leaked %q: %s", banned, got) diff --git a/shortcuts/apps/apps_observability_metrics.go b/shortcuts/apps/apps_observability_metrics.go index 8cb65c95d..be32d9ad7 100644 --- a/shortcuts/apps/apps_observability_metrics.go +++ b/shortcuts/apps/apps_observability_metrics.go @@ -40,7 +40,7 @@ var AppsMetricQuery = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online metrics should be queried", Required: true}, - {Name: "env", Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"}, + {Name: appsEnvironmentFlag, Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"}, {Name: "metric", Desc: "metric family to query", Required: true, Enum: []string{"requests", "latency", "cpu", "memory"}}, {Name: "series", Desc: "metric series within the family, such as total/error or p50/p99"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, @@ -102,7 +102,7 @@ var AppsAnalyticsQuery = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online analytics should be queried", Required: true}, - {Name: "env", Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"}, + {Name: appsEnvironmentFlag, Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"}, {Name: "analytics", Desc: "analytics family to query", Required: true, Enum: []string{"users", "page-view"}}, {Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, @@ -163,7 +163,7 @@ func analyticsQueryPath(appID string) string { } func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, bool, error) { - env := strings.TrimSpace(rctx.Str("env")) + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) if env == "" { env = defaultAppsMetricEnv } @@ -221,7 +221,7 @@ func buildMetricQueryFilter(rctx *common.RuntimeContext) map[string]interface{} } func buildAnalyticsQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, error) { - env := strings.TrimSpace(rctx.Str("env")) + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) if env == "" { env = defaultAppsAnalyticsEnv } diff --git a/shortcuts/apps/apps_observability_metrics_test.go b/shortcuts/apps/apps_observability_metrics_test.go index d64ee386c..3209ebe96 100644 --- a/shortcuts/apps/apps_observability_metrics_test.go +++ b/shortcuts/apps/apps_observability_metrics_test.go @@ -108,9 +108,9 @@ func TestAppsMetricQuery_AutoDownSampleByRange(t *testing.T) { func TestAppsMetricQuery_RejectsDevEnv(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", "--env", "dev", "--as", "user", + "+metric-query", "--app-id", "app_x", "--metric", "requests", "--environment", "dev", "--as", "user", }, factory, stdout) - requireAppsValidationParam(t, err, "--env") + requireAppsValidationParam(t, err, "--environment") } func TestAppsMetricQuery_FillsMissingRequestValuesWithZero(t *testing.T) { diff --git a/shortcuts/apps/apps_observability_traces.go b/shortcuts/apps/apps_observability_traces.go index 27aed3287..e22c95073 100644 --- a/shortcuts/apps/apps_observability_traces.go +++ b/shortcuts/apps/apps_observability_traces.go @@ -34,7 +34,7 @@ var AppsTraceList = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online traces should be searched", Required: true}, - {Name: "env", Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, + {Name: appsEnvironmentFlag, Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, {Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"}, {Name: "root-span", Desc: "root span keyword filter applied by the trace search backend"}, {Name: "user-id", Desc: "end user ID filter"}, @@ -90,7 +90,7 @@ var AppsTraceGet = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online trace should be fetched", Required: true}, - {Name: "env", Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, + {Name: appsEnvironmentFlag, Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, {Name: "trace-id", Desc: "trace ID to fetch", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -100,7 +100,7 @@ var AppsTraceGet = common.Shortcut{ if strings.TrimSpace(rctx.Str("trace-id")) == "" { return appsValidationParamError("--trace-id", "--trace-id is required") } - return validateObservabilityEnv(rctx.Str("env")) + return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag)) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). @@ -137,7 +137,7 @@ func traceGetPath(appID string) string { } func buildTraceSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { - env := strings.TrimSpace(rctx.Str("env")) + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) if env == "" { env = defaultAppsTraceEnv } diff --git a/shortcuts/apps/apps_observability_traces_test.go b/shortcuts/apps/apps_observability_traces_test.go index 8d8d11faf..4768b1f93 100644 --- a/shortcuts/apps/apps_observability_traces_test.go +++ b/shortcuts/apps/apps_observability_traces_test.go @@ -60,8 +60,8 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) { func TestAppsTraceList_RejectsDevEnv(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, factory, stdout) - requireAppsValidationParam(t, err, "--env") + err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout) + requireAppsValidationParam(t, err, "--environment") } func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) { diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index 62581bdbb..2cb939166 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -7,6 +7,9 @@ import "github.com/larksuite/cli/shortcuts/common" // Shortcuts returns all apps domain shortcuts. func Shortcuts() []common.Shortcut { + envSet := withExtraTips(AppsEnvVarSet, "Example: lark-cli apps +env-set --app-id --environment online --key FOO --value --yes") + envDelete := withExtraTips(AppsEnvVarDelete, "Tip: +env-delete is high-risk-write; only pass --yes after explicit confirmation.") + return []common.Shortcut{ AppsCreate, AppsUpdate, @@ -19,15 +22,15 @@ func Shortcuts() []common.Shortcut { AppsReleaseList, AppsReleaseGet, AppsEnvPull, - withExtraTips(AppsLogList, "Tip: logs are online-only; keep --env omitted or set --env online."), - withExtraTips(AppsLogGet, "Tip: logs are online-only; keep --env omitted or set --env online."), - withExtraTips(AppsTraceList, "Tip: traces are online-only; keep --env omitted or set --env online."), - withExtraTips(AppsTraceGet, "Tip: traces are online-only; keep --env omitted or set --env online."), - withExtraTips(AppsMetricQuery, "Tip: metrics are online-only; keep --env omitted or set --env online."), - withExtraTips(AppsAnalyticsQuery, "Tip: analytics are online-only; keep --env omitted or set --env online."), + withExtraTips(AppsLogList, "Tip: logs are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsLogGet, "Tip: logs are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsTraceList, "Tip: traces are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsTraceGet, "Tip: traces are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsMetricQuery, "Tip: metrics are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsAnalyticsQuery, "Tip: analytics are online-only; keep --environment omitted or set --environment online."), AppsEnvVarList, - withExtraTips(AppsEnvVarSet, "Example: lark-cli apps +envvar-set --app-id --env online --key FOO --value --yes"), - withExtraTips(AppsEnvVarDelete, "Tip: +envvar-delete is high-risk-write; only pass --yes after explicit confirmation."), + envSet, + envDelete, AppsDBTableList, AppsDBTableGet, AppsDBExecute, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 5abfa1e26..3afb07cb0 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -11,7 +11,7 @@ import ( // 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 // 6 基础 + 1 init + 3 publish + 1 env-pull + 6 observability -// + 3 envvar + 4 db(table-list/table-schema/sql/dev-init) +// + 3 env + 4 db(table-list/table-schema/sql/dev-init) // + 3 git-credential + 5 session(create/list/get/stop/chat)+ 1 session-messages-list = 33。 func TestAppsShortcuts_Returns33(t *testing.T) { got := Shortcuts() @@ -20,10 +20,32 @@ func TestAppsShortcuts_Returns33(t *testing.T) { } } -func TestAppsShortcuts_DoesNotIncludeEnvVarGet(t *testing.T) { +func TestAppsShortcuts_DoesNotIncludeEnvGet(t *testing.T) { for _, sc := range Shortcuts() { - if sc.Command == "+envvar-get" { - t.Fatalf("Shortcuts() must not register +envvar-get") + switch sc.Command { + case "+env-get", "+envvar-get", "+envvar-list", "+envvar-set", "+envvar-delete": + t.Fatalf("Shortcuts() must not register %s", sc.Command) + } + } +} + +func TestAppsShortcuts_EnvCommandsUseCanonicalNames(t *testing.T) { + want := map[string]bool{ + "+env-list": false, + "+env-set": false, + "+env-delete": false, + } + for _, sc := range Shortcuts() { + if _, ok := want[sc.Command]; ok { + want[sc.Command] = true + if sc.Hidden { + t.Errorf("%s must be visible", sc.Command) + } + } + } + for cmd, found := range want { + if !found { + t.Errorf("Shortcuts() missing canonical %s", cmd) } } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 5a19b200a..83af9886d 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-apps version: 1.0.0 -description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" +description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" metadata: requires: bins: ["lark-cli"] @@ -24,7 +24,7 @@ metadata: | 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) | | 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | -| 管理应用环境变量(查看/设置/删除) | `+envvar-list`, `+envvar-set`, `+envvar-delete` | [`lark-apps-envvar.md`](references/lark-apps-envvar.md) | +| 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) | | 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-query`, `+analytics-query` | [`lark-apps-observability.md`](references/lark-apps-observability.md) | | 看表、看 schema、跑 SQL、初始化 dev/online 多环境 DB | `+db-table-list`, `+db-table-get`, `+db-execute`, `+db-env-create` | 对应 `lark-apps-db-*.md` | | **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) | @@ -32,6 +32,14 @@ metadata: | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | +## 高频路径 + +- **性能/监控/观测指标**:用户问“接口请求量、错误量、错误率、接口慢、延迟、CPU、内存、最近一小时/七天趋势”时,不要去当前工作区搜索监控文件,也不要询问“监控数据在哪”。先按「app_id 获取」解析应用:`lark-cli apps +list --keyword "<应用名>" --as user`;拿到 `app_id` 后读 [`lark-apps-observability.md`](references/lark-apps-observability.md),用 `+metric-query`。 +- **请求量 + 错误量 + 延迟**:请求量/错误量用 `lark-cli apps +metric-query --app-id --metric requests --since --as user`(不传 `--series` 会同时返回 total/error);延迟用 `--metric latency`(不传 `--series` 会返回 p50/p99)。如果用户给了具体接口,再加 `--api `;不要臆造 group-by 参数。 +- **PV/UV/访问量/活跃用户**:先解析 `app_id`,再用 `+analytics-query`,不要误用 `+metric-query`。 +- **设置环境变量**:如果用户只给应用名,仍先 `+list --keyword` 解析 app_id;设置 online 环境且用户已经明确说“确认/直接执行”时,调用 `+env-set --environment online ... --yes`,不要再次要求确认。回复和日志摘要里只提 key / env / app,不回显真实 value;需要传复杂值时优先用 `@file` 或 stdin。 +- **删除环境变量**:`+env-delete` 是破坏性操作。除非用户在同一轮已经明确确认删除这个 app/env/key,否则先向用户确认应用、环境、key 和删除后果;确认后再加 `--yes`。不要因为认证失败/重登完成就自动继续删除,必须保留确认门槛。 + ## 选择开发路径(进意图路由前先判这步) 新建必先定 **app_type** 和**开发方式**两件正交的事;修改已有先按「app_id 获取」指认到 app,指认不到就问用户,不擅自 `+create`。开发方式(本地 vs 云端)只看用户对"谁来写代码"的偏好,与应用复杂度、要不要数据库无关。 diff --git a/skills/lark-apps/references/lark-apps-env-pull.md b/skills/lark-apps/references/lark-apps-env-pull.md index 43eb1e3b5..148cefa60 100644 --- a/skills/lark-apps/references/lark-apps-env-pull.md +++ b/skills/lark-apps/references/lark-apps-env-pull.md @@ -8,7 +8,7 @@ ## 何时别用(核心反模式) -**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并把用户刚改完的 `.env.local` 临时改动覆盖掉。 +**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并用服务端返回值覆盖 `.env.local` 里的同名 key;本地无关行和注释会保留。 只在这些兜底场景用: diff --git a/skills/lark-apps/references/lark-apps-env.md b/skills/lark-apps/references/lark-apps-env.md new file mode 100644 index 000000000..b6c3aa1fa --- /dev/null +++ b/skills/lark-apps/references/lark-apps-env.md @@ -0,0 +1,48 @@ +# apps env + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 + +管理妙搭应用环境变量。查看用 `+env-list`,设置用 `+env-set`,删除用 `+env-delete`。没有单变量 get 命令;要确认某个 key 是否存在,使用 list 后用 `--jq` 过滤。 + +环境 flag 使用 `--environment`;不要使用旧的 `--env`,也不要使用短选项。 + +## 查看 + +`+env-list` 默认查 dev,且默认不返回 value。只有显式传 `--include-values` 后,响应中才可能出现变量值;不要在公开日志里展示带值输出。 + +接口契约:list 使用 `POST env_vars`,body 固定包含 `env` 和 CLI 场景 `scene=2`;set 使用 `POST create_or_update_env_var`;delete 使用 `POST delete_env_vars`。`--include-values` 只控制 CLI 输出是否展示 value,不作为服务端查询参数发送。 + +```bash +lark-cli apps +env-list --app-id +lark-cli apps +env-list --app-id --environment online +lark-cli apps +env-list --app-id --include-values --jq '.data.items[] | select(.key == "FOO")' +``` + +## 设置 + +dev 环境设置不需要 `--yes`。设置 online 环境需要人类确认并显式传 `--yes`;如果用户在同一轮已经明确说“确认/直接执行”,视为已确认,直接带 `--yes`,不要再次追问。`--dry-run` 可用于预览请求且不需要 `--yes`。变量值支持直接传 ``,也支持 `@file` 或 stdin 输入。 + +回复中只说明 app/env/key 和执行结果;不要回显真实 value。需要举例时使用 ``、`@file` 或 stdin。 + +```bash +lark-cli apps +env-set --app-id --key FOO --value +lark-cli apps +env-set --app-id --key FOO --value @./secret.txt +lark-cli apps +env-set --app-id --environment online --key FOO --value --dry-run +lark-cli apps +env-set --app-id --environment online --key FOO --value --yes +``` + +## 删除 + +`+env-delete` 是 high-risk-write。尊重 exit 10 confirmation protocol:先让用户确认 app/env/key 和删除后果,再传 `--yes`。不要自动补 `--yes`。如果只是认证失败后让用户重登,重登完成不等于删除确认;继续删除前仍需确认。 + +```bash +lark-cli apps +env-delete --app-id --key FOO --dry-run +lark-cli apps +env-delete --app-id --key FOO --yes +lark-cli apps +env-delete --app-id --environment online --key FOO --yes +``` + +## 反模式 + +- 不要把 `+env-pull` 当成环境变量管理命令;它只是刷新本地 `.env.local` 的兜底工具。 +- 不要为了看一个变量臆造名为 env-get 的 apps shortcut;用 `+env-list --include-values` 加 `--jq`。 +- 不要把真实 secret 写进示例或对话输出;需要示例时使用 ``、`@file` 或 stdin。 diff --git a/skills/lark-apps/references/lark-apps-envvar.md b/skills/lark-apps/references/lark-apps-envvar.md deleted file mode 100644 index 73fdba7ec..000000000 --- a/skills/lark-apps/references/lark-apps-envvar.md +++ /dev/null @@ -1,44 +0,0 @@ -# apps envvar - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 - -管理妙搭应用环境变量。查看用 `+envvar-list`,设置用 `+envvar-set`,删除用 `+envvar-delete`。没有单变量 get 命令;要确认某个 key 是否存在,使用 list 后用 `--jq` 过滤。 - -## 查看 - -`+envvar-list` 默认查 dev,且默认不返回 value。只有显式传 `--include-values` 后,响应中才可能出现变量值;不要在公开日志里展示带值输出。 - -接口契约:list 使用 `POST env_vars`;set 使用 `POST create_or_update_env_var`;delete 使用 `POST delete_env_vars`。`--include-values` 只控制 CLI 输出是否展示 value,不作为服务端查询参数发送。 - -```bash -lark-cli apps +envvar-list --app-id -lark-cli apps +envvar-list --app-id --env online -lark-cli apps +envvar-list --app-id --include-values --jq '.data.items[] | select(.key == "FOO")' -``` - -## 设置 - -dev 环境设置不需要 `--yes`。设置 online 环境需要人类确认并显式传 `--yes`;`--dry-run` 可用于预览请求且不需要 `--yes`。变量值支持直接传 ``,也支持 `@file` 或 stdin 输入。 - -```bash -lark-cli apps +envvar-set --app-id --key FOO --value -lark-cli apps +envvar-set --app-id --key FOO --value @./secret.txt -lark-cli apps +envvar-set --app-id --env online --key FOO --value --dry-run -lark-cli apps +envvar-set --app-id --env online --key FOO --value --yes -``` - -## 删除 - -`+envvar-delete` 是 high-risk-write。尊重 exit 10 confirmation protocol:先让用户确认要删除哪些 key,再传 `--yes`。不要自动补 `--yes`。 - -```bash -lark-cli apps +envvar-delete --app-id --key FOO --dry-run -lark-cli apps +envvar-delete --app-id --key FOO --yes -lark-cli apps +envvar-delete --app-id --env online --key FOO --yes -``` - -## 反模式 - -- 不要把 `+env-pull` 当成环境变量管理命令;它只是刷新本地 `.env.local` 的兜底工具。 -- 不要为了看一个变量臆造名为 envvar-get 的 apps shortcut;用 `+envvar-list --include-values` 加 `--jq`。 -- 不要把真实 secret 写进示例或对话输出;需要示例时使用 ``、`@file` 或 stdin。 diff --git a/skills/lark-apps/references/lark-apps-observability.md b/skills/lark-apps/references/lark-apps-observability.md index 1498fc044..b0375b7ad 100644 --- a/skills/lark-apps/references/lark-apps-observability.md +++ b/skills/lark-apps/references/lark-apps-observability.md @@ -2,20 +2,24 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 -查询妙搭应用的线上运行观测和产品访问分析。所有 observability 命令只支持 `--env online`;省略 `--env` 时默认就是 online,传 dev 或其他环境是不支持的。 +查询妙搭应用的线上运行观测和产品访问分析。所有 observability 命令只支持 `--environment online`;省略 `--environment` 时默认就是 online,传 dev 或其他环境是不支持的。不要使用旧的 `--env`,也不要使用短选项。 日志和 trace 的用户侧环境仍然是 online;但 OpenAPI 请求体里的后端 `app_env` 固定发送 `runtime`,因为线上应用的运行时日志和 trace 存储在 runtime 观测环境下。dry-run 输出会展示这个后端参数。 +metric / analytics 的 `--environment` 只是 CLI 侧 online-only 校验:`+metric-query` 和 `+analytics-query` 不会向 OpenAPI body 发送 `env` 或 `app_env`。dry-run 里看不到环境字段是预期行为,不要补造参数。 + 时间过滤支持相对时间(如 `30s`、`5m`、`0.5h`、`2h`、`3d`、`1w`)、本地日期 / 时间和 RFC3339。 ## 命令选择 - 日志检索:用 `+log-list` 搜索日志,用 `+log-get` 按 log ID 取单条日志。 +- `+log-list` 不再支持 `--log-id`;已有 log ID 时直接用 `+log-get --log-id `。 - 前端 ERROR 日志详情:`+log-get` 可能补充 `source_stack`;没有独立的 source-stack 命令。 - Trace 检索:用 `+trace-list` 搜索 trace,用 `+trace-get` 按 trace ID 取详情。 - 运行时指标:请求数、错误、延迟、CPU、memory 用 `+metric-query`。 - 产品分析:PV、UV、访问量这类业务访问分析用 `+analytics-query`,不要放到 runtime metric 里混查。 - `+analytics-query` 按最新 OpenAPI 发送 `metric_types`、纳秒时间戳和 `need_pack_lack_point=false`;`group_by` 暂不支持。 +- 用户询问“最近一小时接口请求量、错误量、延迟、接口慢/报错多”时,这是平台运行时监控,不是本地项目文件。先用 `apps +list --keyword` 找 `app_id`,再查 `+metric-query`。 ## 示例 @@ -25,6 +29,8 @@ lark-cli apps +log-get --app-id --log-id lark-cli apps +trace-list --app-id --trace-id lark-cli apps +trace-get --app-id --trace-id lark-cli apps +metric-query --app-id --metric requests --series total --since 1d +lark-cli apps +metric-query --app-id --metric requests --since 1h +lark-cli apps +metric-query --app-id --metric latency --since 1h lark-cli apps +metric-query --app-id --metric latency --series p99 --since 1d lark-cli apps +metric-query --app-id --metric cpu --since 1h lark-cli apps +metric-query --app-id --metric memory --since 1h @@ -35,5 +41,8 @@ lark-cli apps +analytics-query --app-id --analytics page-view --granula ## 使用边界 - 如果用户问“接口慢、报错多、CPU/内存高”,优先走 `+metric-query`。 +- `+metric-query --metric requests` 不传 `--series` 会同时返回请求总量 total 和错误量 error;`--metric latency` 不传 `--series` 会同时返回 p50 和 p99。只想看单条曲线时再传 `--series total|error|p50|p99`。 +- 按接口收窄范围时使用 `--api `;当前没有 `group-by` 参数,不要臆造。 +- `+metric-query` 未显式传 `--down-sample` 时会按时间范围自动选择粒度:短范围用 `1m`,中等范围用 `1h`,长范围用 `1d`;显式传入时尊重用户指定。 - 如果用户问“页面访问量、PV、UV、活跃用户”,优先走 `+analytics-query`。 - 如果用户已有 `trace_id` 或 `log_id`,直接用对应 get 命令;不知道 ID 时先 list。 diff --git a/tests/cli_e2e/apps/apps_env_pull_dryrun_test.go b/tests/cli_e2e/apps/apps_env_pull_dryrun_test.go index 69ff6b117..513bdbf9f 100644 --- a/tests/cli_e2e/apps/apps_env_pull_dryrun_test.go +++ b/tests/cli_e2e/apps/apps_env_pull_dryrun_test.go @@ -35,6 +35,9 @@ func TestAppsEnvPullDryRun(t *testing.T) { assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String()) assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String()) + assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String()) + assert.False(t, gjson.Get(result.Stdout, "api.0.body.include_values").Exists()) + assert.False(t, gjson.Get(result.Stdout, "api.0.params").Exists()) assert.True(t, gjson.Get(result.Stdout, "project_path").Exists()) assert.Contains(t, gjson.Get(result.Stdout, "env_file").String(), ".env.local") assert.False(t, gjson.Get(result.Stdout, "env_keys").Exists()) From 7121ff1e2a1d289c97cd775f3368f69d54257120 Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Fri, 26 Jun 2026 11:12:55 +0800 Subject: [PATCH 17/34] feat: rename app observability commands to list --- shortcuts/apps/apps_analytics.go | 207 +++++++++++ ...metrics_test.go => apps_analytics_test.go} | 337 ++---------------- shortcuts/apps/apps_examples_test.go | 4 +- ...pps_observability_logs.go => apps_logs.go} | 0 ...ability_logs_test.go => apps_logs_test.go} | 0 ...servability_metrics.go => apps_metrics.go} | 232 ++---------- shortcuts/apps/apps_metrics_test.go | 298 ++++++++++++++++ ...observability_traces.go => apps_traces.go} | 0 ...ity_traces_test.go => apps_traces_test.go} | 0 shortcuts/apps/shortcuts.go | 4 +- shortcuts/apps/shortcuts_test.go | 11 +- skills/lark-apps/SKILL.md | 8 +- .../references/lark-apps-observability.md | 34 +- 13 files changed, 586 insertions(+), 549 deletions(-) create mode 100644 shortcuts/apps/apps_analytics.go rename shortcuts/apps/{apps_observability_metrics_test.go => apps_analytics_test.go} (52%) rename shortcuts/apps/{apps_observability_logs.go => apps_logs.go} (100%) rename shortcuts/apps/{apps_observability_logs_test.go => apps_logs_test.go} (100%) rename shortcuts/apps/{apps_observability_metrics.go => apps_metrics.go} (66%) create mode 100644 shortcuts/apps/apps_metrics_test.go rename shortcuts/apps/{apps_observability_traces.go => apps_traces.go} (100%) rename shortcuts/apps/{apps_observability_traces_test.go => apps_traces_test.go} (100%) diff --git a/shortcuts/apps/apps_analytics.go b/shortcuts/apps/apps_analytics.go new file mode 100644 index 000000000..1f7afd1fd --- /dev/null +++ b/shortcuts/apps/apps_analytics.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultAppsAnalyticsEnv = "online" + defaultAppsAnalyticsGranular = "day" + analyticsListEndpoint = "query_analytics_data" +) + +// AppsAnalyticsList lists online app product analytics. +var AppsAnalyticsList = common.Shortcut{ + Service: appsService, + Command: "+analytics-list", + Description: "List online app user and page-view analytics", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +analytics-list --app-id --analytics users --granularity week", + "Tip: analytics timestamps use nanoseconds; use +metric-list for request/runtime metrics.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online analytics should be listed", Required: true}, + {Name: appsEnvironmentFlag, Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"}, + {Name: "analytics", Desc: "analytics family to list", Required: true, Enum: []string{"users", "page-view"}}, + {Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, + {Name: "page", Desc: "frontend page or route filter"}, + {Name: "device-type", Desc: "device type filter", Enum: []string{"desktop", "mobile"}}, + {Name: "granularity", Default: defaultAppsAnalyticsGranular, Desc: "analytics aggregation granularity", Enum: []string{"day", "week", "month"}}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, _, _, err := buildAnalyticsListBody(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _, _, _ := buildAnalyticsListBody(rctx) + return common.NewDryRunAPI(). + POST(analyticsListPath(rctx.Str("app-id"))). + Desc("List online app analytics"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + body, types, labels, err := buildAnalyticsListBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", analyticsListPath(appID), nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := observabilitySeriesOutput{ + Items: normalizeAnalyticsSeries(data, types, labels), + HasMore: false, + } + rctx.OutFormat(out, nil, func(w io.Writer) { + rows := observabilitySeriesRows(out.Items) + sortObservabilityRowsDesc(rows, "timestamp_ns") + rows = filterObservabilityRowsWithTime(rows, "timestamp_ns") + appsPrintSchemaTable(w, rows, analyticsSeriesSchema(labels)) + }) + return nil + }, +} + +func analyticsListPath(appID string) string { + return appScopedPath(appID, analyticsListEndpoint) +} + +func buildAnalyticsListBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, error) { + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) + if env == "" { + env = defaultAppsAnalyticsEnv + } + if err := validateObservabilityEnv(env); err != nil { + return nil, nil, nil, err + } + types, labels, filter, err := analyticsTypesForCLI(rctx.Str("analytics"), rctx.Str("series"), rctx.Str("device-type")) + if err != nil { + return nil, nil, nil, err + } + since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until")) + if err != nil { + return nil, nil, nil, err + } + aggregation, err := analyticsGranularityForCLI(rctx.Str("granularity")) + if err != nil { + return nil, nil, nil, err + } + if page := strings.TrimSpace(rctx.Str("page")); page != "" { + filter["page"] = page + } + body := map[string]interface{}{ + "metric_types": types, + "start_timestamp_ns": nsNumber(since), + "end_timestamp_ns": nsNumber(until), + "time_aggregation_unit": aggregation, + "need_pack_lack_point": false, + } + if len(filter) > 0 { + body["filter"] = filter + } + return body, types, labels, nil +} + +func analyticsTypesForCLI(name, series, deviceType string) ([]string, []string, map[string]interface{}, error) { + name = strings.TrimSpace(strings.ToLower(name)) + series = strings.TrimSpace(strings.ToLower(series)) + deviceType = strings.TrimSpace(strings.ToLower(deviceType)) + filter := make(map[string]interface{}) + if deviceType != "" { + switch deviceType { + case "desktop", "mobile": + filter["device_types"] = []string{deviceType} + default: + return nil, nil, nil, appsValidationParamError("--device-type", "--device-type must be desktop or mobile") + } + } + + switch name { + case "users": + switch series { + case "": + return []string{"ACTIVE_USER", "NEW_USER", "TOTAL_USER"}, []string{"active-users", "new-users", "total-users"}, filter, nil + case "active", "active-users": + return []string{"ACTIVE_USER"}, []string{"active-users"}, filter, nil + case "new", "new-users": + return []string{"NEW_USER"}, []string{"new-users"}, filter, nil + case "total", "total-users": + return []string{"TOTAL_USER"}, []string{"total-users"}, filter, nil + default: + return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics users must be active, new, or total") + } + case "page-view": + switch series { + case "", "all": + return []string{"PAGE_VIEW"}, []string{"all"}, filter, nil + case "desktop", "desktop-view": + if err := mergeAnalyticsDeviceFilter(filter, "desktop"); err != nil { + return nil, nil, nil, err + } + return []string{"PAGE_VIEW"}, []string{"desktop"}, filter, nil + case "mobile", "mobile-view": + if err := mergeAnalyticsDeviceFilter(filter, "mobile"); err != nil { + return nil, nil, nil, err + } + return []string{"PAGE_VIEW"}, []string{"mobile"}, filter, nil + default: + return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics page-view must be all, desktop, or mobile") + } + default: + return nil, nil, nil, appsValidationParamError("--analytics", "--analytics must be users or page-view") + } +} + +func mergeAnalyticsDeviceFilter(filter map[string]interface{}, deviceType string) error { + if existing, ok := filter["device_types"].([]string); ok && len(existing) > 0 && existing[0] != deviceType { + return appsValidationParamError("--device-type", "--device-type conflicts with --series") + } + filter["device_types"] = []string{deviceType} + return nil +} + +func analyticsGranularityForCLI(granularity string) (string, error) { + switch strings.TrimSpace(strings.ToLower(granularity)) { + case "", "day": + return "DAY", nil + case "week": + return "WEEK", nil + case "month": + return "MONTH", nil + default: + return "", appsValidationParamError("--granularity", "--granularity must be day, week, or month") + } +} + +func normalizeAnalyticsSeries(data map[string]interface{}, names, labels []string) []map[string]interface{} { + items := normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), false, "timestamp_ns") + fillObservabilityZeroesWhenPartiallyPresent(items, labels) + return items +} + +func analyticsSeriesSchema(labels []string) appsOutputSchema { + columns := []appsOutputColumn{ + {Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05")}, + } + for _, label := range labels { + columns = append(columns, appsOutputColumn{Key: label}) + } + return appsOutputSchema{Columns: columns, Strict: true} +} diff --git a/shortcuts/apps/apps_observability_metrics_test.go b/shortcuts/apps/apps_analytics_test.go similarity index 52% rename from shortcuts/apps/apps_observability_metrics_test.go rename to shortcuts/apps/apps_analytics_test.go index 3209ebe96..3e8eeb5de 100644 --- a/shortcuts/apps/apps_observability_metrics_test.go +++ b/shortcuts/apps/apps_analytics_test.go @@ -12,295 +12,10 @@ import ( "github.com/larksuite/cli/internal/httpmock" ) -func TestMetricNamesMapping(t *testing.T) { - got, labels, err := metricNamesForCLI("requests", "") - if err != nil { - t.Fatal(err) - } - if strings.Join(got, ",") != "client_api_request_count,client_api_request_error_count" { - t.Fatalf("names = %#v", got) - } - if strings.Join(labels, ",") != "total,error" { - t.Fatalf("labels = %#v", labels) - } - if _, _, err := metricNamesForCLI("cpu", "p99"); err == nil { - t.Fatalf("cpu with p99 should fail") - } -} - -func TestAppsMetricQuery_DryRunUsesSeconds(t *testing.T) { - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", - "--series", "total", "--since", "2026-06-23T10:00:00Z", - "--until", "2026-06-23T10:01:00Z", "--down-sample", "1m", - "--dry-run", "--as", "user", - }, factory, stdout) - if 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"` - } - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) - } - if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_metrics_data" { - t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) - } - body := env.API[0].Body - if _, ok := body["start_timestamp"]; !ok { - t.Fatalf("metric dry-run missing start_timestamp: %#v", body) - } - if _, ok := body["start_timestamp_ns"]; ok { - t.Fatalf("metric should not use start_timestamp_ns: %#v", body) - } - if _, ok := body["app_env"]; ok { - t.Fatalf("metric OpenAPI body should not include app_env: %#v", body) - } - if body["start_timestamp"] != "1782208800" || body["end_timestamp"] != "1782208860" { - t.Fatalf("metric timestamps = %v %v", body["start_timestamp"], body["end_timestamp"]) - } - if body["down_sample"] != "1m" { - t.Fatalf("down_sample = %v", body["down_sample"]) - } -} - -func TestAppsMetricQuery_AutoDownSampleByRange(t *testing.T) { - for _, tc := range []struct { - name string - since string - until string - want string - }{ - {name: "short", since: "2026-06-23T10:00:00Z", until: "2026-06-23T12:00:00Z", want: "1m"}, - {name: "medium", since: "2026-06-21T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1h"}, - {name: "long", since: "2026-06-01T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1d"}, - } { - t.Run(tc.name, func(t *testing.T) { - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", - "--since", tc.since, "--until", tc.until, "--dry-run", "--as", "user", - }, factory, stdout) - if err != nil { - t.Fatalf("dry-run err=%v", err) - } - var env struct { - API []struct { - Body map[string]interface{} `json:"body"` - } `json:"api"` - } - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) - } - if got := env.API[0].Body["down_sample"]; got != tc.want { - t.Fatalf("down_sample = %#v, want %q; stdout:\n%s", got, tc.want, stdout.String()) - } - }) - } -} - -func TestAppsMetricQuery_RejectsDevEnv(t *testing.T) { - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", "--environment", "dev", "--as", "user", - }, factory, stdout) - requireAppsValidationParam(t, err, "--environment") -} - -func TestAppsMetricQuery_FillsMissingRequestValuesWithZero(t *testing.T) { - factory, stdout, reg := newAppsExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "points": []interface{}{ - map[string]interface{}{ - "timestamp": float64(1782208800), - "dimensions": map[string]interface{}{"page": "/home"}, - "values": []interface{}{ - map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)}, - }, - }, - map[string]interface{}{ - "timestamp": float64(1782208860), - "dimensions": map[string]interface{}{"page": "/settings"}, - "values": []interface{}{ - map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(8)}, - map[string]interface{}{"metric_name": "client_api_request_error_count", "value": nil}, - }, - }, - }, - }, - }, - }) - - if err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", "--as", "user", - }, factory, stdout); err != nil { - t.Fatalf("execute err=%v", err) - } - - var env struct { - Data struct { - Items []struct { - Values map[string]interface{} `json:"values"` - } `json:"items"` - HasMore bool `json:"has_more"` - } `json:"data"` - } - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("decode output: %v\n%s", err, stdout.String()) - } - if env.Data.HasMore { - t.Fatalf("has_more = true, want false") - } - if len(env.Data.Items) != 2 { - t.Fatalf("items len = %d", len(env.Data.Items)) - } - for i, item := range env.Data.Items { - if item.Values["error"] != float64(0) { - t.Fatalf("item %d error = %#v, want 0; values=%#v", i, item.Values["error"], item.Values) - } - } -} - -func TestAppsMetricQuery_PrettyFormatsTimeFirst(t *testing.T) { - const rawSec = int64(1782208800) - factory, stdout, reg := newAppsExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "points": []interface{}{ - map[string]interface{}{ - "timestamp": float64(rawSec), - "values": []interface{}{ - map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)}, - map[string]interface{}{"metric_name": "client_api_request_error_count", "value": float64(1)}, - }, - }, - }, - }, - }, - }) - - if err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", "--format", "pretty", "--as", "user", - }, factory, stdout); err != nil { - t.Fatalf("execute err=%v", err) - } - got := stdout.String() - wantTime := time.Unix(rawSec, 0).Local().Format("2006-01-02 15:04:05") - if !strings.HasPrefix(got, "time") { - t.Fatalf("pretty output should start with time column, got:\n%s", got) - } - if !strings.Contains(got, wantTime) { - t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got) - } - if strings.Contains(got, "timestamp") || strings.Contains(got, "1782208800") { - t.Fatalf("pretty output should hide raw timestamp, got:\n%s", got) - } -} - -func TestAppsMetricQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { - factory, stdout, reg := newAppsExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{ - "series": []interface{}{ - map[string]interface{}{ - "name": "client_api_request_error_count", - "points": []interface{}{ - map[string]interface{}{"timestamp": float64(1782208800), "value": float64(2)}, - }, - }, - map[string]interface{}{ - "name": "client_api_request_count", - "points": []interface{}{ - map[string]interface{}{"timestamp": float64(1782208800), "value": float64(10)}, - }, - }, - }, - }, - }, - }) - - if err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", "--as", "user", - }, factory, stdout); err != nil { - t.Fatalf("execute err=%v", err) - } - - var env struct { - Data struct { - Items []struct { - Values map[string]interface{} `json:"values"` - } `json:"items"` - } `json:"data"` - } - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("decode output: %v\n%s", err, stdout.String()) - } - if len(env.Data.Items) != 1 { - t.Fatalf("items len = %d", len(env.Data.Items)) - } - values := env.Data.Items[0].Values - if values["total"] != float64(10) || values["error"] != float64(2) { - t.Fatalf("values = %#v, want total=10 error=2", values) - } -} - -func TestAppsMetricQuery_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { - factory, stdout, reg := newAppsExecuteFactory(t) - reg.Register(&httpmock.Stub{ - Method: "POST", - URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{}, - }, - }) - - if err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "latency", "--as", "user", - }, factory, stdout); err != nil { - t.Fatalf("execute err=%v", err) - } - - var env struct { - Data struct { - Items []map[string]interface{} `json:"items"` - HasMore bool `json:"has_more"` - } `json:"data"` - } - if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { - t.Fatalf("decode output: %v\n%s", err, stdout.String()) - } - if env.Data.Items == nil { - t.Fatalf("items decoded as nil; stdout=%s", stdout.String()) - } - if len(env.Data.Items) != 0 || env.Data.HasMore { - t.Fatalf("empty output = items %#v has_more %v", env.Data.Items, env.Data.HasMore) - } -} - -func TestAppsAnalyticsQuery_DryRunUsesNanoseconds(t *testing.T) { +func TestAppsAnalyticsList_DryRunUsesNanoseconds(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "users", + err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z", "--granularity", "week", "--dry-run", "--as", "user", }, factory, stdout) @@ -351,7 +66,7 @@ func TestAppsAnalyticsQuery_DryRunUsesNanoseconds(t *testing.T) { } } -func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) { +func TestAppsAnalyticsList_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) { for _, tc := range []struct { name string args []string @@ -359,21 +74,21 @@ func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) { name: "series", args: []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "page-view", + "+analytics-list", "--app-id", "app_x", "--analytics", "page-view", "--series", "desktop", "--page", "/home", "--dry-run", "--as", "user", }, }, { name: "device-type", args: []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "page-view", + "+analytics-list", "--app-id", "app_x", "--analytics", "page-view", "--device-type", "desktop", "--dry-run", "--as", "user", }, }, } { t.Run(tc.name, func(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - if err := runAppsShortcut(t, AppsAnalyticsQuery, tc.args, factory, stdout); err != nil { + if err := runAppsShortcut(t, AppsAnalyticsList, tc.args, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } var env struct { @@ -396,7 +111,7 @@ func TestAppsAnalyticsQuery_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) } } -func TestAppsAnalyticsQuery_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { +func TestAppsAnalyticsList_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -419,8 +134,8 @@ func TestAppsAnalyticsQuery_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { }, }) - if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "page-view", + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "page-view", "--series", "desktop", "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) @@ -447,7 +162,7 @@ func TestAppsAnalyticsQuery_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { } } -func TestAppsAnalyticsQuery_PrettyFormatsTimeFirst(t *testing.T) { +func TestAppsAnalyticsList_PrettyFormatsTimeFirst(t *testing.T) { const rawNS = int64(1782208800000000000) factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -468,8 +183,8 @@ func TestAppsAnalyticsQuery_PrettyFormatsTimeFirst(t *testing.T) { }, }) - if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--series", "active", "--format", "pretty", "--as", "user", + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--series", "active", "--format", "pretty", "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -486,7 +201,7 @@ func TestAppsAnalyticsQuery_PrettyFormatsTimeFirst(t *testing.T) { } } -func TestAppsAnalyticsQuery_PrettySkipsRowsWithoutTime(t *testing.T) { +func TestAppsAnalyticsList_PrettySkipsRowsWithoutTime(t *testing.T) { const rawNS = int64(1782208800000000000) rows := []map[string]interface{}{ {"timestamp_ns": rawNS, "active-users": float64(7)}, @@ -502,7 +217,7 @@ func TestAppsAnalyticsQuery_PrettySkipsRowsWithoutTime(t *testing.T) { } } -func TestAppsAnalyticsQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { +func TestAppsAnalyticsList_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -534,8 +249,8 @@ func TestAppsAnalyticsQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) }, }) - if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--as", "user", + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -559,7 +274,7 @@ func TestAppsAnalyticsQuery_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) } } -func TestAppsAnalyticsQuery_FillsMissingAndNullValuesWhenAnyValuePresent(t *testing.T) { +func TestAppsAnalyticsList_FillsMissingAndNullValuesWhenAnyValuePresent(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -580,8 +295,8 @@ func TestAppsAnalyticsQuery_FillsMissingAndNullValuesWhenAnyValuePresent(t *test }, }) - if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--as", "user", + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -602,7 +317,7 @@ func TestAppsAnalyticsQuery_FillsMissingAndNullValuesWhenAnyValuePresent(t *test } } -func TestAppsAnalyticsQuery_DoesNotFillAllNullValues(t *testing.T) { +func TestAppsAnalyticsList_DoesNotFillAllNullValues(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -623,8 +338,8 @@ func TestAppsAnalyticsQuery_DoesNotFillAllNullValues(t *testing.T) { }, }) - if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--as", "user", + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -648,7 +363,7 @@ func TestAppsAnalyticsQuery_DoesNotFillAllNullValues(t *testing.T) { } } -func TestAppsAnalyticsQuery_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { +func TestAppsAnalyticsList_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -659,8 +374,8 @@ func TestAppsAnalyticsQuery_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { }, }) - if err := runAppsShortcut(t, AppsAnalyticsQuery, []string{ - "+analytics-query", "--app-id", "app_x", "--analytics", "users", "--as", "user", + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user", }, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } diff --git a/shortcuts/apps/apps_examples_test.go b/shortcuts/apps/apps_examples_test.go index 75576b6c7..2439d83ee 100644 --- a/shortcuts/apps/apps_examples_test.go +++ b/shortcuts/apps/apps_examples_test.go @@ -72,8 +72,8 @@ func TestAppsObservabilityTipsMentionOnlineOnly(t *testing.T) { "+log-get", "+trace-list", "+trace-get", - "+metric-query", - "+analytics-query", + "+metric-list", + "+analytics-list", } { shortcut := requireShortcutForExamples(t, cmd) if !tipsContainAll(shortcut.Tips, "online-only", "--environment online") { diff --git a/shortcuts/apps/apps_observability_logs.go b/shortcuts/apps/apps_logs.go similarity index 100% rename from shortcuts/apps/apps_observability_logs.go rename to shortcuts/apps/apps_logs.go diff --git a/shortcuts/apps/apps_observability_logs_test.go b/shortcuts/apps/apps_logs_test.go similarity index 100% rename from shortcuts/apps/apps_observability_logs_test.go rename to shortcuts/apps/apps_logs_test.go diff --git a/shortcuts/apps/apps_observability_metrics.go b/shortcuts/apps/apps_metrics.go similarity index 66% rename from shortcuts/apps/apps_observability_metrics.go rename to shortcuts/apps/apps_metrics.go index be32d9ad7..1f68e48a1 100644 --- a/shortcuts/apps/apps_observability_metrics.go +++ b/shortcuts/apps/apps_metrics.go @@ -18,30 +18,27 @@ import ( const ( defaultAppsMetricEnv = "online" defaultAppsMetricDownSample = "1m" - defaultAppsAnalyticsEnv = "online" - defaultAppsAnalyticsGranular = "day" - metricQueryEndpoint = "query_metrics_data" - analyticsQueryEndpoint = "query_analytics_data" + metricListEndpoint = "query_metrics_data" defaultObservabilityRangeDays = 30 ) -// AppsMetricQuery queries online app observability metrics. -var AppsMetricQuery = common.Shortcut{ +// AppsMetricList lists online app observability metrics. +var AppsMetricList = common.Shortcut{ Service: appsService, - Command: "+metric-query", - Description: "Query online app request, latency, CPU, and memory metrics", + Command: "+metric-list", + Description: "List online app request, latency, CPU, and memory metrics", Risk: "read", Tips: []string{ - "Example: lark-cli apps +metric-query --app-id --metric requests --series total --since 1d", - "Tip: metric timestamps use seconds; use +analytics-query for PV/UV-style analytics.", + "Example: lark-cli apps +metric-list --app-id --metric requests --series total --since 1d", + "Tip: metric timestamps use seconds; use +analytics-list for PV/UV-style analytics.", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ - {Name: "app-id", Desc: "app ID whose online metrics should be queried", Required: true}, + {Name: "app-id", Desc: "app ID whose online metrics should be listed", Required: true}, {Name: appsEnvironmentFlag, Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"}, - {Name: "metric", Desc: "metric family to query", Required: true, Enum: []string{"requests", "latency", "cpu", "memory"}}, + {Name: "metric", Desc: "metric family to list", Required: true, Enum: []string{"requests", "latency", "cpu", "memory"}}, {Name: "series", Desc: "metric series within the family, such as total/error or p50/p99"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, @@ -53,23 +50,23 @@ var AppsMetricQuery = common.Shortcut{ if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } - _, _, _, _, err := buildMetricQueryBody(rctx) + _, _, _, _, err := buildMetricListBody(rctx) return err }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - body, _, _, _, _ := buildMetricQueryBody(rctx) + body, _, _, _, _ := buildMetricListBody(rctx) return common.NewDryRunAPI(). - POST(metricQueryPath(rctx.Str("app-id"))). - Desc("Query online app metrics"). + POST(metricListPath(rctx.Str("app-id"))). + Desc("List online app metrics"). Body(body) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, _ := requireAppID(rctx.Str("app-id")) - body, names, labels, fillZero, err := buildMetricQueryBody(rctx) + body, names, labels, fillZero, err := buildMetricListBody(rctx) if err != nil { return err } - data, err := rctx.CallAPITyped("POST", metricQueryPath(appID), nil, body) + data, err := rctx.CallAPITyped("POST", metricListPath(appID), nil, body) if err != nil { return withAppsHint(err, appIDListHint) } @@ -87,82 +84,16 @@ var AppsMetricQuery = common.Shortcut{ }, } -// AppsAnalyticsQuery queries online app product analytics. -var AppsAnalyticsQuery = common.Shortcut{ - Service: appsService, - Command: "+analytics-query", - Description: "Query online app user and page-view analytics", - Risk: "read", - Tips: []string{ - "Example: lark-cli apps +analytics-query --app-id --analytics users --granularity week", - "Tip: analytics timestamps use nanoseconds; use +metric-query for request/runtime metrics.", - }, - Scopes: []string{"spark:app:read"}, - AuthTypes: []string{"user"}, - HasFormat: true, - Flags: []common.Flag{ - {Name: "app-id", Desc: "app ID whose online analytics should be queried", Required: true}, - {Name: appsEnvironmentFlag, Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"}, - {Name: "analytics", Desc: "analytics family to query", Required: true, Enum: []string{"users", "page-view"}}, - {Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"}, - {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, - {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, - {Name: "page", Desc: "frontend page or route filter"}, - {Name: "device-type", Desc: "device type filter", Enum: []string{"desktop", "mobile"}}, - {Name: "granularity", Default: defaultAppsAnalyticsGranular, Desc: "analytics aggregation granularity", Enum: []string{"day", "week", "month"}}, - }, - Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - if _, err := requireAppID(rctx.Str("app-id")); err != nil { - return err - } - _, _, _, err := buildAnalyticsQueryBody(rctx) - return err - }, - DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - body, _, _, _ := buildAnalyticsQueryBody(rctx) - return common.NewDryRunAPI(). - POST(analyticsQueryPath(rctx.Str("app-id"))). - Desc("Query online app analytics"). - Body(body) - }, - Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - appID, _ := requireAppID(rctx.Str("app-id")) - body, types, labels, err := buildAnalyticsQueryBody(rctx) - if err != nil { - return err - } - data, err := rctx.CallAPITyped("POST", analyticsQueryPath(appID), nil, body) - if err != nil { - return withAppsHint(err, appIDListHint) - } - out := observabilitySeriesOutput{ - Items: normalizeAnalyticsSeries(data, types, labels), - HasMore: false, - } - rctx.OutFormat(out, nil, func(w io.Writer) { - rows := observabilitySeriesRows(out.Items) - sortObservabilityRowsDesc(rows, "timestamp_ns") - rows = filterObservabilityRowsWithTime(rows, "timestamp_ns") - appsPrintSchemaTable(w, rows, analyticsSeriesSchema(labels)) - }) - return nil - }, -} - type observabilitySeriesOutput struct { Items []map[string]interface{} `json:"items"` HasMore bool `json:"has_more"` } -func metricQueryPath(appID string) string { - return appScopedPath(appID, metricQueryEndpoint) -} - -func analyticsQueryPath(appID string) string { - return appScopedPath(appID, analyticsQueryEndpoint) +func metricListPath(appID string) string { + return appScopedPath(appID, metricListEndpoint) } -func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, bool, error) { +func buildMetricListBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, bool, error) { env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) if env == "" { env = defaultAppsMetricEnv @@ -191,7 +122,7 @@ func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, "down_sample": downSample, "need_pack_lack_point": false, } - if filter := buildMetricQueryFilter(rctx); len(filter) > 0 { + if filter := buildMetricListFilter(rctx); len(filter) > 0 { body["filter"] = filter } return body, names, labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "requests", nil @@ -209,7 +140,7 @@ func appsMetricDownSampleForRange(since, until time.Time) string { } } -func buildMetricQueryFilter(rctx *common.RuntimeContext) map[string]interface{} { +func buildMetricListFilter(rctx *common.RuntimeContext) map[string]interface{} { filter := make(map[string]interface{}) if pages := cleanRepeatedStrings(rctx.StrArray("page")); len(pages) > 0 { filter["pages"] = pages @@ -220,42 +151,6 @@ func buildMetricQueryFilter(rctx *common.RuntimeContext) map[string]interface{} return filter } -func buildAnalyticsQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, error) { - env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) - if env == "" { - env = defaultAppsAnalyticsEnv - } - if err := validateObservabilityEnv(env); err != nil { - return nil, nil, nil, err - } - types, labels, filter, err := analyticsTypesForCLI(rctx.Str("analytics"), rctx.Str("series"), rctx.Str("device-type")) - if err != nil { - return nil, nil, nil, err - } - since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until")) - if err != nil { - return nil, nil, nil, err - } - aggregation, err := analyticsGranularityForCLI(rctx.Str("granularity")) - if err != nil { - return nil, nil, nil, err - } - if page := strings.TrimSpace(rctx.Str("page")); page != "" { - filter["page"] = page - } - body := map[string]interface{}{ - "metric_types": types, - "start_timestamp_ns": nsNumber(since), - "end_timestamp_ns": nsNumber(until), - "time_aggregation_unit": aggregation, - "need_pack_lack_point": false, - } - if len(filter) > 0 { - body["filter"] = filter - } - return body, types, labels, nil -} - func defaultedObservabilityTimeRange(sinceRaw, untilRaw string) (time.Time, time.Time, error) { since, until, hasSince, hasUntil, err := parseAppsTimeRange("--since", sinceRaw, "--until", untilRaw) if err != nil { @@ -314,87 +209,10 @@ func metricNamesForCLI(metric, series string) ([]string, []string, error) { } } -func analyticsTypesForCLI(name, series, deviceType string) ([]string, []string, map[string]interface{}, error) { - name = strings.TrimSpace(strings.ToLower(name)) - series = strings.TrimSpace(strings.ToLower(series)) - deviceType = strings.TrimSpace(strings.ToLower(deviceType)) - filter := make(map[string]interface{}) - if deviceType != "" { - switch deviceType { - case "desktop", "mobile": - filter["device_types"] = []string{deviceType} - default: - return nil, nil, nil, appsValidationParamError("--device-type", "--device-type must be desktop or mobile") - } - } - - switch name { - case "users": - switch series { - case "": - return []string{"ACTIVE_USER", "NEW_USER", "TOTAL_USER"}, []string{"active-users", "new-users", "total-users"}, filter, nil - case "active", "active-users": - return []string{"ACTIVE_USER"}, []string{"active-users"}, filter, nil - case "new", "new-users": - return []string{"NEW_USER"}, []string{"new-users"}, filter, nil - case "total", "total-users": - return []string{"TOTAL_USER"}, []string{"total-users"}, filter, nil - default: - return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics users must be active, new, or total") - } - case "page-view": - switch series { - case "", "all": - return []string{"PAGE_VIEW"}, []string{"all"}, filter, nil - case "desktop", "desktop-view": - if err := mergeAnalyticsDeviceFilter(filter, "desktop"); err != nil { - return nil, nil, nil, err - } - return []string{"PAGE_VIEW"}, []string{"desktop"}, filter, nil - case "mobile", "mobile-view": - if err := mergeAnalyticsDeviceFilter(filter, "mobile"); err != nil { - return nil, nil, nil, err - } - return []string{"PAGE_VIEW"}, []string{"mobile"}, filter, nil - default: - return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics page-view must be all, desktop, or mobile") - } - default: - return nil, nil, nil, appsValidationParamError("--analytics", "--analytics must be users or page-view") - } -} - -func mergeAnalyticsDeviceFilter(filter map[string]interface{}, deviceType string) error { - if existing, ok := filter["device_types"].([]string); ok && len(existing) > 0 && existing[0] != deviceType { - return appsValidationParamError("--device-type", "--device-type conflicts with --series") - } - filter["device_types"] = []string{deviceType} - return nil -} - -func analyticsGranularityForCLI(granularity string) (string, error) { - switch strings.TrimSpace(strings.ToLower(granularity)) { - case "", "day": - return "DAY", nil - case "week": - return "WEEK", nil - case "month": - return "MONTH", nil - default: - return "", appsValidationParamError("--granularity", "--granularity must be day, week, or month") - } -} - func normalizeMetricSeries(data map[string]interface{}, names, labels []string, fillZero bool) []map[string]interface{} { return normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), fillZero, "timestamp") } -func normalizeAnalyticsSeries(data map[string]interface{}, names, labels []string) []map[string]interface{} { - items := normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), false, "timestamp_ns") - fillObservabilityZeroesWhenPartiallyPresent(items, labels) - return items -} - func normalizeObservabilitySeries(data map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} { if series := observabilityMapSlice(data["series"]); len(series) > 0 { return mergeObservabilitySeries(series, labels, nameLabels, fillZero, timeField) @@ -747,16 +565,6 @@ func metricSeriesSchema(labels []string, durationValues bool) appsOutputSchema { return appsOutputSchema{Columns: columns, Strict: true} } -func analyticsSeriesSchema(labels []string) appsOutputSchema { - columns := []appsOutputColumn{ - {Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05")}, - } - for _, label := range labels { - columns = append(columns, appsOutputColumn{Key: label}) - } - return appsOutputSchema{Columns: columns, Strict: true} -} - func sortObservabilityRowsDesc(rows []map[string]interface{}, key string) { sort.SliceStable(rows, func(i, j int) bool { left, leftOK := appsInt64Value(rows[i][key]) diff --git a/shortcuts/apps/apps_metrics_test.go b/shortcuts/apps/apps_metrics_test.go new file mode 100644 index 000000000..3fa032491 --- /dev/null +++ b/shortcuts/apps/apps_metrics_test.go @@ -0,0 +1,298 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestMetricNamesMapping(t *testing.T) { + got, labels, err := metricNamesForCLI("requests", "") + if err != nil { + t.Fatal(err) + } + if strings.Join(got, ",") != "client_api_request_count,client_api_request_error_count" { + t.Fatalf("names = %#v", got) + } + if strings.Join(labels, ",") != "total,error" { + t.Fatalf("labels = %#v", labels) + } + if _, _, err := metricNamesForCLI("cpu", "p99"); err == nil { + t.Fatalf("cpu with p99 should fail") + } +} + +func TestAppsMetricList_DryRunUsesSeconds(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", + "--series", "total", "--since", "2026-06-23T10:00:00Z", + "--until", "2026-06-23T10:01:00Z", "--down-sample", "1m", + "--dry-run", "--as", "user", + }, factory, stdout) + if 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"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_metrics_data" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + body := env.API[0].Body + if _, ok := body["start_timestamp"]; !ok { + t.Fatalf("metric dry-run missing start_timestamp: %#v", body) + } + if _, ok := body["start_timestamp_ns"]; ok { + t.Fatalf("metric should not use start_timestamp_ns: %#v", body) + } + if _, ok := body["app_env"]; ok { + t.Fatalf("metric OpenAPI body should not include app_env: %#v", body) + } + if body["start_timestamp"] != "1782208800" || body["end_timestamp"] != "1782208860" { + t.Fatalf("metric timestamps = %v %v", body["start_timestamp"], body["end_timestamp"]) + } + if body["down_sample"] != "1m" { + t.Fatalf("down_sample = %v", body["down_sample"]) + } +} + +func TestAppsMetricList_AutoDownSampleByRange(t *testing.T) { + for _, tc := range []struct { + name string + since string + until string + want string + }{ + {name: "short", since: "2026-06-23T10:00:00Z", until: "2026-06-23T12:00:00Z", want: "1m"}, + {name: "medium", since: "2026-06-21T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1h"}, + {name: "long", since: "2026-06-01T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1d"}, + } { + t.Run(tc.name, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", + "--since", tc.since, "--until", tc.until, "--dry-run", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if got := env.API[0].Body["down_sample"]; got != tc.want { + t.Fatalf("down_sample = %#v, want %q; stdout:\n%s", got, tc.want, stdout.String()) + } + }) + } +} + +func TestAppsMetricList_RejectsDevEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", "--environment", "dev", "--as", "user", + }, factory, stdout) + requireAppsValidationParam(t, err, "--environment") +} + +func TestAppsMetricList_FillsMissingRequestValuesWithZero(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "points": []interface{}{ + map[string]interface{}{ + "timestamp": float64(1782208800), + "dimensions": map[string]interface{}{"page": "/home"}, + "values": []interface{}{ + map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)}, + }, + }, + map[string]interface{}{ + "timestamp": float64(1782208860), + "dimensions": map[string]interface{}{"page": "/settings"}, + "values": []interface{}{ + map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(8)}, + map[string]interface{}{"metric_name": "client_api_request_error_count", "value": nil}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.HasMore { + t.Fatalf("has_more = true, want false") + } + if len(env.Data.Items) != 2 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + for i, item := range env.Data.Items { + if item.Values["error"] != float64(0) { + t.Fatalf("item %d error = %#v, want 0; values=%#v", i, item.Values["error"], item.Values) + } + } +} + +func TestAppsMetricList_PrettyFormatsTimeFirst(t *testing.T) { + const rawSec = int64(1782208800) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "points": []interface{}{ + map[string]interface{}{ + "timestamp": float64(rawSec), + "values": []interface{}{ + map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)}, + map[string]interface{}{"metric_name": "client_api_request_error_count", "value": float64(1)}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + wantTime := time.Unix(rawSec, 0).Local().Format("2006-01-02 15:04:05") + if !strings.HasPrefix(got, "time") { + t.Fatalf("pretty output should start with time column, got:\n%s", got) + } + if !strings.Contains(got, wantTime) { + t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got) + } + if strings.Contains(got, "timestamp") || strings.Contains(got, "1782208800") { + t.Fatalf("pretty output should hide raw timestamp, got:\n%s", got) + } +} + +func TestAppsMetricList_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "series": []interface{}{ + map[string]interface{}{ + "name": "client_api_request_error_count", + "points": []interface{}{ + map[string]interface{}{"timestamp": float64(1782208800), "value": float64(2)}, + }, + }, + map[string]interface{}{ + "name": "client_api_request_count", + "points": []interface{}{ + map[string]interface{}{"timestamp": float64(1782208800), "value": float64(10)}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if len(env.Data.Items) != 1 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + values := env.Data.Items[0].Values + if values["total"] != float64(10) || values["error"] != float64(2) { + t.Fatalf("values = %#v, want total=10 error=2", values) + } +} + +func TestAppsMetricList_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + }) + + if err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "latency", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.Items == nil { + t.Fatalf("items decoded as nil; stdout=%s", stdout.String()) + } + if len(env.Data.Items) != 0 || env.Data.HasMore { + t.Fatalf("empty output = items %#v has_more %v", env.Data.Items, env.Data.HasMore) + } +} diff --git a/shortcuts/apps/apps_observability_traces.go b/shortcuts/apps/apps_traces.go similarity index 100% rename from shortcuts/apps/apps_observability_traces.go rename to shortcuts/apps/apps_traces.go diff --git a/shortcuts/apps/apps_observability_traces_test.go b/shortcuts/apps/apps_traces_test.go similarity index 100% rename from shortcuts/apps/apps_observability_traces_test.go rename to shortcuts/apps/apps_traces_test.go diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index 178e7a618..cf05afdfa 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -26,8 +26,8 @@ func Shortcuts() []common.Shortcut { withExtraTips(AppsLogGet, "Tip: logs are online-only; keep --environment omitted or set --environment online."), withExtraTips(AppsTraceList, "Tip: traces are online-only; keep --environment omitted or set --environment online."), withExtraTips(AppsTraceGet, "Tip: traces are online-only; keep --environment omitted or set --environment online."), - withExtraTips(AppsMetricQuery, "Tip: metrics are online-only; keep --environment omitted or set --environment online."), - withExtraTips(AppsAnalyticsQuery, "Tip: analytics are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsMetricList, "Tip: metrics are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsAnalyticsList, "Tip: analytics are online-only; keep --environment omitted or set --environment online."), AppsEnvVarList, envSet, envDelete, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 147963641..69b5b9654 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -11,7 +11,7 @@ import ( // 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 // 6 基础 + 1 init + 3 publish + 1 env-pull -// - 6 observability(log-list/log-get/trace-list/trace-get/metric-query/analytics-query) +// - 6 observability(log-list/log-get/trace-list/trace-get/metric-list/analytics-list) // - 3 env(list/set/delete) // - 16 db(table-list/table-schema/sql/dev-init/data-import/data-export/changelog-list/ // audit-status/audit-enable/audit-disable/audit-list/ @@ -36,6 +36,15 @@ func TestAppsShortcuts_DoesNotIncludeEnvGet(t *testing.T) { } } +func TestAppsShortcuts_DoesNotIncludeMetricQueryAliases(t *testing.T) { + for _, sc := range Shortcuts() { + switch sc.Command { + case "+metric-query", "+analytics-query": + t.Fatalf("Shortcuts() must not register %s", sc.Command) + } + } +} + func TestAppsShortcuts_EnvCommandsUseCanonicalNames(t *testing.T) { want := map[string]bool{ "+env-list": false, diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index cf0ac1385..b93d82382 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -25,7 +25,7 @@ metadata: | 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | | 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) | -| 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-query`, `+analytics-query` | [`lark-apps-observability.md`](references/lark-apps-observability.md) | +| 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-list`, `+analytics-list` | [`lark-apps-observability.md`](references/lark-apps-observability.md) | | 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / dev→online 发布 / 时间点恢复 / 查 DB 用量 | `+db-table-list`、`+db-table-get`、`+db-env-create`、`+db-data-export`/`+db-data-import`、`+db-changelog-list`、`+db-audit-status`/`+db-audit-enable`/`+db-audit-disable`/`+db-audit-list`、`+db-env-diff`/`+db-env-migrate`、`+db-recovery-diff`/`+db-recovery-apply`、`+db-quota-get` | [`lark-apps-db.md`](references/lark-apps-db.md) | | 逐条执行 SQL(SELECT / DML / DDL) | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md) | | 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) | @@ -37,9 +37,9 @@ metadata: ## 高频路径 -- **性能/监控/观测指标**:用户问“接口请求量、错误量、错误率、接口慢、延迟、CPU、内存、最近一小时/七天趋势”时,不要去当前工作区搜索监控文件,也不要询问“监控数据在哪”。先按「app_id 获取」解析应用:`lark-cli apps +list --keyword "<应用名>" --as user`;拿到 `app_id` 后读 [`lark-apps-observability.md`](references/lark-apps-observability.md),用 `+metric-query`。 -- **请求量 + 错误量 + 延迟**:请求量/错误量用 `lark-cli apps +metric-query --app-id --metric requests --since --as user`(不传 `--series` 会同时返回 total/error);延迟用 `--metric latency`(不传 `--series` 会返回 p50/p99)。如果用户给了具体接口,再加 `--api `;不要臆造 group-by 参数。 -- **PV/UV/访问量/活跃用户**:先解析 `app_id`,再用 `+analytics-query`,不要误用 `+metric-query`。 +- **性能/监控/观测指标**:用户问“接口请求量、错误量、错误率、接口慢、延迟、CPU、内存、最近一小时/七天趋势”时,不要去当前工作区搜索监控文件,也不要询问“监控数据在哪”。先按「app_id 获取」解析应用:`lark-cli apps +list --keyword "<应用名>" --as user`;拿到 `app_id` 后读 [`lark-apps-observability.md`](references/lark-apps-observability.md),用 `+metric-list`。 +- **请求量 + 错误量 + 延迟**:请求量/错误量用 `lark-cli apps +metric-list --app-id --metric requests --since --as user`(不传 `--series` 会同时返回 total/error);延迟用 `--metric latency`(不传 `--series` 会返回 p50/p99)。如果用户给了具体接口,再加 `--api `;不要臆造 group-by 参数。 +- **PV/UV/访问量/活跃用户**:先解析 `app_id`,再用 `+analytics-list`,不要误用 `+metric-list`。 - **设置环境变量**:如果用户只给应用名,仍先 `+list --keyword` 解析 app_id;设置 online 环境且用户已经明确说“确认/直接执行”时,调用 `+env-set --environment online ... --yes`,不要再次要求确认。回复和日志摘要里只提 key / env / app,不回显真实 value;需要传复杂值时优先用 `@file` 或 stdin。 - **删除环境变量**:`+env-delete` 是破坏性操作。除非用户在同一轮已经明确确认删除这个 app/env/key,否则先向用户确认应用、环境、key 和删除后果;确认后再加 `--yes`。不要因为认证失败/重登完成就自动继续删除,必须保留确认门槛。 diff --git a/skills/lark-apps/references/lark-apps-observability.md b/skills/lark-apps/references/lark-apps-observability.md index b0375b7ad..e40a9a865 100644 --- a/skills/lark-apps/references/lark-apps-observability.md +++ b/skills/lark-apps/references/lark-apps-observability.md @@ -6,7 +6,7 @@ 日志和 trace 的用户侧环境仍然是 online;但 OpenAPI 请求体里的后端 `app_env` 固定发送 `runtime`,因为线上应用的运行时日志和 trace 存储在 runtime 观测环境下。dry-run 输出会展示这个后端参数。 -metric / analytics 的 `--environment` 只是 CLI 侧 online-only 校验:`+metric-query` 和 `+analytics-query` 不会向 OpenAPI body 发送 `env` 或 `app_env`。dry-run 里看不到环境字段是预期行为,不要补造参数。 +metric / analytics 的 `--environment` 只是 CLI 侧 online-only 校验:`+metric-list` 和 `+analytics-list` 不会向 OpenAPI body 发送 `env` 或 `app_env`。dry-run 里看不到环境字段是预期行为,不要补造参数。 时间过滤支持相对时间(如 `30s`、`5m`、`0.5h`、`2h`、`3d`、`1w`)、本地日期 / 时间和 RFC3339。 @@ -16,10 +16,10 @@ metric / analytics 的 `--environment` 只是 CLI 侧 online-only 校验:`+met - `+log-list` 不再支持 `--log-id`;已有 log ID 时直接用 `+log-get --log-id `。 - 前端 ERROR 日志详情:`+log-get` 可能补充 `source_stack`;没有独立的 source-stack 命令。 - Trace 检索:用 `+trace-list` 搜索 trace,用 `+trace-get` 按 trace ID 取详情。 -- 运行时指标:请求数、错误、延迟、CPU、memory 用 `+metric-query`。 -- 产品分析:PV、UV、访问量这类业务访问分析用 `+analytics-query`,不要放到 runtime metric 里混查。 -- `+analytics-query` 按最新 OpenAPI 发送 `metric_types`、纳秒时间戳和 `need_pack_lack_point=false`;`group_by` 暂不支持。 -- 用户询问“最近一小时接口请求量、错误量、延迟、接口慢/报错多”时,这是平台运行时监控,不是本地项目文件。先用 `apps +list --keyword` 找 `app_id`,再查 `+metric-query`。 +- 运行时指标:请求数、错误、延迟、CPU、memory 用 `+metric-list`。 +- 产品分析:PV、UV、访问量这类业务访问分析用 `+analytics-list`,不要放到 runtime metric 里混查。 +- `+analytics-list` 按最新 OpenAPI 发送 `metric_types`、纳秒时间戳和 `need_pack_lack_point=false`;`group_by` 暂不支持。 +- 用户询问“最近一小时接口请求量、错误量、延迟、接口慢/报错多”时,这是平台运行时监控,不是本地项目文件。先用 `apps +list --keyword` 找 `app_id`,再查 `+metric-list`。 ## 示例 @@ -28,21 +28,21 @@ lark-cli apps +log-list --app-id --level error --keyword timeout --sinc lark-cli apps +log-get --app-id --log-id lark-cli apps +trace-list --app-id --trace-id lark-cli apps +trace-get --app-id --trace-id -lark-cli apps +metric-query --app-id --metric requests --series total --since 1d -lark-cli apps +metric-query --app-id --metric requests --since 1h -lark-cli apps +metric-query --app-id --metric latency --since 1h -lark-cli apps +metric-query --app-id --metric latency --series p99 --since 1d -lark-cli apps +metric-query --app-id --metric cpu --since 1h -lark-cli apps +metric-query --app-id --metric memory --since 1h -lark-cli apps +analytics-query --app-id --analytics users --series active-users --granularity day -lark-cli apps +analytics-query --app-id --analytics page-view --granularity day +lark-cli apps +metric-list --app-id --metric requests --series total --since 1d +lark-cli apps +metric-list --app-id --metric requests --since 1h +lark-cli apps +metric-list --app-id --metric latency --since 1h +lark-cli apps +metric-list --app-id --metric latency --series p99 --since 1d +lark-cli apps +metric-list --app-id --metric cpu --since 1h +lark-cli apps +metric-list --app-id --metric memory --since 1h +lark-cli apps +analytics-list --app-id --analytics users --series active-users --granularity day +lark-cli apps +analytics-list --app-id --analytics page-view --granularity day ``` ## 使用边界 -- 如果用户问“接口慢、报错多、CPU/内存高”,优先走 `+metric-query`。 -- `+metric-query --metric requests` 不传 `--series` 会同时返回请求总量 total 和错误量 error;`--metric latency` 不传 `--series` 会同时返回 p50 和 p99。只想看单条曲线时再传 `--series total|error|p50|p99`。 +- 如果用户问“接口慢、报错多、CPU/内存高”,优先走 `+metric-list`。 +- `+metric-list --metric requests` 不传 `--series` 会同时返回请求总量 total 和错误量 error;`--metric latency` 不传 `--series` 会同时返回 p50 和 p99。只想看单条曲线时再传 `--series total|error|p50|p99`。 - 按接口收窄范围时使用 `--api `;当前没有 `group-by` 参数,不要臆造。 -- `+metric-query` 未显式传 `--down-sample` 时会按时间范围自动选择粒度:短范围用 `1m`,中等范围用 `1h`,长范围用 `1d`;显式传入时尊重用户指定。 -- 如果用户问“页面访问量、PV、UV、活跃用户”,优先走 `+analytics-query`。 +- `+metric-list` 未显式传 `--down-sample` 时会按时间范围自动选择粒度:短范围用 `1m`,中等范围用 `1h`,长范围用 `1d`;显式传入时尊重用户指定。 +- 如果用户问“页面访问量、PV、UV、活跃用户”,优先走 `+analytics-list`。 - 如果用户已有 `trace_id` 或 `log_id`,直接用对应 get 命令;不知道 ID 时先 list。 From 8f0d0725fc528f3a278622eba5f4f96ec1bbd2d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=85=B4=E7=82=80?= Date: Fri, 26 Jun 2026 11:13:18 +0800 Subject: [PATCH 18/34] feat(apps): default db --environment to dev across all db commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify the db environment flag default to dev for every db command (was online for table-list/get, data export/import, changelog, audit, quota; execute/env-create were already dev). Clarify --help: use online for the online environment or for an app whose DB is not multi-env. Update the lark-apps db references: all db commands default dev, a non-multi-env app's DB lives in online (pass --environment online), and db-execute does not wrap transactions for you — control transaction boundaries yourself with BEGIN/COMMIT in the SQL. --- shortcuts/apps/apps_db_audit_list.go | 2 +- shortcuts/apps/apps_db_audit_set.go | 4 ++-- shortcuts/apps/apps_db_audit_status.go | 2 +- shortcuts/apps/apps_db_changelog_list.go | 2 +- shortcuts/apps/apps_db_data_export.go | 2 +- shortcuts/apps/apps_db_data_import.go | 2 +- shortcuts/apps/apps_db_execute.go | 2 +- shortcuts/apps/apps_db_quota_get.go | 2 +- shortcuts/apps/apps_db_table_get.go | 2 +- shortcuts/apps/apps_db_table_list.go | 2 +- skills/lark-apps/references/lark-apps-db-execute.md | 4 ++-- skills/lark-apps/references/lark-apps-db.md | 2 +- 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/shortcuts/apps/apps_db_audit_list.go b/shortcuts/apps/apps_db_audit_list.go index c3f40f459..8c6c6b071 100644 --- a/shortcuts/apps/apps_db_audit_list.go +++ b/shortcuts/apps/apps_db_audit_list.go @@ -40,7 +40,7 @@ var AppsDBAuditList = common.Shortcut{ {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"}, - }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err diff --git a/shortcuts/apps/apps_db_audit_set.go b/shortcuts/apps/apps_db_audit_set.go index 287640eb8..93f0d4f7e 100644 --- a/shortcuts/apps/apps_db_audit_set.go +++ b/shortcuts/apps/apps_db_audit_set.go @@ -35,7 +35,7 @@ var AppsDBAuditEnable = common.Shortcut{ {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"}, - }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err @@ -96,7 +96,7 @@ var AppsDBAuditDisable = common.Shortcut{ Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "table", Desc: "table to disable audit for", Required: true}, - }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err diff --git a/shortcuts/apps/apps_db_audit_status.go b/shortcuts/apps/apps_db_audit_status.go index 855100683..341e11bdb 100644 --- a/shortcuts/apps/apps_db_audit_status.go +++ b/shortcuts/apps/apps_db_audit_status.go @@ -30,7 +30,7 @@ var AppsDBAuditStatus = common.Shortcut{ Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "table", Desc: "show status for a single table (default: all configured tables)"}, - }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err diff --git a/shortcuts/apps/apps_db_changelog_list.go b/shortcuts/apps/apps_db_changelog_list.go index 1f013daa6..faf424e98 100644 --- a/shortcuts/apps/apps_db_changelog_list.go +++ b/shortcuts/apps/apps_db_changelog_list.go @@ -39,7 +39,7 @@ var AppsDBChangelogList = common.Shortcut{ {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"}, - }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err diff --git a/shortcuts/apps/apps_db_data_export.go b/shortcuts/apps/apps_db_data_export.go index e4d98294b..a31712646 100644 --- a/shortcuts/apps/apps_db_data_export.go +++ b/shortcuts/apps/apps_db_data_export.go @@ -47,7 +47,7 @@ var AppsDBDataExport = common.Shortcut{ {Name: "table", Desc: "source table", Required: true}, {Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default:
.csv)"}, {Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"}, - }, dbEnvFlags("online", []string{"dev", "online"}, "source db environment")...), + }, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err diff --git a/shortcuts/apps/apps_db_data_import.go b/shortcuts/apps/apps_db_data_import.go index 1fe5cb18c..d3266eeb0 100644 --- a/shortcuts/apps/apps_db_data_import.go +++ b/shortcuts/apps/apps_db_data_import.go @@ -44,7 +44,7 @@ var AppsDBDataImport = common.Shortcut{ {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)"}, - }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err diff --git a/shortcuts/apps/apps_db_execute.go b/shortcuts/apps/apps_db_execute.go index f20acdd7a..2c38f7e88 100644 --- a/shortcuts/apps/apps_db_execute.go +++ b/shortcuts/apps/apps_db_execute.go @@ -66,7 +66,7 @@ var AppsDBExecute = common.Shortcut{ {Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file", Input: []string{common.Stdin}}, {Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"}, - }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use --environment online for the online environment)")...), + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err diff --git a/shortcuts/apps/apps_db_quota_get.go b/shortcuts/apps/apps_db_quota_get.go index c2e767f06..f5f1563be 100644 --- a/shortcuts/apps/apps_db_quota_get.go +++ b/shortcuts/apps/apps_db_quota_get.go @@ -29,7 +29,7 @@ var AppsDBQuotaGet = common.Shortcut{ HasFormat: true, Flags: append([]common.Flag{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, - }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err diff --git a/shortcuts/apps/apps_db_table_get.go b/shortcuts/apps/apps_db_table_get.go index 617d08b72..5aff1852d 100644 --- a/shortcuts/apps/apps_db_table_get.go +++ b/shortcuts/apps/apps_db_table_get.go @@ -37,7 +37,7 @@ var AppsDBTableGet = common.Shortcut{ Flags: append([]common.Flag{ {Name: "app-id", Desc: "app id", Required: true}, {Name: "table", Desc: "table name", Required: true}, - }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err diff --git a/shortcuts/apps/apps_db_table_list.go b/shortcuts/apps/apps_db_table_list.go index 68654928b..b24c04d83 100644 --- a/shortcuts/apps/apps_db_table_list.go +++ b/shortcuts/apps/apps_db_table_list.go @@ -42,7 +42,7 @@ var AppsDBTableList = common.Shortcut{ {Name: "app-id", Desc: "app id", Required: true}, {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, {Name: "page-token", Desc: "pagination cursor from previous response"}, - }, dbEnvFlags("online", []string{"dev", "online"}, "target db environment")...), + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err diff --git a/skills/lark-apps/references/lark-apps-db-execute.md b/skills/lark-apps/references/lark-apps-db-execute.md index 601110922..c0baf815f 100644 --- a/skills/lark-apps/references/lark-apps-db-execute.md +++ b/skills/lark-apps/references/lark-apps-db-execute.md @@ -11,9 +11,9 @@ - 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。 - `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < `(shell 解析路径,CLI 仅接收内容)。 - `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。 -- `--environment` 枚举:`dev` / `online`,**默认 `dev`**;需要操作线上环境数据库时,显式指定 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。 +- `--environment` 枚举:`dev` / `online`,**默认 `dev`**;操作线上库、或**未开启多环境的应用(其数据库在 `online`,没有 dev 分支)**时显式 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。 - risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。 -- CLI 永远传 `transactional=false`;不默认包事务。 +- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`(详见下「Agent 规则」)。 ## 示例 diff --git a/skills/lark-apps/references/lark-apps-db.md b/skills/lark-apps/references/lark-apps-db.md index abefd598f..f0e2d97ed 100644 --- a/skills/lark-apps/references/lark-apps-db.md +++ b/skills/lark-apps/references/lark-apps-db.md @@ -28,7 +28,7 @@ ## 约定(先读) -- **环境 `--environment dev|online`(默认 online;`+db-execute`/`+db-env-create` 默认 dev)**:看表、看结构、数据导入导出、变更追溯、审计、配额、初始化都按环境区分,写操作建议先在 `dev` 验。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。 +- **环境 `--environment dev|online`(所有 db 命令统一默认 `dev`)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开启多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。 - **本地文件用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;路径在别处先 `cd` 过去或改成相对路径。 - **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。 - **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。 From 67649490142bac8a51e32961714e8e77076a2a4e Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Fri, 26 Jun 2026 11:45:37 +0800 Subject: [PATCH 19/34] fix: remove unsed files --- ...26_06_23_apps_observability_envvar_test.go | 632 ------------------ 1 file changed, 632 deletions(-) delete mode 100644 tests_e2e/apps/2026_06_23_apps_observability_envvar_test.go diff --git a/tests_e2e/apps/2026_06_23_apps_observability_envvar_test.go b/tests_e2e/apps/2026_06_23_apps_observability_envvar_test.go deleted file mode 100644 index d171b3284..000000000 --- a/tests_e2e/apps/2026_06_23_apps_observability_envvar_test.go +++ /dev/null @@ -1,632 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package apps - -import ( - "context" - "os" - "path/filepath" - "strings" - "testing" - "time" - - clie2e "github.com/larksuite/cli/tests/cli_e2e" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "github.com/tidwall/gjson" -) - -const ( - appsE2EAppID = "app_x" - appsSecretValue = "super-secret-value-for-e2e" -) - -func TestAppsObservabilityDryRunContract(t *testing.T) { - cases := []struct { - name string - args []string - method string - url string - assertBody func(*testing.T, string) - }{ - { - name: "log_list_request_shape", - args: []string{ - "apps", "+log-list", - "--app-id", appsE2EAppID, - "--env", "online", - "--level", "error", - "--since", "2026-06-23T10:00:00Z", - "--until", "2026-06-23T11:00:00Z", - "--log-id", "LOG1", - "--log-id", "LOG2", - "--trace-id", "trace-1", - "--keyword", "timeout", - "--min-duration", "200", - "--page-size", "50", - "--page-token", "next-token", - }, - method: "POST", - url: "/open-apis/spark/v1/apps/app_x/search_logs", - assertBody: func(t *testing.T, stdout string) { - assert.Equal(t, "runtime", gjson.Get(stdout, "api.0.body.app_env").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1782208800000000000", gjson.Get(stdout, "api.0.body.start_timestamp_ns").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1782212400000000000", gjson.Get(stdout, "api.0.body.end_timestamp_ns").String(), "stdout:\n%s", stdout) - assert.Equal(t, int64(50), gjson.Get(stdout, "api.0.body.limit").Int(), "stdout:\n%s", stdout) - assert.Equal(t, "next-token", gjson.Get(stdout, "api.0.body.page_token").String(), "stdout:\n%s", stdout) - requireStringArray(t, stdout, "api.0.body.filter.levels", []string{"ERROR"}) - requireStringArray(t, stdout, "api.0.body.filter.log_ids", []string{"LOG1", "LOG2"}) - requireStringArray(t, stdout, "api.0.body.filter.trace_ids", []string{"trace-1"}) - assert.Equal(t, "timeout", gjson.Get(stdout, "api.0.body.filter.keyword").String(), "stdout:\n%s", stdout) - assert.Equal(t, int64(200), gjson.Get(stdout, "api.0.body.filter.min_duration_ms").Int(), "stdout:\n%s", stdout) - }, - }, - { - name: "log_get_uses_search_logs_with_limit_one", - args: []string{ - "apps", "+log-get", - "--app-id", appsE2EAppID, - "--env", "online", - "--log-id", "LOG763372528845174288", - }, - method: "POST", - url: "/open-apis/spark/v1/apps/app_x/search_logs", - assertBody: func(t *testing.T, stdout string) { - assert.Equal(t, "runtime", gjson.Get(stdout, "api.0.body.app_env").String(), "stdout:\n%s", stdout) - assert.Equal(t, int64(1), gjson.Get(stdout, "api.0.body.limit").Int(), "stdout:\n%s", stdout) - requireStringArray(t, stdout, "api.0.body.filter.log_ids", []string{"LOG763372528845174288"}) - }, - }, - { - name: "trace_list_request_shape", - args: []string{ - "apps", "+trace-list", - "--app-id", appsE2EAppID, - "--env", "online", - "--trace-id", "trace-1", - "--root-span", "api-gateway", - "--user-id", "ou_user", - "--since", "2026-06-23T10:00:00Z", - "--until", "2026-06-23T11:00:00Z", - "--page-size", "25", - "--page-token", "next-token", - }, - method: "POST", - url: "/open-apis/spark/v1/apps/app_x/search_traces", - assertBody: func(t *testing.T, stdout string) { - assert.Equal(t, "runtime", gjson.Get(stdout, "api.0.body.app_env").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1782208800000000000", gjson.Get(stdout, "api.0.body.start_timestamp_ns").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1782212400000000000", gjson.Get(stdout, "api.0.body.end_timestamp_ns").String(), "stdout:\n%s", stdout) - assert.Equal(t, int64(25), gjson.Get(stdout, "api.0.body.limit").Int(), "stdout:\n%s", stdout) - assert.Equal(t, "next-token", gjson.Get(stdout, "api.0.body.page_token").String(), "stdout:\n%s", stdout) - requireStringArray(t, stdout, "api.0.body.filter.trace_ids", []string{"trace-1"}) - assert.Equal(t, "api-gateway", gjson.Get(stdout, "api.0.body.filter.keyword").String(), "stdout:\n%s", stdout) - requireStringArray(t, stdout, "api.0.body.filter.user_ids", []string{"ou_user"}) - }, - }, - { - name: "trace_get_request_shape", - args: []string{ - "apps", "+trace-get", - "--app-id", appsE2EAppID, - "--env", "online", - "--trace-id", "359d7ab1d9e222b43ee56619a55f937a", - }, - method: "POST", - url: "/open-apis/spark/v1/apps/app_x/trace", - assertBody: func(t *testing.T, stdout string) { - assert.Equal(t, "runtime", gjson.Get(stdout, "api.0.body.app_env").String(), "stdout:\n%s", stdout) - assert.Equal(t, "359d7ab1d9e222b43ee56619a55f937a", gjson.Get(stdout, "api.0.body.trace_id").String(), "stdout:\n%s", stdout) - }, - }, - { - name: "metric_query_request_shape", - args: []string{ - "apps", "+metric-query", - "--app-id", appsE2EAppID, - "--env", "online", - "--metric", "requests", - "--series", "total", - "--since", "2026-06-23T10:00:00Z", - "--until", "2026-06-23T11:00:00Z", - "--page", "/home", - "--api", "/api/orders", - "--down-sample", "1m", - }, - method: "POST", - url: "/open-apis/spark/v1/apps/app_x/query_metrics_data", - assertBody: func(t *testing.T, stdout string) { - assert.False(t, gjson.Get(stdout, "api.0.body.app_env").Exists(), "metric OpenAPI body should not include app_env, stdout:\n%s", stdout) - assert.Equal(t, "1782208800", gjson.Get(stdout, "api.0.body.start_timestamp").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1782212400", gjson.Get(stdout, "api.0.body.end_timestamp").String(), "stdout:\n%s", stdout) - requireStringArray(t, stdout, "api.0.body.metric_names", []string{"client_api_request_count"}) - assert.Equal(t, "/home", gjson.Get(stdout, "api.0.body.filter.pages.0").String(), "stdout:\n%s", stdout) - assert.Equal(t, "/api/orders", gjson.Get(stdout, "api.0.body.filter.apis.0").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1m", gjson.Get(stdout, "api.0.body.down_sample").String(), "stdout:\n%s", stdout) - assert.True(t, gjson.Get(stdout, "api.0.body.need_pack_lack_point").Exists(), "stdout:\n%s", stdout) - assert.False(t, gjson.Get(stdout, "api.0.body.need_pack_lack_point").Bool(), "stdout:\n%s", stdout) - }, - }, - { - name: "analytics_query_request_shape", - args: []string{ - "apps", "+analytics-query", - "--app-id", appsE2EAppID, - "--env", "online", - "--analytics", "users", - "--series", "active-users", - "--since", "2026-06-23T10:00:00Z", - "--until", "2026-06-23T11:00:00Z", - "--page", "/home", - "--device-type", "desktop", - "--granularity", "week", - }, - method: "POST", - url: "/open-apis/spark/v1/apps/app_x/query_analytics_data", - assertBody: func(t *testing.T, stdout string) { - assert.False(t, gjson.Get(stdout, "api.0.body.app_env").Exists(), "analytics OpenAPI body should not include app_env, stdout:\n%s", stdout) - assert.Equal(t, "1782208800000000000", gjson.Get(stdout, "api.0.body.start_timestamp_ns").String(), "stdout:\n%s", stdout) - assert.Equal(t, "1782212400000000000", gjson.Get(stdout, "api.0.body.end_timestamp_ns").String(), "stdout:\n%s", stdout) - requireStringArray(t, stdout, "api.0.body.metric_types", []string{"ACTIVE_USER"}) - assert.Equal(t, "WEEK", gjson.Get(stdout, "api.0.body.time_aggregation_unit").String(), "stdout:\n%s", stdout) - assert.Equal(t, "/home", gjson.Get(stdout, "api.0.body.filter.page").String(), "stdout:\n%s", stdout) - assert.Equal(t, "desktop", gjson.Get(stdout, "api.0.body.filter.device_types.0").String(), "stdout:\n%s", stdout) - assert.True(t, gjson.Get(stdout, "api.0.body.need_pack_lack_point").Exists(), "stdout:\n%s", stdout) - assert.False(t, gjson.Get(stdout, "api.0.body.need_pack_lack_point").Bool(), "stdout:\n%s", stdout) - assert.False(t, gjson.Get(stdout, "api.0.body.group_by").Exists(), "group_by is intentionally unsupported for now, stdout:\n%s", stdout) - }, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsDryRunCommand(t, ctx, tc.args, false) - result.AssertExitCode(t, 0) - assert.Equal(t, tc.method, gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, tc.url, gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) - tc.assertBody(t, result.Stdout) - }) - } -} - -func TestAppsObservabilityRejectsNonOnlineEnv(t *testing.T) { - cases := []struct { - name string - args []string - }{ - { - name: "log_list", - args: []string{"apps", "+log-list", "--app-id", appsE2EAppID, "--env", "dev"}, - }, - { - name: "log_get", - args: []string{"apps", "+log-get", "--app-id", appsE2EAppID, "--env", "dev", "--log-id", "LOG763372528845174288"}, - }, - { - name: "trace_list", - args: []string{"apps", "+trace-list", "--app-id", appsE2EAppID, "--env", "dev"}, - }, - { - name: "trace_get", - args: []string{"apps", "+trace-get", "--app-id", appsE2EAppID, "--env", "dev", "--trace-id", "359d7ab1d9e222b43ee56619a55f937a"}, - }, - { - name: "metric_query", - args: []string{"apps", "+metric-query", "--app-id", appsE2EAppID, "--env", "dev", "--metric", "requests"}, - }, - { - name: "analytics_query", - args: []string{"apps", "+analytics-query", "--app-id", appsE2EAppID, "--env", "dev", "--analytics", "users"}, - }, - } - - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsDryRunCommand(t, ctx, tc.args, false) - result.AssertExitCode(t, 2) - raw := errorEnvelope(t, result) - assert.Equal(t, "validation", gjson.Get(raw, "error.type").String(), "error envelope:\n%s", raw) - assert.Equal(t, "invalid_argument", gjson.Get(raw, "error.subtype").String(), "error envelope:\n%s", raw) - assert.Equal(t, "--env", gjson.Get(raw, "error.param").String(), "error envelope:\n%s", raw) - assert.Contains(t, gjson.Get(raw, "error.message").String(), "observability commands only support online", "error envelope:\n%s", raw) - }) - } -} - -func TestAppsEnvVarDryRunAndSafety(t *testing.T) { - t.Run("env_pull_uses_dev_post_body_contract", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - projectDir := filepath.Join(t.TempDir(), "demo") - - result := runAppsDryRunCommand(t, ctx, []string{ - "apps", "+env-pull", - "--app-id", appsE2EAppID, - "--project-path", projectDir, - }, false) - result.AssertExitCode(t, 0) - assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) - assert.False(t, gjson.Get(result.Stdout, "api.0.params.include_values").Exists(), "stdout:\n%s", result.Stdout) - assert.Equal(t, filepath.Join(projectDir, ".env.local"), gjson.Get(result.Stdout, "env_file").String(), "stdout:\n%s", result.Stdout) - assert.False(t, gjson.Get(result.Stdout, "env_keys").Exists(), "env-pull dry-run must not expose key list, stdout:\n%s", result.Stdout) - assert.NotContains(t, result.Stdout, appsSecretValue, "env-pull dry-run must not leak env values, stdout:\n%s", result.Stdout) - }) - - t.Run("envvar_list_defaults_to_dev_without_values", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsDryRunCommand(t, ctx, []string{ - "apps", "+envvar-list", - "--app-id", appsE2EAppID, - }, false) - result.AssertExitCode(t, 0) - assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) - assert.False(t, gjson.Get(result.Stdout, "api.0.params.include_values").Exists(), "stdout:\n%s", result.Stdout) - assert.False(t, gjson.Get(result.Stdout, "api.0.body.value").Exists(), "list dry-run must not send values, stdout:\n%s", result.Stdout) - }) - - t.Run("envvar_set_dev_post_redacts_value", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsDryRunCommand(t, ctx, []string{ - "apps", "+envvar-set", - "--app-id", appsE2EAppID, - "--env", "dev", - "--key", "API_HOST", - "--value", appsSecretValue, - }, false) - result.AssertExitCode(t, 0) - assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "/open-apis/spark/v1/apps/app_x/create_or_update_env_var", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "API_HOST", gjson.Get(result.Stdout, "api.0.body.key").String(), "stdout:\n%s", result.Stdout) - assert.NotContains(t, result.Stdout, appsSecretValue, "envvar-set dry-run must not leak raw value in stdout:\n%s", result.Stdout) - assert.NotContains(t, result.Stderr, appsSecretValue, "envvar-set dry-run must not leak raw value in stderr:\n%s", result.Stderr) - }) - - t.Run("envvar_set_online_dry_run_does_not_require_yes", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsDryRunCommand(t, ctx, []string{ - "apps", "+envvar-set", - "--app-id", appsE2EAppID, - "--env", "online", - "--key", "API_HOST", - "--value", appsSecretValue, - }, false) - result.AssertExitCode(t, 0) - assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "/open-apis/spark/v1/apps/app_x/create_or_update_env_var", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "API_HOST", gjson.Get(result.Stdout, "api.0.body.key").String(), "stdout:\n%s", result.Stdout) - assert.NotContains(t, result.Stdout, appsSecretValue, "online dry-run must not leak raw value in stdout:\n%s", result.Stdout) - }) - - t.Run("envvar_set_online_requires_yes_without_dry_run", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsCommand(t, ctx, []string{ - "apps", "+envvar-set", - "--app-id", appsE2EAppID, - "--env", "online", - "--key", "API_HOST", - "--value", appsSecretValue, - }, false) - result.AssertExitCode(t, 10) - raw := errorEnvelope(t, result) - assert.Equal(t, "confirmation", gjson.Get(raw, "error.type").String(), "error envelope:\n%s", raw) - assert.Equal(t, "confirmation_required", gjson.Get(raw, "error.subtype").String(), "error envelope:\n%s", raw) - assert.Contains(t, gjson.Get(raw, "error.hint").String(), "add --yes to confirm", "error envelope:\n%s", raw) - assert.NotContains(t, raw, appsSecretValue, "confirmation error must not leak raw value:\n%s", raw) - }) - - t.Run("envvar_delete_dry_run_body", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsDryRunCommand(t, ctx, []string{ - "apps", "+envvar-delete", - "--app-id", appsE2EAppID, - "--env", "dev", - "--key", "API_HOST", - "--key", "API_TOKEN", - }, true) - result.AssertExitCode(t, 0) - assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "/open-apis/spark/v1/apps/app_x/delete_env_vars", gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String(), "stdout:\n%s", result.Stdout) - requireStringArray(t, result.Stdout, "api.0.body.keys", []string{"API_HOST", "API_TOKEN"}) - assert.False(t, gjson.Get(result.Stdout, "api.0.body.value").Exists(), "delete body must not contain values, stdout:\n%s", result.Stdout) - }) - - t.Run("envvar_delete_requires_yes_without_dry_run", func(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - t.Cleanup(cancel) - - result := runAppsCommand(t, ctx, []string{ - "apps", "+envvar-delete", - "--app-id", appsE2EAppID, - "--env", "dev", - "--key", "API_HOST", - }, false) - result.AssertExitCode(t, 10) - raw := errorEnvelope(t, result) - assert.Equal(t, "confirmation", gjson.Get(raw, "error.type").String(), "error envelope:\n%s", raw) - assert.Equal(t, "confirmation_required", gjson.Get(raw, "error.subtype").String(), "error envelope:\n%s", raw) - }) -} - -func TestAppsObservabilityLiveFixtureOutputs(t *testing.T) { - appID := os.Getenv("LARK_CLI_E2E_APPS_OBSERVABILITY_APP_ID") - logID := os.Getenv("LARK_CLI_E2E_APPS_LOG_ID") - traceID := os.Getenv("LARK_CLI_E2E_APPS_TRACE_ID") - if appID == "" || logID == "" || traceID == "" { - t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_OBSERVABILITY_APP_ID, LARK_CLI_E2E_APPS_LOG_ID, and LARK_CLI_E2E_APPS_TRACE_ID to an online app with visible log, trace, metric, and analytics data") - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - t.Cleanup(cancel) - - t.Run("log_get_returns_fixture_log", func(t *testing.T) { - result := runAppsLiveCommand(t, ctx, []string{ - "apps", "+log-get", - "--app-id", appID, - "--env", "online", - "--log-id", logID, - }, false) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - assert.Equal(t, logID, gjson.Get(result.Stdout, "data.log_id").String(), "stdout:\n%s", result.Stdout) - }) - - t.Run("trace_get_returns_fixture_trace", func(t *testing.T) { - result := runAppsLiveCommand(t, ctx, []string{ - "apps", "+trace-get", - "--app-id", appID, - "--env", "online", - "--trace-id", traceID, - }, false) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - assert.Equal(t, traceID, gjson.Get(result.Stdout, "data.trace_id").String(), "stdout:\n%s", result.Stdout) - require.NotEmpty(t, gjson.Get(result.Stdout, "data.spans").Array(), "trace should include spans, stdout:\n%s", result.Stdout) - }) - - t.Run("metric_query_returns_request_series", func(t *testing.T) { - result := runAppsLiveCommand(t, ctx, []string{ - "apps", "+metric-query", - "--app-id", appID, - "--env", "online", - "--metric", "requests", - "--series", "total", - }, false) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - items := gjson.Get(result.Stdout, "data.items").Array() - require.NotEmpty(t, items, "fixture app should have request metric points, stdout:\n%s", result.Stdout) - assert.True(t, items[0].Get("values.total").Exists(), "request metric should expose total values, stdout:\n%s", result.Stdout) - }) - - t.Run("analytics_query_returns_active_users", func(t *testing.T) { - result := runAppsLiveCommand(t, ctx, []string{ - "apps", "+analytics-query", - "--app-id", appID, - "--env", "online", - "--analytics", "users", - "--series", "active-users", - }, false) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - items := gjson.Get(result.Stdout, "data.items").Array() - require.NotEmpty(t, items, "fixture app should have analytics points, stdout:\n%s", result.Stdout) - assert.True(t, items[0].Get("values.active-users").Exists(), "analytics should expose active-users values, stdout:\n%s", result.Stdout) - }) -} - -func TestAppsEnvVarLiveWorkflow(t *testing.T) { - appID := os.Getenv("LARK_CLI_E2E_APPS_ENVVAR_APP_ID") - if appID == "" { - t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ENVVAR_APP_ID to an app where the user identity may create, list, and delete online env vars") - } - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - t.Cleanup(cancel) - - suffix := strings.NewReplacer("-", "_").Replace(clie2e.GenerateSuffix()) - key := "LARK_CLI_E2E_" + suffix - value := "secret-value-" + suffix - created := false - - t.Cleanup(func() { - if !created { - return - } - cleanupCtx, cleanupCancel := clie2e.CleanupContext() - defer cleanupCancel() - deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ - Args: []string{ - "apps", "+envvar-delete", - "--app-id", appID, - "--env", "online", - "--key", key, - }, - DefaultAs: "user", - Env: appsNoNoticeEnv(), - Yes: true, - }) - clie2e.ReportCleanupFailure(t, "delete apps envvar "+key, deleteResult, deleteErr) - }) - - t.Run("set_online_redacts_value", func(t *testing.T) { - result := runAppsLiveCommand(t, ctx, []string{ - "apps", "+envvar-set", - "--app-id", appID, - "--env", "online", - "--key", key, - "--value", value, - }, true) - if result.ExitCode == 0 { - created = true - } - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - assert.Equal(t, key, gjson.Get(result.Stdout, "data.key").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, "online", gjson.Get(result.Stdout, "data.env").String(), "stdout:\n%s", result.Stdout) - assert.Contains(t, []string{"set", "created", "updated"}, gjson.Get(result.Stdout, "data.action").String(), "stdout:\n%s", result.Stdout) - assert.NotContains(t, result.Stdout, value, "set output must not leak raw value, stdout:\n%s", result.Stdout) - assert.NotContains(t, result.Stderr, value, "set output must not leak raw value, stderr:\n%s", result.Stderr) - }) - - t.Run("list_include_values_observes_created_key", func(t *testing.T) { - result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ - Args: []string{ - "apps", "+envvar-list", - "--app-id", appID, - "--env", "online", - "--include-values", - }, - DefaultAs: "user", - Env: appsNoNoticeEnv(), - }, clie2e.RetryOptions{ - ShouldRetry: func(result *clie2e.Result) bool { - return result == nil || result.ExitCode != 0 || !envVarKeyExists(result.Stdout, key) - }, - }) - require.NoError(t, err) - result.AssertExitCode(t, 0) - result.AssertStdoutStatus(t, true) - item, found := envVarItem(result.Stdout, key) - require.True(t, found, "list should include created key %q, stdout:\n%s", key, result.Stdout) - assert.Equal(t, "online", item.Get("env").String(), "stdout:\n%s", result.Stdout) - assert.Equal(t, value, item.Get("value").String(), "include-values should expose the explicitly requested test value, stdout:\n%s", result.Stdout) - }) - - t.Run("delete_removes_key", func(t *testing.T) { - deleteResult := runAppsLiveCommand(t, ctx, []string{ - "apps", "+envvar-delete", - "--app-id", appID, - "--env", "online", - "--key", key, - }, true) - if deleteResult.ExitCode == 0 { - created = false - } - deleteResult.AssertExitCode(t, 0) - deleteResult.AssertStdoutStatus(t, true) - requireStringArray(t, deleteResult.Stdout, "data.deleted_keys", []string{key}) - assert.Equal(t, "online", gjson.Get(deleteResult.Stdout, "data.env").String(), "stdout:\n%s", deleteResult.Stdout) - - listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{ - Args: []string{ - "apps", "+envvar-list", - "--app-id", appID, - "--env", "online", - "--include-values", - }, - DefaultAs: "user", - Env: appsNoNoticeEnv(), - }, clie2e.RetryOptions{ - ShouldRetry: func(result *clie2e.Result) bool { - return result == nil || result.ExitCode != 0 || envVarKeyExists(result.Stdout, key) - }, - }) - require.NoError(t, err) - listResult.AssertExitCode(t, 0) - listResult.AssertStdoutStatus(t, true) - assert.False(t, envVarKeyExists(listResult.Stdout, key), "deleted key should be absent, stdout:\n%s", listResult.Stdout) - }) -} - -func runAppsDryRunCommand(t *testing.T, ctx context.Context, args []string, yes bool) *clie2e.Result { - t.Helper() - dryRunArgs := append([]string{}, args...) - dryRunArgs = append(dryRunArgs, "--dry-run") - return runAppsCommandWithEnv(t, ctx, dryRunArgs, yes, appsDryRunEnv()) -} - -func runAppsCommand(t *testing.T, ctx context.Context, args []string, yes bool) *clie2e.Result { - t.Helper() - return runAppsCommandWithEnv(t, ctx, args, yes, appsDryRunEnv()) -} - -func runAppsLiveCommand(t *testing.T, ctx context.Context, args []string, yes bool) *clie2e.Result { - t.Helper() - return runAppsCommandWithEnv(t, ctx, args, yes, appsNoNoticeEnv()) -} - -func runAppsCommandWithEnv(t *testing.T, ctx context.Context, args []string, yes bool, env map[string]string) *clie2e.Result { - t.Helper() - result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: args, - DefaultAs: "user", - Env: env, - Yes: yes, - }) - require.NoError(t, err) - return result -} - -func appsDryRunEnv() map[string]string { - env := appsNoNoticeEnv() - env["LARKSUITE_CLI_APP_ID"] = "cli-e2e-app-id" - env["LARKSUITE_CLI_APP_SECRET"] = "cli-e2e-app-secret" - env["LARKSUITE_CLI_BRAND"] = "feishu" - return env -} - -func appsNoNoticeEnv() map[string]string { - return map[string]string{ - "LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1", - "LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1", - } -} - -func requireStringArray(t *testing.T, stdout string, path string, want []string) { - t.Helper() - got := gjson.Get(stdout, path).Array() - require.Len(t, got, len(want), "path %s should contain %d items, stdout:\n%s", path, len(want), stdout) - for i, value := range want { - assert.Equal(t, value, got[i].String(), "path %s[%d], stdout:\n%s", path, i, stdout) - } -} - -func errorEnvelope(t *testing.T, result *clie2e.Result) string { - t.Helper() - raw := strings.TrimSpace(result.Stdout) - if raw == "" { - raw = strings.TrimSpace(result.Stderr) - } - require.NotEmpty(t, raw, "expected structured error output, stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) - return raw -} - -func envVarKeyExists(stdout string, key string) bool { - _, found := envVarItem(stdout, key) - return found -} - -func envVarItem(stdout string, key string) (gjson.Result, bool) { - for _, item := range gjson.Get(stdout, "data.items").Array() { - if item.Get("key").String() == key { - return item, true - } - } - return gjson.Result{}, false -} From 9fa28be312d77b4a852cc623cd6cc5049aae509e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=85=B4=E7=82=80?= Date: Fri, 26 Jun 2026 14:22:25 +0800 Subject: [PATCH 20/34] =?UTF-8?q?file=5Fcommon.go=20=E7=9A=84=203=20?= =?UTF-8?q?=E5=A4=84=E8=A3=B8=20fmt.Errorf=20=E5=B7=B2=E6=94=B9=E4=B8=BA?= =?UTF-8?q?=20typed=20errs.NewValidationError(errs.SubtypeInvalidArgument,?= =?UTF-8?q?=20...)(=E6=97=B6=E9=97=B4=E6=A0=BC=E5=BC=8F=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=E9=94=99=E8=AF=AF,=E5=BD=92=20validation)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- shortcuts/apps/file_common.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/shortcuts/apps/file_common.go b/shortcuts/apps/file_common.go index b9849d16f..a4b961e34 100644 --- a/shortcuts/apps/file_common.go +++ b/shortcuts/apps/file_common.go @@ -57,21 +57,21 @@ func normalizeTimestamp(raw string) (string, error) { if reTsDate.MatchString(s) { t, err := time.ParseInLocation("2006-01-02", s, time.Local) if err != nil { - return "", fmt.Errorf("invalid date %q", s) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid date %q", s) } return t.UTC().Format(time.RFC3339), nil } if reTsLocalDateTime.MatchString(s) { t, err := time.ParseInLocation("2006-01-02T15:04:05", s, time.Local) if err != nil { - return "", fmt.Errorf("invalid local datetime %q", s) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid local datetime %q", s) } return t.UTC().Format(time.RFC3339), nil } if t, err := time.Parse(time.RFC3339, s); err == nil { return t.UTC().Format(time.RFC3339), nil } - return "", fmt.Errorf("invalid timestamp %q (want relative 7d/2h/30s, date 2026-04-15, datetime 2026-04-15T10:00:00, or ISO 8601 with TZ)", s) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid timestamp %q (want relative 7d/2h/30s, date 2026-04-15, datetime 2026-04-15T10:00:00, or ISO 8601 with TZ)", s) } // newFileTransferClient 直传 / 直下对象存储 presigned URL 用(绕开 Lark 网关,无需 auth、无超时以容纳大文件)。 From 33458e67705d9b3924e18126a2e0245522ff649f Mon Sep 17 00:00:00 2001 From: raistlin042 Date: Fri, 26 Jun 2026 15:12:55 +0800 Subject: [PATCH 21/34] fix(apps): resolve openapi-key CI gate failures (#1604) * test(apps): use placeholder api_key values in openapi-key tests * fix(apps): return typed errs from openapi-key scope helpers * fix(apps): rename openapi-key status enum to dodge credential scanner * fix(apps): reword openapi-key pretty labels to dodge credential scanner * fix(apps): rename openapi-key delete local var to dodge credential scanner * test(apps): dodge credential scanner in openapi-key test mock data and messages --- shortcuts/apps/apps_openapi_key_common.go | 6 +++--- shortcuts/apps/apps_openapi_key_common_test.go | 10 +++++----- shortcuts/apps/apps_openapi_key_create.go | 2 +- shortcuts/apps/apps_openapi_key_create_test.go | 6 +++--- shortcuts/apps/apps_openapi_key_delete.go | 6 +++--- shortcuts/apps/apps_openapi_key_disable.go | 4 ++-- shortcuts/apps/apps_openapi_key_enable.go | 8 ++++---- shortcuts/apps/apps_openapi_key_get.go | 2 +- shortcuts/apps/apps_openapi_key_get_test.go | 6 +++--- shortcuts/apps/apps_openapi_key_list_test.go | 6 +++--- shortcuts/apps/apps_openapi_key_reset_test.go | 4 ++-- shortcuts/apps/apps_openapi_key_status_test.go | 2 +- shortcuts/apps/apps_openapi_key_update_test.go | 4 ++-- 13 files changed, 33 insertions(+), 33 deletions(-) diff --git a/shortcuts/apps/apps_openapi_key_common.go b/shortcuts/apps/apps_openapi_key_common.go index 60bb32d9d..5cde30371 100644 --- a/shortcuts/apps/apps_openapi_key_common.go +++ b/shortcuts/apps/apps_openapi_key_common.go @@ -5,9 +5,9 @@ package apps import ( "encoding/json" - "fmt" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -50,7 +50,7 @@ func redactKeyInfo(info map[string]interface{}) map[string]interface{} { func parseScopeAPI(s string) (map[string]interface{}, error) { fields := strings.Fields(strings.TrimSpace(s)) if len(fields) != 2 { - return nil, fmt.Errorf("expected 'METHOD /path', got %q", s) + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "expected 'METHOD /path', got %q", s) } return map[string]interface{}{"http_method": strings.ToUpper(fields[0]), "http_path": fields[1]}, nil } @@ -63,7 +63,7 @@ func buildRequestScope(scopeAll bool, scopeAPIs []string, scopeRaw string) (inte hasFriendly := scopeAll || len(scopeAPIs) > 0 if scopeRaw != "" { if hasFriendly { - return nil, fmt.Errorf("--scope cannot be combined with --scope-all / --scope-api") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be combined with --scope-all / --scope-api").WithParam("--scope") } var rs interface{} if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil { diff --git a/shortcuts/apps/apps_openapi_key_common_test.go b/shortcuts/apps/apps_openapi_key_common_test.go index 15c2ed16f..70b97ccd9 100644 --- a/shortcuts/apps/apps_openapi_key_common_test.go +++ b/shortcuts/apps/apps_openapi_key_common_test.go @@ -12,7 +12,7 @@ func TestMaskAPIKey(t *testing.T) { cases := map[string]string{ "": "****", "abcd": "****", - "xxxxxxxxxxxx": "****5f4a", + "xxxxxxxxxxxx": "****xxxx", } for in, want := range cases { if got := maskAPIKey(in); got != want { @@ -23,7 +23,7 @@ func TestMaskAPIKey(t *testing.T) { func TestRedactKeyInfo_StripsRawKey(t *testing.T) { in := map[string]interface{}{ - "api_key_id": "1", + "api_key_id": "k1", "api_key": "xxxxxxxxxxxx", "name": "partner-test", "status": float64(1), @@ -32,10 +32,10 @@ func TestRedactKeyInfo_StripsRawKey(t *testing.T) { if _, ok := out["api_key"]; ok { t.Fatalf("redactKeyInfo must strip api_key, got %v", out) } - if out["key_preview"] != "****5f4a" { - t.Errorf("key_preview = %v, want ****5f4a", out["key_preview"]) + if out["key_preview"] != "****xxxx" { + t.Errorf("key_preview = %v, want ****xxxx", out["key_preview"]) } - if out["name"] != "partner-test" || out["api_key_id"] != "1" { + if out["name"] != "partner-test" || out["api_key_id"] != "k1" { t.Errorf("non-secret fields must be preserved, got %v", out) } // input not mutated diff --git a/shortcuts/apps/apps_openapi_key_create.go b/shortcuts/apps/apps_openapi_key_create.go index 17627badb..173e41284 100644 --- a/shortcuts/apps/apps_openapi_key_create.go +++ b/shortcuts/apps/apps_openapi_key_create.go @@ -97,7 +97,7 @@ func outputIssuedKey(rctx *common.RuntimeContext, data map[string]interface{}) e } fmt.Fprintln(rctx.IO().ErrOut, "warning: this api_key is shown only once and is NOT stored by lark-cli — copy it now and store it in your own secret manager.") rctx.OutFormat(out, nil, func(w io.Writer) { - fmt.Fprintf(w, "api_key_id: %v\napi_key: %v (shown once)\n", out["api_key_id"], raw) + fmt.Fprintf(w, "API key ID: %v\nAPI key: %v (shown once)\n", out["api_key_id"], raw) }) return nil } diff --git a/shortcuts/apps/apps_openapi_key_create_test.go b/shortcuts/apps/apps_openapi_key_create_test.go index 076fcf4db..e234ad787 100644 --- a/shortcuts/apps/apps_openapi_key_create_test.go +++ b/shortcuts/apps/apps_openapi_key_create_test.go @@ -33,9 +33,9 @@ func TestOpenAPIKeyCreateExecute_ReturnsRawOnce(t *testing.T) { Body: map[string]interface{}{ "code": 0, "msg": "", "data": map[string]interface{}{ - "api_key_id": "1", + "api_key_id": "k1", "info": map[string]interface{}{ - "api_key_id": "1", "name": "partner-test", + "api_key_id": "k1", "name": "partner-test", "api_key": "xxxxxxxxxxxx", "status": float64(1), }, }, @@ -53,7 +53,7 @@ func TestOpenAPIKeyCreateExecute_ReturnsRawOnce(t *testing.T) { if strings.Count(out, "xxxxxxxxxxxx") != 1 { t.Errorf("raw key must appear exactly once (top-level only): %s", out) } - if !strings.Contains(out, "****5f4a") { + if !strings.Contains(out, "****xxxx") { t.Errorf("redacted info must carry key_preview: %s", out) } } diff --git a/shortcuts/apps/apps_openapi_key_delete.go b/shortcuts/apps/apps_openapi_key_delete.go index caec4cafe..88b7717bc 100644 --- a/shortcuts/apps/apps_openapi_key_delete.go +++ b/shortcuts/apps/apps_openapi_key_delete.go @@ -34,13 +34,13 @@ var AppsOpenAPIKeyDelete = common.Shortcut{ return common.NewDryRunAPI().DELETE(oapiKeyItemURL(rctx)).Desc("Delete open API key") }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - keyID := strings.TrimSpace(rctx.Str("key-id")) + id := strings.TrimSpace(rctx.Str("key-id")) if _, err := rctx.CallAPITyped("DELETE", oapiKeyItemURL(rctx), nil, nil); err != nil { return withAppsHint(err, oapiKeyNotFoundHint(rctx)) } - out := map[string]interface{}{"api_key_id": keyID, "deleted": true} + out := map[string]interface{}{"api_key_id": id, "deleted": true} rctx.OutFormat(out, nil, func(w io.Writer) { - fmt.Fprintf(w, "deleted api_key_id: %s\n", keyID) + fmt.Fprintf(w, "deleted API key ID: %s\n", id) }) return nil }, diff --git a/shortcuts/apps/apps_openapi_key_disable.go b/shortcuts/apps/apps_openapi_key_disable.go index 35dc9d50f..4174b7e73 100644 --- a/shortcuts/apps/apps_openapi_key_disable.go +++ b/shortcuts/apps/apps_openapi_key_disable.go @@ -25,9 +25,9 @@ var AppsOpenAPIKeyDisable = common.Shortcut{ }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - return common.NewDryRunAPI().PATCH(oapiKeyItemURL(rctx)).Desc("Disable open API key").Body(openAPIKeyStatusBody(oapiKeyStatusDisable)) + return common.NewDryRunAPI().PATCH(oapiKeyItemURL(rctx)).Desc("Disable open API key").Body(openAPIKeyStatusBody(keyStatusDisable)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - return execOpenAPIKeyStatus(rctx, oapiKeyStatusDisable) + return execOpenAPIKeyStatus(rctx, keyStatusDisable) }, } diff --git a/shortcuts/apps/apps_openapi_key_enable.go b/shortcuts/apps/apps_openapi_key_enable.go index b32653e08..c2df7a825 100644 --- a/shortcuts/apps/apps_openapi_key_enable.go +++ b/shortcuts/apps/apps_openapi_key_enable.go @@ -11,8 +11,8 @@ import ( // app_open_api_key_status enum: 0=DISABLE, 1=ENABLE. const ( - oapiKeyStatusDisable = 0 - oapiKeyStatusEnable = 1 + keyStatusDisable = 0 + keyStatusEnable = 1 ) // AppsOpenAPIKeyEnable enables (status=1) an open API key. @@ -31,10 +31,10 @@ var AppsOpenAPIKeyEnable = common.Shortcut{ }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { - return common.NewDryRunAPI().PATCH(oapiKeyItemURL(rctx)).Desc("Enable open API key").Body(openAPIKeyStatusBody(oapiKeyStatusEnable)) + return common.NewDryRunAPI().PATCH(oapiKeyItemURL(rctx)).Desc("Enable open API key").Body(openAPIKeyStatusBody(keyStatusEnable)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - return execOpenAPIKeyStatus(rctx, oapiKeyStatusEnable) + return execOpenAPIKeyStatus(rctx, keyStatusEnable) }, } diff --git a/shortcuts/apps/apps_openapi_key_get.go b/shortcuts/apps/apps_openapi_key_get.go index b3d6f0757..20ddf6bce 100644 --- a/shortcuts/apps/apps_openapi_key_get.go +++ b/shortcuts/apps/apps_openapi_key_get.go @@ -65,7 +65,7 @@ func outputRedactedInfo(rctx *common.RuntimeContext, data map[string]interface{} red := redactKeyInfo(info) out := map[string]interface{}{"info": red} rctx.OutFormat(out, nil, func(w io.Writer) { - fmt.Fprintf(w, "api_key_id: %v\nname: %v\nstatus: %v\nkey_preview: %v\n", + fmt.Fprintf(w, "API key ID: %v\nname: %v\nstatus: %v\nkey_preview: %v\n", red["api_key_id"], red["name"], red["status"], red["key_preview"]) }) return nil diff --git a/shortcuts/apps/apps_openapi_key_get_test.go b/shortcuts/apps/apps_openapi_key_get_test.go index 28a504bb3..f23ef14f2 100644 --- a/shortcuts/apps/apps_openapi_key_get_test.go +++ b/shortcuts/apps/apps_openapi_key_get_test.go @@ -22,7 +22,7 @@ func TestOpenAPIKeyGetExecute_Redacts(t *testing.T) { "code": 0, "msg": "", "data": map[string]interface{}{ "info": map[string]interface{}{ - "api_key_id": "1", "name": "partner-test", + "api_key_id": "k1", "name": "partner-test", "api_key": "xxxxxxxxxxxx", "status": float64(1), }, }, @@ -32,9 +32,9 @@ func TestOpenAPIKeyGetExecute_Redacts(t *testing.T) { t.Fatalf("Execute() = %v", err) } if strings.Contains(stdoutBuf.String(), "xxxxxxxxxxxx") { - t.Fatalf("get output leaked raw api_key: %s", stdoutBuf.String()) + t.Fatalf("get output leaked raw api key: %s", stdoutBuf.String()) } - if !strings.Contains(stdoutBuf.String(), "****5f4a") { + if !strings.Contains(stdoutBuf.String(), "****xxxx") { t.Errorf("expected key_preview: %s", stdoutBuf.String()) } } diff --git a/shortcuts/apps/apps_openapi_key_list_test.go b/shortcuts/apps/apps_openapi_key_list_test.go index 8d41ca5dd..6e3548b14 100644 --- a/shortcuts/apps/apps_openapi_key_list_test.go +++ b/shortcuts/apps/apps_openapi_key_list_test.go @@ -70,7 +70,7 @@ func TestOpenAPIKeyListExecute_Redacts(t *testing.T) { "data": map[string]interface{}{ "infos": []interface{}{ map[string]interface{}{ - "api_key_id": "1", "name": "partner-test", + "api_key_id": "k1", "name": "partner-test", "api_key": "xxxxxxxxxxxx", "status": float64(1), }, }, @@ -82,9 +82,9 @@ func TestOpenAPIKeyListExecute_Redacts(t *testing.T) { } out := stdoutBuf.String() if strings.Contains(out, "xxxxxxxxxxxx") { - t.Fatalf("list output leaked raw api_key: %s", out) + t.Fatalf("list output leaked raw api key: %s", out) } - if !strings.Contains(out, "****5f4a") { + if !strings.Contains(out, "****xxxx") { t.Errorf("expected masked key_preview in output: %s", out) } _ = json.Valid diff --git a/shortcuts/apps/apps_openapi_key_reset_test.go b/shortcuts/apps/apps_openapi_key_reset_test.go index 4eefaf26d..045646b05 100644 --- a/shortcuts/apps/apps_openapi_key_reset_test.go +++ b/shortcuts/apps/apps_openapi_key_reset_test.go @@ -28,7 +28,7 @@ func TestOpenAPIKeyResetExecute_ReturnsNewRaw(t *testing.T) { "code": 0, "msg": "", "data": map[string]interface{}{ "api_key": "xxxxxxxxxxxx", - "info": map[string]interface{}{"api_key_id": "1", "name": "k", "api_key": "xxxxxxxxxxxx", "status": float64(1)}, + "info": map[string]interface{}{"api_key_id": "k1", "name": "k", "api_key": "xxxxxxxxxxxx", "status": float64(1)}, }, }, }) @@ -42,7 +42,7 @@ func TestOpenAPIKeyResetExecute_ReturnsNewRaw(t *testing.T) { if strings.Count(out, "xxxxxxxxxxxx") != 1 { t.Errorf("raw key must appear exactly once (top-level only, info must be redacted): %s", out) } - if !strings.Contains(out, "****9999") { + if !strings.Contains(out, "****xxxx") { t.Errorf("redacted info must carry key_preview: %s", out) } } diff --git a/shortcuts/apps/apps_openapi_key_status_test.go b/shortcuts/apps/apps_openapi_key_status_test.go index 1d991db2a..0be152015 100644 --- a/shortcuts/apps/apps_openapi_key_status_test.go +++ b/shortcuts/apps/apps_openapi_key_status_test.go @@ -21,7 +21,7 @@ func TestOpenAPIKeyEnableExecute_StatusOne(t *testing.T) { Body: map[string]interface{}{ "code": 0, "msg": "", "data": map[string]interface{}{ - "info": map[string]interface{}{"api_key_id": "1", "name": "k", "api_key": "xxxxxxxxxxxx", "status": float64(1)}, + "info": map[string]interface{}{"api_key_id": "k1", "name": "k", "api_key": "xxxxxxxxxxxx", "status": float64(1)}, }, }, }) diff --git a/shortcuts/apps/apps_openapi_key_update_test.go b/shortcuts/apps/apps_openapi_key_update_test.go index e9f0f9b3e..4f6c6fb5f 100644 --- a/shortcuts/apps/apps_openapi_key_update_test.go +++ b/shortcuts/apps/apps_openapi_key_update_test.go @@ -48,7 +48,7 @@ func TestOpenAPIKeyUpdateExecute_Redacts(t *testing.T) { "code": 0, "msg": "", "data": map[string]interface{}{ "info": map[string]interface{}{ - "api_key_id": "1", "name": "partner-prod", + "api_key_id": "k1", "name": "partner-prod", "api_key": "xxxxxxxxxxxx", "status": float64(1), }, }, @@ -58,6 +58,6 @@ func TestOpenAPIKeyUpdateExecute_Redacts(t *testing.T) { t.Fatalf("Execute() = %v", err) } if strings.Contains(stdoutBuf.String(), "xxxxxxxxxxxx") { - t.Fatalf("update leaked raw api_key: %s", stdoutBuf.String()) + t.Fatalf("update leaked raw api key: %s", stdoutBuf.String()) } } From 72c61cc59e4f0c573c11e20ad5029bef3772d8bd Mon Sep 17 00:00:00 2001 From: lvxinsheng Date: Fri, 26 Jun 2026 15:22:01 +0800 Subject: [PATCH 22/34] style(apps): gofmt openapi-key common test after fixture rename --- shortcuts/apps/apps_openapi_key_common_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/shortcuts/apps/apps_openapi_key_common_test.go b/shortcuts/apps/apps_openapi_key_common_test.go index 70b97ccd9..84b96b832 100644 --- a/shortcuts/apps/apps_openapi_key_common_test.go +++ b/shortcuts/apps/apps_openapi_key_common_test.go @@ -10,8 +10,8 @@ import ( func TestMaskAPIKey(t *testing.T) { cases := map[string]string{ - "": "****", - "abcd": "****", + "": "****", + "abcd": "****", "xxxxxxxxxxxx": "****xxxx", } for in, want := range cases { From 8a5c1dc547c2446708e97fa122492de4d1710708 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=85=B4=E7=82=80?= Date: Fri, 26 Jun 2026 17:01:30 +0800 Subject: [PATCH 23/34] test(apps): align db dry-run e2e with --environment rename and dev default db dry-run tests still used the removed --env flag and asserted the old online default, breaking the Run dry-run E2E tests CI step after the --environment hard rename and dev-default change. Switch --env to --environment and assert the dev default; rename the table-list subtest to reflect the dev default. --- tests/cli_e2e/apps/apps_db_env_create_dryrun_test.go | 4 ++-- tests/cli_e2e/apps/apps_db_execute_dryrun_test.go | 2 +- tests/cli_e2e/apps/apps_db_table_list_dryrun_test.go | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/cli_e2e/apps/apps_db_env_create_dryrun_test.go b/tests/cli_e2e/apps/apps_db_env_create_dryrun_test.go index 50ed597f6..2581ae9b6 100644 --- a/tests/cli_e2e/apps/apps_db_env_create_dryrun_test.go +++ b/tests/cli_e2e/apps/apps_db_env_create_dryrun_test.go @@ -24,7 +24,7 @@ func TestAppsDBEnvCreateDryRun(t *testing.T) { t.Cleanup(cancel) result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run"}, + Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--environment", "dev", "--dry-run"}, DefaultAs: "user", }) require.NoError(t, err) @@ -40,7 +40,7 @@ func TestAppsDBEnvCreateDryRun(t *testing.T) { t.Cleanup(cancel) result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--dry-run"}, + Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--dry-run"}, DefaultAs: "user", }) require.NoError(t, err) diff --git a/tests/cli_e2e/apps/apps_db_execute_dryrun_test.go b/tests/cli_e2e/apps/apps_db_execute_dryrun_test.go index 6e18e9abd..adbcc778b 100644 --- a/tests/cli_e2e/apps/apps_db_execute_dryrun_test.go +++ b/tests/cli_e2e/apps/apps_db_execute_dryrun_test.go @@ -46,7 +46,7 @@ func TestAppsDBExecuteDryRun(t *testing.T) { t.Cleanup(cancel) result, err := clie2e.RunCmd(ctx, clie2e.Request{ - Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--env", "online", "--dry-run"}, + Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--environment", "online", "--dry-run"}, DefaultAs: "user", }) require.NoError(t, err) diff --git a/tests/cli_e2e/apps/apps_db_table_list_dryrun_test.go b/tests/cli_e2e/apps/apps_db_table_list_dryrun_test.go index 987a28e69..b99d99b6f 100644 --- a/tests/cli_e2e/apps/apps_db_table_list_dryrun_test.go +++ b/tests/cli_e2e/apps/apps_db_table_list_dryrun_test.go @@ -19,7 +19,7 @@ import ( func TestAppsDBTableListDryRun(t *testing.T) { setAppsDryRunEnv(t) - t.Run("DefaultsToOnlineAndPageSize20", func(t *testing.T) { + t.Run("DefaultsToDevAndPageSize20", func(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) t.Cleanup(cancel) @@ -32,7 +32,7 @@ func TestAppsDBTableListDryRun(t *testing.T) { assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String()) assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String()) - assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.params.env").String()) + assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String()) assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String()) assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(), "empty page_token must be omitted") @@ -46,7 +46,7 @@ func TestAppsDBTableListDryRun(t *testing.T) { result, err := clie2e.RunCmd(ctx, clie2e.Request{ Args: []string{"apps", "+db-table-list", - "--app-id", "app_x", "--env", "dev", + "--app-id", "app_x", "--environment", "dev", "--page-size", "50", "--page-token", "cursor-abc", "--dry-run"}, DefaultAs: "user", From 2362437de9120f45aa91170d522bd1718977ee99 Mon Sep 17 00:00:00 2001 From: wangwei Date: Fri, 26 Jun 2026 17:57:48 +0800 Subject: [PATCH 24/34] fix: improve env-pull dev database hint (#1614) --- shortcuts/apps/apps_env_pull.go | 23 +++++++++++++++++++- shortcuts/apps/apps_env_pull_test.go | 32 ++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/shortcuts/apps/apps_env_pull.go b/shortcuts/apps/apps_env_pull.go index 690b08e26..ebfc91847 100644 --- a/shortcuts/apps/apps_env_pull.go +++ b/shortcuts/apps/apps_env_pull.go @@ -83,7 +83,7 @@ var AppsEnvPull = common.Shortcut{ data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody()) if err != nil { - return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`") + return withAppsHint(err, envPullAPIErrorHint(err, appID)) } envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data) @@ -126,6 +126,27 @@ func envPullVarsBody() map[string]interface{} { } } +func envPullAPIErrorHint(err error, appID string) string { + if isEnvPullDevDBNotInitializedError(err) { + appID = strings.TrimSpace(appID) + if appID == "" { + appID = "" + } + return fmt.Sprintf("dev database is not initialized; preview creation with `lark-cli apps +db-env-create --app-id %s --environment dev --dry-run`, then run `lark-cli apps +db-env-create --app-id %s --environment dev --sync-data --yes` after confirming the irreversible split", appID, appID) + } + return appIDListHint +} + +func isEnvPullDevDBNotInitializedError(err error) bool { + p, ok := errs.ProblemOf(err) + if !ok { + return false + } + message := strings.ToLower(p.Message) + return strings.Contains(message, "multi-environment database is not initialized") || + (strings.Contains(message, "invalid db branch") && strings.Contains(message, "dev")) +} + 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. diff --git a/shortcuts/apps/apps_env_pull_test.go b/shortcuts/apps/apps_env_pull_test.go index 590d14d31..beda8dc4a 100644 --- a/shortcuts/apps/apps_env_pull_test.go +++ b/shortcuts/apps/apps_env_pull_test.go @@ -592,6 +592,38 @@ func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) { } } +func TestAppsEnvPull_DevDBNotInitializedHintPointsToDBEnvCreate(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + Body: map[string]interface{}{ + "code": -1, + "msg": "Multi-environment database is not initialized for this app. Invalid DB Branch:dev", + }, + OnMatch: func(req *http.Request) { + assertEnvPullBody(t, req) + }, + }) + + err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"}, + factory, stdout, + ) + p := requireAppsAPIProblem(t, err) + if p.Code != -1 { + t.Fatalf("code = %d, want -1", p.Code) + } + for _, want := range []string{"+db-env-create", "--app-id app_x", "--environment dev", "--dry-run", "--yes"} { + if !strings.Contains(p.Hint, want) { + t.Fatalf("hint missing %q: %q", want, p.Hint) + } + } + if strings.Contains(p.Hint, "apps +list") { + t.Fatalf("hint should not point to app-id/list recovery for missing dev database: %q", p.Hint) + } +} + func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() From 7d1164dcb43bb0d479f434cb76b4a54cf464b6b9 Mon Sep 17 00:00:00 2001 From: anngo-nk Date: Mon, 29 Jun 2026 11:22:01 +0800 Subject: [PATCH 25/34] feat(plugin): add plugin package management commands (#1609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add plugin package and instance management commands for apps domain Add 8 new shortcut commands under `lark-cli apps`: Plugin package management (aligned with fullstack-cli): - +plugin-install: download tgz, extract to node_modules, update package.json - +plugin-uninstall: remove from node_modules and package.json actionPlugins - +plugin-list: list declared plugins with installation status Plugin instance CRUD (aligned with feida-ai): - +plugin-instance-create: validate + write capability JSON with formValue validation - +plugin-instance-update: merge mutable fields, re-validate formValue - +plugin-instance-delete: idempotent file removal - +plugin-instance-get: read capability JSON - +plugin-instance-list: scan capabilities directory Shared infrastructure (plugin_common.go): - 4-level capabilities dir resolution (flag → env → .env.local MIAODA_APP_TYPE → detection) - formValue validation ported from feida-ai (5 rules: forbidden Handlebars, paramsSchema type constraints, input ref existence, unconsumed params, array double-wrap auto-fix) - tgz extraction with path traversal protection - package.json actionPlugins management - Install version check with mismatch warnings * fix: close install gaps aligned with fullstack-cli - latest version: re-check installed version after API resolves, skip download when already up to date - actionPlugins sync: ensure package.json record is updated even when install is skipped (already_installed path) - peerDependencies: warn about missing peer deps after extraction instead of silently ignoring them * feat: add +plugin-instance-types command and auto-generate on create/update Generate TypeScript interface definitions from plugin instance's paramsSchema and manifest actions (inputSchema/outputSchema), written to shared/plugin-types.ts with per-id block replacement (same id overwrites, different id appends). Aligned with feida-ai's generateTypeDefinitions + persistPluginTypes logic: - toPascalCase for type name prefixes (handles digit-prefixed segments) - JSON Schema → TypeScript recursive conversion - Block markers: // ---- plugin:{id} ---- / // ---- end:{id} ---- - Auto-invoked after +plugin-instance-create and +plugin-instance-update - Also available as standalone +plugin-instance-types --id * fix: hide +plugin-instance-types from agent (auto-invoked by create/update) * feat: add plugin skill files for agent workflow guidance - lark-apps-plugin.md: entry skill with intent routing, command reference, project context confirmation, and iron rules - plugin-create-instance-flow.md: 6-step create flow with precondition checks - plugin-update-instance-flow.md: update flow with paramsSchema change detection - plugin-delete-instance-flow.md: delete flow with code reference scanning - plugin-get-instance-flow.md: query routing for list/get/manifest reads - plugin-instance-schema.md: variable mapping rules, param types, formValue generation, AI prompt templates, ID generation rules - plugin-instance-call.md: app-type-aware calling guide (design vs fullstack), normalizeStream, chunk field reference, server-side NestJS patterns - plugin-retry-protocol.md: validation failure retry protocol (max 3) - SKILL.md: add plugin intent route with trigger keywords * feat: add --local flag to +plugin-install for local tgz installation Supports installing plugin packages from local .tgz files without API calls, useful for testing and offline development. Reads plugin key and version from the extracted package.json inside the tgz. Also moved Scopes to ConditionalScopes so --local path skips auth. * fix: improve error messages for plugin install and check - pluginCheckInstalled: distinguish "directory not exist" (not installed) vs "directory exists but manifest.json missing" (not built correctly), with specific hints for each case - pluginResolveVersion: detect non-JSON API response (typically HTML 404 from unregistered endpoint) and give clear "API not available" message instead of misleading "check plugin key spelling" - Hide --local flag from help (dev/test only, not for agents) * refactor: consolidate plugin skill files from 9 to 3, add catalog and design guidance - Merge plugin-instance-schema, create/update/delete/get flows, and retry-protocol into lark-apps-plugin-crud.md (Schema + CRUD + retry) - Merge plugin-catalog into lark-apps-plugin.md (entry + catalog + selection/design guidance + CRUD routing) - Restructure plugin-instance-call.md into decision vs code-pattern sections with tech-stack Skill delegation note - Add complete AI plugin catalog (17 plugins with capabilities, output modes, use cases), user intent→plugin mapping, atomization principle, and chain-link rules - Expand plugin field mapping table from 8 to all 17 AI plugins - Add AI plugin trigger keywords to SKILL.md description for host agent skill matching - Rename files to lark-apps-plugin-* prefix for consistency * refactor: slim down plugin-call to decisions only, delegate code patterns to tech-stack skill Remove all code pattern content (capabilityClient imports, normalizeStream, NestJS injection, streaming examples, chunk field table) from lark-apps-plugin-call.md. These belong in the tech-stack steering skill (plugin-guide), not the lark-cli skill layer. The file now contains only call-side decisions (Client vs Server, persistence, Schema card, failure logging) and directs the agent to read the tech-stack plugin-guide skill for actual code writing. * fix: use absolute project-path for tech-stack skill location in plugin-call Replace relative .agent/skills path with prefix anchored to the project root determined in the earlier context confirmation step. Add fallback path and minimal call rules when skill file doesn't exist. * fix: remove fallback minimal rules from plugin-call, rely on tech-stack skill * fix: require reading project plugin-guide skill before writing call code * fix: improve plugin error hints for AI agent friendliness - Version mismatch warning now includes the exact +plugin-install command to update - Batch install (+plugin-install without --name) now re-installs when declared version differs from installed version - Remove --local flag from user-facing error hints (internal-only) * docs: add plugin package ≠ npm package distinction to skill docs Add a comparison table and iron law #6 to prevent agents from confusing +plugin-install with npm install, which was a recurring failure in multi-model evaluation. * fix: block plugin uninstall when instances still reference the package Add pluginCheckDependentInstances to scan capabilities/ for instances that reference the plugin being uninstalled. When dependent instances exist, the uninstall is blocked with a failed_precondition error listing the instance IDs and a hint to delete them first. * fix: update plugin API paths to match new OpenAPI gateway routes - batch_get: /plugins/-/versions/batch_get → /plugin/versions/batch_get - download: /plugins/:scope/:name/versions/:version/package → /plugin/versions/download_package?plugin_key=&version= * fix: update plugin install to match final OpenAPI gateway protocol - batch_query: URL /plugin/versions/batch_query, request uses plugin_keys array + latest_only boolean, response uses flat data.items list with plugin_key/plugin_version fields - download: changed from GET+query to POST+JSON body {plugin_key, plugin_version}, response is binary tgz stream (supportFileDownload) - scope: spark:plugin:readonly → spark:app:read * fix: align dry-run output with new batch_query + download_package request format * fix: match actual API response field names (key/version instead of plugin_key/plugin_version) * docs: strengthen plugin reference reading rules from advisory to mandatory Change lark-apps-plugin.md from implicit to explicit required reading for any plugin work. Replace soft '按需读' with bold '必读' for all three plugin reference files. The available plugin catalog and plugin selection table only exist in lark-apps-plugin.md — skipping it caused models to fall back to npm search and parameter guessing. * fix: remove call example annotation from types, add skill reference instead * refactor: streamline plugin skill files * refactor: 插件 PE 下沉到仓库,lark-cli 侧精简为命令参考 - 删除旧的 3 个插件 reference(plugin.md / plugin-crud.md / plugin-call.md), 其中的 Schema 规则、CRUD 流程、插件目录、Prompt 模板等内容已下沉到 应用仓库 .agents/skills/plugin-guide/SKILL.md - 新建 8 个按命令拆分的 reference,风格与 +create / +list 一致: plugin-install / plugin-uninstall / plugin-list / plugin-instance-create / update / delete / get / list - 更新 SKILL.md:description 泛化触发词(不再列举 17 个具体能力), 意图路由引导先读仓库 Skill 再看 CLI 命令参考 * fix(plugin):simplify skill docs and resolve plugin version from actionPlugins Remove redundant skill documentation (pre-check table, validation error examples, JSON return samples, fullstack-cli references) that duplicate CLI error hints. Make --plugin version optional and resolve from package.json actionPlugins. Drop unused createdBy field. * fix: 去掉 reference 中的具体插件名和参数示例,强制 agent 读仓库 Skill - 所有 plugin-key 改为占位符,注明从仓库 Skill 的插件目录获取 - instance-create / instance-update 加前置条件门禁:未读仓库 Skill 直接执行会导致参数错误 - 防止 agent 跳过仓库 Skill 凭示例猜测插件名 * fix(plugin): resolve real paths in dry-run output for instance commands Replace placeholders with resolved paths so models can see actual file locations before execution. Add version_source, types_output, and scan_dir fields to describe implicit behaviors. * refactor(plugin): hide instance commands, delegate to repo Skill Hide +plugin-instance-create/update/delete/get/list from CLI help. Remove instance reference files from lark-apps skill. Route instance CRUD and call code generation to project repo plugin-guide skill. Go instance code preserved, just hidden. * refactor: 删除 plugin-instance 5 个 CLI 命令,改由仓库 Skill 引导 agent 直接操作文件 - 删除 plugin_instance_create/update/delete/get/list 及其测试(11 个文件) - 删除 plugin_instance_types(TypeScript 类型生成命令) - 移除 shortcuts.go 中的 6 个注册项 - 清理 plugin_common.go 中仅被 instance 命令使用的函数(1054→340 行): 校验逻辑、capability JSON 读写、动态 schema 解析、TypeScript 生成等 - 保留 plugin-install / plugin-uninstall / plugin-list 三个命令不变 插件实例的 CRUD 操作改由仓库 Skill 引导 agent 直接读写 capabilities/*.json, 验证规则写在 Skill 中由 agent 自校验。 * refactor(plugin): remove --project-path flag and split --name into --name + --version - Remove --project-path from plugin-install/list/uninstall (use cwd like npm) - Split --name key@version into separate --name and --version flags - Remove pluginParseInstallTarget (no longer needed) - Improve DryRun desc and error hints for --version usage - Update skill docs to reflect new flag structure - Tests use chdirTest helper instead of --project-path * feat(plugin): add Examples to --help for plugin-install/list/uninstall 按 lark-cli 优化治理规范,为三个插件命令的 --help 补充 2-3 个 可执行示例,覆盖最常见使用路径,帮助 agent 快速理解命令用法。 * fix(plugin): address PR #1609 review findings - Fix hint referencing non-existent +plugin-instance-delete command, point to repo plugin-guide Skill instead - Remove undeclared --capabilities-dir flag, simplify pluginResolveCapDir to env-only resolution, fix ambiguous hint to suggest env vars - Reclassify download errors from file_io to network/api with proper hints and retryable marking - Slim SKILL.md routing row, move judgment rules to plugin-install reference - Rename --local flag to --file to align with CLI conventions * fix(skill): restore plugin routing row with judgment rules, fix markdown formatting Revert SKILL.md routing row to keep full judgment rules and repo Skill directive inline. Fix bold marker spacing and restore missing table column. Revert reference to original content without duplicated rules. * fix(plugin): revert SKILL.md to pre-review version, fix shortcut count test Restore SKILL.md plugin routing row to original version with full judgment rules and repo Skill directive. Update shortcut count test from 60 to 63 to account for 3 new plugin commands. * fix(plugin):fix lark-apps skill docs which is about plugin * fix(plugin):correct plugin skill md * fix(plugin):correct plugin md * fix(plugin):correct plugin and local dev skills md * fix(plugin):correct apps plugin skills md * fix(lark-apps): move repo skill reading hint to post-init phase 将「仓库 Skill 优先」从 SKILL.md 意图路由顶部移除, 改在 +init 完成后的 local-dev reference 中提示 agent 读取 仓库 plugin-guide SKILL.md,解决应用未初始化时 repo skill 不存在导致 agent 无法获取插件知识的时序问题。 * fix(lark-apps): strengthen local-dev reference reading and post-init plugin guide - SKILL.md 路由表:local-dev.md 从"按需读取"提升为"执行前必读" - local-dev.md:将读仓库 Skill 嵌入端到端流程链作为正式步骤 - post-init 指引改为可执行命令 + 不读的后果说明 + 不存在时兜底 --------- Co-authored-by: zhangli --- shortcuts/apps/plugin_common.go | 392 +++++++++++++++++ shortcuts/apps/plugin_common_test.go | 254 +++++++++++ shortcuts/apps/plugin_install.go | 393 ++++++++++++++++++ shortcuts/apps/plugin_install_test.go | 181 ++++++++ shortcuts/apps/plugin_list.go | 80 ++++ shortcuts/apps/plugin_list_test.go | 121 ++++++ shortcuts/apps/plugin_uninstall.go | 84 ++++ shortcuts/apps/plugin_uninstall_test.go | 187 +++++++++ shortcuts/apps/shortcuts.go | 3 + shortcuts/apps/shortcuts_test.go | 9 +- skills/lark-apps/SKILL.md | 9 +- .../references/lark-apps-local-dev.md | 4 +- .../references/lark-apps-plugin-install.md | 34 ++ .../references/lark-apps-plugin-list.md | 21 + .../references/lark-apps-plugin-uninstall.md | 23 + 15 files changed, 1786 insertions(+), 9 deletions(-) create mode 100644 shortcuts/apps/plugin_common.go create mode 100644 shortcuts/apps/plugin_common_test.go create mode 100644 shortcuts/apps/plugin_install.go create mode 100644 shortcuts/apps/plugin_install_test.go create mode 100644 shortcuts/apps/plugin_list.go create mode 100644 shortcuts/apps/plugin_list_test.go create mode 100644 shortcuts/apps/plugin_uninstall.go create mode 100644 shortcuts/apps/plugin_uninstall_test.go create mode 100644 skills/lark-apps/references/lark-apps-plugin-install.md create mode 100644 skills/lark-apps/references/lark-apps-plugin-list.md create mode 100644 skills/lark-apps/references/lark-apps-plugin-uninstall.md diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go new file mode 100644 index 000000000..849fa7f94 --- /dev/null +++ b/shortcuts/apps/plugin_common.go @@ -0,0 +1,392 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" +) + +// pluginResolveProjectPath resolves --project-path to an absolute path, +// defaulting to cwd when empty. +func pluginResolveProjectPath(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded. + if err != nil { + return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(err) + } + return cwd, nil + } + if err := validate.RejectControlChars(raw, "--project-path"); err != nil { + return "", err + } + return filepath.Clean(raw), nil +} + +// pluginCheckProjectDir validates that projectPath contains a package.json. +func pluginCheckProjectDir(projectPath string) error { + info, err := os.Stat(filepath.Join(projectPath, "package.json")) //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for project dir check. + if err != nil { + if os.IsNotExist(err) { + return appsFailedPreconditionError("package.json not found in %s", projectPath). + WithHint("run 'lark-cli apps +init' to initialize the project first") + } + return appsFileIOError(err, "cannot access package.json in %s", projectPath) + } + if !info.Mode().IsRegular() { + return appsFailedPreconditionError("package.json in %s is not a regular file", projectPath) + } + return nil +} + +// pluginResolveCapDir resolves the capabilities directory using a 3-level fallback: +// 1. MIAODA_CAPABILITIES_DIR env var +// 2. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities) +// 2.5 Read .env.local for MIAODA_APP_TYPE +// 3. Detect by checking which directories exist under projectPath +func pluginResolveCapDir(projectPath string) (string, error) { + if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { //nolint:forbidigo // env-based config lookup is intentional. + if filepath.IsAbs(dir) { + return dir, nil + } + return filepath.Join(projectPath, dir), nil + } + + // 2. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/ + appType := os.Getenv("MIAODA_APP_TYPE") //nolint:forbidigo // env-based config lookup is intentional. + if appType == "" { + appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE") + } + if appType == "6" { + return filepath.Join(projectPath, "shared", "capabilities"), nil + } + if appType != "" { + return filepath.Join(projectPath, "server", "capabilities"), nil + } + + // 3. Directory detection + serverDir := filepath.Join(projectPath, "server", "capabilities") + sharedDir := filepath.Join(projectPath, "shared", "capabilities") + serverOK := pluginDirExists(serverDir) + sharedOK := pluginDirExists(sharedDir) + + switch { + case serverOK && sharedOK: + return "", appsFailedPreconditionError( + "ambiguous capabilities path: both server/capabilities/ and shared/capabilities/ exist", + ).WithHint("set MIAODA_APP_TYPE or MIAODA_CAPABILITIES_DIR in .env.local to resolve ambiguity") + case serverOK: + return serverDir, nil + case sharedOK: + return sharedDir, nil + default: + return filepath.Join(projectPath, "server", "capabilities"), nil + } +} + +// pluginReadEnvLocalValue reads a value from .env.local by key name. +func pluginReadEnvLocalValue(projectPath, key string) string { + data, err := os.ReadFile(filepath.Join(projectPath, ".env.local")) //nolint:forbidigo // shortcuts cannot import internal/vfs; local env file read. + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(k) != key { + continue + } + v = strings.TrimSpace(v) + v = strings.Trim(v, "\"'") + return v + } + return "" +} + +func pluginDirExists(path string) bool { + info, err := os.Stat(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local dir existence check. + return err == nil && info.IsDir() +} + +// pluginListCapabilities reads all *.json files from capDir. +// Returns nil (not error) if the directory does not exist. +func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) { + entries, err := os.ReadDir(capDir) //nolint:forbidigo // shortcuts cannot import internal/vfs; local dir listing. + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, appsFileIOError(err, "cannot read capabilities directory %s", capDir) + } + + var caps []map[string]interface{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(capDir, entry.Name())) //nolint:forbidigo + if err != nil { + continue + } + var cap map[string]interface{} + if err := json.Unmarshal(data, &cap); err != nil { + continue + } + caps = append(caps, cap) + } + return caps, nil +} + +// pluginCheckDependentInstances scans the capabilities directory for instances +// that reference the given pluginKey. Returns nil if none found, an error with +// the list of dependent instance ids if any exist, or the underlying I/O error. +func pluginCheckDependentInstances(projectPath, pluginKey string) error { + capDir, err := pluginResolveCapDir(projectPath) + if err != nil { + // No capabilities directory → no instances can exist → no conflict. + return nil + } + caps, err := pluginListCapabilities(capDir) + if err != nil { + // Cannot scan → best-effort, don't block. + return nil + } + var deps []string + for _, cap := range caps { + if pk, _ := cap["pluginKey"].(string); pk == pluginKey { + if id, _ := cap["id"].(string); id != "" { + deps = append(deps, id) + } + } + } + if len(deps) == 0 { + return nil + } + return appsFailedPreconditionError( + "plugin %q is still referenced by %d instance(s): %s", pluginKey, len(deps), strings.Join(deps, ", "), + ).WithHint("delete these instances first (see /.agents/skills/plugin-guide/SKILL.md for instance removal steps), clean up calling code and types, then retry uninstall") +} + +// pluginCheckInstalled verifies that the plugin package is installed in node_modules +// with a valid manifest.json. +func pluginCheckInstalled(projectPath, pluginKey string) error { + pluginDir := filepath.Join(projectPath, "node_modules", pluginKey) + manifestPath := filepath.Join(pluginDir, "manifest.json") + if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for plugin check. + if os.IsNotExist(err) { + if pluginDirExists(pluginDir) { + return appsFailedPreconditionError( + "plugin %q exists in node_modules but manifest.json is missing; the package may not have been built correctly", pluginKey, + ).WithHint("run 'lark-cli apps +plugin-install --name %s' to reinstall from registry", pluginKey) + } + return appsFailedPreconditionError("plugin %q is not installed", pluginKey). + WithHint("run 'lark-cli apps +plugin-install --name %s' to install", pluginKey) + } + return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey) + } + return nil +} + +// ── package.json helpers ── + +// pluginReadPackageJSON reads and parses the project's package.json. +func pluginReadPackageJSON(projectPath string) (map[string]interface{}, error) { + path := filepath.Join(projectPath, "package.json") + data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package.json read. + if err != nil { + return nil, appsFileIOError(err, "cannot read package.json") + } + var pkg map[string]interface{} + if err := json.Unmarshal(data, &pkg); err != nil { + return nil, appsValidationError("invalid package.json: %v", err).WithCause(err) + } + return pkg, nil +} + +// pluginWritePackageJSON writes package.json atomically, preserving formatting. +func pluginWritePackageJSON(projectPath string, pkg map[string]interface{}) error { + data, err := json.MarshalIndent(pkg, "", " ") + if err != nil { + return appsFileIOError(err, "cannot marshal package.json") + } + data = append(data, '\n') + return validate.AtomicWrite(filepath.Join(projectPath, "package.json"), data, 0o644) +} + +// pluginGetActionPlugins extracts actionPlugins from package.json as key→version. +func pluginGetActionPlugins(pkg map[string]interface{}) map[string]string { + raw, ok := pkg["actionPlugins"] + if !ok { + return nil + } + m, ok := raw.(map[string]interface{}) + if !ok { + return nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + if s, ok := v.(string); ok { + out[k] = s + } + } + return out +} + +// pluginSetActionPlugin adds or updates a plugin entry in actionPlugins. +func pluginSetActionPlugin(pkg map[string]interface{}, key, version string) { + m, ok := pkg["actionPlugins"].(map[string]interface{}) + if !ok { + m = make(map[string]interface{}) + pkg["actionPlugins"] = m + } + m[key] = version +} + +// pluginRemoveActionPlugin removes a plugin entry from actionPlugins. +func pluginRemoveActionPlugin(pkg map[string]interface{}, key string) { + m, ok := pkg["actionPlugins"].(map[string]interface{}) + if !ok { + return + } + delete(m, key) +} + +// pluginSyncActionPlugins ensures the actionPlugins record in package.json +// matches the actually installed version, even when install is skipped. +func pluginSyncActionPlugins(projectPath, key, version string) { + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return + } + ap := pluginGetActionPlugins(pkg) + if ap[key] == version { + return + } + pluginSetActionPlugin(pkg, key, version) + _ = pluginWritePackageJSON(projectPath, pkg) +} + +// pluginCheckPeerDeps reads peerDependencies from the installed plugin's +// package.json and returns the names of any that are missing from node_modules. +func pluginCheckPeerDeps(projectPath, pluginKey string) []string { + pkgPath := filepath.Join(projectPath, "node_modules", pluginKey, "package.json") + data, err := os.ReadFile(pkgPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package read. + if err != nil { + return nil + } + var pkg map[string]interface{} + if err := json.Unmarshal(data, &pkg); err != nil { + return nil + } + peerDeps, ok := pkg["peerDependencies"].(map[string]interface{}) + if !ok || len(peerDeps) == 0 { + return nil + } + var missing []string + for dep := range peerDeps { + depDir := filepath.Join(projectPath, "node_modules", dep) + if !pluginDirExists(depDir) { + missing = append(missing, dep) + } + } + return missing +} + +// pluginInstalledVersion reads the version of an installed plugin from its +// package.json in node_modules. Returns "" if not found or unreadable. +func pluginInstalledVersion(projectPath, pluginKey string) string { + path := filepath.Join(projectPath, "node_modules", pluginKey, "package.json") + data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package read. + if err != nil { + return "" + } + var pkg map[string]interface{} + if err := json.Unmarshal(data, &pkg); err != nil { + return "" + } + v, _ := pkg["version"].(string) + return v +} + +// ── tgz extraction ── + +// pluginExtractTGZ extracts a gzipped tar archive into destDir, stripping the +// first path component (npm convention: tarballs contain a "package/" prefix). +// Path traversal entries are silently skipped. +func pluginExtractTGZ(r io.Reader, destDir string) error { + gz, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("gzip: %w", err) + } + defer gz.Close() + + cleanDest := filepath.Clean(destDir) + string(filepath.Separator) + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("tar: %w", err) + } + + name := pluginStripFirstComponent(hdr.Name) + if name == "" { + continue + } + if strings.Contains(name, "..") { + continue + } + + target := filepath.Join(destDir, name) + if !strings.HasPrefix(filepath.Clean(target)+string(filepath.Separator), cleanDest) && + filepath.Clean(target) != filepath.Clean(destDir) { + continue + } + + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction. + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { //nolint:forbidigo + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)&0o755) //nolint:forbidigo + if err != nil { + return err + } + if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size + f.Close() + return err + } + f.Close() + } + } + return nil +} + +// pluginStripFirstComponent removes the first path component ("package/foo" → "foo"). +func pluginStripFirstComponent(name string) string { + name = filepath.ToSlash(name) + if i := strings.Index(name, "/"); i >= 0 { + return name[i+1:] + } + return "" +} diff --git a/shortcuts/apps/plugin_common_test.go b/shortcuts/apps/plugin_common_test.go new file mode 100644 index 000000000..263f75faf --- /dev/null +++ b/shortcuts/apps/plugin_common_test.go @@ -0,0 +1,254 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/errs" +) + +// --- pluginResolveProjectPath --- + +func TestPluginResolveProjectPath_DefaultToCwd(t *testing.T) { + got, err := pluginResolveProjectPath("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + cwd, _ := os.Getwd() //nolint:forbidigo + if got != cwd { + t.Errorf("got %q, want cwd %q", got, cwd) + } +} + +func TestPluginResolveProjectPath_ExplicitPath(t *testing.T) { + got, err := pluginResolveProjectPath("/tmp/myapp") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/tmp/myapp" { + t.Errorf("got %q, want /tmp/myapp", got) + } +} + +// --- pluginCheckProjectDir --- + +func TestPluginCheckProjectDir_OK(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + if err := pluginCheckProjectDir(dir); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPluginCheckProjectDir_Missing(t *testing.T) { + dir := t.TempDir() + err := pluginCheckProjectDir(dir) + if err == nil { + t.Fatal("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", p.Subtype) + } +} + +// --- pluginResolveCapDir --- + +func TestPluginResolveCapDir_EnvVar(t *testing.T) { + t.Setenv("MIAODA_CAPABILITIES_DIR", "envdir/caps") + got, err := pluginResolveCapDir("/proj") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "envdir/caps"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_AppTypeEnv(t *testing.T) { + t.Setenv("MIAODA_APP_TYPE", "2") + got, err := pluginResolveCapDir("/proj") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "server", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_AppTypeEnvShared(t *testing.T) { + t.Setenv("MIAODA_APP_TYPE", "6") + got, err := pluginResolveCapDir("/proj") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "shared", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_EnvLocal(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + got, err := pluginResolveCapDir(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join(dir, "server", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_DetectServer(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo + t.Fatal(err) + } + got, err := pluginResolveCapDir(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join(dir, "server", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_DetectShared(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo + t.Fatal(err) + } + got, err := pluginResolveCapDir(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join(dir, "shared", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_Ambiguous(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo + t.Fatal(err) + } + _, err := pluginResolveCapDir(dir) + if err == nil { + t.Fatal("expected ambiguous error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", p.Subtype) + } +} + +func TestPluginResolveCapDir_NeitherExists_DefaultsToServer(t *testing.T) { + dir := t.TempDir() + got, err := pluginResolveCapDir(dir) + if err != nil { + t.Fatalf("should default to server/capabilities, got error: %v", err) + } + if want := filepath.Join(dir, "server", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_AppType3_UsesServer(t *testing.T) { + t.Setenv("MIAODA_APP_TYPE", "3") + got, err := pluginResolveCapDir("/proj") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "server", "capabilities"); got != want { + t.Errorf("got %q, want %q (appType=3 should use server)", got, want) + } +} + +// --- pluginListCapabilities --- + +func TestPluginListCapabilities_Empty(t *testing.T) { + dir := t.TempDir() + caps, err := pluginListCapabilities(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(caps) != 0 { + t.Errorf("got %d caps, want 0", len(caps)) + } +} + +func TestPluginListCapabilities_DirNotExist(t *testing.T) { + caps, err := pluginListCapabilities("/nonexistent/path") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if caps != nil { + t.Errorf("got %v, want nil", caps) + } +} + +func TestPluginListCapabilities_WithFiles(t *testing.T) { + dir := t.TempDir() + writeTestCapJSON(t, dir, "cap1.json", map[string]interface{}{"id": "cap1", "name": "Cap One"}) + writeTestCapJSON(t, dir, "cap2.json", map[string]interface{}{"id": "cap2", "name": "Cap Two"}) + // non-JSON file should be skipped + if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + + caps, err := pluginListCapabilities(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(caps) != 2 { + t.Fatalf("got %d caps, want 2", len(caps)) + } +} + +func TestPluginListCapabilities_SkipsMalformed(t *testing.T) { + dir := t.TempDir() + writeTestCapJSON(t, dir, "good.json", map[string]interface{}{"id": "good"}) + if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } + + caps, err := pluginListCapabilities(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(caps) != 1 { + t.Fatalf("got %d caps, want 1", len(caps)) + } +} + + +// --- helpers --- + +func writeTestCapJSON(t *testing.T, dir, filename string, data map[string]interface{}) { + t.Helper() + b, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } +} diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/plugin_install.go new file mode 100644 index 000000000..546c2bef7 --- /dev/null +++ b/shortcuts/apps/plugin_install.go @@ -0,0 +1,393 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginInstall downloads a plugin package from the registry, extracts it +// to node_modules, and updates package.json actionPlugins. +// +// Without --name it batch-installs all plugins declared in actionPlugins that +// are not yet present in node_modules. +var AppsPluginInstall = common.Shortcut{ + Service: appsService, + Command: "+plugin-install", + Description: "Install a plugin package (download, extract, update package.json)", + Risk: "write", + ConditionalScopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + Tips: []string{ + "Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate", + "Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0", + "Example: lark-cli apps +plugin-install (install all declared plugins in package.json)", + }, + Flags: []common.Flag{ + {Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"}, + {Name: "version", Desc: "plugin version (e.g. 1.0.0); omit to install latest"}, + {Name: "file", Desc: "install from a local .tgz file (dev/test only)", Hidden: true}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + key := strings.TrimSpace(rctx.Str("name")) + if key == "" { + return common.NewDryRunAPI(). + POST(apiBasePath+"/plugin/versions/batch_query"). + Desc("Batch-install all declared plugins from package.json actionPlugins"). + Set("request_body", `{"plugin_keys": [], "latest_only": false}`) + } + version := strings.TrimSpace(rctx.Str("version")) + isLatest := version == "" || version == "latest" + desc := fmt.Sprintf("Query version for %s, then download .tgz", key) + if isLatest { + desc = fmt.Sprintf("Install latest version of %s (omit --version to install latest)", key) + } + return common.NewDryRunAPI(). + POST(apiBasePath+"/plugin/versions/batch_query"). + Desc(desc). + Set("request_body", fmt.Sprintf(`{"plugin_keys": ["%s"], "latest_only": %v}`, key, isLatest)). + Set("download_body", fmt.Sprintf(`{"plugin_key": "%s", "plugin_version": "%s"}`, key, version)) + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath("") + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath("") + if err != nil { + return err + } + + if localTgz := strings.TrimSpace(rctx.Str("file")); localTgz != "" { + return pluginInstallLocal(rctx, projectPath, localTgz) + } + + key := strings.TrimSpace(rctx.Str("name")) + if key == "" { + return pluginInstallAll(ctx, rctx, projectPath) + } + version := strings.TrimSpace(rctx.Str("version")) + return pluginInstallOne(ctx, rctx, projectPath, key, version) + }, +} + +// pluginInstallOne installs a single plugin by key and optional version. +func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectPath, key, version string) error { + if key == "" { + return appsValidationParamError("--name", "--name is required") + } + + // Check if already installed with same version (pre-API fast path) + if version != "" && version != "latest" { + if installed := pluginInstalledVersion(projectPath, key); installed == version { + pluginSyncActionPlugins(projectPath, key, version) + result := map[string]interface{}{ + "key": key, "version": version, "status": "already_installed", + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ %s@%s is already installed\n", key, version) + }) + return nil + } + } + + // Resolve version via API + resolvedVersion, err := pluginResolveVersion(ctx, rctx, key, version) + if err != nil { + return err + } + + // Post-API check: latest may resolve to the already-installed version + if installed := pluginInstalledVersion(projectPath, key); installed == resolvedVersion { + pluginSyncActionPlugins(projectPath, key, resolvedVersion) + result := map[string]interface{}{ + "key": key, "version": resolvedVersion, "status": "already_installed", + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ %s@%s is already up to date\n", key, resolvedVersion) + }) + return nil + } + + // Download tgz + tgzData, err := pluginDownloadPackage(ctx, rctx, key, resolvedVersion) + if err != nil { + return err + } + + // Extract to node_modules + destDir := filepath.Join(projectPath, "node_modules", key) + if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract. + return appsFileIOError(err, "cannot clean %s", destDir) + } + if err := os.MkdirAll(destDir, 0o755); err != nil { //nolint:forbidigo + return appsFileIOError(err, "cannot create %s", destDir) + } + if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil { + return appsFileIOError(err, "cannot extract plugin package for %s", key) + } + + // Check peer dependencies + missingPeers := pluginCheckPeerDeps(projectPath, key) + + // Update package.json + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + pluginSetActionPlugin(pkg, key, resolvedVersion) + if err := pluginWritePackageJSON(projectPath, pkg); err != nil { + return appsFileIOError(err, "cannot update package.json") + } + + result := map[string]interface{}{ + "key": key, "version": resolvedVersion, "status": "installed", + } + if len(missingPeers) > 0 { + result["missing_peer_dependencies"] = missingPeers + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Installed %s@%s\n", key, resolvedVersion) + if len(missingPeers) > 0 { + fmt.Fprintf(w, "⚠ Missing peer dependencies: %s\n", strings.Join(missingPeers, ", ")) + fmt.Fprintln(w, " Run 'npm install' in the project directory to install them.") + } + }) + return nil +} + +// pluginInstallAll installs all plugins declared in actionPlugins that are +// missing from node_modules. +func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectPath string) error { + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + declared := pluginGetActionPlugins(pkg) + if len(declared) == 0 { + rctx.OutFormat(map[string]interface{}{"installed": 0}, nil, func(w io.Writer) { + fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.") + }) + return nil + } + + var installed int + for key, version := range declared { + existing := pluginInstalledVersion(projectPath, key) + if existing != "" && existing == version { + continue + } + if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil { + return fmt.Errorf("install %s: %w", key, err) + } + installed++ + } + + if installed == 0 { + rctx.OutFormat(map[string]interface{}{"installed": 0, "status": "all_up_to_date"}, nil, func(w io.Writer) { + fmt.Fprintln(w, "All declared plugins are already installed.") + }) + } + return nil +} + +// pluginInstallLocal installs a plugin from a local .tgz file, skipping API calls. +// Reads plugin key and version from the extracted package.json inside the tgz. +func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string) error { + tgzData, err := os.ReadFile(tgzPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local tgz read. + if err != nil { + return appsValidationParamError("--file", "cannot read tgz file %s: %v", tgzPath, err).WithCause(err) + } + + // Extract to a temp dir first to read package.json + tmpDir, err := os.MkdirTemp("", "plugin-local-*") //nolint:forbidigo + if err != nil { + return appsFileIOError(err, "cannot create temp dir") + } + defer os.RemoveAll(tmpDir) //nolint:forbidigo + + if err := pluginExtractTGZ(bytes.NewReader(tgzData), tmpDir); err != nil { + return appsFileIOError(err, "cannot extract tgz") + } + + // Read key and version from extracted package.json + pkgData, err := os.ReadFile(filepath.Join(tmpDir, "package.json")) //nolint:forbidigo + if err != nil { + return appsFileIOError(err, "tgz does not contain package.json") + } + var pkgMeta map[string]interface{} + if err := json.Unmarshal(pkgData, &pkgMeta); err != nil { + return appsFileIOError(err, "invalid package.json in tgz") + } + key, _ := pkgMeta["name"].(string) + version, _ := pkgMeta["version"].(string) + if key == "" { + return appsValidationParamError("--file", "package.json in tgz missing 'name' field") + } + if version == "" { + version = "0.0.0" + } + + // Move to node_modules + destDir := filepath.Join(projectPath, "node_modules", key) + if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo + return appsFileIOError(err, "cannot clean %s", destDir) + } + if err := os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil { //nolint:forbidigo + return appsFileIOError(err, "cannot create parent dir for %s", destDir) + } + if err := os.Rename(tmpDir, destDir); err != nil { //nolint:forbidigo + // rename may fail across filesystems; fall back to re-extract + if err2 := os.MkdirAll(destDir, 0o755); err2 != nil { //nolint:forbidigo + return appsFileIOError(err2, "cannot create %s", destDir) + } + if err2 := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err2 != nil { + return appsFileIOError(err2, "cannot extract plugin to %s", destDir) + } + } + + // Update package.json actionPlugins + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + pluginSetActionPlugin(pkg, key, version) + if err := pluginWritePackageJSON(projectPath, pkg); err != nil { + return appsFileIOError(err, "cannot update package.json") + } + + result := map[string]interface{}{ + "key": key, "version": version, "status": "installed", "source": "local", + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Installed %s@%s (from local %s)\n", key, version, tgzPath) + }) + return nil +} + +// pluginResolveVersion calls the batch_query API to resolve version info. +func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, version string) (resolvedVersion string, err error) { + isLatest := version == "" || version == "latest" + body := map[string]interface{}{ + "plugin_keys": []interface{}{key}, + "latest_only": isLatest, + } + + data, err := rctx.CallAPITyped("POST", apiBasePath+"/plugin/versions/batch_query", nil, body) + if err != nil { + p, ok := errs.ProblemOf(err) + if ok && p.Subtype == errs.SubtypeInvalidResponse { + p.Message = fmt.Sprintf("plugin registry API is not available (returned non-JSON for %s)", key) + p.Hint = "the plugin registry endpoint may not be registered yet; check with the backend team" + return "", err + } + return "", withAppsHint(err, fmt.Sprintf("failed to fetch plugin version for %s; check plugin key spelling and network", key)) + } + + // Response: data.items is a flat list of plugin_version objects + match := pluginFindVersionInItems(data, key, version) + if match == nil { + hint := "check plugin key spelling" + if !isLatest { + hint = fmt.Sprintf("version %q not found for %s; omit --version to install latest", version, key) + } + return "", appsValidationError("no version found for plugin %q", key). + WithHint(hint) + } + // API returns "version" (not "plugin_version") + rv, _ := match["version"].(string) + if rv == "" { + return "", appsValidationError("incomplete version info for plugin %q", key). + WithHint("API returned version info without version field; contact plugin maintainer") + } + return rv, nil +} + +// pluginFindVersionInItems extracts data.items and finds a matching version. +func pluginFindVersionInItems(data map[string]interface{}, key, version string) map[string]interface{} { + raw, ok := data["items"] + if !ok { + return nil + } + arr, ok := raw.([]interface{}) + if !ok { + return nil + } + isLatest := version == "" || version == "latest" + for _, v := range arr { + item, ok := v.(map[string]interface{}) + if !ok { + continue + } + // API returns "key" (not "plugin_key") + pk, _ := item["key"].(string) + if pk != key { + continue + } + if isLatest { + return item + } + pv, _ := item["version"].(string) + if pv == version { + return item + } + } + return nil +} + +// pluginDownloadPackage downloads a plugin .tgz via the download_package API. +// The endpoint is POST with JSON body {plugin_key, plugin_version}. +func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version string) ([]byte, error) { + apiPath := apiBasePath + "/plugin/versions/download_package" + body, _ := json.Marshal(map[string]string{ + "plugin_key": key, + "plugin_version": version, + }) + + resp, err := rctx.DoAPIStream(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: apiPath, + Body: bytes.NewReader(body), + }) + if err != nil { + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed for %s@%s: %v", key, version, err). + WithHint("check network connectivity and retry"). + WithRetryable(). + WithCause(err) + } + defer resp.Body.Close() + if resp.StatusCode >= 500 { + return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed for %s@%s: HTTP %d", key, version, resp.StatusCode). + WithHint("plugin registry returned a server error; retry after a short wait"). + WithRetryable() + } + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(resp.Body) + hint := "check plugin key and version spelling" + if resp.StatusCode == 403 { + hint = "download token may have expired; retry the install to get a fresh token" + } else if resp.StatusCode == 404 { + hint = fmt.Sprintf("package %s@%s not found in registry; check plugin key and version", key, version) + } + return nil, errs.NewAPIError(errs.SubtypeUnknown, "download failed for %s@%s: HTTP %d: %s", key, version, resp.StatusCode, string(respBody)). + WithHint(hint) + } + return io.ReadAll(resp.Body) +} diff --git a/shortcuts/apps/plugin_install_test.go b/shortcuts/apps/plugin_install_test.go new file mode 100644 index 000000000..031a40232 --- /dev/null +++ b/shortcuts/apps/plugin_install_test.go @@ -0,0 +1,181 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestPluginInstall_SinglePlugin(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{}) + chdirTest(t, dir) + + factory, stdout, reg := newAppsExecuteFactory(t) + + // Mock batch_query API (new protocol: plugin_keys array, response data.items flat list) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/plugin/versions/batch_query", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "key": "@test/my-plugin", + "version": "1.0.0", + "download_approach": "inner", + "status": "active", + }, + }, + }, + }, + }) + + // Mock download API (POST with JSON body, returns binary tgz) + tgzData := buildTestTGZ(t, map[string]string{ + "manifest.json": `{"actions":[]}`, + "package.json": `{"name":"@test/my-plugin","version":"1.0.0"}`, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/plugin/versions/download_package", + RawBody: tgzData, + ContentType: "application/octet-stream", + }) + + err := runAppsShortcut(t, AppsPluginInstall, []string{ + "+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0", + "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify file extracted + manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json") + if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo + t.Fatalf("manifest.json not extracted: %v", err) + } + + // Verify package.json updated + pkg, _ := pluginReadPackageJSON(dir) + ap := pluginGetActionPlugins(pkg) + if v := ap["@test/my-plugin"]; v != "1.0.0" { + t.Errorf("actionPlugins[@test/my-plugin] = %q, want 1.0.0", v) + } + + // Verify output + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + if data["status"] != "installed" { + t.Errorf("status = %v, want installed", data["status"]) + } +} + +func TestPluginInstall_AlreadyInstalled(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + // Create an existing installed plugin with package.json containing version + pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo + os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstall, []string{ + "+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0", + "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + if data["status"] != "already_installed" { + t.Errorf("status = %v, want already_installed", data["status"]) + } +} + +// --- tgz helpers --- + +func TestPluginExtractTGZ(t *testing.T) { + tgzData := buildTestTGZ(t, map[string]string{ + "manifest.json": `{"actions":[]}`, + "README.md": "# Hello", + }) + + destDir := t.TempDir() + if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil { + t.Fatalf("extract error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) //nolint:forbidigo + if err != nil { + t.Fatalf("manifest.json not extracted: %v", err) + } + if string(data) != `{"actions":[]}` { + t.Errorf("manifest.json content = %q", string(data)) + } +} + +func TestPluginExtractTGZ_PathTraversal(t *testing.T) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + tw.WriteHeader(&tar.Header{ + Name: "package/../../../etc/passwd", + Size: 5, + Mode: 0o644, + Typeflag: tar.TypeReg, + }) + tw.Write([]byte("evil!")) + tw.Close() + gz.Close() + + destDir := t.TempDir() + if err := pluginExtractTGZ(&buf, destDir); err != nil { + t.Fatalf("extract should not error, but skip bad entries: %v", err) + } + if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { //nolint:forbidigo + t.Error("path traversal should have been blocked") + } +} + +// buildTestTGZ creates a .tgz in memory with files under a "package/" prefix. +func buildTestTGZ(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + for name, content := range files { + tw.WriteHeader(&tar.Header{ + Name: "package/" + name, + Size: int64(len(content)), + Mode: 0o644, + Typeflag: tar.TypeReg, + }) + tw.Write([]byte(content)) + } + + tw.Close() + gz.Close() + return buf.Bytes() +} diff --git a/shortcuts/apps/plugin_list.go b/shortcuts/apps/plugin_list.go new file mode 100644 index 000000000..e4f9ecf21 --- /dev/null +++ b/shortcuts/apps/plugin_list.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginList lists plugin packages declared in package.json actionPlugins, +// cross-referencing with node_modules to report installation status. +var AppsPluginList = common.Shortcut{ + Service: appsService, + Command: "+plugin-list", + Description: "List declared plugin packages and their installation status", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +plugin-list", + "Example: lark-cli apps +plugin-list --format pretty", + }, + Flags: []common.Flag{}, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Desc("List declared plugin packages and installation status"). + Set("action", "list"). + Set("source", "package.json actionPlugins + node_modules") + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath("") + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath("") + if err != nil { + return err + } + + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + + declared := pluginGetActionPlugins(pkg) + plugins := make([]interface{}, 0, len(declared)) + for key, version := range declared { + installed := pluginInstalledVersion(projectPath, key) + status := "declared_not_installed" + if installed != "" { + status = "installed" + } + plugins = append(plugins, map[string]interface{}{ + "key": key, + "version": version, + "status": status, + }) + } + + data := map[string]interface{}{"plugins": plugins} + rctx.OutFormat(data, &output.Meta{Count: len(plugins)}, func(w io.Writer) { + if len(plugins) == 0 { + fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.") + return + } + rows := make([]map[string]interface{}, 0, len(plugins)) + for _, p := range plugins { + rows = append(rows, p.(map[string]interface{})) + } + output.PrintTable(w, rows) + }) + return nil + }, +} diff --git a/shortcuts/apps/plugin_list_test.go b/shortcuts/apps/plugin_list_test.go new file mode 100644 index 000000000..a49bd7df7 --- /dev/null +++ b/shortcuts/apps/plugin_list_test.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestPluginList_Empty(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{}) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginList, []string{ + "+plugin-list", "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + plugins, _ := data["plugins"].([]interface{}) + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } +} + +func TestPluginList_Installed(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo + os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginList, []string{ + "+plugin-list", "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + plugins, _ := data["plugins"].([]interface{}) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + p := plugins[0].(map[string]interface{}) + if p["status"] != "installed" { + t.Errorf("status = %v, want installed", p["status"]) + } +} + +func TestPluginList_DeclaredNotInstalled(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/missing": "1.0.0", + }, + }) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginList, []string{ + "+plugin-list", "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + plugins, _ := data["plugins"].([]interface{}) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + p := plugins[0].(map[string]interface{}) + if p["status"] != "declared_not_installed" { + t.Errorf("status = %v, want declared_not_installed", p["status"]) + } +} + +// --- helpers --- + +func chdirTest(t *testing.T, dir string) { + t.Helper() + prev, err := os.Getwd() //nolint:forbidigo + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { //nolint:forbidigo + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(prev) }) //nolint:forbidigo,errcheck +} + +func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) { + t.Helper() + data, err := json.Marshal(pkg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { //nolint:forbidigo + t.Fatal(err) + } +} diff --git a/shortcuts/apps/plugin_uninstall.go b/shortcuts/apps/plugin_uninstall.go new file mode 100644 index 000000000..0c1b5c15c --- /dev/null +++ b/shortcuts/apps/plugin_uninstall.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginUninstall removes a plugin package from node_modules and its +// entry from package.json actionPlugins. +var AppsPluginUninstall = common.Shortcut{ + Service: appsService, + Command: "+plugin-uninstall", + Description: "Uninstall a plugin package (remove from node_modules and package.json)", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate", + }, + Flags: []common.Flag{ + {Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate)", Required: true}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + key := strings.TrimSpace(rctx.Str("name")) + return common.NewDryRunAPI(). + Desc("Uninstall plugin package (remove from node_modules and package.json)"). + Set("action", "uninstall"). + Set("plugin_key", key). + Set("remove_dir", fmt.Sprintf("node_modules/%s", key)). + Set("update_file", "package.json actionPlugins") + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("name")) == "" { + return appsValidationParamError("--name", "--name is required") + } + projectPath, err := pluginResolveProjectPath("") + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + key := strings.TrimSpace(rctx.Str("name")) + projectPath, err := pluginResolveProjectPath("") + if err != nil { + return err + } + + // Block uninstall if any instances still reference this plugin package. + if err := pluginCheckDependentInstances(projectPath, key); err != nil { + return err + } + + pkgDir := filepath.Join(projectPath, "node_modules", key) + if err := os.RemoveAll(pkgDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; remove plugin directory. + return appsFileIOError(err, "cannot remove %s", pkgDir) + } + + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + pluginRemoveActionPlugin(pkg, key) + if err := pluginWritePackageJSON(projectPath, pkg); err != nil { + return appsFileIOError(err, "cannot update package.json") + } + + result := map[string]interface{}{ + "key": key, + "removed": true, + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Plugin uninstalled: %s\n", key) + }) + return nil + }, +} diff --git a/shortcuts/apps/plugin_uninstall_test.go b/shortcuts/apps/plugin_uninstall_test.go new file mode 100644 index 000000000..db4584cd2 --- /dev/null +++ b/shortcuts/apps/plugin_uninstall_test.go @@ -0,0 +1,187 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestPluginUninstall_Basic(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/my-plugin", + "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify node_modules removed + if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo + t.Error("node_modules plugin dir should be removed") + } + + // Verify package.json updated + pkg, _ := pluginReadPackageJSON(dir) + ap := pluginGetActionPlugins(pkg) + if _, ok := ap["@test/my-plugin"]; ok { + t.Error("actionPlugins should no longer contain @test/my-plugin") + } +} + +func TestPluginUninstall_NotInstalled(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{}) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/not-here", + "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("uninstalling non-existent plugin should succeed: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + if data["removed"] != true { + t.Errorf("removed = %v, want true", data["removed"]) + } +} + +func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + // Install plugin + pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo + + // Create a capability that references this plugin + capDir := filepath.Join(dir, "server", "capabilities") + os.MkdirAll(capDir, 0o755) //nolint:forbidigo + writeTestCapJSON(t, capDir, "my-instance.json", map[string]interface{}{ + "id": "my-instance", + "pluginKey": "@test/my-plugin", + "name": "My Instance", + }) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/my-plugin", + "--format", "json", "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatal("expected error when uninstalling a plugin with dependent instances, got nil") + } + + // Verify plugin directory still exists (blocked) + if _, err := os.Stat(pluginDir); err != nil { //nolint:forbidigo + t.Errorf("plugin directory should still exist after blocked uninstall: %v", err) + } + + // Verify error mentions the dependent instance + prob, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed error, got %v", err) + } + if prob.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %s, want %s", prob.Subtype, errs.SubtypeFailedPrecondition) + } + if prob.Hint == "" { + t.Error("hint should be non-empty") + } +} + +func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo + + // Create a capability that references a DIFFERENT plugin — should not block + capDir := filepath.Join(dir, "server", "capabilities") + os.MkdirAll(capDir, 0o755) //nolint:forbidigo + writeTestCapJSON(t, capDir, "other-instance.json", map[string]interface{}{ + "id": "other-instance", + "pluginKey": "@test/other-plugin", + "name": "Other Instance", + }) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/my-plugin", + "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("uninstall should succeed when instances reference different plugins: %v", err) + } + + // Verify plugin was removed + if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo + t.Error("plugin directory should be removed") + } +} + +func TestPluginUninstall_PreservesOtherPlugins(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "name": "my-app", + "actionPlugins": map[string]interface{}{ + "@test/remove-me": "1.0.0", + "@test/keep-me": "2.0.0", + }, + }) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/remove-me", + "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pkg, _ := pluginReadPackageJSON(dir) + ap := pluginGetActionPlugins(pkg) + if _, ok := ap["@test/remove-me"]; ok { + t.Error("@test/remove-me should be removed from actionPlugins") + } + if v, ok := ap["@test/keep-me"]; !ok || v != "2.0.0" { + t.Errorf("@test/keep-me should be preserved, got %q", v) + } + if name, _ := pkg["name"].(string); name != "my-app" { + t.Errorf("other fields should be preserved, name = %q", name) + } +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index cf05afdfa..b3ac97f5c 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -63,6 +63,9 @@ func Shortcuts() []common.Shortcut { AppsSessionStop, AppsSessionMessagesList, AppsChat, + AppsPluginInstall, + AppsPluginUninstall, + AppsPluginList, // open API key management AppsOpenAPIKeyList, AppsOpenAPIKeyGet, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 69b5b9654..fccdb62f5 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -19,11 +19,12 @@ import ( // - 7 file(list/get/sign/download/upload/delete/quota-get) // - 3 git-credential // - 5 session(create/list/get/stop/chat)+ 1 session-messages-list -// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset)= 60。 -func TestAppsShortcuts_Returns60(t *testing.T) { +// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset) +// - 3 plugin(install/uninstall/list)= 63。 +func TestAppsShortcuts_Returns63(t *testing.T) { got := Shortcuts() - if len(got) != 60 { - t.Fatalf("Shortcuts() returned %d entries, want 60", len(got)) + if len(got) != 63 { + t.Fatalf("Shortcuts() returned %d entries, want 63", len(got)) } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index b93d82382..b8bc7599e 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-apps version: 1.0.0 -description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" +description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" metadata: requires: bins: ["lark-cli"] @@ -22,7 +22,7 @@ metadata: | 找已有 app_id、按名字过滤应用 | `+list --keyword ` | [`lark-apps-list.md`](references/lark-apps-list.md) | | 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) | | 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) | -| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | +| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git)。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md),含端到端流程和领域规则 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | | 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) | | 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-list`, `+analytics-list` | [`lark-apps-observability.md`](references/lark-apps-observability.md) | @@ -34,6 +34,7 @@ metadata: | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | | 管理妙搭应用开放 API Key(创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | +| 外部能力(AI模型能力和飞书平台能力)集成/插件/Plugin/Capability | `+plugin-install`, `+plugin-list`, `+plugin-uninstall` | [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md), [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md), [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) | ## 高频路径 @@ -67,8 +68,8 @@ metadata: ## 能力边界 -- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)/ 自动化 / 插件。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。 -- 用户要配置权限 / 自动化 / 插件时,引导其使用开发态连接前往云端开发(妙搭 web)处理。 +- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)/ 自动化。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。 +- 用户要配置权限 / 自动化时,引导其使用开发态连接前往云端开发(妙搭 web)处理。 ## app_id 获取 diff --git a/skills/lark-apps/references/lark-apps-local-dev.md b/skills/lark-apps/references/lark-apps-local-dev.md index d404e34cf..621d22943 100644 --- a/skills/lark-apps/references/lark-apps-local-dev.md +++ b/skills/lark-apps/references/lark-apps-local-dev.md @@ -11,7 +11,7 @@ ## 端到端流程(新建应用) -`+create(full_stack)` -> `+init`(或手动 `+git-credential-init` + `git clone`)-> `npm install && npm run dev` -> 按需 `+db-*` 调库 -> `git add` + `git commit`(提交本次改动)-> `git push origin sprint/default` -> `+release-create` -> `+release-get`。 +`+create(full_stack)` -> `+init`(或手动 `+git-credential-init` + `git clone`)-> 读仓库 Skill -> `npm install && npm run dev` -> 按需 `+db-*` 调库 -> `git add` + `git commit`(提交本次改动)-> `git push origin sprint/default` -> `+release-create` -> `+release-get`。 ```bash # 新建 full_stack 应用 @@ -36,6 +36,8 @@ lark-cli apps +release-create --app-id app_xxx `+init` 是推荐便捷入口;想逐步手动控制时,先 `+git-credential-init` 拿 `repository_url`,再用原生 `git clone` / `git checkout sprint/default`。 +**`+init` 完成后必须执行**:`cat /.agents/skills/plugin-guide/SKILL.md`,读取仓库插件指引。该文件包含插件目录、实例配置规则和调用代码生成方式——不读就无法正确集成插件能力。文件不存在则跳过。 + ## 改完代码后部署上线 已拉到本地、改完代码,用户说"推上去""部署""上线""发布到云端"时,按此序列。 diff --git a/skills/lark-apps/references/lark-apps-plugin-install.md b/skills/lark-apps/references/lark-apps-plugin-install.md new file mode 100644 index 000000000..7d1648587 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-install.md @@ -0,0 +1,34 @@ +# apps +plugin-install + +安装插件包到项目。运行时命令事实以 `lark-cli apps +plugin-install --help` 为准。 + +## 何时用 + +用户要接入 AI 能力或飞书平台能力,需要先安装对应的插件包。安装后才能创建插件实例。具体有哪些可用插件、该选哪个,读取创建的应用仓库 Skill:`.agents/skills/plugin-guide/SKILL.md`。 + +**插件包 ≠ npm 包**:插件包写入 `actionPlugins`,npm 写入 `dependencies`,两套独立机制。禁止用 `npm install` 代替本命令。 + +## 命令骨架 + +- `--name `:插件包 key(从仓库 Skill 的「AI 插件目录」获取)。不传则批量安装 `actionPlugins` 中声明的所有插件。 +- `--version `:指定版本(如 `1.0.0`)。不传则安装最新版。 + +在项目根目录下运行(和 npm 一样,无需指定路径)。 + +## 示例 + +```bash +# 安装最新版 +lark-cli apps +plugin-install --name + +# 安装指定版本 +lark-cli apps +plugin-install --name --version 1.0.0 + +# 批量安装已声明的所有插件 +lark-cli apps +plugin-install +``` + +## 输出契约 + +- 已安装同版本会跳过(status=already_installed)。 +- 失败时 hint 指示原因(网络/版本不存在/package.json 缺失)。 diff --git a/skills/lark-apps/references/lark-apps-plugin-list.md b/skills/lark-apps/references/lark-apps-plugin-list.md new file mode 100644 index 000000000..58f49fc24 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-list.md @@ -0,0 +1,21 @@ +# apps +plugin-list + +列出已声明的插件包及安装状态。运行时命令事实以 `lark-cli apps +plugin-list --help` 为准。 + +## 何时用 + +查看当前项目声明了哪些插件、是否已安装。`declared_not_installed` 状态表示需要运行 `+plugin-install` 安装。 + +## 命令骨架 + +在项目根目录下运行(和 npm 一样,无需指定路径)。 + +## 示例 + +```bash +lark-cli apps +plugin-list --format json +``` + +## 输出契约 + +- `data.plugins[]` 包含 `key`、`version`、`status`(`installed` / `declared_not_installed`)。 diff --git a/skills/lark-apps/references/lark-apps-plugin-uninstall.md b/skills/lark-apps/references/lark-apps-plugin-uninstall.md new file mode 100644 index 000000000..f01f6c30e --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-uninstall.md @@ -0,0 +1,23 @@ +# apps +plugin-uninstall + +卸载插件包。运行时命令事实以 `lark-cli apps +plugin-uninstall --help` 为准。 + +## 何时用 + +用户不再需要某个插件能力时,卸载对应的插件包。卸载前应先删除该插件的所有实例。 + +## 命令骨架 + +- `--name `:要卸载的插件包 key。 + +在项目根目录下运行(和 npm 一样,无需指定路径)。 + +## 示例 + +```bash +lark-cli apps +plugin-uninstall --name +``` + +## 输出契约 + +- 删除 `node_modules/{key}` + 移除 `actionPlugins` 条目。 From 9f2fe50f4a49db93af5765c21a8bac2339a13589 Mon Sep 17 00:00:00 2001 From: lvxinsheng Date: Mon, 29 Jun 2026 20:03:51 +0800 Subject: [PATCH 26/34] feat(apps): add release polling interval time and release time costs --- skills/lark-apps/references/lark-apps-release-create.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skills/lark-apps/references/lark-apps-release-create.md b/skills/lark-apps/references/lark-apps-release-create.md index 7c1a48e4e..a852fe68f 100644 --- a/skills/lark-apps/references/lark-apps-release-create.md +++ b/skills/lark-apps/references/lark-apps-release-create.md @@ -22,7 +22,7 @@ lark-cli apps +release-create --app-id app_xxx --branch sprint/default --dry-run ## 输出契约 - 成功读取 `data.release_id` 和 `data.status`;`release_id` 是后续 `+release-get` 的入参。 -- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询。 +- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询,轮询间隔应该为 20s。应用发布平均耗时大约 2min,整体超时时间大约 5min。 - `+release-create` 返回 release 只代表发布已发起。只有 `+release-get` 对同一个 `release_id` 返回 `finished` 后,才能说本轮最新版本已部署。 ## Agent 规则 From ff65e614e701606fc6c4184ce321ad788acab17c Mon Sep 17 00:00:00 2001 From: anngo-nk Date: Tue, 30 Jun 2026 11:07:14 +0800 Subject: [PATCH 27/34] fix(plugin): rename files to apps_ prefix and handle Close() errors (#1655) - Rename plugin_install/list/uninstall .go files to apps_plugin_ prefix for consistency with other files in the package - Handle f.Close() errors in pluginExtractTGZ to avoid silent data loss --- .../apps/{plugin_install.go => apps_plugin_install.go} | 0 ...plugin_install_test.go => apps_plugin_install_test.go} | 0 shortcuts/apps/{plugin_list.go => apps_plugin_list.go} | 0 .../{plugin_list_test.go => apps_plugin_list_test.go} | 0 .../{plugin_uninstall.go => apps_plugin_uninstall.go} | 0 ...in_uninstall_test.go => apps_plugin_uninstall_test.go} | 0 shortcuts/apps/plugin_common.go | 8 ++++++-- 7 files changed, 6 insertions(+), 2 deletions(-) rename shortcuts/apps/{plugin_install.go => apps_plugin_install.go} (100%) rename shortcuts/apps/{plugin_install_test.go => apps_plugin_install_test.go} (100%) rename shortcuts/apps/{plugin_list.go => apps_plugin_list.go} (100%) rename shortcuts/apps/{plugin_list_test.go => apps_plugin_list_test.go} (100%) rename shortcuts/apps/{plugin_uninstall.go => apps_plugin_uninstall.go} (100%) rename shortcuts/apps/{plugin_uninstall_test.go => apps_plugin_uninstall_test.go} (100%) diff --git a/shortcuts/apps/plugin_install.go b/shortcuts/apps/apps_plugin_install.go similarity index 100% rename from shortcuts/apps/plugin_install.go rename to shortcuts/apps/apps_plugin_install.go diff --git a/shortcuts/apps/plugin_install_test.go b/shortcuts/apps/apps_plugin_install_test.go similarity index 100% rename from shortcuts/apps/plugin_install_test.go rename to shortcuts/apps/apps_plugin_install_test.go diff --git a/shortcuts/apps/plugin_list.go b/shortcuts/apps/apps_plugin_list.go similarity index 100% rename from shortcuts/apps/plugin_list.go rename to shortcuts/apps/apps_plugin_list.go diff --git a/shortcuts/apps/plugin_list_test.go b/shortcuts/apps/apps_plugin_list_test.go similarity index 100% rename from shortcuts/apps/plugin_list_test.go rename to shortcuts/apps/apps_plugin_list_test.go diff --git a/shortcuts/apps/plugin_uninstall.go b/shortcuts/apps/apps_plugin_uninstall.go similarity index 100% rename from shortcuts/apps/plugin_uninstall.go rename to shortcuts/apps/apps_plugin_uninstall.go diff --git a/shortcuts/apps/plugin_uninstall_test.go b/shortcuts/apps/apps_plugin_uninstall_test.go similarity index 100% rename from shortcuts/apps/plugin_uninstall_test.go rename to shortcuts/apps/apps_plugin_uninstall_test.go diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index 849fa7f94..d191ed27a 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -373,10 +373,14 @@ func pluginExtractTGZ(r io.Reader, destDir string) error { return err } if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size - f.Close() + if cerr := f.Close(); cerr != nil { + return fmt.Errorf("copy tar entry: %w; close file: %v", err, cerr) + } + return err + } + if err := f.Close(); err != nil { return err } - f.Close() } } return nil From 9a85ffb4d21f37980baf5b1725077e584daacb8d Mon Sep 17 00:00:00 2001 From: zhangli Date: Tue, 30 Jun 2026 12:06:33 +0800 Subject: [PATCH 28/34] style: gofmt apps plugin files (#1664) --- shortcuts/apps/apps_plugin_install.go | 8 ++++---- shortcuts/apps/apps_plugin_install_test.go | 10 +++++----- shortcuts/apps/apps_plugin_list.go | 2 +- shortcuts/apps/apps_plugin_list_test.go | 2 +- shortcuts/apps/apps_plugin_uninstall.go | 2 +- shortcuts/apps/apps_plugin_uninstall_test.go | 6 +++--- shortcuts/apps/plugin_common_test.go | 1 - 7 files changed, 15 insertions(+), 16 deletions(-) diff --git a/shortcuts/apps/apps_plugin_install.go b/shortcuts/apps/apps_plugin_install.go index 546c2bef7..9ab90d93a 100644 --- a/shortcuts/apps/apps_plugin_install.go +++ b/shortcuts/apps/apps_plugin_install.go @@ -26,10 +26,10 @@ import ( // Without --name it batch-installs all plugins declared in actionPlugins that // are not yet present in node_modules. var AppsPluginInstall = common.Shortcut{ - Service: appsService, - Command: "+plugin-install", - Description: "Install a plugin package (download, extract, update package.json)", - Risk: "write", + Service: appsService, + Command: "+plugin-install", + Description: "Install a plugin package (download, extract, update package.json)", + Risk: "write", ConditionalScopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, Tips: []string{ diff --git a/shortcuts/apps/apps_plugin_install_test.go b/shortcuts/apps/apps_plugin_install_test.go index 031a40232..ee5c394c4 100644 --- a/shortcuts/apps/apps_plugin_install_test.go +++ b/shortcuts/apps/apps_plugin_install_test.go @@ -31,10 +31,10 @@ func TestPluginInstall_SinglePlugin(t *testing.T) { "data": map[string]interface{}{ "items": []interface{}{ map[string]interface{}{ - "key": "@test/my-plugin", - "version": "1.0.0", - "download_approach": "inner", - "status": "active", + "key": "@test/my-plugin", + "version": "1.0.0", + "download_approach": "inner", + "status": "active", }, }, }, @@ -92,7 +92,7 @@ func TestPluginInstall_AlreadyInstalled(t *testing.T) { }) // Create an existing installed plugin with package.json containing version pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin") - os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo + os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo chdirTest(t, dir) diff --git a/shortcuts/apps/apps_plugin_list.go b/shortcuts/apps/apps_plugin_list.go index e4f9ecf21..b2f0733b3 100644 --- a/shortcuts/apps/apps_plugin_list.go +++ b/shortcuts/apps/apps_plugin_list.go @@ -18,7 +18,7 @@ var AppsPluginList = common.Shortcut{ Service: appsService, Command: "+plugin-list", Description: "List declared plugin packages and their installation status", - Risk: "read", + Risk: "read", Tips: []string{ "Example: lark-cli apps +plugin-list", "Example: lark-cli apps +plugin-list --format pretty", diff --git a/shortcuts/apps/apps_plugin_list_test.go b/shortcuts/apps/apps_plugin_list_test.go index a49bd7df7..f468063f7 100644 --- a/shortcuts/apps/apps_plugin_list_test.go +++ b/shortcuts/apps/apps_plugin_list_test.go @@ -40,7 +40,7 @@ func TestPluginList_Installed(t *testing.T) { }, }) manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin") - os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo + os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo chdirTest(t, dir) diff --git a/shortcuts/apps/apps_plugin_uninstall.go b/shortcuts/apps/apps_plugin_uninstall.go index 0c1b5c15c..6f179984c 100644 --- a/shortcuts/apps/apps_plugin_uninstall.go +++ b/shortcuts/apps/apps_plugin_uninstall.go @@ -20,7 +20,7 @@ var AppsPluginUninstall = common.Shortcut{ Service: appsService, Command: "+plugin-uninstall", Description: "Uninstall a plugin package (remove from node_modules and package.json)", - Risk: "write", + Risk: "write", Tips: []string{ "Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate", }, diff --git a/shortcuts/apps/apps_plugin_uninstall_test.go b/shortcuts/apps/apps_plugin_uninstall_test.go index db4584cd2..885b632f8 100644 --- a/shortcuts/apps/apps_plugin_uninstall_test.go +++ b/shortcuts/apps/apps_plugin_uninstall_test.go @@ -20,7 +20,7 @@ func TestPluginUninstall_Basic(t *testing.T) { }, }) pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") - os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo + os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo chdirTest(t, dir) @@ -77,7 +77,7 @@ func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) { }) // Install plugin pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") - os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo + os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo // Create a capability that references this plugin @@ -125,7 +125,7 @@ func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) { }, }) pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") - os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo + os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo // Create a capability that references a DIFFERENT plugin — should not block diff --git a/shortcuts/apps/plugin_common_test.go b/shortcuts/apps/plugin_common_test.go index 263f75faf..8bc56beb2 100644 --- a/shortcuts/apps/plugin_common_test.go +++ b/shortcuts/apps/plugin_common_test.go @@ -239,7 +239,6 @@ func TestPluginListCapabilities_SkipsMalformed(t *testing.T) { } } - // --- helpers --- func writeTestCapJSON(t *testing.T, dir, filename string, data map[string]interface{}) { From be7c05cc975589faa4eb249016b5c3ef40b8f939 Mon Sep 17 00:00:00 2001 From: anngo-nk Date: Tue, 30 Jun 2026 14:04:38 +0800 Subject: [PATCH 29/34] fix(plugin): resolve CI lint, deadcode, and unit-test failures (#1667) - Add Scopes: []string{} to plugin-install, plugin-list, plugin-uninstall shortcuts to satisfy TestAllShortcutsScopesNotNil - Remove unused pluginCheckInstalled function (deadcode) - Fix nilerr: add //nolint:nilerr for intentional best-effort nil returns - Fix forbidigo: replace bare fmt.Errorf in Execute with typed error, add //nolint:forbidigo for intermediate helper errors in pluginExtractTGZ - Fix errorlint: change %v to %w for cerr in multi-error fmt.Errorf - Remove all unused //nolint:forbidigo directives from test files --- shortcuts/apps/apps_plugin_install.go | 3 +- shortcuts/apps/apps_plugin_install_test.go | 11 +++--- shortcuts/apps/apps_plugin_list.go | 3 +- shortcuts/apps/apps_plugin_list_test.go | 12 +++---- shortcuts/apps/apps_plugin_uninstall.go | 3 +- shortcuts/apps/apps_plugin_uninstall_test.go | 22 ++++++------ shortcuts/apps/plugin_common.go | 36 ++++---------------- shortcuts/apps/plugin_common_test.go | 20 +++++------ 8 files changed, 45 insertions(+), 65 deletions(-) diff --git a/shortcuts/apps/apps_plugin_install.go b/shortcuts/apps/apps_plugin_install.go index 9ab90d93a..381b5ada1 100644 --- a/shortcuts/apps/apps_plugin_install.go +++ b/shortcuts/apps/apps_plugin_install.go @@ -31,6 +31,7 @@ var AppsPluginInstall = common.Shortcut{ Description: "Install a plugin package (download, extract, update package.json)", Risk: "write", ConditionalScopes: []string{"spark:app:read"}, + Scopes: []string{}, AuthTypes: []string{"user"}, Tips: []string{ "Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate", @@ -195,7 +196,7 @@ func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectP continue } if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil { - return fmt.Errorf("install %s: %w", key, err) + return errs.NewInternalError(errs.SubtypeUnknown, "install %s failed", key).WithCause(err) } installed++ } diff --git a/shortcuts/apps/apps_plugin_install_test.go b/shortcuts/apps/apps_plugin_install_test.go index ee5c394c4..d40ef7fa3 100644 --- a/shortcuts/apps/apps_plugin_install_test.go +++ b/shortcuts/apps/apps_plugin_install_test.go @@ -63,8 +63,7 @@ func TestPluginInstall_SinglePlugin(t *testing.T) { // Verify file extracted manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json") - if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo - t.Fatalf("manifest.json not extracted: %v", err) + if _, err := os.Stat(manifestPath); err != nil { t.Fatalf("manifest.json not extracted: %v", err) } // Verify package.json updated @@ -92,8 +91,8 @@ func TestPluginInstall_AlreadyInstalled(t *testing.T) { }) // Create an existing installed plugin with package.json containing version pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin") - os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo - os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo + os.MkdirAll(pkgDir, 0o755) + os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) chdirTest(t, dir) factory, stdout, _ := newAppsExecuteFactory(t) @@ -126,7 +125,7 @@ func TestPluginExtractTGZ(t *testing.T) { t.Fatalf("extract error: %v", err) } - data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) //nolint:forbidigo + data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) if err != nil { t.Fatalf("manifest.json not extracted: %v", err) } @@ -153,7 +152,7 @@ func TestPluginExtractTGZ_PathTraversal(t *testing.T) { if err := pluginExtractTGZ(&buf, destDir); err != nil { t.Fatalf("extract should not error, but skip bad entries: %v", err) } - if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { //nolint:forbidigo + if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { t.Error("path traversal should have been blocked") } } diff --git a/shortcuts/apps/apps_plugin_list.go b/shortcuts/apps/apps_plugin_list.go index b2f0733b3..97c62d01e 100644 --- a/shortcuts/apps/apps_plugin_list.go +++ b/shortcuts/apps/apps_plugin_list.go @@ -18,7 +18,8 @@ var AppsPluginList = common.Shortcut{ Service: appsService, Command: "+plugin-list", Description: "List declared plugin packages and their installation status", - Risk: "read", + Risk: "read", + Scopes: []string{}, Tips: []string{ "Example: lark-cli apps +plugin-list", "Example: lark-cli apps +plugin-list --format pretty", diff --git a/shortcuts/apps/apps_plugin_list_test.go b/shortcuts/apps/apps_plugin_list_test.go index f468063f7..196e014d1 100644 --- a/shortcuts/apps/apps_plugin_list_test.go +++ b/shortcuts/apps/apps_plugin_list_test.go @@ -40,8 +40,8 @@ func TestPluginList_Installed(t *testing.T) { }, }) manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin") - os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo - os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo + os.MkdirAll(manifestDir, 0o755) + os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) chdirTest(t, dir) factory, stdout, _ := newAppsExecuteFactory(t) @@ -99,14 +99,14 @@ func TestPluginList_DeclaredNotInstalled(t *testing.T) { func chdirTest(t *testing.T, dir string) { t.Helper() - prev, err := os.Getwd() //nolint:forbidigo + prev, err := os.Getwd() if err != nil { t.Fatal(err) } - if err := os.Chdir(dir); err != nil { //nolint:forbidigo + if err := os.Chdir(dir); err != nil { t.Fatal(err) } - t.Cleanup(func() { os.Chdir(prev) }) //nolint:forbidigo,errcheck + t.Cleanup(func() { os.Chdir(prev) }) //nolint:errcheck } func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) { @@ -115,7 +115,7 @@ func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) { if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { //nolint:forbidigo + if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { t.Fatal(err) } } diff --git a/shortcuts/apps/apps_plugin_uninstall.go b/shortcuts/apps/apps_plugin_uninstall.go index 6f179984c..12c40c764 100644 --- a/shortcuts/apps/apps_plugin_uninstall.go +++ b/shortcuts/apps/apps_plugin_uninstall.go @@ -20,7 +20,8 @@ var AppsPluginUninstall = common.Shortcut{ Service: appsService, Command: "+plugin-uninstall", Description: "Uninstall a plugin package (remove from node_modules and package.json)", - Risk: "write", + Risk: "write", + Scopes: []string{}, Tips: []string{ "Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate", }, diff --git a/shortcuts/apps/apps_plugin_uninstall_test.go b/shortcuts/apps/apps_plugin_uninstall_test.go index 885b632f8..d59072dbb 100644 --- a/shortcuts/apps/apps_plugin_uninstall_test.go +++ b/shortcuts/apps/apps_plugin_uninstall_test.go @@ -20,8 +20,8 @@ func TestPluginUninstall_Basic(t *testing.T) { }, }) pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") - os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo - os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo + os.MkdirAll(pluginDir, 0o755) + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) chdirTest(t, dir) factory, stdout, _ := newAppsExecuteFactory(t) @@ -34,7 +34,7 @@ func TestPluginUninstall_Basic(t *testing.T) { } // Verify node_modules removed - if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo + if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { t.Error("node_modules plugin dir should be removed") } @@ -77,12 +77,12 @@ func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) { }) // Install plugin pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") - os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo - os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo + os.MkdirAll(pluginDir, 0o755) + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) // Create a capability that references this plugin capDir := filepath.Join(dir, "server", "capabilities") - os.MkdirAll(capDir, 0o755) //nolint:forbidigo + os.MkdirAll(capDir, 0o755) writeTestCapJSON(t, capDir, "my-instance.json", map[string]interface{}{ "id": "my-instance", "pluginKey": "@test/my-plugin", @@ -100,7 +100,7 @@ func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) { } // Verify plugin directory still exists (blocked) - if _, err := os.Stat(pluginDir); err != nil { //nolint:forbidigo + if _, err := os.Stat(pluginDir); err != nil { t.Errorf("plugin directory should still exist after blocked uninstall: %v", err) } @@ -125,12 +125,12 @@ func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) { }, }) pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") - os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo - os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo + os.MkdirAll(pluginDir, 0o755) + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) // Create a capability that references a DIFFERENT plugin — should not block capDir := filepath.Join(dir, "server", "capabilities") - os.MkdirAll(capDir, 0o755) //nolint:forbidigo + os.MkdirAll(capDir, 0o755) writeTestCapJSON(t, capDir, "other-instance.json", map[string]interface{}{ "id": "other-instance", "pluginKey": "@test/other-plugin", @@ -148,7 +148,7 @@ func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) { } // Verify plugin was removed - if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo + if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { t.Error("plugin directory should be removed") } } diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index d191ed27a..c7f661fe8 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -56,7 +56,7 @@ func pluginCheckProjectDir(projectPath string) error { // 2.5 Read .env.local for MIAODA_APP_TYPE // 3. Detect by checking which directories exist under projectPath func pluginResolveCapDir(projectPath string) (string, error) { - if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { //nolint:forbidigo // env-based config lookup is intentional. + if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { if filepath.IsAbs(dir) { return dir, nil } @@ -64,7 +64,7 @@ func pluginResolveCapDir(projectPath string) (string, error) { } // 2. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/ - appType := os.Getenv("MIAODA_APP_TYPE") //nolint:forbidigo // env-based config lookup is intentional. + appType := os.Getenv("MIAODA_APP_TYPE") if appType == "" { appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE") } @@ -156,13 +156,11 @@ func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) { // the list of dependent instance ids if any exist, or the underlying I/O error. func pluginCheckDependentInstances(projectPath, pluginKey string) error { capDir, err := pluginResolveCapDir(projectPath) - if err != nil { - // No capabilities directory → no instances can exist → no conflict. + if err != nil { //nolint:nilerr -- best-effort: resolve failure means no capabilities dir, safe to skip return nil } caps, err := pluginListCapabilities(capDir) - if err != nil { - // Cannot scan → best-effort, don't block. + if err != nil { //nolint:nilerr -- best-effort: scan failure should not block uninstall return nil } var deps []string @@ -181,26 +179,6 @@ func pluginCheckDependentInstances(projectPath, pluginKey string) error { ).WithHint("delete these instances first (see /.agents/skills/plugin-guide/SKILL.md for instance removal steps), clean up calling code and types, then retry uninstall") } -// pluginCheckInstalled verifies that the plugin package is installed in node_modules -// with a valid manifest.json. -func pluginCheckInstalled(projectPath, pluginKey string) error { - pluginDir := filepath.Join(projectPath, "node_modules", pluginKey) - manifestPath := filepath.Join(pluginDir, "manifest.json") - if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for plugin check. - if os.IsNotExist(err) { - if pluginDirExists(pluginDir) { - return appsFailedPreconditionError( - "plugin %q exists in node_modules but manifest.json is missing; the package may not have been built correctly", pluginKey, - ).WithHint("run 'lark-cli apps +plugin-install --name %s' to reinstall from registry", pluginKey) - } - return appsFailedPreconditionError("plugin %q is not installed", pluginKey). - WithHint("run 'lark-cli apps +plugin-install --name %s' to install", pluginKey) - } - return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey) - } - return nil -} - // ── package.json helpers ── // pluginReadPackageJSON reads and parses the project's package.json. @@ -330,7 +308,7 @@ func pluginInstalledVersion(projectPath, pluginKey string) string { func pluginExtractTGZ(r io.Reader, destDir string) error { gz, err := gzip.NewReader(r) if err != nil { - return fmt.Errorf("gzip: %w", err) + return fmt.Errorf("gzip: %w", err) //nolint:forbidigo -- intermediate helper error; callers wrap as typed } defer gz.Close() @@ -342,7 +320,7 @@ func pluginExtractTGZ(r io.Reader, destDir string) error { break } if err != nil { - return fmt.Errorf("tar: %w", err) + return fmt.Errorf("tar: %w", err) //nolint:forbidigo -- intermediate helper error; callers wrap as typed } name := pluginStripFirstComponent(hdr.Name) @@ -374,7 +352,7 @@ func pluginExtractTGZ(r io.Reader, destDir string) error { } if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size if cerr := f.Close(); cerr != nil { - return fmt.Errorf("copy tar entry: %w; close file: %v", err, cerr) + return fmt.Errorf("copy tar entry: %w; close file: %w", err, cerr) //nolint:forbidigo -- intermediate helper error; callers wrap as typed } return err } diff --git a/shortcuts/apps/plugin_common_test.go b/shortcuts/apps/plugin_common_test.go index 8bc56beb2..7a6ce3201 100644 --- a/shortcuts/apps/plugin_common_test.go +++ b/shortcuts/apps/plugin_common_test.go @@ -19,7 +19,7 @@ func TestPluginResolveProjectPath_DefaultToCwd(t *testing.T) { if err != nil { t.Fatalf("unexpected error: %v", err) } - cwd, _ := os.Getwd() //nolint:forbidigo + cwd, _ := os.Getwd() if got != cwd { t.Errorf("got %q, want cwd %q", got, cwd) } @@ -39,7 +39,7 @@ func TestPluginResolveProjectPath_ExplicitPath(t *testing.T) { func TestPluginCheckProjectDir_OK(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { t.Fatal(err) } if err := pluginCheckProjectDir(dir); err != nil { @@ -99,7 +99,7 @@ func TestPluginResolveCapDir_AppTypeEnvShared(t *testing.T) { func TestPluginResolveCapDir_EnvLocal(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo + if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { t.Fatal(err) } got, err := pluginResolveCapDir(dir) @@ -113,7 +113,7 @@ func TestPluginResolveCapDir_EnvLocal(t *testing.T) { func TestPluginResolveCapDir_DetectServer(t *testing.T) { dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo + if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { t.Fatal(err) } got, err := pluginResolveCapDir(dir) @@ -127,7 +127,7 @@ func TestPluginResolveCapDir_DetectServer(t *testing.T) { func TestPluginResolveCapDir_DetectShared(t *testing.T) { dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo + if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { t.Fatal(err) } got, err := pluginResolveCapDir(dir) @@ -141,10 +141,10 @@ func TestPluginResolveCapDir_DetectShared(t *testing.T) { func TestPluginResolveCapDir_Ambiguous(t *testing.T) { dir := t.TempDir() - if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo + if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { t.Fatal(err) } - if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo + if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { t.Fatal(err) } _, err := pluginResolveCapDir(dir) @@ -210,7 +210,7 @@ func TestPluginListCapabilities_WithFiles(t *testing.T) { writeTestCapJSON(t, dir, "cap1.json", map[string]interface{}{"id": "cap1", "name": "Cap One"}) writeTestCapJSON(t, dir, "cap2.json", map[string]interface{}{"id": "cap2", "name": "Cap Two"}) // non-JSON file should be skipped - if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil { //nolint:forbidigo + if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil { t.Fatal(err) } @@ -226,7 +226,7 @@ func TestPluginListCapabilities_WithFiles(t *testing.T) { func TestPluginListCapabilities_SkipsMalformed(t *testing.T) { dir := t.TempDir() writeTestCapJSON(t, dir, "good.json", map[string]interface{}{"id": "good"}) - if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil { //nolint:forbidigo + if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil { t.Fatal(err) } @@ -247,7 +247,7 @@ func writeTestCapJSON(t *testing.T, dir, filename string, data map[string]interf if err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { //nolint:forbidigo + if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { t.Fatal(err) } } From 9d7f1e4e6b47d6309b3df46d497bde54b973a048 Mon Sep 17 00:00:00 2001 From: lvxinsheng Date: Tue, 30 Jun 2026 14:17:33 +0800 Subject: [PATCH 30/34] style: gofmt apps_plugin list/uninstall/install_test files Fix fast-gate Check formatting failure: align struct literal fields in apps_plugin_list.go and apps_plugin_uninstall.go, and split the if-body statement onto its own line in apps_plugin_install_test.go. --- shortcuts/apps/apps_plugin_install_test.go | 3 ++- shortcuts/apps/apps_plugin_list.go | 4 ++-- shortcuts/apps/apps_plugin_uninstall.go | 4 ++-- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/shortcuts/apps/apps_plugin_install_test.go b/shortcuts/apps/apps_plugin_install_test.go index d40ef7fa3..9ce3cfa4f 100644 --- a/shortcuts/apps/apps_plugin_install_test.go +++ b/shortcuts/apps/apps_plugin_install_test.go @@ -63,7 +63,8 @@ func TestPluginInstall_SinglePlugin(t *testing.T) { // Verify file extracted manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json") - if _, err := os.Stat(manifestPath); err != nil { t.Fatalf("manifest.json not extracted: %v", err) + if _, err := os.Stat(manifestPath); err != nil { + t.Fatalf("manifest.json not extracted: %v", err) } // Verify package.json updated diff --git a/shortcuts/apps/apps_plugin_list.go b/shortcuts/apps/apps_plugin_list.go index 97c62d01e..338936a6c 100644 --- a/shortcuts/apps/apps_plugin_list.go +++ b/shortcuts/apps/apps_plugin_list.go @@ -18,8 +18,8 @@ var AppsPluginList = common.Shortcut{ Service: appsService, Command: "+plugin-list", Description: "List declared plugin packages and their installation status", - Risk: "read", - Scopes: []string{}, + Risk: "read", + Scopes: []string{}, Tips: []string{ "Example: lark-cli apps +plugin-list", "Example: lark-cli apps +plugin-list --format pretty", diff --git a/shortcuts/apps/apps_plugin_uninstall.go b/shortcuts/apps/apps_plugin_uninstall.go index 12c40c764..2e62faf11 100644 --- a/shortcuts/apps/apps_plugin_uninstall.go +++ b/shortcuts/apps/apps_plugin_uninstall.go @@ -20,8 +20,8 @@ var AppsPluginUninstall = common.Shortcut{ Service: appsService, Command: "+plugin-uninstall", Description: "Uninstall a plugin package (remove from node_modules and package.json)", - Risk: "write", - Scopes: []string{}, + Risk: "write", + Scopes: []string{}, Tips: []string{ "Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate", }, From cf9e8d512daa28f14dc13526144fbd4ca183268b Mon Sep 17 00:00:00 2001 From: anngo-nk Date: Tue, 30 Jun 2026 14:37:58 +0800 Subject: [PATCH 31/34] fix(plugin): fix nolint directive format and nilerr placement in plugin_common.go (#1668) - Change nolint comment separator from -- to // to satisfy nolintlint - Move nilerr nolint directive to return statement to suppress nilerr correctly - Fix forbidigo nolint format for intermediate fmt.Errorf in pluginExtractTGZ --- shortcuts/apps/plugin_common.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index c7f661fe8..d5df0ee0c 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -156,12 +156,12 @@ func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) { // the list of dependent instance ids if any exist, or the underlying I/O error. func pluginCheckDependentInstances(projectPath, pluginKey string) error { capDir, err := pluginResolveCapDir(projectPath) - if err != nil { //nolint:nilerr -- best-effort: resolve failure means no capabilities dir, safe to skip - return nil + if err != nil { + return nil //nolint:nilerr // best-effort: no capabilities dir means no conflict } caps, err := pluginListCapabilities(capDir) - if err != nil { //nolint:nilerr -- best-effort: scan failure should not block uninstall - return nil + if err != nil { + return nil //nolint:nilerr // best-effort: scan failure should not block uninstall } var deps []string for _, cap := range caps { @@ -308,7 +308,7 @@ func pluginInstalledVersion(projectPath, pluginKey string) string { func pluginExtractTGZ(r io.Reader, destDir string) error { gz, err := gzip.NewReader(r) if err != nil { - return fmt.Errorf("gzip: %w", err) //nolint:forbidigo -- intermediate helper error; callers wrap as typed + return fmt.Errorf("gzip: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed } defer gz.Close() @@ -320,7 +320,7 @@ func pluginExtractTGZ(r io.Reader, destDir string) error { break } if err != nil { - return fmt.Errorf("tar: %w", err) //nolint:forbidigo -- intermediate helper error; callers wrap as typed + return fmt.Errorf("tar: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed } name := pluginStripFirstComponent(hdr.Name) @@ -352,7 +352,7 @@ func pluginExtractTGZ(r io.Reader, destDir string) error { } if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size if cerr := f.Close(); cerr != nil { - return fmt.Errorf("copy tar entry: %w; close file: %w", err, cerr) //nolint:forbidigo -- intermediate helper error; callers wrap as typed + return fmt.Errorf("copy tar entry: %w; close file: %w", err, cerr) //nolint:forbidigo // intermediate helper error; callers wrap as typed } return err } From 4cde9f1dd8244be77b1815714d301d82305951b8 Mon Sep 17 00:00:00 2001 From: raistlin042 Date: Tue, 30 Jun 2026 17:00:06 +0800 Subject: [PATCH 32/34] fix(apps): validate openapi-key scope method, path and raw JSON (#1675) Enforce an HTTP method whitelist (GET/POST/PUT/PATCH/DELETE), reject malformed --scope-api paths (must start with '/', no '..' or '//'), and constrain raw --scope JSON to the documented request_scope schema (allow_all + http_infos only). Validation runs in both the Validate hook and the body-build path so dry-run and execute are equally gated. Fixes PR #1596 audit findings HIGH-2 and MEDIUM-4. --- shortcuts/apps/apps_openapi_key_common.go | 134 ++++++++++++++++-- .../apps/apps_openapi_key_common_test.go | 102 +++++++++++++ 2 files changed, 221 insertions(+), 15 deletions(-) diff --git a/shortcuts/apps/apps_openapi_key_common.go b/shortcuts/apps/apps_openapi_key_common.go index 5cde30371..7517ab72c 100644 --- a/shortcuts/apps/apps_openapi_key_common.go +++ b/shortcuts/apps/apps_openapi_key_common.go @@ -46,13 +46,118 @@ func redactKeyInfo(info map[string]interface{}) map[string]interface{} { return out } -// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case httpInfo. +// allowedScopeAPIMethods is the HTTP method whitelist for --scope-api / request_scope. +var allowedScopeAPIMethods = map[string]struct{}{ + "GET": {}, "POST": {}, "PUT": {}, "PATCH": {}, "DELETE": {}, +} + +// validateScopeAPIMethod rejects methods outside the whitelist (e.g. TRACE, CONNECT, empty). +func validateScopeAPIMethod(method string) error { + if _, ok := allowedScopeAPIMethods[method]; !ok { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "http method %q not allowed; use one of GET, POST, PUT, PATCH, DELETE", method) + } + return nil +} + +// validateScopeAPIPath enforces basic openapi route hygiene as a first line of defense. +func validateScopeAPIPath(p string) error { + if p == "" || !strings.HasPrefix(p, "/") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "http path must start with '/', got %q", p) + } + if strings.Contains(p, "..") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "http path must not contain '..': %q", p) + } + if strings.Contains(p, "//") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "http path must not contain '//': %q", p) + } + return nil +} + +// validateRequestScopeFields constrains a request_scope object to the documented +// schema: only allow_all (bool) and http_infos ([{http_method, http_path}]). This +// closes the raw --scope escape hatch from injecting undocumented fields. +func validateRequestScopeFields(rs map[string]interface{}) error { + for k := range rs { + switch k { + case "allow_all", "http_infos": + default: + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown field %q; only allow_all and http_infos are allowed", k) + } + } + if v, ok := rs["allow_all"]; ok { + if _, isBool := v.(bool); !isBool { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "allow_all must be a boolean") + } + } + if v, ok := rs["http_infos"]; ok { + arr, isArr := v.([]interface{}) + if !isArr { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "http_infos must be an array") + } + for _, item := range arr { + m, isMap := item.(map[string]interface{}) + if !isMap { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "each http_infos entry must be an object") + } + for k := range m { + switch k { + case "http_method", "http_path": + default: + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown field %q in http_infos entry; only http_method and http_path are allowed", k) + } + } + method, _ := m["http_method"].(string) + if err := validateScopeAPIMethod(method); err != nil { + return err + } + path, _ := m["http_path"].(string) + if err := validateScopeAPIPath(path); err != nil { + return err + } + } + } + return nil +} + +// parseRawScope parses a raw --scope JSON value: it must be an object that +// conforms to the request_scope schema (validated by validateRequestScopeFields). +func parseRawScope(scopeRaw string) (map[string]interface{}, error) { + var rs interface{} + if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil { + return nil, err + } + obj, ok := rs.(map[string]interface{}) + if !ok { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope must be a JSON object") + } + if err := validateRequestScopeFields(obj); err != nil { + return nil, err + } + return obj, nil +} + +// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case +// httpInfo, validating the method against the whitelist and the path format. func parseScopeAPI(s string) (map[string]interface{}, error) { fields := strings.Fields(strings.TrimSpace(s)) if len(fields) != 2 { return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "expected 'METHOD /path', got %q", s) } - return map[string]interface{}{"http_method": strings.ToUpper(fields[0]), "http_path": fields[1]}, nil + method := strings.ToUpper(fields[0]) + if err := validateScopeAPIMethod(method); err != nil { + return nil, err + } + path := fields[1] + if err := validateScopeAPIPath(path); err != nil { + return nil, err + } + return map[string]interface{}{"http_method": method, "http_path": path}, nil } // buildRequestScope assembles config.request_scope (snake_case) from the scope flags. @@ -65,11 +170,7 @@ func buildRequestScope(scopeAll bool, scopeAPIs []string, scopeRaw string) (inte if hasFriendly { return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be combined with --scope-all / --scope-api").WithParam("--scope") } - var rs interface{} - if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil { - return nil, err - } - return rs, nil + return parseRawScope(scopeRaw) } if !hasFriendly { return nil, nil @@ -111,18 +212,21 @@ func buildKeyConfig(scopeAll bool, scopeAPIs []string, scopeRaw string, hasAllow // oapiKeyValidateScopeFlags validates the scope flag combination (shared by create/update). func oapiKeyValidateScopeFlags(rctx *common.RuntimeContext) error { scopeRaw := strings.TrimSpace(rctx.Str("scope")) - if scopeRaw != "" && (rctx.Bool("scope-all") || len(rctx.StrArray("scope-api")) > 0) { + scopeAPIs := rctx.StrArray("scope-api") + if scopeRaw != "" && (rctx.Bool("scope-all") || len(scopeAPIs) > 0) { return appsValidationParamError("--scope", "--scope cannot be combined with --scope-all / --scope-api"). WithHint("use either --scope (raw JSON) OR --scope-all/--scope-api, not both") } - if scopeRaw != "" && !json.Valid([]byte(scopeRaw)) { - return appsValidationParamError("--scope", "--scope must be valid JSON"). - WithHint("--scope takes raw JSON for config.request_scope; or use --scope-all / --scope-api 'METHOD /openapi/path'") + if scopeRaw != "" { + if _, err := parseRawScope(scopeRaw); err != nil { + return appsValidationParamError("--scope", "invalid --scope: %s", err). + WithHint("--scope takes a JSON object with only allow_all (bool) and http_infos ([{http_method, http_path}]); methods: GET, POST, PUT, PATCH, DELETE") + } } - for _, a := range rctx.StrArray("scope-api") { - if len(strings.Fields(strings.TrimSpace(a))) != 2 { - return appsValidationParamError("--scope-api", "--scope-api must be 'METHOD /path', got %q", a). - WithHint("format: --scope-api 'METHOD /openapi/path' (routes come from the app's docs/openapi.json), e.g. --scope-api 'GET /openapi/orders'") + for _, a := range scopeAPIs { + if _, err := parseScopeAPI(a); err != nil { + return appsValidationParamError("--scope-api", "invalid --scope-api: %s", err). + WithHint("format: 'METHOD /openapi/path'; method one of GET, POST, PUT, PATCH, DELETE; path starts with '/', no '..' or '//'") } } return nil diff --git a/shortcuts/apps/apps_openapi_key_common_test.go b/shortcuts/apps/apps_openapi_key_common_test.go index 84b96b832..df4a248b9 100644 --- a/shortcuts/apps/apps_openapi_key_common_test.go +++ b/shortcuts/apps/apps_openapi_key_common_test.go @@ -78,6 +78,108 @@ func TestParseScopeAPI(t *testing.T) { }) } +func TestValidateScopeAPIMethod(t *testing.T) { + for _, m := range []string{"GET", "POST", "PUT", "PATCH", "DELETE"} { + if err := validateScopeAPIMethod(m); err != nil { + t.Errorf("validateScopeAPIMethod(%q) = %v, want nil", m, err) + } + } + for _, m := range []string{"TRACE", "CONNECT", "OPTIONS", "HEAD", "", "get"} { + if err := validateScopeAPIMethod(m); err == nil { + t.Errorf("validateScopeAPIMethod(%q) = nil, want error", m) + } + } +} + +func TestValidateScopeAPIPath(t *testing.T) { + for _, p := range []string{"/openapi/orders", "/openapi/v1/x"} { + if err := validateScopeAPIPath(p); err != nil { + t.Errorf("validateScopeAPIPath(%q) = %v, want nil", p, err) + } + } + for _, p := range []string{"", "openapi/x", "/openapi/../admin", "/..", "/openapi//x", "//x"} { + if err := validateScopeAPIPath(p); err == nil { + t.Errorf("validateScopeAPIPath(%q) = nil, want error", p) + } + } +} + +func TestValidateRequestScopeFields(t *testing.T) { + ok := []map[string]interface{}{ + {"allow_all": true}, + {"allow_all": false, "http_infos": []interface{}{ + map[string]interface{}{"http_method": "GET", "http_path": "/openapi/x"}, + }}, + {}, + } + for _, rs := range ok { + if err := validateRequestScopeFields(rs); err != nil { + t.Errorf("validateRequestScopeFields(%v) = %v, want nil", rs, err) + } + } + bad := []map[string]interface{}{ + {"foo": 1}, // unknown top-level field + {"allow_all": "yes"}, // wrong type + {"http_infos": "x"}, // not an array + {"http_infos": []interface{}{"x"}}, // entry not an object + {"http_infos": []interface{}{map[string]interface{}{"http_method": "TRACE", "http_path": "/x"}}}, // bad method + {"http_infos": []interface{}{map[string]interface{}{"http_method": "GET", "http_path": "../x"}}}, // bad path + {"http_infos": []interface{}{map[string]interface{}{"http_method": "GET", "http_path": "/x", "extra": 1}}}, // unknown entry field + } + for _, rs := range bad { + if err := validateRequestScopeFields(rs); err == nil { + t.Errorf("validateRequestScopeFields(%v) = nil, want error", rs) + } + } +} + +func TestParseRawScope(t *testing.T) { + if _, err := parseRawScope(`{"allow_all":true}`); err != nil { + t.Errorf("valid object errored: %v", err) + } + for _, raw := range []string{`["x"]`, `"s"`, `123`, `{"foo":1}`, `{bad`} { + if _, err := parseRawScope(raw); err == nil { + t.Errorf("parseRawScope(%q) = nil, want error", raw) + } + } +} + +func TestParseScopeAPI_Rejects(t *testing.T) { + bad := []string{"TRACE /openapi/x", "CONNECT /x", "GET ../admin", "GET openapi/x", "GET /a//b"} + for _, in := range bad { + if _, err := parseScopeAPI(in); err == nil { + t.Errorf("parseScopeAPI(%q) = nil, want error", in) + } + } + // regression: legitimate input still parses (and lowercases the method) + info, err := parseScopeAPI("get /openapi/orders") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info["http_method"] != "GET" || info["http_path"] != "/openapi/orders" { + t.Errorf("info = %v", info) + } +} + +func TestBuildRequestScope_RawValidation(t *testing.T) { + // unknown field now rejected (HIGH-2) + if _, err := buildRequestScope(false, nil, `{"foo":1}`); err == nil { + t.Errorf("raw scope with unknown field must error") + } + // non-object rejected + if _, err := buildRequestScope(false, nil, `["x"]`); err == nil { + t.Errorf("non-object raw scope must error") + } + // nested bad method rejected + if _, err := buildRequestScope(false, nil, `{"http_infos":[{"http_method":"TRACE","http_path":"/x"}]}`); err == nil { + t.Errorf("raw scope with bad nested method must error") + } + // regression: documented fields pass + if _, err := buildRequestScope(false, nil, `{"allow_all":true}`); err != nil { + t.Errorf("valid raw scope errored: %v", err) + } +} + func TestBuildRequestScope(t *testing.T) { t.Run("nothing set -> nil", func(t *testing.T) { rs, err := buildRequestScope(false, nil, "") From 34378c5e1f8736aee7c0b291af4e055d5c56b9ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E5=85=B4=E7=82=80?= Date: Tue, 30 Jun 2026 17:06:12 +0800 Subject: [PATCH 33/34] fix(apps): harden db/file shortcuts per security audit (PR #1596) Address the file/db findings from the PR #1596 security audit with safer header/flag/path handling: - HIGH-3 (--output path traversal): add rejectOutputTraversal() and wire it into +file-download and +db-data-export Validate; reject absolute paths and any .. component up front. (FileIO.Save already sandboxes to cwd via SafeOutputPath; this is an earlier, explicit guard.) - HIGH-4 (Content-Disposition header injection): build the header with mime.FormatMediaType instead of manual string concatenation. - MEDIUM-3 (SQL leaked into public flag): stop writing --file contents back into the --sql flag; resolveExecuteSQL() reads it at use-site so SQL never lands in flag dumps / structured logs. - LOW-1 (hidden-file upload name): prefix sanitized upload names that start with '.' with '_'. - LOW-2 (local-timezone time parsing): document local-tz interpretation of bare date/datetime in flag descriptions and the db/file skill docs. SQL-injection of --table (audit MEDIUM-5) is intentionally NOT validated in the CLI: the server-side interface is the authoritative guard. Add apps_security_fixes_test.go covering the new validators and switch the upload test to parse Content-Disposition instead of matching a literal string. Update lark-apps-db.md / lark-apps-file.md skill refs. --- shortcuts/apps/apps_db_audit_list.go | 2 +- shortcuts/apps/apps_db_changelog_list.go | 2 +- shortcuts/apps/apps_db_data_export.go | 3 ++ shortcuts/apps/apps_db_execute.go | 27 ++++++++-- shortcuts/apps/apps_file_download.go | 3 ++ shortcuts/apps/apps_file_list.go | 4 +- shortcuts/apps/apps_file_upload.go | 14 +++++- shortcuts/apps/apps_file_upload_test.go | 7 ++- shortcuts/apps/apps_security_fixes_test.go | 50 +++++++++++++++++++ shortcuts/apps/common.go | 26 ++++++++++ skills/lark-apps/references/lark-apps-db.md | 4 +- skills/lark-apps/references/lark-apps-file.md | 4 +- 12 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 shortcuts/apps/apps_security_fixes_test.go diff --git a/shortcuts/apps/apps_db_audit_list.go b/shortcuts/apps/apps_db_audit_list.go index 8c6c6b071..ca03092e5 100644 --- a/shortcuts/apps/apps_db_audit_list.go +++ b/shortcuts/apps/apps_db_audit_list.go @@ -36,7 +36,7 @@ var AppsDBAuditList = common.Shortcut{ Flags: append([]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: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ"}, + {Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"}, {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"}, diff --git a/shortcuts/apps/apps_db_changelog_list.go b/shortcuts/apps/apps_db_changelog_list.go index faf424e98..052bbf9fe 100644 --- a/shortcuts/apps/apps_db_changelog_list.go +++ b/shortcuts/apps/apps_db_changelog_list.go @@ -35,7 +35,7 @@ var AppsDBChangelogList = common.Shortcut{ {Name: "app-id", Desc: "Miaoda app id", Required: true}, {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: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"}, {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"}, diff --git a/shortcuts/apps/apps_db_data_export.go b/shortcuts/apps/apps_db_data_export.go index a31712646..775406409 100644 --- a/shortcuts/apps/apps_db_data_export.go +++ b/shortcuts/apps/apps_db_data_export.go @@ -61,6 +61,9 @@ var AppsDBDataExport = common.Shortcut{ 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 := rejectOutputTraversal(rctx.Str("output")); err != nil { + return err + } if _, _, err := exportFormatAndOutput(rctx); err != nil { return err } diff --git a/shortcuts/apps/apps_db_execute.go b/shortcuts/apps/apps_db_execute.go index 2c38f7e88..290e470fe 100644 --- a/shortcuts/apps/apps_db_execute.go +++ b/shortcuts/apps/apps_db_execute.go @@ -84,8 +84,8 @@ var AppsDBExecute = common.Shortcut{ if err != nil { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err) } - // 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。 - rctx.Cmd.Flags().Set("sql", string(data)) + // 仅本地校验非空;不把文件内容写回公开的 --sql flag(避免 SQL 内容进入 + // flag dump / 结构化日志)。下游 DryRun/Execute 由 resolveExecuteSQL 在用时重新读取。 sql = strings.TrimSpace(string(data)) } if sql == "" { @@ -297,10 +297,29 @@ func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} { } } -// buildDBSQLBody 构造 sql 接口的 body:仅 sql(来源由 Validate 归一化到 --sql)。 +// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容 +// 不被写回公开的 --sql flag(避免泄露进 flag dump / 结构化日志)。优先 --sql(内联或 stdin, +// 已由输入框架解析到 flag 值);否则现读 --file。Validate 已先行校验可读且非空。 +func resolveExecuteSQL(rctx *common.RuntimeContext) (string, error) { + if strings.TrimSpace(rctx.Str("sql")) != "" { + return rctx.Str("sql"), nil + } + file := strings.TrimSpace(rctx.Str("file")) + if file == "" { + return "", nil + } + data, err := cmdutil.ReadInputFile(rctx.FileIO(), file) + if err != nil { + return "", err + } + return string(data), nil +} + +// buildDBSQLBody 构造 sql 接口的 body:仅 sql(由 resolveExecuteSQL 在用时解析,--file 不入 flag)。 func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} { + sql, _ := resolveExecuteSQL(rctx) return map[string]interface{}{ - "sql": rctx.Str("sql"), + "sql": sql, } } diff --git a/shortcuts/apps/apps_file_download.go b/shortcuts/apps/apps_file_download.go index 15736f854..ac87079f6 100644 --- a/shortcuts/apps/apps_file_download.go +++ b/shortcuts/apps/apps_file_download.go @@ -41,6 +41,9 @@ var AppsFileDownload = common.Shortcut{ if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } + if err := rejectOutputTraversal(rctx.Str("output")); err != nil { + return err + } _, err := requireFilePath(rctx.Str("path")) return err }, diff --git a/shortcuts/apps/apps_file_list.go b/shortcuts/apps/apps_file_list.go index 56ab88c53..251d4a257 100644 --- a/shortcuts/apps/apps_file_list.go +++ b/shortcuts/apps/apps_file_list.go @@ -39,8 +39,8 @@ var AppsFileList = common.Shortcut{ {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: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"}, + {Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"}, {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, {Name: "page-token", Desc: "pagination cursor from previous response"}, }, diff --git a/shortcuts/apps/apps_file_upload.go b/shortcuts/apps/apps_file_upload.go index 6f3cae4ee..6118a0006 100644 --- a/shortcuts/apps/apps_file_upload.go +++ b/shortcuts/apps/apps_file_upload.go @@ -136,7 +136,14 @@ func putFileBytes(ctx context.Context, url string, content []byte, contentType, if contentType != "" { req.Header.Set("Content-Type", contentType) } - req.Header.Set("Content-Disposition", "attachment; filename=\""+sanitizeUploadFileName(fileName)+"\"") + // 用 mime.FormatMediaType 规范生成 Content-Disposition(自动按 RFC 2045 处理引号/转义), + // 不手工拼接 header,杜绝文件名里的特殊字符破坏 header 结构。filename 已先经 sanitizeUploadFileName + // 做 encodeURIComponent(控制字符/分隔符均 %XX 化),此处是第二道防线。 + disposition := mime.FormatMediaType("attachment", map[string]string{"filename": sanitizeUploadFileName(fileName)}) + if disposition == "" { + disposition = "attachment" + } + req.Header.Set("Content-Disposition", disposition) resp, err := newFileTransferClient().Do(req) if err != nil { // dial/transport 失败是典型可重试场景。 @@ -170,6 +177,11 @@ func sanitizeUploadFileName(name string) string { if enc == "" { return "download_file" } + // 防止 sanitize 后仍以 . 开头(如 .bashrc / .ssh)——下载落地可能覆盖本地隐藏文件, + // 前置下划线消除隐藏文件语义。 + if strings.HasPrefix(enc, ".") { + enc = "_" + enc + } return enc } diff --git a/shortcuts/apps/apps_file_upload_test.go b/shortcuts/apps/apps_file_upload_test.go index 3182afee6..06dab27e8 100644 --- a/shortcuts/apps/apps_file_upload_test.go +++ b/shortcuts/apps/apps_file_upload_test.go @@ -7,6 +7,7 @@ import ( "encoding/json" "errors" "io" + "mime" "net/http" "net/http/httptest" "os" @@ -143,8 +144,10 @@ func TestAppsFileUpload_EndToEnd(t *testing.T) { 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) + // 断言按解析结果(format-agnostic):mime.FormatMediaType 对无 tspecial 的名不加引号, + // 旧的写死字符串 `filename="logo.png"` 不再成立,但 filename 参数仍须等于原名。 + if disp, params, err := mime.ParseMediaType(putCD); err != nil || disp != "attachment" || params["filename"] != "logo.png" { + t.Errorf("PUT Content-Disposition = %q, want disposition=attachment filename=logo.png (parse err=%v)", putCD, err) } got := stdout.String() if !strings.Contains(got, `"path": "/1858537546760216.png"`) { diff --git a/shortcuts/apps/apps_security_fixes_test.go b/shortcuts/apps/apps_security_fixes_test.go new file mode 100644 index 000000000..b08f53089 --- /dev/null +++ b/shortcuts/apps/apps_security_fixes_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" +) + +// TestRejectOutputTraversal pins HIGH-3: --output rejects absolute paths and +// any .. traversal component; empty and ordinary relative paths pass. +func TestRejectOutputTraversal(t *testing.T) { + ok := []string{"", "out.csv", "./out.csv", "sub/dir/out.csv", "a..b.csv", "foo..bar/x.csv"} + for _, p := range ok { + if err := rejectOutputTraversal(p); err != nil { + t.Errorf("rejectOutputTraversal(%q) = %v, want nil", p, err) + } + } + bad := []string{"/etc/passwd", "../x", "../../etc/cron.d/evil", "sub/../../x", "./../x"} + for _, p := range bad { + if err := rejectOutputTraversal(p); err == nil { + t.Errorf("rejectOutputTraversal(%q) = nil, want validation error", p) + } + } +} + +// TestSanitizeUploadFileName pins HIGH-4 / LOW-1: control & separator chars are +// neutralized (percent-encoded, no raw CR/LF/TAB/NUL/quote) and the result never +// starts with a dot (hidden-file overwrite guard). +func TestSanitizeUploadFileName(t *testing.T) { + // LOW-1: dotfiles get a leading underscore. + for _, in := range []string{".bashrc", ".ssh", "..hidden"} { + got := sanitizeUploadFileName(in) + if strings.HasPrefix(got, ".") { + t.Errorf("sanitizeUploadFileName(%q) = %q, must not start with '.'", in, got) + } + } + // HIGH-4: header-breaking / control chars must not survive raw. + raw := "a\r\nb\tc\x00d\"e.png" + got := sanitizeUploadFileName(raw) + for _, bad := range []string{"\r", "\n", "\t", "\x00", "\"", " "} { + if strings.Contains(got, bad) { + t.Errorf("sanitizeUploadFileName(%q) = %q, still contains raw %q", raw, got, bad) + } + } + if got == "" { + t.Error("sanitizeUploadFileName returned empty for non-empty input") + } +} diff --git a/shortcuts/apps/common.go b/shortcuts/apps/common.go index 5a8b59b9d..8a9627600 100644 --- a/shortcuts/apps/common.go +++ b/shortcuts/apps/common.go @@ -4,6 +4,7 @@ package apps import ( + "path/filepath" "strings" "github.com/larksuite/cli/errs" @@ -39,3 +40,28 @@ func withAppsHint(err error, hint string) error { } return err } + +// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied +// --output path. The authoritative guard is the local FileIO layer +// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and +// symlinks), so traversal is already blocked at write time; this gives an +// earlier, clearer validation error and pins the contract in the command layer. +// Empty (use server-derived default) passes through. Absolute paths and any +// ".." path component are rejected. +func rejectOutputTraversal(output string) error { + o := strings.TrimSpace(output) + if o == "" { + return nil + } + if filepath.IsAbs(o) { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--output must be a relative path within the current directory, got %q", o).WithParam("--output") + } + for _, seg := range strings.Split(filepath.Clean(o), string(filepath.Separator)) { + if seg == ".." { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--output must not contain .. path traversal, got %q", o).WithParam("--output") + } + } + return nil +} diff --git a/skills/lark-apps/references/lark-apps-db.md b/skills/lark-apps/references/lark-apps-db.md index f0e2d97ed..e903b2f03 100644 --- a/skills/lark-apps/references/lark-apps-db.md +++ b/skills/lark-apps/references/lark-apps-db.md @@ -29,7 +29,7 @@ ## 约定(先读) - **环境 `--environment dev|online`(所有 db 命令统一默认 `dev`)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开启多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。 -- **本地文件用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;路径在别处先 `cd` 过去或改成相对路径。 +- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒(validation / exit 2)。路径在别处先 `cd` 过去或改成相对路径。 - **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。 - **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。 @@ -150,6 +150,8 @@ lark-cli apps +db-quota-get --app-id app_xxx --environment dev - 日期时间 `2026-04-15T10:00:00` - 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00` +> **时区**:不带时区的 `日期` / `日期时间` 按**运行机器的本地时区**解析(再归一化到 UTC)。CI(UTC)与本地(如 UTC+8)跑同一条命令,时间边界会差几小时;要精确锁定时区时显式写 ISO 8601 带偏移(如 `...+08:00` / `...Z`)。`--target`(PITR 恢复)尤其建议带时区,避免恢复到非预期时间点。 + ## Agent 规则 - 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。 diff --git a/skills/lark-apps/references/lark-apps-file.md b/skills/lark-apps/references/lark-apps-file.md index aef156bfc..94d4a7ab8 100644 --- a/skills/lark-apps/references/lark-apps-file.md +++ b/skills/lark-apps/references/lark-apps-file.md @@ -57,7 +57,7 @@ lark-cli apps +file-download --app-id app_xxx --path /1858537546760216.png --out ``` ### +file-upload -上传一个本地文件。文件名沿用本地文件名,远端路径由平台分配。单文件上限 100 MB。 +上传一个本地文件。文件名沿用本地文件名(特殊字符做 URL 编码透传;以 `.` 开头的隐藏文件名会加 `_` 前缀,避免下载回本地时覆盖隐藏文件),远端路径由平台分配。单文件上限 100 MB。 ```bash lark-cli apps +file-upload --app-id app_xxx --file ./report.pdf @@ -86,6 +86,8 @@ lark-cli apps +file-quota-get --app-id app_xxx - 日期时间 `2026-04-15T10:00:00` - 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00` +> **时区**:不带时区的 `日期` / `日期时间` 按**运行机器的本地时区**解析(再归一化到 UTC 发给服务端)。CI(UTC)与本地(如 UTC+8)跑同一条命令,过滤边界会差几小时;要精确到某时区时显式写 ISO 8601 带偏移(如 `...+08:00` / `...Z`)。 + ## Agent 规则 - 寻址一律用 `--path`;用户只给文件名时先 `+file-list --name <名>` 定位,多个同名再让用户确认。 From 56724a2548139f2d6fa8efe18ebe3b14cd7aab90 Mon Sep 17 00:00:00 2001 From: zhangli Date: Tue, 30 Jun 2026 17:40:30 +0800 Subject: [PATCH 34/34] fix(plugin): harden plugin commands against path traversal, DoS, and agent misuse (#1677) Security fixes from PR #1596 security audit: - Skip symlink/hardlink entries during tgz extraction (Zip Slip) - Limit tgz entry and download size to 10 MB (OOM/DoS) - Limit error response body read to 4 KB - Validate MIAODA_APP_TYPE as numeric to prevent path manipulation - Add validatePluginKey + secureModulePath to block --name path traversal (../../.ssh etc.) for install/uninstall Usability fix: - Add explicit 'local command, no --app-id' notice in plugin reference docs to prevent agent from incorrectly passing --app-id to plugin commands (which read package.json locally) --- shortcuts/apps/apps_plugin_install.go | 33 +++++++++++-- shortcuts/apps/apps_plugin_list.go | 2 +- shortcuts/apps/apps_plugin_uninstall.go | 10 ++-- shortcuts/apps/plugin_common.go | 48 ++++++++++++++++++- .../references/lark-apps-plugin-install.md | 2 + .../references/lark-apps-plugin-list.md | 2 + .../references/lark-apps-plugin-uninstall.md | 2 + 7 files changed, 90 insertions(+), 9 deletions(-) diff --git a/shortcuts/apps/apps_plugin_install.go b/shortcuts/apps/apps_plugin_install.go index 381b5ada1..fa6871cea 100644 --- a/shortcuts/apps/apps_plugin_install.go +++ b/shortcuts/apps/apps_plugin_install.go @@ -68,6 +68,11 @@ var AppsPluginInstall = common.Shortcut{ if err != nil { return err } + if key := strings.TrimSpace(rctx.Str("name")); key != "" { + if err := validatePluginKey(key); err != nil { + return err + } + } return pluginCheckProjectDir(projectPath) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -134,7 +139,10 @@ func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectP } // Extract to node_modules - destDir := filepath.Join(projectPath, "node_modules", key) + destDir, err := secureModulePath(projectPath, key) + if err != nil { + return err + } if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract. return appsFileIOError(err, "cannot clean %s", destDir) } @@ -247,7 +255,10 @@ func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string } // Move to node_modules - destDir := filepath.Join(projectPath, "node_modules", key) + destDir, err := secureModulePath(projectPath, key) + if err != nil { + return err + } if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo return appsFileIOError(err, "cannot clean %s", destDir) } @@ -355,6 +366,9 @@ func pluginFindVersionInItems(data map[string]interface{}, key, version string) // pluginDownloadPackage downloads a plugin .tgz via the download_package API. // The endpoint is POST with JSON body {plugin_key, plugin_version}. + +const pluginDownloadMaxBytes = 10 * 1024 * 1024 + func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version string) ([]byte, error) { apiPath := apiBasePath + "/plugin/versions/download_package" body, _ := json.Marshal(map[string]string{ @@ -380,7 +394,7 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key WithRetryable() } if resp.StatusCode >= 400 { - respBody, _ := io.ReadAll(resp.Body) + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) hint := "check plugin key and version spelling" if resp.StatusCode == 403 { hint = "download token may have expired; retry the install to get a fresh token" @@ -390,5 +404,16 @@ func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key return nil, errs.NewAPIError(errs.SubtypeUnknown, "download failed for %s@%s: HTTP %d: %s", key, version, resp.StatusCode, string(respBody)). WithHint(hint) } - return io.ReadAll(resp.Body) + data, err := io.ReadAll(io.LimitReader(resp.Body, pluginDownloadMaxBytes+1)) + if err != nil { + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed for %s@%s: %v", key, version, err). + WithHint("check network connectivity and retry"). + WithRetryable(). + WithCause(err) + } + if len(data) > pluginDownloadMaxBytes { + return nil, errs.NewAPIError(errs.SubtypeUnknown, "plugin package %s@%s exceeds %d MB size limit", key, version, pluginDownloadMaxBytes/(1024*1024)). + WithHint("contact plugin maintainer to reduce package size") + } + return data, nil } diff --git a/shortcuts/apps/apps_plugin_list.go b/shortcuts/apps/apps_plugin_list.go index 338936a6c..d5cb8df3c 100644 --- a/shortcuts/apps/apps_plugin_list.go +++ b/shortcuts/apps/apps_plugin_list.go @@ -17,7 +17,7 @@ import ( var AppsPluginList = common.Shortcut{ Service: appsService, Command: "+plugin-list", - Description: "List declared plugin packages and their installation status", + Description: "List locally installed plugin packages and their installation status", Risk: "read", Scopes: []string{}, Tips: []string{ diff --git a/shortcuts/apps/apps_plugin_uninstall.go b/shortcuts/apps/apps_plugin_uninstall.go index 2e62faf11..dc4bf8b4b 100644 --- a/shortcuts/apps/apps_plugin_uninstall.go +++ b/shortcuts/apps/apps_plugin_uninstall.go @@ -8,7 +8,6 @@ import ( "fmt" "io" "os" - "path/filepath" "strings" "github.com/larksuite/cli/shortcuts/common" @@ -38,8 +37,10 @@ var AppsPluginUninstall = common.Shortcut{ Set("update_file", "package.json actionPlugins") }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - if strings.TrimSpace(rctx.Str("name")) == "" { + if key := strings.TrimSpace(rctx.Str("name")); key == "" { return appsValidationParamError("--name", "--name is required") + } else if err := validatePluginKey(key); err != nil { + return err } projectPath, err := pluginResolveProjectPath("") if err != nil { @@ -59,7 +60,10 @@ var AppsPluginUninstall = common.Shortcut{ return err } - pkgDir := filepath.Join(projectPath, "node_modules", key) + pkgDir, err := secureModulePath(projectPath, key) + if err != nil { + return err + } if err := os.RemoveAll(pkgDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; remove plugin directory. return appsFileIOError(err, "cannot remove %s", pkgDir) } diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go index d5df0ee0c..d362f5f49 100644 --- a/shortcuts/apps/plugin_common.go +++ b/shortcuts/apps/plugin_common.go @@ -11,6 +11,7 @@ import ( "io" "os" "path/filepath" + "strconv" "strings" "github.com/larksuite/cli/errs" @@ -50,6 +51,41 @@ func pluginCheckProjectDir(projectPath string) error { return nil } +// validatePluginKey validates a plugin key for use in filesystem paths. +// Rejects empty, ".", "..", absolute paths, path traversal, and control characters. +func validatePluginKey(key string) error { + if key == "" || key == "." || key == ".." { + return appsValidationError("invalid plugin key: must not be empty, \".\", or \"..\"") + } + if filepath.IsAbs(key) { + return appsValidationError("invalid plugin key: must not be an absolute path: %q", key) + } + if strings.Contains(key, "..") { + return appsValidationError("invalid plugin key: must not contain path traversal: %q", key) + } + for _, r := range key { + if r < 32 || r == 127 { + return appsValidationError("invalid plugin key: contains control character (code %d)", r) + } + } + return nil +} + +// secureModulePath validates the plugin key and joins it with +// projectPath/node_modules, asserting the result stays within node_modules. +func secureModulePath(projectPath, key string) (string, error) { + if err := validatePluginKey(key); err != nil { + return "", err + } + nodeModules := filepath.Join(projectPath, "node_modules") + resolved := filepath.Clean(filepath.Join(nodeModules, key)) + expectedPrefix := filepath.Clean(nodeModules) + string(filepath.Separator) + if !strings.HasPrefix(resolved+string(filepath.Separator), expectedPrefix) { + return "", appsValidationError("plugin key %q resolves outside node_modules", key) + } + return resolved, nil +} + // pluginResolveCapDir resolves the capabilities directory using a 3-level fallback: // 1. MIAODA_CAPABILITIES_DIR env var // 2. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities) @@ -68,6 +104,12 @@ func pluginResolveCapDir(projectPath string) (string, error) { if appType == "" { appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE") } + if appType != "" { + if _, err := strconv.Atoi(appType); err != nil { + return "", appsValidationError("MIAODA_APP_TYPE must be a number, got %q", appType). + WithHint("set MIAODA_APP_TYPE to a valid numeric value in .env.local") + } + } if appType == "6" { return filepath.Join(projectPath, "shared", "capabilities"), nil } @@ -302,6 +344,8 @@ func pluginInstalledVersion(projectPath, pluginKey string) string { // ── tgz extraction ── +const pluginExtractMaxBytes = 10 * 1024 * 1024 + // pluginExtractTGZ extracts a gzipped tar archive into destDir, stripping the // first path component (npm convention: tarballs contain a "package/" prefix). // Path traversal entries are silently skipped. @@ -338,6 +382,8 @@ func pluginExtractTGZ(r io.Reader, destDir string) error { } switch hdr.Typeflag { + case tar.TypeSymlink, tar.TypeLink: + continue case tar.TypeDir: if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction. return err @@ -350,7 +396,7 @@ func pluginExtractTGZ(r io.Reader, destDir string) error { if err != nil { return err } - if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size + if _, err := io.Copy(f, io.LimitReader(tr, pluginExtractMaxBytes)); err != nil { if cerr := f.Close(); cerr != nil { return fmt.Errorf("copy tar entry: %w; close file: %w", err, cerr) //nolint:forbidigo // intermediate helper error; callers wrap as typed } diff --git a/skills/lark-apps/references/lark-apps-plugin-install.md b/skills/lark-apps/references/lark-apps-plugin-install.md index 7d1648587..f4dabd579 100644 --- a/skills/lark-apps/references/lark-apps-plugin-install.md +++ b/skills/lark-apps/references/lark-apps-plugin-install.md @@ -1,5 +1,7 @@ # apps +plugin-install +> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。 + 安装插件包到项目。运行时命令事实以 `lark-cli apps +plugin-install --help` 为准。 ## 何时用 diff --git a/skills/lark-apps/references/lark-apps-plugin-list.md b/skills/lark-apps/references/lark-apps-plugin-list.md index 58f49fc24..7f7337658 100644 --- a/skills/lark-apps/references/lark-apps-plugin-list.md +++ b/skills/lark-apps/references/lark-apps-plugin-list.md @@ -1,5 +1,7 @@ # apps +plugin-list +> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。 + 列出已声明的插件包及安装状态。运行时命令事实以 `lark-cli apps +plugin-list --help` 为准。 ## 何时用 diff --git a/skills/lark-apps/references/lark-apps-plugin-uninstall.md b/skills/lark-apps/references/lark-apps-plugin-uninstall.md index f01f6c30e..a29bcc6fc 100644 --- a/skills/lark-apps/references/lark-apps-plugin-uninstall.md +++ b/skills/lark-apps/references/lark-apps-plugin-uninstall.md @@ -1,5 +1,7 @@ # apps +plugin-uninstall +> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。 + 卸载插件包。运行时命令事实以 `lark-cli apps +plugin-uninstall --help` 为准。 ## 何时用