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_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_analytics_test.go b/shortcuts/apps/apps_analytics_test.go new file mode 100644 index 000000000..3e8eeb5de --- /dev/null +++ b/shortcuts/apps/apps_analytics_test.go @@ -0,0 +1,459 @@ +// 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 TestAppsAnalyticsList_DryRunUsesNanoseconds(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + 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) + 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"]) + } + 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 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"]) + } + 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"]) + } +} + +func TestAppsAnalyticsList_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + { + name: "series", + args: []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "page-view", + "--series", "desktop", "--page", "/home", "--dry-run", "--as", "user", + }, + }, + { + name: "device-type", + args: []string{ + "+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, AppsAnalyticsList, 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) + } + if tc.name == "series" && filter["page"] != "/home" { + t.Fatalf("filter.page = %#v, want /home", filter["page"]) + } + }) + } +} + +func TestAppsAnalyticsList_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{}{ + "series": []interface{}{ + map[string]interface{}{ + "metric_type": "PAGE_VIEW", + "points": []interface{}{ + map[string]interface{}{ + "timestamp_ns": float64(1782208800000000000), + "value": float64(21), + }, + }, + }, + }, + }, + }, + }) + + 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) + } + + 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 TestAppsAnalyticsList_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, 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) + } + 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 TestAppsAnalyticsList_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 TestAppsAnalyticsList_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, AppsAnalyticsList, []string{ + "+analytics-list", "--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 TestAppsAnalyticsList_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, AppsAnalyticsList, []string{ + "+analytics-list", "--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 TestAppsAnalyticsList_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, AppsAnalyticsList, []string{ + "+analytics-list", "--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 TestAppsAnalyticsList_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, AppsAnalyticsList, []string{ + "+analytics-list", "--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") + } +} diff --git a/shortcuts/apps/apps_db_audit_list.go b/shortcuts/apps/apps_db_audit_list.go new file mode 100644 index 000000000..ca03092e5 --- /dev/null +++ b/shortcuts/apps/apps_db_audit_list.go @@ -0,0 +1,302 @@ +// 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: 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 (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"}, + }, 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 + } + 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") + } + 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 := dbEnv(rctx) + + // 多表查询: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": dbEnv(rctx), + "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..93f0d4f7e --- /dev/null +++ b/shortcuts/apps/apps_db_audit_set.go @@ -0,0 +1,144 @@ +// 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: 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"}, + }, 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 + } + 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": 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 { + 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": dbEnv(rctx)}, + 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: append([]common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Desc: "table to disable audit for", Required: true}, + }, 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 + } + 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": dbEnv(rctx)}). + 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": dbEnv(rctx)}, + 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..341e11bdb --- /dev/null +++ b/shortcuts/apps/apps_db_audit_status.go @@ -0,0 +1,140 @@ +// 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: 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("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 + } + return rejectLegacyEnvFlag(rctx) + }, + 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": dbEnv(rctx)} + 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..052bbf9fe --- /dev/null +++ b/shortcuts/apps/apps_db_changelog_list.go @@ -0,0 +1,152 @@ +// 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 --environment dev, create it first with `lark-cli apps +db-env-create --app-id --environment 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: append([]common.Flag{ + {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 (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"}, + }, 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 + } + if err := rejectLegacyEnvFlag(rctx); 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": dbEnv(rctx), + "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..a179b14e1 --- /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", "--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) + } + 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..775406409 --- /dev/null +++ b/shortcuts/apps/apps_db_data_export.go @@ -0,0 +1,194 @@ +// 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: 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)"}, + }, 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 + } + if err := rejectLegacyEnvFlag(rctx); 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 := rejectOutputTraversal(rctx.Str("output")); err != nil { + return err + } + 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": dbEnv(rctx), "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, dbEnv(rctx), table) + + resp, err := rctx.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: appDataExportPath(appID), + QueryParams: larkcore.QueryParams{ + "env": []string{dbEnv(rctx)}, + "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..d3266eeb0 --- /dev/null +++ b/shortcuts/apps/apps_db_data_import.go @@ -0,0 +1,144 @@ +// 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: 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)"}, + }, 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 + } + if err := rejectLegacyEnvFlag(rctx); 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": dbEnv(rctx), "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{dbEnv(rctx)}, "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..0902e2cf1 --- /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", "--environment", "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_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_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..290e470fe 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,51 +51,45 @@ 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'", + `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{ - {Name: "app-id", Desc: "app id", Required: true}, + 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 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 } + if err := rejectLegacyEnvFlag(rctx); err != nil { + return err + } 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 flag(避免 SQL 内容进入 + // flag dump / 结构化日志)。下游 DryRun/Execute 由 resolveExecuteSQL 在用时重新读取。 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 +97,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)) }, @@ -110,27 +110,30 @@ 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 解出来放进 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 +143,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 +218,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。 @@ -205,15 +292,34 @@ 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, } } -// 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, } } @@ -354,10 +460,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 +567,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 +582,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..7bb277e43 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,27 +34,134 @@ 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) + } + // PRD 单 DDL:data = {command} + var env struct { + Data struct { + Command string `json:"command"` + } `json:"data"` } - if len(env.Data.Results) != 1 { - t.Fatalf("data.results = %d items (want 1)", len(env.Data.Results)) + 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, - []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) } @@ -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, @@ -94,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) @@ -124,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) } @@ -147,6 +273,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 +305,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 +325,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 len(env.Data) != 1 { + t.Fatalf("data length = %d, want 1; got: %v", len(env.Data), env.Data) } - if env.Data.Results[0]["sql_type"] != "SELECT" { - t.Fatalf("results[0].sql_type = %v, want SELECT", env.Data.Results[0]["sql_type"]) - } - if env.Data.Results[0]["record_count"] != float64(1) { - t.Fatalf("results[0].record_count = %v, want 1", env.Data.Results[0]["record_count"]) + 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 +368,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 +395,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 +454,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 +477,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 +514,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 +584,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 +616,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 +649,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) - } - if pfErr.Code != output.ExitAPI { - t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want a typed errs.* error, got %T: %v", err, err) } - payload := decodePartialFailureData(t, stdout.String()) - if got := payload["statement_index"]; got != float64(1) { - t.Errorf("statement_index = %v, want 1", got) + 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["error_code"]; got != float64(1300002) { - t.Errorf("error_code = %v, want 1300002", got) + if p.Code != 1300002 { + t.Errorf("code = %d, want 1300002", p.Code) } - msg, _ := payload["error_message"].(string) - if !strings.Contains(msg, "(at statement 2 of 2)") { - t.Errorf("error_message missing statement locator: %q", msg) + if !strings.Contains(p.Message, "(at statement 2 of 2)") { + t.Errorf("message missing statement locator: %q", p.Message) } - if got := payload["rolled_back"]; got != false { - t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got) + // 无 BEGIN → 前序逐条 commit、未回滚,语义写在 hint。 + if !strings.Contains(p.Hint, "committed and not rolled back") { + t.Errorf("hint should state prior statements committed & not rolled back: %q", p.Hint) } - 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 +724,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 +738,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()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want a typed errs.* error, got %T: %v", err, err) + } + if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError { + t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype) } - var pfErr *output.PartialFailureError - if !errors.As(err, &pfErr) { - t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err) + if !strings.Contains(p.Message, "(at statement 4 of 4)") { + t.Errorf("message missing statement locator: %q", p.Message) } - 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) + // 事务整批回滚 / 前序未落库 的语义写在 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) } - if got := payload["statement_index"]; got != float64(0) { - t.Errorf("statement_index = %v, want 0", got) +} + +// 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}, } - note, _ := payload["note"].(string) - if !strings.Contains(note, "no statements were applied") { - t.Errorf("note should say nothing was applied, got %q", note) + 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 +804,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 +826,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 +842,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 +855,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 +874,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 +896,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 +909,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 +940,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 +949,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 +973,7 @@ func TestRenderSingleStatementPretty_Branches(t *testing.T) { } } +// TestRenderSelectRowsAsTable_Branches 断言 renderSelectRowsAsTable 对空串 / 空数组 / 非法 JSON 回退 / 正常 rows 各分支的输出。 func TestRenderSelectRowsAsTable_Branches(t *testing.T) { cases := []struct { name string @@ -816,35 +995,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..f5f1563be --- /dev/null +++ b/shortcuts/apps/apps_db_quota_get.go @@ -0,0 +1,101 @@ +// 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 --environment dev", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: append([]common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + }, 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 + } + 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": 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": dbEnv(rctx)}, 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_db_table_get.go b/shortcuts/apps/apps_db_table_get.go index af0e63f00..5aff1852d 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("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 } + 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..b24c04d83 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("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 { - _, 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_env.go b/shortcuts/apps/apps_env.go new file mode 100644 index 000000000..57c05b5e0 --- /dev/null +++ b/shortcuts/apps/apps_env.go @@ -0,0 +1,412 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "sort" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultAppsEnvVarEnv = "dev" + defaultAppsEnvVarScene = 2 +) + +// AppsEnvVarList lists app environment variables without values by default. +var AppsEnvVarList = common.Shortcut{ + Service: appsService, + Command: "+env-list", + Description: "List app environment variables", + Risk: "read", + Tips: []string{ + "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: 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 { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if err := validateEnvVarEnv(envVarEnv(rctx)); 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(). + POST(envVarCollectionPath(appID)). + Desc("List app environment variables"). + Body(buildEnvVarListBody(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("POST", envVarCollectionPath(appID), nil, buildEnvVarListBody(rctx)) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := normalizeEnvVarListOutput(data, includeValues) + rctx.OutFormat(out, nil, func(w io.Writer) { + appsPrintSchemaTable(w, out.Items, envVarListSchema(includeValues)) + }) + return nil + }, +} + +// AppsEnvVarSet sets one app environment variable. Values are never printed. +var AppsEnvVarSet = common.Shortcut{ + Service: appsService, + Command: "+env-set", + Description: "Set an app environment variable", + Risk: "write", + Tips: []string{ + "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: 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"}, + }, + 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(). + POST(envVarCreateOrUpdatePath(appID)). + Desc("Set app environment variable"). + Body(map[string]interface{}{ + "key": key, + "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 +env-set --environment online", + "apps +env-set --environment 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 + } + 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, envVarMutationHint(err)) + } + action := envVarStringAny(data, "action") + if action == "" { + action = "set" + } + rctx.OutFormat(map[string]interface{}{ + "key": key, + "env": env, + "action": action, + }, nil, nil) + return nil + }, +} + +// AppsEnvVarDelete deletes one or more app environment variables. +var AppsEnvVarDelete = common.Shortcut{ + Service: appsService, + Command: "+env-delete", + Description: "Delete app environment variables", + Risk: "high-risk-write", + Tips: []string{ + "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: 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 { + 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(). + POST(envVarDeletePath(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) + data, err := rctx.CallAPITyped("POST", envVarDeletePath(appID), nil, buildEnvVarDeleteBody(env, keys)) + if err != nil { + return withAppsHint(err, envVarMutationHint(err)) + } + deletedKeys := envVarStringSliceAny(data, "deleted_keys", "deletedKeys") + if len(deletedKeys) == 0 { + deletedKeys = keys + } + rctx.OutFormat(map[string]interface{}{ + "env": env, + "deleted_keys": deletedKeys, + }, nil, nil) + return nil + }, +} + +func envVarEnv(rctx *common.RuntimeContext) string { + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) + if env == "" { + return defaultAppsEnvVarEnv + } + return env +} + +func envVarCollectionPath(appID string) string { + return appScopedPath(appID, "env_vars") +} + +func envVarCreateOrUpdatePath(appID string) string { + return appScopedPath(appID, "create_or_update_env_var") +} + +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), + "scene": defaultAppsEnvVarScene, + } +} + +func buildEnvVarDeleteBody(env string, keys []string) map[string]interface{} { + return map[string]interface{}{ + "env": env, + "keys": keys, + } +} + +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 == "" { + 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 + } + if raw := data["envVars"]; 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 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 { + return value + } + } + 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 { + return value + } + } + return false +} diff --git a/shortcuts/apps/apps_env_pull.go b/shortcuts/apps/apps_env_pull.go index e2242f9a1..ebfc91847 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))). + POST(envPullVarsPath(appID)). Desc("Pull app startup env vars into the local .env.local file"). + Body(envPullVarsBody()). Set("project_path", projectPath). Set("env_file", envFile) }, @@ -80,10 +81,9 @@ 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, nil) + 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) @@ -116,6 +116,37 @@ 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 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. @@ -150,13 +181,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 @@ -203,7 +240,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 1e9b34247..beda8dc4a 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" @@ -31,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() @@ -255,7 +261,7 @@ func TestBuildEnvPullSuccessDataSuppressesEnvKeysAndValues(t *testing.T) { } } -func TestAppsEnvPull_DryRunUsesPostAndResolvedEnvFile(t *testing.T) { +func TestAppsEnvPull_DryRunUsesPostBodyAndResolvedEnvFile(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) projectDir := t.TempDir() @@ -272,6 +278,9 @@ func TestAppsEnvPull_DryRunUsesPostAndResolvedEnvFile(t *testing.T) { 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"`) { + 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) } @@ -283,6 +292,9 @@ func TestAppsEnvPull_PrettyOutput_WithDatabaseLine(t *testing.T) { reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", + OnMatch: func(req *http.Request) { + assertEnvPullBody(t, req) + }, Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ @@ -550,6 +562,68 @@ 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_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() diff --git a/shortcuts/apps/apps_env_test.go b/shortcuts/apps/apps_env_test.go new file mode 100644 index 000000000..4913bf5c4 --- /dev/null +++ b/shortcuts/apps/apps_env_test.go @@ -0,0 +1,409 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +func assertEnvVarBody(t *testing.T, req *http.Request, want map[string]interface{}) { + t.Helper() + if req.URL.RawQuery != "" { + t.Fatalf("query should be empty, got %q", 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) + } +} + +func expectedEnvVarSceneJSON() float64 { + return float64(defaultAppsEnvVarScene) +} + +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: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + OnMatch: func(req *http.Request) { + assertEnvVarBody(t, req, map[string]interface{}{"env": "dev", "scene": expectedEnvVarSceneJSON()}) + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "dev"}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvVarList, + []string{"+env-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: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + OnMatch: func(req *http.Request) { + assertEnvVarBody(t, req, map[string]interface{}{"env": "online", "scene": expectedEnvVarSceneJSON()}) + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "online"}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvVarList, + []string{"+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--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) + } +} + +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{"+env-set", "--app-id", "app_x", "--environment", "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{"+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) + } + + got := stdout.String() + if strings.Contains(got, "super-secret") { + t.Fatalf("dry-run must redact value: %s", got) + } + 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) + } + } + 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"] != "" || 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: "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) + + if err := runAppsShortcut(t, AppsEnvVarSet, + []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) + } + + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + 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": "updated"`} { + 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: "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) + + if err := runAppsShortcut(t, AppsEnvVarDelete, + []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) + } + + 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 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{"+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) + } + + 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 != "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" { + 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, + []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{"+env-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{"+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 5b977d0ff..2439d83ee 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 +") { @@ -50,3 +53,62 @@ func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) { } } } + +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) + } + + envDelete := requireShortcutForExamples(t, "+env-delete") + if !tipsContainAll(envDelete.Tips, "--yes") { + t.Fatalf("+env-delete tips must include --yes: %#v", envDelete.Tips) + } +} + +func TestAppsObservabilityTipsMentionOnlineOnly(t *testing.T) { + for _, cmd := range []string{ + "+log-list", + "+log-get", + "+trace-list", + "+trace-get", + "+metric-list", + "+analytics-list", + } { + shortcut := requireShortcutForExamples(t, cmd) + if !tipsContainAll(shortcut.Tips, "online-only", "--environment 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/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..ac87079f6 --- /dev/null +++ b/shortcuts/apps/apps_file_download.go @@ -0,0 +1,125 @@ +// 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 + } + if err := rejectOutputTraversal(rctx.Str("output")); 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..251d4a257 --- /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 (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"}, + }, + 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..6118a0006 --- /dev/null +++ b/shortcuts/apps/apps_file_upload.go @@ -0,0 +1,218 @@ +// 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) + } + // 用 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 失败是典型可重试场景。 + 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" + } + // 防止 sanitize 后仍以 . 开头(如 .bashrc / .ssh)——下载落地可能覆盖本地隐藏文件, + // 前置下划线消除隐藏文件语义。 + if strings.HasPrefix(enc, ".") { + enc = "_" + enc + } + 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..06dab27e8 --- /dev/null +++ b/shortcuts/apps/apps_file_upload_test.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "io" + "mime" + "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 当文件名)。 + // 断言按解析结果(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"`) { + 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/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/apps_hints_test.go b/shortcuts/apps/apps_hints_test.go index 6c959f497..a4af9254e 100644 --- a/shortcuts/apps/apps_hints_test.go +++ b/shortcuts/apps/apps_hints_test.go @@ -21,6 +21,9 @@ func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) { 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) { + assertEnvPullBody(t, req) + }, }) err := runAppsShortcut(t, AppsEnvPull, diff --git a/shortcuts/apps/apps_logs.go b/shortcuts/apps/apps_logs.go new file mode 100644 index 000000000..0123ad530 --- /dev/null +++ b/shortcuts/apps/apps_logs.go @@ -0,0 +1,877 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "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" + 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. +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: 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)"}, + {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) { + appsPrintSchemaTable(w, appsProjectRows(logListRows(out.Items), logSummarySchema), logSummarySchema) + }) + 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: 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 { + return err + } + if strings.TrimSpace(rctx.Str("log-id")) == "" { + return appsValidationParamError("--log-id", "--log-id is required") + } + return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag)) + }, + 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 := 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 --environment online") + } + log := out.Items[0] + enrichLogSourceStack(rctx, appID, log) + rctx.OutFormat(log, nil, func(w io.Writer) { + appsPrintSchemaTable(w, appsProjectRows([]map[string]interface{}{logSummaryRow(log)}, logSummarySchema), logSummarySchema) + }) + return nil + }, +} + +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"` + 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(appsEnvironmentFlag)) + 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": appsObservabilityBackendEnv, + "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": appsObservabilityBackendEnv, + "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"] = nsNumber(since) + } + if hasUntil { + body["end_timestamp_ns"] = nsNumber(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 traceIDs := cleanRepeatedStrings(rctx.StrArray("trace-id")); len(traceIDs) > 0 { + filter["trace_ids"] = traceIDs + } + addTrimmedLogFilterString(filter, "keyword", rctx.Str("keyword")) + 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 + } +} + +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) + 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") + 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 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) != "" { + 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 +} + +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) != "" { + 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 { + markSourceStackResolveError(log, err) + } + return + } + stack := firstLogValue(data, "source_stack", "sourceStack", "frames") + if stack == nil { + stack = data + } + log["source_stack_status"] = sourceStackStatusOK + 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" { + 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 := 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") + 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 + } + body := map[string]interface{}{ + "commit_id": commitID, + "source_map_file_prefix": prefix, + "frames": frames, + } + 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 { + for _, source := range sources { + if s := firstLogString(source, keys...); s != "" { + return s + } + } + return "" +} + +func firstFramesInMaps(sources []map[string]interface{}, keys ...string) []interface{} { + for _, key := range keys { + for _, source := range sources { + 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 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 normalized, ok := normalizeFrame(frame); ok { + out = append(out, normalized) + if len(out) >= sourceStackMaxFrames { + return out + } + } + } + return out + case string: + return parseFrameString(frames) + default: + return nil + } +} + +func normalizeFrame(frame interface{}) (map[string]interface{}, bool) { + switch f := frame.(type) { + case map[string]interface{}: + return normalizeFrameMap(f) + case map[string]string: + 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 parsePositiveInt(v) + default: + 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 == "" { + 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 == "" { + 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 { + return value + } + } + return nil +} diff --git a/shortcuts/apps/apps_logs_test.go b/shortcuts/apps/apps_logs_test.go new file mode 100644 index 000000000..e456aa2f6 --- /dev/null +++ b/shortcuts/apps/apps_logs_test.go @@ -0,0 +1,664 @@ +// 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 TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsLogList, []string{ + "+log-list", "--app-id", "app_x", "--level", "error", + "--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", + "--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"] != "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{}) + 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"] != "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", "--environment", "dev", "--as", "user"}, factory, stdout) + requireAppsValidationParam(t, err, "--environment") +} + +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"]) + } + if sent["app_env"] != "runtime" { + t.Fatalf("app_env = %v, want runtime", sent["app_env"]) + } +} + +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{ + 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 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{ + 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_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{ + 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) + } + 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_metrics.go b/shortcuts/apps/apps_metrics.go new file mode 100644 index 000000000..1f68e48a1 --- /dev/null +++ b/shortcuts/apps/apps_metrics.go @@ -0,0 +1,587 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultAppsMetricEnv = "online" + defaultAppsMetricDownSample = "1m" + metricListEndpoint = "query_metrics_data" + defaultObservabilityRangeDays = 30 +) + +// AppsMetricList lists online app observability metrics. +var AppsMetricList = common.Shortcut{ + Service: appsService, + Command: "+metric-list", + Description: "List online app request, latency, CPU, and memory metrics", + Risk: "read", + Tips: []string{ + "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 listed", Required: true}, + {Name: appsEnvironmentFlag, Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"}, + {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"}, + {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 := buildMetricListBody(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _, _, _, _ := buildMetricListBody(rctx) + return common.NewDryRunAPI(). + 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 := buildMetricListBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", metricListPath(appID), nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := observabilitySeriesOutput{ + Items: normalizeMetricSeries(data, names, labels, fillZero), + HasMore: false, + } + rctx.OutFormat(out, nil, func(w io.Writer) { + 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 + }, +} + +type observabilitySeriesOutput struct { + Items []map[string]interface{} `json:"items"` + HasMore bool `json:"has_more"` +} + +func metricListPath(appID string) string { + return appScopedPath(appID, metricListEndpoint) +} + +func buildMetricListBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, bool, error) { + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) + if env == "" { + env = defaultAppsMetricEnv + } + if err := validateObservabilityEnv(env); err != nil { + return nil, nil, nil, false, err + } + names, labels, err := metricNamesForCLI(rctx.Str("metric"), rctx.Str("series")) + if err != nil { + return nil, nil, nil, false, err + } + since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until")) + if err != nil { + return nil, nil, nil, false, err + } + downSample := strings.TrimSpace(rctx.Str("down-sample")) + if !rctx.Changed("down-sample") { + downSample = appsMetricDownSampleForRange(since, until) + } else if downSample == "" { + downSample = defaultAppsMetricDownSample + } + body := map[string]interface{}{ + "metric_names": names, + "start_timestamp": secNumber(since), + "end_timestamp": secNumber(until), + "down_sample": downSample, + "need_pack_lack_point": false, + } + if filter := buildMetricListFilter(rctx); len(filter) > 0 { + body["filter"] = filter + } + 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 buildMetricListFilter(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 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 normalizeMetricSeries(data map[string]interface{}, names, labels []string, fillZero bool) []map[string]interface{} { + return normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), fillZero, "timestamp") +} + +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) + } + if items := observabilityMapSlice(data["items"]); len(items) > 0 { + if observabilityHasNestedPoints(items) { + return mergeObservabilitySeries(items, labels, nameLabels, fillZero, timeField) + } + return normalizeObservabilityPoints(items, labels, nameLabels, fillZero, timeField) + } + for _, key := range []string{"points", "data_points", "dataPoints"} { + if points := observabilityMapSlice(data[key]); len(points) > 0 { + return normalizeObservabilityPoints(points, labels, nameLabels, 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, 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 { + label := observabilitySeriesLabel(serie, labels, nameLabels, 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, nameLabels) + } + } + if fillZero { + fillObservabilityZeroes(items, labels) + } + return items +} + +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, nameLabels, 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 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) { + case map[string]interface{}: + for _, label := range labels { + if value, ok := v[label]; ok { + values[label] = value + } + } + for name, label := range nameLabels { + if value, ok := v[name]; ok { + values[label] = value + } + } + case []interface{}: + 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 + } + } + } + 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, 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{}: + 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 +} + +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, 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) + if label := nameLabels[value]; label != "" { + return label + } + if containsObservabilityLabel(labels, value) { + return value + } + } + } + if index >= 0 && index < len(labels) { + return labels[index] + } + 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" { + 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) + } + 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 { + 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 +} + +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 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_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_common.go b/shortcuts/apps/apps_observability_common.go new file mode 100644 index 000000000..22d2b7116 --- /dev/null +++ b/shortcuts/apps/apps_observability_common.go @@ -0,0 +1,202 @@ +// 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 + appsEnvironmentFlag = "environment" + + // 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 { + 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("--environment", "observability commands only support online (got %q)", env). + WithHint("only online is supported; omit --environment to use the default online environment") + } +} + +func validateEnvVarEnv(env string) error { + switch strings.TrimSpace(env) { + case "dev", "online": + return nil + default: + return appsValidationParamError("--environment", "env var commands only support --environment dev or --environment 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 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 + 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, 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) { + s = strings.TrimSpace(s) + if len(s) < 2 { + return 0, false + } + 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++ { + 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.ParseFloat(number, 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 > float64(maxDuration)/float64(unitDuration) { + return 0, false + } + duration := time.Duration(n * float64(unitDuration)) + if duration <= 0 { + return 0, false + } + return duration, true +} + +func nsNumber(t time.Time) string { + return strconv.FormatInt(t.UnixNano(), 10) +} + +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 new file mode 100644 index 000000000..9fed5e665 --- /dev/null +++ b/shortcuts/apps/apps_observability_common_test.go @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "errors" + "strings" + "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, "--environment") + if p.Subtype != errs.SubtypeInvalidArgument { + 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) + } +} + +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(""), "--environment") + requireAppsValidationParam(t, validateEnvVarEnv("boe"), "--environment") + 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 := nsNumber(ts); got != "1782209472123456789" { + t.Fatalf("nsNumber = %q", got) + } + if got := secNumber(ts); got != "1782209472" { + t.Fatalf("secNumber = %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: "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)}, + {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 +} diff --git a/shortcuts/apps/apps_openapi_key_common.go b/shortcuts/apps/apps_openapi_key_common.go new file mode 100644 index 000000000..7517ab72c --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_common.go @@ -0,0 +1,233 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + + "github.com/larksuite/cli/errs" + "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 +} + +// 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) + } + 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. +// 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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be combined with --scope-all / --scope-api").WithParam("--scope") + } + return parseRawScope(scopeRaw) + } + 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")) + 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 != "" { + 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 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 new file mode 100644 index 000000000..df4a248b9 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_common_test.go @@ -0,0 +1,356 @@ +// 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": "****xxxx", + } + 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": "k1", + "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"] != "****xxxx" { + t.Errorf("key_preview = %v, want ****xxxx", out["key_preview"]) + } + if out["name"] != "partner-test" || out["api_key_id"] != "k1" { + 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 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, "") + 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..173e41284 --- /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..e234ad787 --- /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": "k1", + "info": map[string]interface{}{ + "api_key_id": "k1", "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, "****xxxx") { + 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..88b7717bc --- /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 { + 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": id, "deleted": true} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "deleted API key ID: %s\n", id) + }) + 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..4174b7e73 --- /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(keyStatusDisable)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + return execOpenAPIKeyStatus(rctx, keyStatusDisable) + }, +} diff --git a/shortcuts/apps/apps_openapi_key_enable.go b/shortcuts/apps/apps_openapi_key_enable.go new file mode 100644 index 000000000..c2df7a825 --- /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 ( + keyStatusDisable = 0 + keyStatusEnable = 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(keyStatusEnable)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + return execOpenAPIKeyStatus(rctx, keyStatusEnable) + }, +} + +// 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..20ddf6bce --- /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..f23ef14f2 --- /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": "k1", "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(), "****xxxx") { + 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..6e3548b14 --- /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": "k1", "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, "****xxxx") { + 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..045646b05 --- /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": "k1", "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, "****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 new file mode 100644 index 000000000..0be152015 --- /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": "k1", "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..4f6c6fb5f --- /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": "k1", "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/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) + } +} diff --git a/shortcuts/apps/apps_plugin_install.go b/shortcuts/apps/apps_plugin_install.go new file mode 100644 index 000000000..fa6871cea --- /dev/null +++ b/shortcuts/apps/apps_plugin_install.go @@ -0,0 +1,419 @@ +// 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"}, + Scopes: []string{}, + 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 + } + 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 { + 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, 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) + } + 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 errs.NewInternalError(errs.SubtypeUnknown, "install %s failed", key).WithCause(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, 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) + } + 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}. + +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{ + "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(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" + } 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) + } + 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_install_test.go b/shortcuts/apps/apps_plugin_install_test.go new file mode 100644 index 000000000..9ce3cfa4f --- /dev/null +++ b/shortcuts/apps/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 { + 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) + os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) + 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")) + 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 { + 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/apps_plugin_list.go b/shortcuts/apps/apps_plugin_list.go new file mode 100644 index 000000000..d5cb8df3c --- /dev/null +++ b/shortcuts/apps/apps_plugin_list.go @@ -0,0 +1,81 @@ +// 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 locally installed plugin packages and their installation status", + Risk: "read", + Scopes: []string{}, + 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/apps_plugin_list_test.go b/shortcuts/apps/apps_plugin_list_test.go new file mode 100644 index 000000000..196e014d1 --- /dev/null +++ b/shortcuts/apps/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) + os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) + 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() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(prev) }) //nolint: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 { + t.Fatal(err) + } +} diff --git a/shortcuts/apps/apps_plugin_uninstall.go b/shortcuts/apps/apps_plugin_uninstall.go new file mode 100644 index 000000000..dc4bf8b4b --- /dev/null +++ b/shortcuts/apps/apps_plugin_uninstall.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "os" + "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", + Scopes: []string{}, + 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 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 { + 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, 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) + } + + 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/apps_plugin_uninstall_test.go b/shortcuts/apps/apps_plugin_uninstall_test.go new file mode 100644 index 000000000..d59072dbb --- /dev/null +++ b/shortcuts/apps/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) + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) + 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) { + 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) + 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) + 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 { + 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) + 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) + 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) { + 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/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/apps_traces.go b/shortcuts/apps/apps_traces.go new file mode 100644 index 000000000..e22c95073 --- /dev/null +++ b/shortcuts/apps/apps_traces.go @@ -0,0 +1,664 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultAppsTraceEnv = "online" + traceSearchEndpoint = "search_traces" + traceGetEndpoint = "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: 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"}, + {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"}, + }, + 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) { + appsPrintSchemaTable(w, appsProjectRows(traceListRows(out.Items), traceSummarySchema), traceSummarySchema) + }) + 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: 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 { + 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(appsEnvironmentFlag)) + }, + 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) { + appsPrintSchemaTable(w, appsProjectRows([]map[string]interface{}{traceDetailSummary(trace)}, traceSummarySchema), traceSummarySchema) + }) + 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(appsEnvironmentFlag)) + 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": appsObservabilityBackendEnv, + "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{} { + return map[string]interface{}{ + "app_env": appsObservabilityBackendEnv, + "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"] = nsNumber(since) + } + if hasUntil { + body["end_timestamp_ns"] = nsNumber(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")) + addTrimmedTraceFilterStrings(filter, "user_ids", 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 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) + 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 { + span = normalizeTraceSpan(span) + 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 + 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 +} + +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", "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 +} + +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 +} + +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": firstItemString(item, "root_span", "name", "span_name"), + "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_traces_test.go b/shortcuts/apps/apps_traces_test.go new file mode 100644 index 000000000..4768b1f93 --- /dev/null +++ b/shortcuts/apps/apps_traces_test.go @@ -0,0 +1,453 @@ +// 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 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", "--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) + } + + 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"] != "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{}) + 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) + } + 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"] != "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 TestAppsTraceList_RejectsDevEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + 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) { + 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/trace" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + if env.API[0].Body["app_env"] != "runtime" || 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 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{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/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) + } +} + +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/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/shortcuts/apps/db_common.go b/shortcuts/apps/db_common.go index 9223e46e5..982c9bee2 100644 --- a/shortcuts/apps/db_common.go +++ b/shortcuts/apps/db_common.go @@ -4,12 +4,79 @@ package apps import ( + "context" + "encoding/json" "fmt" "strings" + "time" + "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。 +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 +99,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..a4b961e34 --- /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 "", 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 "", 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 "", 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、无超时以容纳大文件)。 +// +//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/plugin_common.go b/shortcuts/apps/plugin_common.go new file mode 100644 index 000000000..d362f5f49 --- /dev/null +++ b/shortcuts/apps/plugin_common.go @@ -0,0 +1,420 @@ +// 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" + "strconv" + "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 +} + +// 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) +// 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 != "" { + 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") + 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 + } + 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 { + return nil //nolint:nilerr // best-effort: no capabilities dir means no conflict + } + caps, err := pluginListCapabilities(capDir) + if err != nil { + return nil //nolint:nilerr // best-effort: scan failure should not block uninstall + } + 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") +} + +// ── 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 ── + +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. +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 + } + 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) //nolint:forbidigo // intermediate helper error; callers wrap as typed + } + + 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.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 + } + 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, 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 + } + return err + } + if err := f.Close(); err != nil { + return err + } + } + } + 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..7a6ce3201 --- /dev/null +++ b/shortcuts/apps/plugin_common_test.go @@ -0,0 +1,253 @@ +// 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() + 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 { + 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 { + 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 { + 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 { + 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 { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { + 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 { + 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 { + 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 { + t.Fatal(err) + } +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index e15489fa1..b3ac97f5c 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,10 +22,38 @@ func Shortcuts() []common.Shortcut { AppsReleaseList, AppsReleaseGet, AppsEnvPull, + 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(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, AppsDBTableList, 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, @@ -32,5 +63,22 @@ func Shortcuts() []common.Shortcut { AppsSessionStop, AppsSessionMessagesList, AppsChat, + AppsPluginInstall, + AppsPluginUninstall, + AppsPluginList, + // open API key management + AppsOpenAPIKeyList, + AppsOpenAPIKeyGet, + AppsOpenAPIKeyCreate, + AppsOpenAPIKeyUpdate, + AppsOpenAPIKeyEnable, + AppsOpenAPIKeyDisable, + AppsOpenAPIKeyDelete, + AppsOpenAPIKeyReset, } } + +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..fccdb62f5 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -10,12 +10,60 @@ 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(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/ +// 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 +// - 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) != 24 { - t.Fatalf("Shortcuts() returned %d entries, want 24", len(got)) + if len(got) != 63 { + t.Fatalf("Shortcuts() returned %d entries, want 63", len(got)) + } +} + +func TestAppsShortcuts_DoesNotIncludeEnvGet(t *testing.T) { + for _, sc := range Shortcuts() { + 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_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, + "+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) + } } } @@ -40,6 +88,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 +97,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..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静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、可见范围时使用。不负责普通云盘文件上传(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,13 +22,27 @@ 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) | -| 看表、看 schema、跑 SQL、初始化 dev/online 多环境 DB | `+db-table-list`, `+db-table-get`, `+db-execute`, `+db-env-create` | 对应 `lark-apps-db-*.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) | +| 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / 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) | +| 管理妙搭应用开放 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) | + +## 高频路径 + +- **性能/监控/观测指标**:用户问“接口请求量、错误量、错误率、接口慢、延迟、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`。不要因为认证失败/重登完成就自动继续删除,必须保留确认门槛。 ## 选择开发路径(进意图路由前先判这步) @@ -54,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-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..c0baf815f 100644 --- a/skills/lark-apps/references/lark-apps-db-execute.md +++ b/skills/lark-apps/references/lark-apps-db-execute.md @@ -11,30 +11,34 @@ - 必填:`--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`**;操作线上库、或**未开启多环境的应用(其数据库在 `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 规则」)。 ## 示例 ```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 ``` ## 输出契约 -- 成功默认 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..e903b2f03 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-db.md @@ -0,0 +1,162 @@ +# 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` | 列出某环境的数据表 | `--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` | 查数据库存储用量 | `--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`。 +- **本地文件 / `--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`),格式见末尾。 + +## 各命令 + +### 表与结构 + +**`+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 --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 --environment dev --format pretty +``` + +### 多环境数据库(初始化 + 发布) + +**`+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 --environment dev --dry-run +lark-cli apps +db-env-create --app-id app_xxx --environment 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 --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 --environment 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 --environment 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` + +> **时区**:不带时区的 `日期` / `日期时间` 按**运行机器的本地时区**解析(再归一化到 UTC)。CI(UTC)与本地(如 UTC+8)跑同一条命令,时间边界会差几小时;要精确锁定时区时显式写 ISO 8601 带偏移(如 `...+08:00` / `...Z`)。`--target`(PITR 恢复)尤其建议带时区,避免恢复到非预期时间点。 + +## Agent 规则 + +- 用户说「本地 / 开发库 / 调试库」优先 `--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` 看请求或先在 `--environment 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-env-pull.md b/skills/lark-apps/references/lark-apps-env-pull.md index e1e0082dc..148cefa60 100644 --- a/skills/lark-apps/references/lark-apps-env-pull.md +++ b/skills/lark-apps/references/lark-apps-env-pull.md @@ -2,11 +2,13 @@ > **前置条件:** 先阅读 [`../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 的本地恢复工具:内部固定 `POST env_vars`,body 为 `env=dev`。它没有 `--env` flag,也不管理线上环境变量。 ## 何时别用(核心反模式) -**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并把用户刚改完的 `.env.local` 临时改动覆盖掉。 +**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并用服务端返回值覆盖 `.env.local` 里的同名 key;本地无关行和注释会保留。 只在这些兜底场景用: @@ -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-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-file.md b/skills/lark-apps/references/lark-apps-file.md new file mode 100644 index 000000000..94d4a7ab8 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-file.md @@ -0,0 +1,96 @@ +# 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 +上传一个本地文件。文件名沿用本地文件名(特殊字符做 URL 编码透传;以 `.` 开头的隐藏文件名会加 `_` 前缀,避免下载回本地时覆盖隐藏文件),远端路径由平台分配。单文件上限 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` + +> **时区**:不带时区的 `日期` / `日期时间` 按**运行机器的本地时区**解析(再归一化到 UTC 发给服务端)。CI(UTC)与本地(如 UTC+8)跑同一条命令,过滤边界会差几小时;要精确到某时区时显式写 ISO 8601 带偏移(如 `...+08:00` / `...Z`)。 + +## Agent 规则 + +- 寻址一律用 `--path`;用户只给文件名时先 `+file-list --name <名>` 定位,多个同名再让用户确认。 +- 上传 / 下载的本地路径用工作目录内相对路径;不在当前目录就 `cd` 过去或改相对路径。 +- 用户要「分享链接 / 临时下载地址」时用 `+file-sign`,把返回的链接转述给用户。 +- 删除前判断意图:已明确要删且授权时可直接带 `--yes`;不确定删哪些时先 `+file-list` 给用户确认。批量删除部分失败不报错,按逐项结果向用户说明哪些成功、哪些没删掉及原因。 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-observability.md b/skills/lark-apps/references/lark-apps-observability.md new file mode 100644 index 000000000..e40a9a865 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-observability.md @@ -0,0 +1,48 @@ +# apps observability + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 + +查询妙搭应用的线上运行观测和产品访问分析。所有 observability 命令只支持 `--environment online`;省略 `--environment` 时默认就是 online,传 dev 或其他环境是不支持的。不要使用旧的 `--env`,也不要使用短选项。 + +日志和 trace 的用户侧环境仍然是 online;但 OpenAPI 请求体里的后端 `app_env` 固定发送 `runtime`,因为线上应用的运行时日志和 trace 存储在 runtime 观测环境下。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。 + +## 命令选择 + +- 日志检索:用 `+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-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`。 + +## 示例 + +```bash +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 +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-list`。 +- `+metric-list --metric requests` 不传 `--series` 会同时返回请求总量 total 和错误量 error;`--metric latency` 不传 `--series` 会同时返回 p50 和 p99。只想看单条曲线时再传 `--series total|error|p50|p99`。 +- 按接口收窄范围时使用 `--api `;当前没有 `group-by` 参数,不要臆造。 +- `+metric-list` 未显式传 `--down-sample` 时会按时间范围自动选择粒度:短范围用 `1m`,中等范围用 `1h`,长范围用 `1d`;显式传入时尊重用户指定。 +- 如果用户问“页面访问量、PV、UV、活跃用户”,优先走 `+analytics-list`。 +- 如果用户已有 `trace_id` 或 `log_id`,直接用对应 get 命令;不知道 ID 时先 list。 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),不在此重复。 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..f4dabd579 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-install.md @@ -0,0 +1,36 @@ +# apps +plugin-install + +> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。 + +安装插件包到项目。运行时命令事实以 `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..7f7337658 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-list.md @@ -0,0 +1,23 @@ +# apps +plugin-list + +> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。 + +列出已声明的插件包及安装状态。运行时命令事实以 `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..a29bcc6fc --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-uninstall.md @@ -0,0 +1,25 @@ +# apps +plugin-uninstall + +> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。 + +卸载插件包。运行时命令事实以 `lark-cli apps +plugin-uninstall --help` 为准。 + +## 何时用 + +用户不再需要某个插件能力时,卸载对应的插件包。卸载前应先删除该插件的所有实例。 + +## 命令骨架 + +- `--name `:要卸载的插件包 key。 + +在项目根目录下运行(和 npm 一样,无需指定路径)。 + +## 示例 + +```bash +lark-cli apps +plugin-uninstall --name +``` + +## 输出契约 + +- 删除 `node_modules/{key}` + 移除 `actionPlugins` 条目。 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 规则 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", 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())