mirror of
https://github.com/larksuite/cli.git
synced 2026-07-03 22:24:31 +08:00
Compare commits
70 Commits
feat/chart
...
feat/apps-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
412dcba4b8 | ||
|
|
e5f66ce22e | ||
|
|
1d313a56b1 | ||
|
|
1864b7fae9 | ||
|
|
22ae7ab04d | ||
|
|
110107458a | ||
|
|
e28a00c2fe | ||
|
|
2f50e39203 | ||
|
|
b5d3e9896e | ||
|
|
a552aed3bc | ||
|
|
70aec2726b | ||
|
|
52894d095b | ||
|
|
7810a01eba | ||
|
|
b33fe32718 | ||
|
|
4229ea7735 | ||
|
|
72c61cc59e | ||
|
|
33458e6770 | ||
|
|
35446837a2 | ||
|
|
9fa28be312 | ||
|
|
bca7f7d30d | ||
|
|
6764949014 | ||
|
|
eb3ace1427 | ||
|
|
8f0d0725fc | ||
|
|
7121ff1e2a | ||
|
|
431160a204 | ||
|
|
490006ee7b | ||
|
|
4e2abab504 | ||
|
|
3e430dd821 | ||
|
|
9efa8b3b69 | ||
|
|
81c3736da2 | ||
|
|
6cbb9d68b8 | ||
|
|
0ff2957c6e | ||
|
|
f334cc9b34 | ||
|
|
41aefd63f0 | ||
|
|
09984fa92a | ||
|
|
de5de57ced | ||
|
|
d2452b7f9c | ||
|
|
911f584ab0 | ||
|
|
08340bf3aa | ||
|
|
a99dc33195 | ||
|
|
0552c5c595 | ||
|
|
bb891e0c50 | ||
|
|
d5f65d1aa4 | ||
|
|
5365cb97ab | ||
|
|
8037bd8037 | ||
|
|
0f88409ab8 | ||
|
|
2cfe090c1d | ||
|
|
dbc1c93b71 | ||
|
|
6ff02ea10c | ||
|
|
46c99cb878 | ||
|
|
8939bff9c5 | ||
|
|
736db1ce72 | ||
|
|
2beb110523 | ||
|
|
112183f447 | ||
|
|
9b9ac8759e | ||
|
|
fdcd9f6dde | ||
|
|
e9fde3e8f7 | ||
|
|
3b9ee1af67 | ||
|
|
8d061ea3bd | ||
|
|
a5386f6053 | ||
|
|
d6c37232e6 | ||
|
|
999ac4e7d6 | ||
|
|
a91f2cdd85 | ||
|
|
d80636d7da | ||
|
|
0a999171be | ||
|
|
1d9f102b36 | ||
|
|
d7820f7c1f | ||
|
|
b8f45c96d7 | ||
|
|
9dc032ca73 | ||
|
|
e9f2da086f |
80
internal/output/spinner.go
Normal file
80
internal/output/spinner.go
Normal file
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
54
internal/output/spinner_test.go
Normal file
54
internal/output/spinner_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
207
shortcuts/apps/apps_analytics.go
Normal file
207
shortcuts/apps/apps_analytics.go
Normal file
@@ -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 <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}
|
||||
}
|
||||
459
shortcuts/apps/apps_analytics_test.go
Normal file
459
shortcuts/apps/apps_analytics_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
302
shortcuts/apps/apps_db_audit_list.go
Normal file
302
shortcuts/apps/apps_db_audit_list.go
Normal file
@@ -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 <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"},
|
||||
{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, ", "))
|
||||
}
|
||||
144
shortcuts/apps/apps_db_audit_set.go
Normal file
144
shortcuts/apps/apps_db_audit_set.go
Normal file
@@ -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 <app_id>`"
|
||||
|
||||
// AppsDBAuditEnable 为某张表开启行级审计(变更追溯)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/audit_set,body {table, enabled:true, retention}。--retention 默认 7d。
|
||||
var AppsDBAuditEnable = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-audit-enable",
|
||||
Description: "Enable row-change audit logging for a table",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-audit-enable --app-id <app_id> --table orders --retention 30d",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: 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 <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}
|
||||
}
|
||||
140
shortcuts/apps/apps_db_audit_status.go
Normal file
140
shortcuts/apps/apps_db_audit_status.go
Normal file
@@ -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 <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)
|
||||
}
|
||||
316
shortcuts/apps/apps_db_audit_test.go
Normal file
316
shortcuts/apps/apps_db_audit_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
152
shortcuts/apps/apps_db_changelog_list.go
Normal file
152
shortcuts/apps/apps_db_changelog_list.go
Normal file
@@ -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 <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 <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"},
|
||||
{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)
|
||||
}
|
||||
143
shortcuts/apps/apps_db_changelog_list_test.go
Normal file
143
shortcuts/apps/apps_db_changelog_list_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
191
shortcuts/apps/apps_db_data_export.go
Normal file
191
shortcuts/apps/apps_db_data_export.go
Normal file
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const dbDataExportMaxRows = 5000
|
||||
const dbDataExportMaxBytes = 1 * 1024 * 1024 // 1 MB
|
||||
|
||||
const dbDataExportHint = "verify --app-id and --table; if too large, filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets"
|
||||
|
||||
// AppsDBDataExport 把应用数据表导出到本地文件(csv/json/sql)。
|
||||
//
|
||||
// GET /apps/{app_id}/db/data_export,返回原始字节(非 JSON 信封)。
|
||||
// 行数不随导出文件返回:CLI 原子编排——先查 GetAppTableRecordList 的 total,再导出文件。
|
||||
// 数据格式由 --output 扩展名推断(默认 csv,缺省输出 <table>.csv);上限 5000 行 / 1 MB。
|
||||
var AppsDBDataExport = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-data-export",
|
||||
Description: "Export rows from a Miaoda app table to a local file (csv/json/sql)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-data-export --app-id <app_id> --table orders --output ./orders.csv",
|
||||
"Format follows the --output extension: .csv / .json / .sql (default csv).",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: 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: <table>.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 := 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、输出 <table>.csv。
|
||||
func exportFormatAndOutput(rctx *common.RuntimeContext) (format, outPath string, err error) {
|
||||
table := strings.TrimSpace(rctx.Str("table"))
|
||||
out := strings.TrimSpace(rctx.Str("output"))
|
||||
if out == "" {
|
||||
return "csv", table + ".csv", nil
|
||||
}
|
||||
f, ferr := resolveDataFormat(filepath.Ext(out), true)
|
||||
if ferr != nil {
|
||||
return "", "", ferr
|
||||
}
|
||||
return f, out, nil
|
||||
}
|
||||
193
shortcuts/apps/apps_db_data_export_test.go
Normal file
193
shortcuts/apps/apps_db_data_export_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
144
shortcuts/apps/apps_db_data_import.go
Normal file
144
shortcuts/apps/apps_db_data_import.go
Normal file
@@ -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 <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": "<contents of --file>"})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
file := strings.TrimSpace(rctx.Str("file"))
|
||||
content, err := cmdutil.ReadInputFile(rctx.FileIO(), file)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file")
|
||||
}
|
||||
if len(content) > dbDataImportMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", len(content)).WithParam("--file")
|
||||
}
|
||||
fileName := filepath.Base(file)
|
||||
table := importTableName(rctx)
|
||||
|
||||
// multipart:file_name 走表单字段、文件本体走 form-files;env / table 走 query。
|
||||
fd := larkcore.NewFormdata()
|
||||
fd.AddField("file_name", fileName)
|
||||
fd.AddFile("file", bytes.NewReader(content))
|
||||
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: appDataImportPath(appID),
|
||||
QueryParams: larkcore.QueryParams{"env": []string{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))
|
||||
}
|
||||
161
shortcuts/apps/apps_db_data_import_test.go
Normal file
161
shortcuts/apps/apps_db_data_import_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 <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 <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 <app_id> --yes",
|
||||
"Example: lark-cli apps +db-env-create --environment dev --sync-data --app-id <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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
191
shortcuts/apps/apps_db_env_migrate.go
Normal file
191
shortcuts/apps/apps_db_env_migrate.go
Normal file
@@ -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 <app_id>",
|
||||
"Apply the previewed changes with +db-env-migrate --yes.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Preview dev→online migration").Body(map[string]interface{}{"dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stop := rctx.StartSpinner("Previewing migration diff (dev → online)")
|
||||
defer stop()
|
||||
data, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true})
|
||||
stop()
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(data, "from"), common.GetString(data, "to")
|
||||
changes := projectMigrationChanges(data["changes"])
|
||||
out := map[string]interface{}{"from": from, "to": to, "changes": changes}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderMigrationDiff(w, from, to, changes)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBEnvMigrate 把 dev 的待发布结构变更发布到 online(异步,CLI 轮询至完成)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_migrate,body {dry_run:false} → task_id,轮询 env_migrate_status
|
||||
// 至 success;后端 status:applied,CLI 对外统一呈现 migrated。high-risk-write。
|
||||
var AppsDBEnvMigrate = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-env-migrate",
|
||||
Description: "Publish pending dev→online schema changes (irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-env-migrate --app-id <app_id> --yes",
|
||||
"Preview first with +db-env-diff.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
_, err := requireAppID(rctx.Str("app-id"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appEnvMigratePath(appID)).Desc("Apply dev→online migration").Body(map[string]interface{}{"dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stop := rctx.StartSpinner("Applying migration (dev → online)")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbEnvMigrateHint)
|
||||
}
|
||||
from, to := common.GetString(submit, "from"), common.GetString(submit, "to")
|
||||
taskID := common.GetString(submit, "task_id")
|
||||
applied := intFromAny(submit["changes_applied"])
|
||||
if applied == 0 {
|
||||
applied = len(projectMigrationChanges(submit["changes"]))
|
||||
}
|
||||
// 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。
|
||||
if taskID != "" {
|
||||
final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
case "success", "applied", "migrated":
|
||||
return true, nil
|
||||
case "failed":
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", migrateFailMsg(d, taskID)), dbEnvMigrateHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
if n := intFromAny(final["changes_applied"]); n > 0 {
|
||||
applied = n
|
||||
}
|
||||
}
|
||||
stop() // clear spinner before printing the result
|
||||
out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Migrated %s → %s (%d changes)\n", from, to, applied)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
type migrationChange struct {
|
||||
Type string `json:"type"`
|
||||
Table string `json:"table"`
|
||||
Statement string `json:"statement"`
|
||||
}
|
||||
|
||||
// projectMigrationChanges 把服务端原始变更项投影为白名单 migrationChange(type/table/statement)。
|
||||
func projectMigrationChanges(raw interface{}) []migrationChange {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]migrationChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, migrationChange{
|
||||
Type: common.GetString(m, "type"),
|
||||
Table: common.GetString(m, "table"),
|
||||
Statement: common.GetString(m, "statement"),
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderMigrationDiff 渲染 dev→online 待发布变更:无变更打提示,否则逐条打 statement。
|
||||
func renderMigrationDiff(w io.Writer, from, to string, changes []migrationChange) {
|
||||
if len(changes) == 0 {
|
||||
fmt.Fprintf(w, "No pending changes from %s to %s.\n", from, to)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "%s → %s (%d changes):\n\n", from, to, len(changes))
|
||||
for _, c := range changes {
|
||||
fmt.Fprintf(w, " %s\n", c.Statement)
|
||||
}
|
||||
}
|
||||
|
||||
// migrateFailMsg 取发布失败信息:优先服务端 error_message,缺失则用带 task_id 的兜底文案。
|
||||
func migrateFailMsg(d map[string]interface{}, taskID string) string {
|
||||
if m := common.GetString(d, "error_message"); m != "" {
|
||||
return m
|
||||
}
|
||||
return fmt.Sprintf("migration apply failed (task_id=%s)", taskID)
|
||||
}
|
||||
|
||||
// intFromAny 把 JSON number / json.Number 转 int(计数用)。
|
||||
func intFromAny(v interface{}) int {
|
||||
if f, ok := numericAsFloat(v); ok {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
369
shortcuts/apps/apps_db_env_recovery_quota_test.go
Normal file
369
shortcuts/apps/apps_db_env_recovery_quota_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: ✗ <message> [<code>]` + 末尾「前序语句已落地」提示
|
||||
//
|
||||
// 失败语义: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 <app_id> --sql "SELECT * FROM orders LIMIT 10" --yes`,
|
||||
`Example: lark-cli apps +db-execute --app-id <app_id> --env dev --file ./migration.sql --yes`,
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.results[].sql_type'",
|
||||
`Example: lark-cli apps +db-execute --app-id <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 = 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 <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 <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 <fields>)。
|
||||
// 这不是无界 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,7 +292,7 @@ func parseErrorSentinel(data string) (int, string) {
|
||||
// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。
|
||||
func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": rctx.Str("env"),
|
||||
"env": dbEnv(rctx),
|
||||
"transactional": false,
|
||||
}
|
||||
}
|
||||
@@ -354,10 +441,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 +548,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 +563,7 @@ func dmlVerb(sqlType string) string {
|
||||
return "affected"
|
||||
}
|
||||
|
||||
// plural 返回英文复数后缀:n==1 时空串,否则 "s"。
|
||||
func plural(n int64) string {
|
||||
if n == 1 {
|
||||
return ""
|
||||
|
||||
@@ -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 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 envelope: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data.Results) != 1 {
|
||||
t.Fatalf("data.results = %d items (want 1)", len(env.Data.Results))
|
||||
if len(env.Data) != 1 {
|
||||
t.Fatalf("data = %d rows (want 1)\n%s", len(env.Data), 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[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 {
|
||||
Command string `json:"command"`
|
||||
RowsAffected int `json:"rows_affected"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Command != "INSERT" || env.Data.RowsAffected != 3 {
|
||||
t.Fatalf("data = %+v, want {command:INSERT, rows_affected:3}", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleDDLJSONShape 断言单条 DDL 的 JSON data 形如 {command}。
|
||||
func TestAppsDBExecute_SingleDDLJSONShape(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[{"sql_type":"CREATE_TABLE","data":"[]"}]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "create", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 单 DDL:data = {command}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Command string `json:"command"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if env.Data.Command != "CREATE_TABLE" {
|
||||
t.Fatalf("data.command = %q, want CREATE_TABLE", env.Data.Command)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_MultiStatementJSONShape 断言多语句的 JSON data 是元素数组,且 SELECT 包成 {command:"SELECT", rows:[...]}。
|
||||
func TestAppsDBExecute_MultiStatementJSONShape(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"result": `[` +
|
||||
`{"sql_type":"INSERT","data":"","affected_rows":1},` +
|
||||
`{"sql_type":"SELECT","data":"[{\"id\":999}]","record_count":1}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
// PRD 多语句:data 是元素数组;SELECT 包成 {command:"SELECT", rows:[...]}
|
||||
var env struct {
|
||||
Data []map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
|
||||
t.Fatalf("decode: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if len(env.Data) != 2 {
|
||||
t.Fatalf("data = %d elements (want 2)\n%s", len(env.Data), stdout.String())
|
||||
}
|
||||
if env.Data[0]["command"] != "INSERT" || env.Data[0]["rows_affected"] != float64(1) {
|
||||
t.Fatalf("data[0] = %v, want {command:INSERT, rows_affected:1}", env.Data[0])
|
||||
}
|
||||
if env.Data[1]["command"] != "SELECT" {
|
||||
t.Fatalf("data[1].command = %v, want SELECT", env.Data[1]["command"])
|
||||
}
|
||||
rows, ok := env.Data[1]["rows"].([]interface{})
|
||||
if !ok || len(rows) != 1 {
|
||||
t.Fatalf("data[1].rows = %v, want 1 row", env.Data[1]["rows"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_DryRunSendsTransactionalFalse 断言 dry-run 发出的请求是 POST、params 带 transactional=false(DBA 模式)且 transactional 不在 body 里。
|
||||
func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]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) <verb>"、各类 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,64 +649,36 @@ func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) {
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("multi-statement failure must return a partial-failure error; stdout:\n%s", stdout.String())
|
||||
t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
// json 失败路径不得打成功 envelope。
|
||||
if strings.Contains(stdout.String(), `"ok": true`) {
|
||||
t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if pfErr.Code != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
payload := decodePartialFailureData(t, stdout.String())
|
||||
if got := payload["statement_index"]; got != float64(1) {
|
||||
t.Errorf("statement_index = %v, want 1", got)
|
||||
if p.Code != 1300002 {
|
||||
t.Errorf("code = %d, want 1300002", p.Code)
|
||||
}
|
||||
if got := payload["error_code"]; got != float64(1300002) {
|
||||
t.Errorf("error_code = %v, want 1300002", got)
|
||||
if !strings.Contains(p.Message, "(at statement 2 of 2)") {
|
||||
t.Errorf("message missing statement locator: %q", p.Message)
|
||||
}
|
||||
msg, _ := payload["error_message"].(string)
|
||||
if !strings.Contains(msg, "(at statement 2 of 2)") {
|
||||
t.Errorf("error_message missing statement locator: %q", msg)
|
||||
// 无 BEGIN → 前序逐条 commit、未回滚,语义写在 hint。
|
||||
if !strings.Contains(p.Hint, "committed and not rolled back") {
|
||||
t.Errorf("hint should state prior statements committed & not rolled back: %q", p.Hint)
|
||||
}
|
||||
if got := payload["rolled_back"]; got != false {
|
||||
t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got)
|
||||
if output.ExitCodeOf(err) != output.ExitAPI {
|
||||
t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI)
|
||||
}
|
||||
results, _ := payload["results"].([]interface{})
|
||||
if len(results) != 2 {
|
||||
t.Errorf("results length = %d, want 2 (persisted statement + ERROR sentinel)", len(results))
|
||||
}
|
||||
note, _ := payload["note"].(string)
|
||||
if !strings.Contains(note, "already applied") {
|
||||
t.Errorf("note should warn prior statements persisted, got %q", note)
|
||||
}
|
||||
}
|
||||
|
||||
// decodePartialFailureData 解析 stdout 上 ok:false 的 partial-failure envelope,返回 data 块。
|
||||
func decodePartialFailureData(t *testing.T, stdoutStr string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdoutStr), &envelope); err != nil {
|
||||
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, stdoutStr)
|
||||
}
|
||||
if envelope.OK {
|
||||
t.Fatalf("envelope.ok = true, want false on partial failure")
|
||||
}
|
||||
if envelope.Data == nil {
|
||||
t.Fatalf("envelope.data missing; stdout:\n%s", stdoutStr)
|
||||
}
|
||||
return envelope.Data
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败(server 也返 code:0 + ERROR 哨兵)
|
||||
// 同样走 partial failure:statement_index=0、note 说明无语句落地、message 标注 (at statement 1 of 1)。
|
||||
// 同样升级成 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{
|
||||
@@ -592,26 +695,92 @@ 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("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
var pfErr *output.PartialFailureError
|
||||
if !errors.As(err, &pfErr) {
|
||||
t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err)
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
payload := decodePartialFailureData(t, stdout.String())
|
||||
msg, _ := payload["error_message"].(string)
|
||||
if !strings.Contains(msg, "(at statement 1 of 1)") {
|
||||
t.Errorf("error_message missing locator: %q", msg)
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
if got := payload["statement_index"]; got != float64(0) {
|
||||
t.Errorf("statement_index = %v, want 0", got)
|
||||
if !strings.Contains(p.Message, "(at statement 1 of 1)") {
|
||||
t.Errorf("message missing locator: %q", p.Message)
|
||||
}
|
||||
note, _ := payload["note"].(string)
|
||||
if !strings.Contains(note, "no statements were applied") {
|
||||
t.Errorf("note should say nothing was applied, got %q", note)
|
||||
// 第一条就失败、无落地 的语义写在 hint。
|
||||
if !strings.Contains(p.Hint, "No statements were applied") {
|
||||
t.Errorf("hint should state nothing applied: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsDBExecute_TransactionFailureRolledBack 钉死「显式事务内失败 → 整批回滚」:
|
||||
// 实测后端把 BEGIN 也作为 statement 返回;completed 含未配对 BEGIN → inferRolledBack 判定回滚。
|
||||
// 回滚语义现写在 hint(miaoda 原句 "Transaction rolled back; no changes persisted."),失败位置在 message。
|
||||
func TestAppsDBExecute_TransactionFailureRolledBack(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/sql_commands",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
// BOE 实测 wire:BEGIN; CREATE; INSERT(ok); INSERT(dup→ERROR)
|
||||
"result": `[` +
|
||||
`{"sql_type":"BEGIN","data":"[]"},` +
|
||||
`{"sql_type":"CREATE_TABLE","data":"[]"},` +
|
||||
`{"sql_type":"INSERT","data":"[{\"rowCount\":1}]","affected_rows":1},` +
|
||||
`{"sql_type":"ERROR","data":"{\"code\":\"k_dl_1300002\",\"message\":\"duplicate key value violates unique constraint\"}"}` +
|
||||
`]`,
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runAppsShortcut(t, AppsDBExecute,
|
||||
[]string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"},
|
||||
factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("transaction failure must return a typed error; stdout:\n%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("want a typed errs.* error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
|
||||
t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
|
||||
}
|
||||
if !strings.Contains(p.Message, "(at statement 4 of 4)") {
|
||||
t.Errorf("message missing statement locator: %q", p.Message)
|
||||
}
|
||||
// 事务整批回滚 / 前序未落库 的语义写在 hint(miaoda 原句)。
|
||||
if !strings.Contains(p.Hint, "Transaction rolled back; no changes persisted.") {
|
||||
t.Errorf("hint should state transaction rolled back & nothing persisted: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInferRolledBack_Cases 断言 inferRolledBack 按 BEGIN/COMMIT/ROLLBACK 计数判定失败时事务是否仍开着(即整批回滚)。
|
||||
func TestInferRolledBack_Cases(t *testing.T) {
|
||||
stmt := func(t string) map[string]interface{} { return map[string]interface{}{"sql_type": t} }
|
||||
cases := []struct {
|
||||
name string
|
||||
completed []map[string]interface{}
|
||||
want bool
|
||||
}{
|
||||
{"empty", nil, false},
|
||||
{"autocommit single", []map[string]interface{}{stmt("INSERT")}, false},
|
||||
{"open tx (unmatched BEGIN)", []map[string]interface{}{stmt("BEGIN"), stmt("CREATE_TABLE"), stmt("INSERT")}, true},
|
||||
{"closed tx (BEGIN+COMMIT)", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("COMMIT")}, false},
|
||||
{"reopened tx", []map[string]interface{}{stmt("BEGIN"), stmt("COMMIT"), stmt("BEGIN"), stmt("INSERT")}, true},
|
||||
{"rollback closes tx", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("ROLLBACK")}, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := inferRolledBack(c.completed); got != c.want {
|
||||
t.Errorf("inferRolledBack(%s) = %v, want %v", c.name, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCellString_AllKinds 断言 cellString 对 nil/string/bool/整数/小数/对象各类型的字符串化结果。
|
||||
func TestCellString_AllKinds(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
101
shortcuts/apps/apps_db_quota_get.go
Normal file
101
shortcuts/apps/apps_db_quota_get.go
Normal file
@@ -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 <app_id>",
|
||||
"Example: lark-cli apps +db-quota-get --app-id <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)
|
||||
}
|
||||
267
shortcuts/apps/apps_db_recovery.go
Normal file
267
shortcuts/apps/apps_db_recovery.go
Normal file
@@ -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 <app_id> --target 2h",
|
||||
"Apply with +db-recovery-apply --target <same> --yes.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery").
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := rctx.Str("target")
|
||||
preview, err := runRecoveryPreview(rctx, appID, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := recoveryDiffOutput(target, preview)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
renderRecoveryDiff(w, target, out)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// AppsDBRecoveryApply 把数据库恢复到某个时间点(覆盖当前数据,异步,CLI 轮询至完成)。
|
||||
//
|
||||
// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:false};目标=当前态时短路 no_changes,
|
||||
// 否则轮询 env_recovery_apply_status 至 success。high-risk-write。
|
||||
var AppsDBRecoveryApply = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+db-recovery-apply",
|
||||
Description: "Restore the database to a point in time (overwrites current data, irreversible)",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +db-recovery-apply --app-id <app_id> --target 2026-04-15T10:00:00Z --yes",
|
||||
"Preview first with +db-recovery-diff.",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return normalizeTimeFlags(rctx, "target")
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery").
|
||||
Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := rctx.Str("target")
|
||||
stop := rctx.StartSpinner("Restoring database (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false})
|
||||
if err != nil {
|
||||
return withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
// 目标=当前态 → 后端短路 no_changes,不轮询。
|
||||
if strings.ToLower(common.GetString(submit, "status")) == "no_changes" {
|
||||
stop()
|
||||
out := map[string]interface{}{"status": "no_changes", "target": target}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
io.WriteString(w, "No changes — database is already at this state.\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 30*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "status")) {
|
||||
case "success", "restored", "ready":
|
||||
return true, nil
|
||||
case "failed":
|
||||
msg := common.GetString(d, "error_message")
|
||||
if msg == "" {
|
||||
msg = fmt.Sprintf("recovery to %s failed", target)
|
||||
}
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
if perr != nil {
|
||||
return perr
|
||||
}
|
||||
stop()
|
||||
out := map[string]interface{}{"status": "restored", "target": target}
|
||||
if n := intFromAny(final["restore_time_sec"]); n > 0 {
|
||||
out["restore_time_sec"] = n
|
||||
}
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
if n, ok := out["restore_time_sec"].(int); ok {
|
||||
fmt.Fprintf(w, "✓ Database restored to %s (%ds elapsed)\n", target, n)
|
||||
} else {
|
||||
fmt.Fprintf(w, "✓ Database restored to %s\n", target)
|
||||
}
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// runRecoveryPreview 触发 PITR 预览(dry_run=true)拿 preview_request_id,轮询 diff_status 至终态。
|
||||
func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) {
|
||||
stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")")
|
||||
defer stop()
|
||||
submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true})
|
||||
if err != nil {
|
||||
return nil, withAppsHint(err, dbRecoveryHint)
|
||||
}
|
||||
prid := common.GetString(submit, "preview_request_id")
|
||||
if prid == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id")
|
||||
}
|
||||
return pollUntil(rctx.Ctx(), 1*time.Second, 10*time.Minute,
|
||||
func() (map[string]interface{}, error) {
|
||||
return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil)
|
||||
},
|
||||
func(d map[string]interface{}) (bool, error) {
|
||||
switch strings.ToLower(common.GetString(d, "preview_status")) {
|
||||
case "success":
|
||||
return true, nil
|
||||
case "failed":
|
||||
msg := common.GetString(d, "error_message")
|
||||
if msg == "" {
|
||||
msg = "recovery preview failed"
|
||||
}
|
||||
return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
}
|
||||
|
||||
type recoveryChange struct {
|
||||
Table string `json:"table"`
|
||||
Inserted interface{} `json:"inserted,omitempty"`
|
||||
Deleted interface{} `json:"deleted,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
DroppedAt string `json:"dropped_at,omitempty"`
|
||||
}
|
||||
|
||||
// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。
|
||||
func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} {
|
||||
arr, _ := preview["changes"].([]interface{})
|
||||
changes := make([]recoveryChange, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
m, ok := it.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, recoveryChange{
|
||||
Table: common.GetString(m, "table"),
|
||||
Inserted: m["inserted"],
|
||||
Deleted: m["deleted"],
|
||||
Action: common.GetString(m, "action"),
|
||||
DroppedAt: common.GetString(m, "dropped_at"),
|
||||
})
|
||||
}
|
||||
tablesAffected := intFromAny(preview["tables_affected"])
|
||||
if tablesAffected == 0 {
|
||||
tablesAffected = len(changes)
|
||||
}
|
||||
est := intFromAny(preview["estimated_seconds"])
|
||||
if est == 0 {
|
||||
est = 30 // PRD 兜底
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"target": target, "tables_affected": tablesAffected,
|
||||
"changes": changes, "estimated_seconds": est,
|
||||
}
|
||||
}
|
||||
|
||||
// renderRecoveryDiff 渲染 PITR 恢复预览:受影响表数、逐表变化描述及预估耗时;无变更打提示。
|
||||
func renderRecoveryDiff(w io.Writer, target string, out map[string]interface{}) {
|
||||
changes, _ := out["changes"].([]recoveryChange)
|
||||
if len(changes) == 0 {
|
||||
io.WriteString(w, "No changes — database is already at this state.\n")
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "Recovery preview (→ %s):\n\n", target)
|
||||
fmt.Fprintf(w, " tables affected: %d\n", intFromAny(out["tables_affected"]))
|
||||
for _, c := range changes {
|
||||
fmt.Fprintf(w, " %s: %s\n", c.Table, describeRecoveryChange(c))
|
||||
}
|
||||
fmt.Fprintf(w, "\n estimated time: ~%ds\n", intFromAny(out["estimated_seconds"]))
|
||||
}
|
||||
|
||||
// describeRecoveryChange:schema 动作 或 数据行变化二选一(无 modified,对齐设计)。
|
||||
func describeRecoveryChange(c recoveryChange) string {
|
||||
switch c.Action {
|
||||
case "restore_table":
|
||||
return "table will be restored"
|
||||
case "drop_table":
|
||||
return "table will be dropped"
|
||||
case "alter_table":
|
||||
return "table will be altered"
|
||||
case "unavailable":
|
||||
if c.DroppedAt != "" {
|
||||
return "diff unavailable: " + c.DroppedAt
|
||||
}
|
||||
return "diff unavailable"
|
||||
}
|
||||
parts := make([]string, 0, 2)
|
||||
if n := intFromAny(c.Inserted); n != 0 {
|
||||
parts = append(parts, fmt.Sprintf("+%d rows", n))
|
||||
}
|
||||
if n := intFromAny(c.Deleted); n != 0 {
|
||||
parts = append(parts, fmt.Sprintf("-%d rows", n))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "no changes"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
@@ -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 <app_id>`; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id <app_id> --env dev`"
|
||||
const dbTableGetHint = "verify --app-id and --table are correct; list tables with `lark-cli apps +db-table-list --app-id <app_id>`; if targeting --environment dev, create it first with `lark-cli apps +db-env-create --app-id <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"
|
||||
}
|
||||
|
||||
@@ -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 <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 <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 != "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
412
shortcuts/apps/apps_env.go
Normal file
412
shortcuts/apps/apps_env.go
Normal file
@@ -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 <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 <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": "<redacted>",
|
||||
})
|
||||
},
|
||||
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 <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
|
||||
}
|
||||
@@ -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,8 +81,7 @@ var AppsEnvPull = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, 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`")
|
||||
}
|
||||
@@ -116,6 +116,16 @@ var AppsEnvPull = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func envPullVarsPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
func envPullVarsBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": "dev",
|
||||
}
|
||||
}
|
||||
|
||||
func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
||||
if strings.TrimSpace(projectPath) == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
@@ -150,13 +160,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 +219,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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,36 @@ func TestAppsEnvPull_ExecuteUsesNestedDataEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
RawBody: []byte("[]"),
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvPullBody(t, req)
|
||||
},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsEnvPull,
|
||||
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
|
||||
factory, stdout,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-object JSON failure, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
if strings.Contains(p.Hint, "apps +list") || strings.Contains(p.Hint, "--app-id") {
|
||||
t.Fatalf("hint should not point to app-id/list recovery for malformed upstream JSON: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
projectDir := t.TempDir()
|
||||
|
||||
409
shortcuts/apps/apps_env_test.go
Normal file
409
shortcuts/apps/apps_env_test.go
Normal file
@@ -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"] != "<redacted>" || 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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
148
shortcuts/apps/apps_file_delete.go
Normal file
148
shortcuts/apps/apps_file_delete.go
Normal file
@@ -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 <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))
|
||||
}
|
||||
132
shortcuts/apps/apps_file_delete_test.go
Normal file
132
shortcuts/apps/apps_file_delete_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
122
shortcuts/apps/apps_file_download.go
Normal file
122
shortcuts/apps/apps_file_download.go
Normal file
@@ -0,0 +1,122 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsFileDownload downloads a file to a local path via a signed URL。
|
||||
//
|
||||
// 两步:POST /apps/{app_id}/storage/file_sign 拿 signed_url(presigned,直连对象存储),
|
||||
// 再客户端 GET signed_url 落盘到 --output(默认远端 basename)。不单设 download 接口。
|
||||
var AppsFileDownload = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-download",
|
||||
Description: "Download a file to a local path (via a signed URL)",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-download --app-id <app_id> --path /1858537546760216.png --output ./logo.png",
|
||||
"Example (omit --output): lark-cli apps +file-download --app-id <app_id> --path /1858537546760216.png # saves to ./1858537546760216.png",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "path", Desc: "remote file path", Required: true},
|
||||
{Name: "output", Desc: "local output path (default: remote file basename in cwd)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := requireFilePath(rctx.Str("path"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
remotePath, _ := requireFilePath(rctx.Str("path"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appFileSignPath(appID)).
|
||||
Desc("Sign a download URL, then GET it to --output").
|
||||
Body(map[string]interface{}{"path": remotePath})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remotePath, err := requireFilePath(rctx.Str("path"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 1. 签名拿 presigned signed_url。
|
||||
signData, err := rctx.CallAPITyped("POST", appFileSignPath(appID), nil, map[string]interface{}{"path": remotePath})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signedURL := common.GetString(signData, "signed_url")
|
||||
if signedURL == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "sign returned no signed_url")
|
||||
}
|
||||
|
||||
// 2. 直连 GET signed_url 落盘。
|
||||
out := strings.TrimSpace(rctx.Str("output"))
|
||||
if out == "" {
|
||||
out = path.Base(strings.TrimPrefix(remotePath, "/"))
|
||||
if out == "" || out == "." || out == "/" {
|
||||
out = "download"
|
||||
}
|
||||
}
|
||||
req, err := http.NewRequestWithContext(rctx.Ctx(), http.MethodGet, signedURL, nil) //nolint:forbidigo // GET from a presigned object-storage URL bypasses the Lark gateway; raw HTTP required (not a Lark API call).
|
||||
if err != nil {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "build download request").WithCause(err)
|
||||
}
|
||||
resp, err := newFileTransferClient().Do(req) //nolint:forbidigo // see above: direct presigned-URL download, RuntimeContext.DoAPI does not apply.
|
||||
if err != nil {
|
||||
// dial/transport 失败是典型可重试场景。
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed").WithCause(err).WithRetryable()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
// 5xx 是上游瞬时故障,标 retryable;4xx(如签名过期)需重新签名而非盲重试,不标。
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed: HTTP %d", resp.StatusCode).WithRetryable()
|
||||
}
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{
|
||||
ContentType: resp.Header.Get("Content-Type"),
|
||||
ContentLength: resp.ContentLength,
|
||||
}, resp.Body)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
resolved, perr := rctx.FileIO().ResolvePath(out)
|
||||
if perr != nil || resolved == "" {
|
||||
resolved = out
|
||||
}
|
||||
result := map[string]interface{}{
|
||||
"path": remotePath,
|
||||
"output": resolved,
|
||||
"size_bytes": saved.Size(),
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Downloaded %s → %s (%s)\n", remotePath, resolved, humanBytes(saved.Size()))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
122
shortcuts/apps/apps_file_download_test.go
Normal file
122
shortcuts/apps/apps_file_download_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
87
shortcuts/apps/apps_file_get.go
Normal file
87
shortcuts/apps/apps_file_get.go
Normal file
@@ -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=<path>。file 仅按 path 精确寻址,无按名寻址。
|
||||
// pretty 渲染 key/value:file_name / path / size(含 bytes) / type / uploaded_by(只 name) / uploaded_at /
|
||||
// download_url(条件出现)。server created_at/created_by → uploaded_at/uploaded_by。
|
||||
var AppsFileGet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-get",
|
||||
Description: "Get a single file's metadata by path",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-get --app-id <app_id> --path /1858537546760216.png",
|
||||
"Tip: extract a single field with --jq, e.g. -q '.size_bytes' or -q '.download_url'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "path", Desc: "remote file path", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := requireFilePath(rctx.Str("path"))
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appFileGetPath(appID)).
|
||||
Desc("Get Miaoda app file metadata").
|
||||
Params(buildFileGetParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appFileGetPath(appID), buildFileGetParams(rctx), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info := projectFileInfo(data)
|
||||
rctx.OutFormat(info, nil, func(w io.Writer) {
|
||||
renderFileGetPretty(w, info)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// buildFileGetParams 组装 file_get 查询参数:按 path 精确寻址单文件。
|
||||
func buildFileGetParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
path, _ := requireFilePath(rctx.Str("path"))
|
||||
return map[string]interface{}{"path": path}
|
||||
}
|
||||
|
||||
// renderFileGetPretty 输出对齐 key/value;uploaded_by 只展示 name(id 仅 json 保留)。
|
||||
func renderFileGetPretty(w io.Writer, info fileInfo) {
|
||||
pairs := [][2]string{
|
||||
{"file_name", dashIfEmpty(info.FileName)},
|
||||
{"path", info.Path},
|
||||
{"size", fileSizeDetail(info.SizeBytes)},
|
||||
{"type", dashIfEmpty(info.Type)},
|
||||
}
|
||||
if info.UploadedBy != nil {
|
||||
pairs = append(pairs, [2]string{"uploaded_by", info.UploadedBy.Name})
|
||||
}
|
||||
pairs = append(pairs, [2]string{"uploaded_at", dashIfEmpty(info.UploadedAt)})
|
||||
if info.DownloadURL != "" {
|
||||
pairs = append(pairs, [2]string{"download_url", info.DownloadURL})
|
||||
}
|
||||
renderKeyValuePairs(w, pairs)
|
||||
}
|
||||
89
shortcuts/apps/apps_file_get_test.go
Normal file
89
shortcuts/apps/apps_file_get_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
145
shortcuts/apps/apps_file_list.go
Normal file
145
shortcuts/apps/apps_file_list.go
Normal file
@@ -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 <app_id>",
|
||||
"Tip: filter fields with --jq, e.g. -q '.data.items[].path'",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "name", Desc: "filter by exact file name"},
|
||||
{Name: "path", Desc: "filter by exact remote path"},
|
||||
{Name: "type", Desc: "filter by MIME type"},
|
||||
{Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"},
|
||||
{Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"},
|
||||
{Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"},
|
||||
{Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ"},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "page size"},
|
||||
{Name: "page-token", Desc: "pagination cursor from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
// 设计原则三:<timestamp> 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。
|
||||
for _, f := range []string{"uploaded-since", "uploaded-until"} {
|
||||
if strings.TrimSpace(rctx.Str(f)) == "" {
|
||||
continue
|
||||
}
|
||||
n, err := normalizeTimestamp(rctx.Str(f))
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: %v", f, err).WithParam("--" + f)
|
||||
}
|
||||
_ = rctx.Cmd.Flags().Set(f, n)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
GET(appFileListPath(appID)).
|
||||
Desc("List Miaoda app files").
|
||||
Params(buildFileListParams(rctx))
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := rctx.CallAPITyped("GET", appFileListPath(appID), buildFileListParams(rctx), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 白名单投影:server created_at/created_by → uploaded_at/uploaded_by,替换原始 items[]。
|
||||
items := projectFileItems(data["items"])
|
||||
data["items"] = items
|
||||
rctx.OutFormat(data, nil, func(w io.Writer) {
|
||||
renderFileListPretty(w, items)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// projectFileItems 把服务端原始 items 逐项投影为白名单 fileInfo(created_*→uploaded_*)。
|
||||
func projectFileItems(raw interface{}) []fileInfo {
|
||||
arr, _ := raw.([]interface{})
|
||||
out := make([]fileInfo, 0, len(arr))
|
||||
for _, it := range arr {
|
||||
if m, ok := it.(map[string]interface{}); ok {
|
||||
out = append(out, projectFileInfo(m))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildFileListParams 组装 file_list 查询参数:page_size 及可选 name/path/type/size_gt/size_lt/uploaded_since/uploaded_until/page_token。
|
||||
func buildFileListParams(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": rctx.Int("page-size"),
|
||||
}
|
||||
addStr := func(flag, key string) {
|
||||
if v := strings.TrimSpace(rctx.Str(flag)); v != "" {
|
||||
params[key] = v
|
||||
}
|
||||
}
|
||||
addStr("name", "name")
|
||||
addStr("path", "path")
|
||||
addStr("type", "type")
|
||||
addStr("uploaded-since", "uploaded_since")
|
||||
addStr("uploaded-until", "uploaded_until")
|
||||
addStr("page-token", "page_token")
|
||||
if v := rctx.Int("size-gt"); v > 0 {
|
||||
params["size_gt"] = v
|
||||
}
|
||||
if v := rctx.Int("size-lt"); v > 0 {
|
||||
params["size_lt"] = v
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
// renderFileListPretty 5 列对齐表:file_name / path / size / type / uploaded_at。
|
||||
func renderFileListPretty(w io.Writer, items []fileInfo) {
|
||||
if len(items) == 0 {
|
||||
io.WriteString(w, "No files found.\n")
|
||||
return
|
||||
}
|
||||
headers := []string{"file_name", "path", "size", "type", "uploaded_at"}
|
||||
rows := make([][]string, 0, len(items))
|
||||
for _, it := range items {
|
||||
rows = append(rows, []string{
|
||||
dashIfEmpty(it.FileName),
|
||||
it.Path,
|
||||
humanBytes(it.SizeBytes),
|
||||
dashIfEmpty(it.Type),
|
||||
dashIfEmpty(it.UploadedAt),
|
||||
})
|
||||
}
|
||||
renderAlignedTable(w, headers, rows)
|
||||
}
|
||||
252
shortcuts/apps/apps_file_list_test.go
Normal file
252
shortcuts/apps/apps_file_list_test.go
Normal file
@@ -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"
|
||||
)
|
||||
|
||||
// 设计原则三:<timestamp> 四种格式 → 统一 RFC3339 UTC。
|
||||
func TestNormalizeTimestamp_AllFormats(t *testing.T) {
|
||||
// 空串透传
|
||||
if got, err := normalizeTimestamp(" "); err != nil || got != "" {
|
||||
t.Fatalf("empty → %q,%v want \"\",nil", got, err)
|
||||
}
|
||||
|
||||
// ISO 8601 带 TZ:Z 原样、显式偏移换算到 UTC
|
||||
mustEq := func(in, want string) {
|
||||
got, err := normalizeTimestamp(in)
|
||||
if err != nil || got != want {
|
||||
t.Errorf("normalizeTimestamp(%q)=%q,%v want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
mustEq("2026-04-15T10:00:00Z", "2026-04-15T10:00:00Z")
|
||||
mustEq("2026-04-15T10:00:00+08:00", "2026-04-15T02:00:00Z") // +08:00 → UTC -8h
|
||||
|
||||
// date / local datetime:按本地时区解释再转 UTC(与 time.ParseInLocation 对齐)
|
||||
dExp, _ := time.ParseInLocation("2006-01-02", "2026-04-15", time.Local)
|
||||
mustEq("2026-04-15", dExp.UTC().Format(time.RFC3339))
|
||||
ldExp, _ := time.ParseInLocation("2006-01-02T15:04:05", "2026-04-15T10:00:00", time.Local)
|
||||
mustEq("2026-04-15T10:00:00", ldExp.UTC().Format(time.RFC3339))
|
||||
|
||||
// 相对:从现在往前推,结果应 ≈ now-dur(5s 容差)
|
||||
for _, c := range []struct {
|
||||
in string
|
||||
dur time.Duration
|
||||
}{{"30s", 30 * time.Second}, {"5m", 5 * time.Minute}, {"2h", 2 * time.Hour}, {"3d", 72 * time.Hour}, {"1w", 7 * 24 * time.Hour}} {
|
||||
got, err := normalizeTimestamp(c.in)
|
||||
if err != nil {
|
||||
t.Errorf("normalizeTimestamp(%q) err=%v", c.in, err)
|
||||
continue
|
||||
}
|
||||
ts, perr := time.Parse(time.RFC3339, got)
|
||||
if perr != nil {
|
||||
t.Errorf("normalizeTimestamp(%q)=%q not RFC3339", c.in, got)
|
||||
continue
|
||||
}
|
||||
want := time.Now().Add(-c.dur)
|
||||
if diff := want.Sub(ts); diff > 5*time.Second || diff < -5*time.Second {
|
||||
t.Errorf("normalizeTimestamp(%q)=%q off by %v from now-%v", c.in, got, diff, c.dur)
|
||||
}
|
||||
}
|
||||
|
||||
// 非法格式 → error
|
||||
for _, bad := range []string{"notatime", "7x", "2026/04/15", "2026-13-99"} {
|
||||
if _, err := normalizeTimestamp(bad); err == nil {
|
||||
t.Errorf("normalizeTimestamp(%q) expected error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fileListURL = "/open-apis/spark/v1/apps/app_x/storage/file_list"
|
||||
|
||||
// TestAppsFileList_RequiresAppID 验证空白 --app-id 触发 --app-id typed 校验错误。
|
||||
func TestAppsFileList_RequiresAppID(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", " ", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--app-id" {
|
||||
t.Fatalf("Param = %q, want --app-id", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_since/until 原样)。
|
||||
func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x",
|
||||
"--name", "logo.png", "--path", "/x.png", "--type", "image/png",
|
||||
"--size-gt", "100", "--size-lt", "9000",
|
||||
"--uploaded-since", "2026-01-01", "--uploaded-until", "2026-02-01",
|
||||
"--page-size", "5", "--page-token", "cur-1",
|
||||
"--dry-run", "--as", "user"},
|
||||
factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
a := env.API[0]
|
||||
if a.Method != "GET" || a.URL != fileListURL {
|
||||
t.Fatalf("method/url = %s %s", a.Method, a.URL)
|
||||
}
|
||||
// 设计原则三:date 入参会被归一化为 RFC3339 UTC,期望值用 normalizeTimestamp 计算(避开本地时区脆弱断言)。
|
||||
sinceN, _ := normalizeTimestamp("2026-01-01")
|
||||
untilN, _ := normalizeTimestamp("2026-02-01")
|
||||
wantStr := map[string]string{
|
||||
"name": "logo.png", "path": "/x.png", "type": "image/png",
|
||||
"uploaded_since": sinceN, "uploaded_until": untilN, "page_token": "cur-1",
|
||||
}
|
||||
for k, v := range wantStr {
|
||||
if a.Params[k] != v {
|
||||
t.Errorf("params.%s = %v, want %v", k, a.Params[k], v)
|
||||
}
|
||||
}
|
||||
// 且确实归一化成了 UTC(以 Z 结尾),不是原样透传。
|
||||
if s, _ := a.Params["uploaded_since"].(string); !strings.HasSuffix(s, "Z") {
|
||||
t.Errorf("uploaded_since not normalized to RFC3339 UTC: %v", a.Params["uploaded_since"])
|
||||
}
|
||||
for _, k := range []string{"size_gt", "size_lt", "page_size"} {
|
||||
if _, ok := a.Params[k]; !ok {
|
||||
t.Errorf("params missing %s: %v", k, a.Params)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 0 值过滤器不下发(size-gt/lt 缺省 0、空字符串过滤器)。
|
||||
func TestAppsFileList_DryRunOmitsEmptyFilters(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Params map[string]interface{} `json:"params"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
for _, banned := range []string{"name", "path", "type", "size_gt", "size_lt", "uploaded_since", "uploaded_until", "page_token"} {
|
||||
if _, ok := env.API[0].Params[banned]; ok {
|
||||
t.Errorf("params should omit empty %s: %v", banned, env.API[0].Params)
|
||||
}
|
||||
}
|
||||
if _, ok := env.API[0].Params["page_size"]; !ok {
|
||||
t.Errorf("params should always carry page_size: %v", env.API[0].Params)
|
||||
}
|
||||
}
|
||||
|
||||
// created_at/created_by → uploaded_at/uploaded_by;created_by 是 JSON 字符串 → parse 成对象。
|
||||
func TestAppsFileList_SuccessProjectsCreatedToUploaded(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: fileListURL,
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"file_name": "logo.png",
|
||||
"path": "/1858537546760216.png",
|
||||
"size_bytes": 24580,
|
||||
"type": "image/png",
|
||||
"created_at": "2026-04-15T10:30:00Z",
|
||||
"created_by": `{"id":"7311","name":"alice"}`,
|
||||
"download_url": "/spark/app/x/1858537546760216.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"uploaded_at": "2026-04-15T10:30:00Z"`, `"uploaded_by"`, `"name": "alice"`, `"id": "7311"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
// created_* 不应再出现在输出。
|
||||
for _, banned := range []string{"created_at", "created_by"} {
|
||||
if strings.Contains(got, banned) {
|
||||
t.Errorf("stdout should not contain %q (renamed to uploaded_*):\n%s", banned, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileList_PrettyTableAndEmpty 验证 pretty 非空时渲染表头与人类可读 size,空结果时输出 "No files found."。
|
||||
func TestAppsFileList_PrettyTableAndEmpty(t *testing.T) {
|
||||
// 非空:5 列表头。
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: fileListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{
|
||||
"file_name": "logo.png", "path": "/x.png", "size_bytes": 24576, "type": "image/png",
|
||||
"created_at": "2026-04-15T10:30:00Z",
|
||||
}},
|
||||
}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, "file_name") || !strings.Contains(got, "uploaded_at") || !strings.Contains(got, "24 KB") {
|
||||
t.Fatalf("pretty table malformed:\n%s", got)
|
||||
}
|
||||
|
||||
// 空:No files found.
|
||||
factory2, stdout2, reg2 := newAppsExecuteFactory(t)
|
||||
reg2.Register(&httpmock.Stub{
|
||||
Method: "GET", URL: fileListURL,
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}},
|
||||
})
|
||||
if err := runAppsShortcut(t, AppsFileList,
|
||||
[]string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if !strings.Contains(stdout2.String(), "No files found.") {
|
||||
t.Fatalf("empty pretty should say 'No files found.', got: %s", stdout2.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFileUser_Cases 验证 parseFileUser:合法 JSON 解析成对象,空串/非法/全空字段均返回 nil。
|
||||
func TestParseFileUser_Cases(t *testing.T) {
|
||||
if u := parseFileUser(`{"id":"1","name":"a"}`); u == nil || u.ID != "1" || u.Name != "a" {
|
||||
t.Fatalf("valid parse failed: %#v", u)
|
||||
}
|
||||
if u := parseFileUser(""); u != nil {
|
||||
t.Errorf("empty → nil, got %#v", u)
|
||||
}
|
||||
if u := parseFileUser("not json"); u != nil {
|
||||
t.Errorf("invalid → nil, got %#v", u)
|
||||
}
|
||||
if u := parseFileUser(`{"id":"","name":""}`); u != nil {
|
||||
t.Errorf("all-empty → nil, got %#v", u)
|
||||
}
|
||||
}
|
||||
93
shortcuts/apps/apps_file_quota_get.go
Normal file
93
shortcuts/apps/apps_file_quota_get.go
Normal file
@@ -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 <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)
|
||||
}
|
||||
96
shortcuts/apps/apps_file_quota_get_test.go
Normal file
96
shortcuts/apps/apps_file_quota_get_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
82
shortcuts/apps/apps_file_sign.go
Normal file
82
shortcuts/apps/apps_file_sign.go
Normal file
@@ -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 <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
|
||||
}
|
||||
74
shortcuts/apps/apps_file_sign_test.go
Normal file
74
shortcuts/apps/apps_file_sign_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
206
shortcuts/apps/apps_file_upload.go
Normal file
206
shortcuts/apps/apps_file_upload.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// fileUploadMaxBytes 是单文件上传上限(100 MB,对齐 miaoda)。
|
||||
const fileUploadMaxBytes = 100 * 1024 * 1024
|
||||
|
||||
// AppsFileUpload uploads a local file to an app's storage(三步直传)。
|
||||
//
|
||||
// 1. POST /apps/{app_id}/storage/file_pre_upload {file_name,file_size,content_type} → {upload_url,upload_id}
|
||||
// 2. 客户端 PUT 文件字节到 presigned upload_url,取响应 ETag
|
||||
// 3. POST /apps/{app_id}/storage/file_upload_callback {upload_id,etag} → 文件元数据
|
||||
// file_name 取本地 basename;path 由平台生成 16 位 ID(不可指定)。仅收 --file。
|
||||
var AppsFileUpload = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+file-upload",
|
||||
Description: "Upload a local file to an app's storage",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +file-upload --app-id <app_id> --file ./logo.png",
|
||||
"Example: lark-cli apps +file-upload --app-id <app_id> --file ./report.pdf -q '.path' # print the platform-generated file path",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "Miaoda app id", Required: true},
|
||||
{Name: "file", Desc: "local file to upload (file_name = basename)", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
f := strings.TrimSpace(rctx.Str("file"))
|
||||
if f == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file")
|
||||
}
|
||||
st, err := rctx.FileIO().Stat(f)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file")
|
||||
}
|
||||
if st.Size() > fileUploadMaxBytes {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(appFilePreUploadPath(appID)).
|
||||
Desc("Pre-upload → client PUT bytes → callback (3-step)").
|
||||
Body(map[string]interface{}{"file_name": filepath.Base(strings.TrimSpace(rctx.Str("file")))})
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
localPath := strings.TrimSpace(rctx.Str("file"))
|
||||
content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err)
|
||||
}
|
||||
fileName := filepath.Base(localPath)
|
||||
contentType := mimeByExt(fileName)
|
||||
|
||||
// 1. pre-upload
|
||||
pre, err := rctx.CallAPITyped("POST", appFilePreUploadPath(appID), nil, map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"file_size": len(content),
|
||||
"content_type": contentType,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uploadURL := common.GetString(pre, "upload_url")
|
||||
uploadID := common.GetString(pre, "upload_id")
|
||||
if uploadURL == "" || uploadID == "" {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "pre-upload returned no upload_url / upload_id")
|
||||
}
|
||||
|
||||
// 2. PUT 文件字节到 presigned URL,取 ETag(带 Content-Disposition 透传原始文件名)
|
||||
etag, err := putFileBytes(rctx.Ctx(), uploadURL, content, contentType, fileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 3. callback
|
||||
result, err := rctx.CallAPITyped("POST", appFileUploadCallbackPath(appID), nil, map[string]interface{}{
|
||||
"upload_id": uploadID,
|
||||
"etag": etag,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info := projectFileInfo(result)
|
||||
rctx.OutFormat(info, nil, func(w io.Writer) {
|
||||
renderFileUploadPretty(w, fileName, info)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// putFileBytes 直连 PUT 文件字节到 presigned URL,返回响应的 ETag。
|
||||
//
|
||||
// Content-Disposition 透传原始文件名:TOS 把它存成对象 metadata,callback 阶段后端
|
||||
// HeadObject 读回解析出 filename 写入 DB 的 display name。不传则后端兜底用 storage key
|
||||
// (平台 16 位 ID)当文件名 —— 即「上传后文件名变成 ID」的根因。
|
||||
//
|
||||
//nolint:forbidigo // direct PUT to a presigned object-storage URL bypasses the Lark gateway — raw HTTP is required (no Lark auth/gateway); RuntimeContext.DoAPI cannot target a presigned URL.
|
||||
func putFileBytes(ctx context.Context, url string, content []byte, contentType, fileName string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(content))
|
||||
if err != nil {
|
||||
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "build upload request").WithCause(err)
|
||||
}
|
||||
req.ContentLength = int64(len(content))
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
req.Header.Set("Content-Disposition", "attachment; filename=\""+sanitizeUploadFileName(fileName)+"\"")
|
||||
resp, err := newFileTransferClient().Do(req)
|
||||
if err != nil {
|
||||
// dial/transport 失败是典型可重试场景。
|
||||
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed").WithCause(err).WithRetryable()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
io.Copy(io.Discard, io.LimitReader(resp.Body, 4096))
|
||||
if resp.StatusCode >= 400 {
|
||||
// 5xx 是上游瞬时故障,标 retryable;4xx(如签名过期)需重新签名而非盲重试,不标。
|
||||
if resp.StatusCode >= 500 {
|
||||
return "", errs.NewNetworkError(errs.SubtypeNetworkServer, "upload failed: HTTP %d", resp.StatusCode).WithRetryable()
|
||||
}
|
||||
return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
return resp.Header.Get("ETag"), nil
|
||||
}
|
||||
|
||||
// sanitizeUploadFileName 对齐 miaoda:先去掉 TOS 非法字符 [:"\/*?<>|,;],再 encodeURIComponent
|
||||
// (UTF-8 百分号编码,兼容中文等非 ASCII,且让 Content-Disposition header 合法),空则兜底 download_file。
|
||||
func sanitizeUploadFileName(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
switch r {
|
||||
case ':', '"', '\\', '/', '*', '?', '<', '>', '|', ',', ';':
|
||||
continue
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
enc := encodeURIComponent(b.String())
|
||||
if enc == "" {
|
||||
return "download_file"
|
||||
}
|
||||
return enc
|
||||
}
|
||||
|
||||
// encodeURIComponent 复刻 JS encodeURIComponent:除 A-Za-z0-9-_.!~*'() 外按 UTF-8 字节 %XX 编码。
|
||||
func encodeURIComponent(s string) string {
|
||||
const keep = "-_.!~*'()"
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || strings.IndexByte(keep, c) >= 0 {
|
||||
b.WriteByte(c)
|
||||
} else {
|
||||
b.WriteString(fmt.Sprintf("%%%02X", c))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mimeByExt 按扩展名推断 Content-Type,未知回退 application/octet-stream。
|
||||
func mimeByExt(name string) string {
|
||||
if t := mime.TypeByExtension(filepath.Ext(name)); t != "" {
|
||||
return t
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
|
||||
// renderFileUploadPretty 打 ✓ Uploaded <local> → <path> + size / download_url。
|
||||
func renderFileUploadPretty(w io.Writer, localName string, info fileInfo) {
|
||||
fmt.Fprintf(w, "✓ Uploaded %s → %s\n", localName, info.Path)
|
||||
fmt.Fprintf(w, "size: %s\n", fileSizeDetail(info.SizeBytes))
|
||||
if info.DownloadURL != "" {
|
||||
fmt.Fprintf(w, "download_url: %s\n", info.DownloadURL)
|
||||
}
|
||||
}
|
||||
179
shortcuts/apps/apps_file_upload_test.go
Normal file
179
shortcuts/apps/apps_file_upload_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
// TestAppsFileUpload_RequiresAppIDAndFile 验证仅含空白的 --file 经 Validate 去空后触发 --file typed 校验错误。
|
||||
func TestAppsFileUpload_RequiresAppIDAndFile(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
// --file is a cobra-required flag; pass whitespace so cobra's required check
|
||||
// passes and our Validate (which trims) rejects it with a typed error.
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", " ", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("Param = %q, want --file", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_RejectsDirectory 验证 --file 指向目录时触发 --file typed 校验错误。
|
||||
func TestAppsFileUpload_RejectsDirectory(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "sub", "--as", "user"}, factory, stdout)
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("err = %T %v, want *errs.ValidationError", err, err)
|
||||
}
|
||||
if ve.Param != "--file" {
|
||||
t.Fatalf("Param = %q, want --file", ve.Param)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_upload,body.file_name 取文件 basename。
|
||||
func TestAppsFileUpload_DryRunPreUpload(t *testing.T) {
|
||||
// Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var env struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
_ = json.Unmarshal([]byte(stdout.String()), &env)
|
||||
a := env.API[0]
|
||||
if a.Method != "POST" || a.URL != "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload" {
|
||||
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
|
||||
}
|
||||
if a.Body["file_name"] != "logo.png" {
|
||||
t.Fatalf("dry-run body.file_name = %v, want logo.png (basename)", a.Body["file_name"])
|
||||
}
|
||||
}
|
||||
|
||||
// 三步直传:pre-upload → 客户端 PUT 字节 → callback。
|
||||
func TestAppsFileUpload_EndToEnd(t *testing.T) {
|
||||
var putBody []byte
|
||||
var putContentType, putCD string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPut {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
putBody, _ = io.ReadAll(r.Body)
|
||||
putContentType = r.Header.Get("Content-Type")
|
||||
putCD = r.Header.Get("Content-Disposition")
|
||||
w.Header().Set("ETag", `"etag-123"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("PNGBYTES"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
oldWD, _ := os.Getwd()
|
||||
if err := os.Chdir(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(oldWD) })
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-1"}},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback",
|
||||
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
|
||||
"file_name": "logo.png", "path": "/1858537546760216.png", "size_bytes": 8, "type": "image/png",
|
||||
"download_url": "/spark/app/x/1858537546760216.png",
|
||||
}},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsFileUpload,
|
||||
[]string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if string(putBody) != "PNGBYTES" {
|
||||
t.Fatalf("PUT body = %q, want file bytes", putBody)
|
||||
}
|
||||
if putContentType != "image/png" {
|
||||
t.Errorf("PUT Content-Type = %q, want image/png", putContentType)
|
||||
}
|
||||
// 原始文件名必须经 Content-Disposition 透传给 TOS(否则后端用 storage key 当文件名)。
|
||||
if putCD != `attachment; filename="logo.png"` {
|
||||
t.Errorf("PUT Content-Disposition = %q, want attachment; filename=\"logo.png\"", putCD)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.Contains(got, `"path": "/1858537546760216.png"`) {
|
||||
t.Errorf("output missing uploaded path:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName:空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。
|
||||
func TestSanitizeUploadFileName_Cases(t *testing.T) {
|
||||
cases := []struct{ in, want string }{
|
||||
{"logo.png", "logo.png"},
|
||||
{"a b.png", "a%20b.png"}, // 空格 → %20(encodeURIComponent)
|
||||
{`a:b/c*d?.png`, "abcd.png"}, // 去掉 TOS 非法字符
|
||||
{"///", "download_file"}, // 全非法 → 兜底
|
||||
{"中.txt", "%E4%B8%AD.txt"}, // 非 ASCII → UTF-8 百分号编码
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := sanitizeUploadFileName(c.in); got != c.want {
|
||||
t.Errorf("sanitizeUploadFileName(%q)=%q want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMimeByExt_Cases 验证 mimeByExt:按扩展名识别 image/png,未知扩展名兜底 application/octet-stream。
|
||||
func TestMimeByExt_Cases(t *testing.T) {
|
||||
if got := mimeByExt("a.png"); !strings.HasPrefix(got, "image/png") {
|
||||
t.Errorf("mimeByExt(a.png)=%q want image/png", got)
|
||||
}
|
||||
if got := mimeByExt("data.unknownext"); got != "application/octet-stream" {
|
||||
t.Errorf("mimeByExt(unknown)=%q want application/octet-stream", got)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
877
shortcuts/apps/apps_logs.go
Normal file
877
shortcuts/apps/apps_logs.go
Normal file
@@ -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 <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 <app_id> --log-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
|
||||
}
|
||||
664
shortcuts/apps/apps_logs_test.go
Normal file
664
shortcuts/apps/apps_logs_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
587
shortcuts/apps/apps_metrics.go
Normal file
587
shortcuts/apps/apps_metrics.go
Normal file
@@ -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 <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
|
||||
}
|
||||
298
shortcuts/apps/apps_metrics_test.go
Normal file
298
shortcuts/apps/apps_metrics_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
202
shortcuts/apps/apps_observability_common.go
Normal file
202
shortcuts/apps/apps_observability_common.go
Normal file
@@ -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)
|
||||
}
|
||||
138
shortcuts/apps/apps_observability_common_test.go
Normal file
138
shortcuts/apps/apps_observability_common_test.go
Normal file
@@ -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
|
||||
}
|
||||
129
shortcuts/apps/apps_openapi_key_common.go
Normal file
129
shortcuts/apps/apps_openapi_key_common.go
Normal file
@@ -0,0 +1,129 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case httpInfo.
|
||||
func parseScopeAPI(s string) (map[string]interface{}, error) {
|
||||
fields := strings.Fields(strings.TrimSpace(s))
|
||||
if len(fields) != 2 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "expected 'METHOD /path', got %q", s)
|
||||
}
|
||||
return map[string]interface{}{"http_method": strings.ToUpper(fields[0]), "http_path": fields[1]}, nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
var rs interface{}
|
||||
if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
if !hasFriendly {
|
||||
return nil, nil
|
||||
}
|
||||
rs := map[string]interface{}{"allow_all": scopeAll}
|
||||
if len(scopeAPIs) > 0 {
|
||||
infos := make([]interface{}, 0, len(scopeAPIs))
|
||||
for _, a := range scopeAPIs {
|
||||
info, err := parseScopeAPI(a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infos = append(infos, info)
|
||||
}
|
||||
rs["http_infos"] = infos
|
||||
}
|
||||
return rs, nil
|
||||
}
|
||||
|
||||
// buildKeyConfig assembles the snake_case config object. Returns nil when nothing is set.
|
||||
func buildKeyConfig(scopeAll bool, scopeAPIs []string, scopeRaw string, hasAllowPreview, allowPreview bool) (map[string]interface{}, error) {
|
||||
rs, err := buildRequestScope(scopeAll, scopeAPIs, scopeRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rs == nil && !hasAllowPreview {
|
||||
return nil, nil
|
||||
}
|
||||
cfg := map[string]interface{}{}
|
||||
if rs != nil {
|
||||
cfg["request_scope"] = rs
|
||||
}
|
||||
if hasAllowPreview {
|
||||
cfg["is_allow_access_preview"] = allowPreview
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// oapiKeyValidateScopeFlags validates the scope flag combination (shared by create/update).
|
||||
func oapiKeyValidateScopeFlags(rctx *common.RuntimeContext) error {
|
||||
scopeRaw := strings.TrimSpace(rctx.Str("scope"))
|
||||
if scopeRaw != "" && (rctx.Bool("scope-all") || len(rctx.StrArray("scope-api")) > 0) {
|
||||
return appsValidationParamError("--scope", "--scope cannot be combined with --scope-all / --scope-api").
|
||||
WithHint("use either --scope (raw JSON) OR --scope-all/--scope-api, not both")
|
||||
}
|
||||
if scopeRaw != "" && !json.Valid([]byte(scopeRaw)) {
|
||||
return appsValidationParamError("--scope", "--scope must be valid JSON").
|
||||
WithHint("--scope takes raw JSON for config.request_scope; or use --scope-all / --scope-api 'METHOD /openapi/path'")
|
||||
}
|
||||
for _, a := range rctx.StrArray("scope-api") {
|
||||
if len(strings.Fields(strings.TrimSpace(a))) != 2 {
|
||||
return appsValidationParamError("--scope-api", "--scope-api must be 'METHOD /path', got %q", a).
|
||||
WithHint("format: --scope-api 'METHOD /openapi/path' (routes come from the app's docs/openapi.json), e.g. --scope-api 'GET /openapi/orders'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
254
shortcuts/apps/apps_openapi_key_common_test.go
Normal file
254
shortcuts/apps/apps_openapi_key_common_test.go
Normal file
@@ -0,0 +1,254 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMaskAPIKey(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"": "****",
|
||||
"abcd": "****",
|
||||
"xxxxxxxxxxxx": "****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 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
110
shortcuts/apps/apps_openapi_key_create.go
Normal file
110
shortcuts/apps/apps_openapi_key_create.go
Normal file
@@ -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 <app_id> --name partner-test",
|
||||
"Example: lark-cli apps +openapi-key-create --app-id <app_id> --name orders-readonly --scope-api 'GET /openapi/orders'",
|
||||
"Example: lark-cli apps +openapi-key-create --app-id <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
|
||||
}
|
||||
86
shortcuts/apps/apps_openapi_key_create_test.go
Normal file
86
shortcuts/apps/apps_openapi_key_create_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
47
shortcuts/apps/apps_openapi_key_delete.go
Normal file
47
shortcuts/apps/apps_openapi_key_delete.go
Normal file
@@ -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 <app_id> --key-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
|
||||
},
|
||||
}
|
||||
35
shortcuts/apps/apps_openapi_key_delete_test.go
Normal file
35
shortcuts/apps/apps_openapi_key_delete_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
33
shortcuts/apps/apps_openapi_key_disable.go
Normal file
33
shortcuts/apps/apps_openapi_key_disable.go
Normal file
@@ -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 <app_id> --key-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)
|
||||
},
|
||||
}
|
||||
53
shortcuts/apps/apps_openapi_key_enable.go
Normal file
53
shortcuts/apps/apps_openapi_key_enable.go
Normal file
@@ -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 <app_id> --key-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)
|
||||
}
|
||||
72
shortcuts/apps/apps_openapi_key_get.go
Normal file
72
shortcuts/apps/apps_openapi_key_get.go
Normal file
@@ -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 <app_id> --key-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: <redacted>} 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
|
||||
}
|
||||
49
shortcuts/apps/apps_openapi_key_get_test.go
Normal file
49
shortcuts/apps/apps_openapi_key_get_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
104
shortcuts/apps/apps_openapi_key_list.go
Normal file
104
shortcuts/apps/apps_openapi_key_list.go
Normal file
@@ -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 <app_id>",
|
||||
"Example: lark-cli apps +openapi-key-list --app-id <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 <app_id>`")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
91
shortcuts/apps/apps_openapi_key_list_test.go
Normal file
91
shortcuts/apps/apps_openapi_key_list_test.go
Normal file
@@ -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
|
||||
}
|
||||
50
shortcuts/apps/apps_openapi_key_reset.go
Normal file
50
shortcuts/apps/apps_openapi_key_reset.go
Normal file
@@ -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 <app_id> --key-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"))))
|
||||
}
|
||||
48
shortcuts/apps/apps_openapi_key_reset_test.go
Normal file
48
shortcuts/apps/apps_openapi_key_reset_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
43
shortcuts/apps/apps_openapi_key_status_test.go
Normal file
43
shortcuts/apps/apps_openapi_key_status_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
82
shortcuts/apps/apps_openapi_key_update.go
Normal file
82
shortcuts/apps/apps_openapi_key_update.go
Normal file
@@ -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 <app_id> --key-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
|
||||
}
|
||||
63
shortcuts/apps/apps_openapi_key_update_test.go
Normal file
63
shortcuts/apps/apps_openapi_key_update_test.go
Normal file
@@ -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())
|
||||
}
|
||||
}
|
||||
351
shortcuts/apps/apps_output_schema.go
Normal file
351
shortcuts/apps/apps_output_schema.go
Normal file
@@ -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
|
||||
}
|
||||
56
shortcuts/apps/apps_output_schema_test.go
Normal file
56
shortcuts/apps/apps_output_schema_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
664
shortcuts/apps/apps_traces.go
Normal file
664
shortcuts/apps/apps_traces.go
Normal file
@@ -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 <app_id> --trace-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 <app_id> --trace-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
|
||||
}
|
||||
453
shortcuts/apps/apps_traces_test.go
Normal file
453
shortcuts/apps/apps_traces_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
228
shortcuts/apps/file_common.go
Normal file
228
shortcuts/apps/file_common.go
Normal file
@@ -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 实现设计原则三的 <timestamp> 多格式输入,统一归一化为 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/<name>。
|
||||
// 路由段不含 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")
|
||||
}
|
||||
}
|
||||
392
shortcuts/apps/plugin_common.go
Normal file
392
shortcuts/apps/plugin_common.go
Normal file
@@ -0,0 +1,392 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// pluginResolveProjectPath resolves --project-path to an absolute path,
|
||||
// defaulting to cwd when empty.
|
||||
func pluginResolveProjectPath(raw string) (string, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
if err != nil {
|
||||
return "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(err)
|
||||
}
|
||||
return cwd, nil
|
||||
}
|
||||
if err := validate.RejectControlChars(raw, "--project-path"); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Clean(raw), nil
|
||||
}
|
||||
|
||||
// pluginCheckProjectDir validates that projectPath contains a package.json.
|
||||
func pluginCheckProjectDir(projectPath string) error {
|
||||
info, err := os.Stat(filepath.Join(projectPath, "package.json")) //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for project dir check.
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return appsFailedPreconditionError("package.json not found in %s", projectPath).
|
||||
WithHint("run 'lark-cli apps +init' to initialize the project first")
|
||||
}
|
||||
return appsFileIOError(err, "cannot access package.json in %s", projectPath)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return appsFailedPreconditionError("package.json in %s is not a regular file", projectPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pluginResolveCapDir resolves the capabilities directory using a 3-level fallback:
|
||||
// 1. MIAODA_CAPABILITIES_DIR env var
|
||||
// 2. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities)
|
||||
// 2.5 Read .env.local for MIAODA_APP_TYPE
|
||||
// 3. Detect by checking which directories exist under projectPath
|
||||
func pluginResolveCapDir(projectPath string) (string, error) {
|
||||
if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { //nolint:forbidigo // env-based config lookup is intentional.
|
||||
if filepath.IsAbs(dir) {
|
||||
return dir, nil
|
||||
}
|
||||
return filepath.Join(projectPath, dir), nil
|
||||
}
|
||||
|
||||
// 2. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/
|
||||
appType := os.Getenv("MIAODA_APP_TYPE") //nolint:forbidigo // env-based config lookup is intentional.
|
||||
if appType == "" {
|
||||
appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE")
|
||||
}
|
||||
if appType == "6" {
|
||||
return filepath.Join(projectPath, "shared", "capabilities"), nil
|
||||
}
|
||||
if appType != "" {
|
||||
return filepath.Join(projectPath, "server", "capabilities"), nil
|
||||
}
|
||||
|
||||
// 3. Directory detection
|
||||
serverDir := filepath.Join(projectPath, "server", "capabilities")
|
||||
sharedDir := filepath.Join(projectPath, "shared", "capabilities")
|
||||
serverOK := pluginDirExists(serverDir)
|
||||
sharedOK := pluginDirExists(sharedDir)
|
||||
|
||||
switch {
|
||||
case serverOK && sharedOK:
|
||||
return "", appsFailedPreconditionError(
|
||||
"ambiguous capabilities path: both server/capabilities/ and shared/capabilities/ exist",
|
||||
).WithHint("set MIAODA_APP_TYPE or MIAODA_CAPABILITIES_DIR in .env.local to resolve ambiguity")
|
||||
case serverOK:
|
||||
return serverDir, nil
|
||||
case sharedOK:
|
||||
return sharedDir, nil
|
||||
default:
|
||||
return filepath.Join(projectPath, "server", "capabilities"), nil
|
||||
}
|
||||
}
|
||||
|
||||
// pluginReadEnvLocalValue reads a value from .env.local by key name.
|
||||
func pluginReadEnvLocalValue(projectPath, key string) string {
|
||||
data, err := os.ReadFile(filepath.Join(projectPath, ".env.local")) //nolint:forbidigo // shortcuts cannot import internal/vfs; local env file read.
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok || strings.TrimSpace(k) != key {
|
||||
continue
|
||||
}
|
||||
v = strings.TrimSpace(v)
|
||||
v = strings.Trim(v, "\"'")
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func pluginDirExists(path string) bool {
|
||||
info, err := os.Stat(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local dir existence check.
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
// pluginListCapabilities reads all *.json files from capDir.
|
||||
// Returns nil (not error) if the directory does not exist.
|
||||
func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) {
|
||||
entries, err := os.ReadDir(capDir) //nolint:forbidigo // shortcuts cannot import internal/vfs; local dir listing.
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, appsFileIOError(err, "cannot read capabilities directory %s", capDir)
|
||||
}
|
||||
|
||||
var caps []map[string]interface{}
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(capDir, entry.Name())) //nolint:forbidigo
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cap map[string]interface{}
|
||||
if err := json.Unmarshal(data, &cap); err != nil {
|
||||
continue
|
||||
}
|
||||
caps = append(caps, cap)
|
||||
}
|
||||
return caps, nil
|
||||
}
|
||||
|
||||
// pluginCheckDependentInstances scans the capabilities directory for instances
|
||||
// that reference the given pluginKey. Returns nil if none found, an error with
|
||||
// the list of dependent instance ids if any exist, or the underlying I/O error.
|
||||
func pluginCheckDependentInstances(projectPath, pluginKey string) error {
|
||||
capDir, err := pluginResolveCapDir(projectPath)
|
||||
if err != nil {
|
||||
// No capabilities directory → no instances can exist → no conflict.
|
||||
return nil
|
||||
}
|
||||
caps, err := pluginListCapabilities(capDir)
|
||||
if err != nil {
|
||||
// Cannot scan → best-effort, don't block.
|
||||
return nil
|
||||
}
|
||||
var deps []string
|
||||
for _, cap := range caps {
|
||||
if pk, _ := cap["pluginKey"].(string); pk == pluginKey {
|
||||
if id, _ := cap["id"].(string); id != "" {
|
||||
deps = append(deps, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(deps) == 0 {
|
||||
return nil
|
||||
}
|
||||
return appsFailedPreconditionError(
|
||||
"plugin %q is still referenced by %d instance(s): %s", pluginKey, len(deps), strings.Join(deps, ", "),
|
||||
).WithHint("delete these instances first (see <project-path>/.agents/skills/plugin-guide/SKILL.md for instance removal steps), clean up calling code and types, then retry uninstall")
|
||||
}
|
||||
|
||||
// pluginCheckInstalled verifies that the plugin package is installed in node_modules
|
||||
// with a valid manifest.json.
|
||||
func pluginCheckInstalled(projectPath, pluginKey string) error {
|
||||
pluginDir := filepath.Join(projectPath, "node_modules", pluginKey)
|
||||
manifestPath := filepath.Join(pluginDir, "manifest.json")
|
||||
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for plugin check.
|
||||
if os.IsNotExist(err) {
|
||||
if pluginDirExists(pluginDir) {
|
||||
return appsFailedPreconditionError(
|
||||
"plugin %q exists in node_modules but manifest.json is missing; the package may not have been built correctly", pluginKey,
|
||||
).WithHint("run 'lark-cli apps +plugin-install --name %s' to reinstall from registry", pluginKey)
|
||||
}
|
||||
return appsFailedPreconditionError("plugin %q is not installed", pluginKey).
|
||||
WithHint("run 'lark-cli apps +plugin-install --name %s' to install", pluginKey)
|
||||
}
|
||||
return appsFileIOError(err, "cannot check plugin installation for %s", pluginKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── package.json helpers ──
|
||||
|
||||
// pluginReadPackageJSON reads and parses the project's package.json.
|
||||
func pluginReadPackageJSON(projectPath string) (map[string]interface{}, error) {
|
||||
path := filepath.Join(projectPath, "package.json")
|
||||
data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package.json read.
|
||||
if err != nil {
|
||||
return nil, appsFileIOError(err, "cannot read package.json")
|
||||
}
|
||||
var pkg map[string]interface{}
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
return nil, appsValidationError("invalid package.json: %v", err).WithCause(err)
|
||||
}
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
// pluginWritePackageJSON writes package.json atomically, preserving formatting.
|
||||
func pluginWritePackageJSON(projectPath string, pkg map[string]interface{}) error {
|
||||
data, err := json.MarshalIndent(pkg, "", " ")
|
||||
if err != nil {
|
||||
return appsFileIOError(err, "cannot marshal package.json")
|
||||
}
|
||||
data = append(data, '\n')
|
||||
return validate.AtomicWrite(filepath.Join(projectPath, "package.json"), data, 0o644)
|
||||
}
|
||||
|
||||
// pluginGetActionPlugins extracts actionPlugins from package.json as key→version.
|
||||
func pluginGetActionPlugins(pkg map[string]interface{}) map[string]string {
|
||||
raw, ok := pkg["actionPlugins"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
m, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(m))
|
||||
for k, v := range m {
|
||||
if s, ok := v.(string); ok {
|
||||
out[k] = s
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pluginSetActionPlugin adds or updates a plugin entry in actionPlugins.
|
||||
func pluginSetActionPlugin(pkg map[string]interface{}, key, version string) {
|
||||
m, ok := pkg["actionPlugins"].(map[string]interface{})
|
||||
if !ok {
|
||||
m = make(map[string]interface{})
|
||||
pkg["actionPlugins"] = m
|
||||
}
|
||||
m[key] = version
|
||||
}
|
||||
|
||||
// pluginRemoveActionPlugin removes a plugin entry from actionPlugins.
|
||||
func pluginRemoveActionPlugin(pkg map[string]interface{}, key string) {
|
||||
m, ok := pkg["actionPlugins"].(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
delete(m, key)
|
||||
}
|
||||
|
||||
// pluginSyncActionPlugins ensures the actionPlugins record in package.json
|
||||
// matches the actually installed version, even when install is skipped.
|
||||
func pluginSyncActionPlugins(projectPath, key, version string) {
|
||||
pkg, err := pluginReadPackageJSON(projectPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
ap := pluginGetActionPlugins(pkg)
|
||||
if ap[key] == version {
|
||||
return
|
||||
}
|
||||
pluginSetActionPlugin(pkg, key, version)
|
||||
_ = pluginWritePackageJSON(projectPath, pkg)
|
||||
}
|
||||
|
||||
// pluginCheckPeerDeps reads peerDependencies from the installed plugin's
|
||||
// package.json and returns the names of any that are missing from node_modules.
|
||||
func pluginCheckPeerDeps(projectPath, pluginKey string) []string {
|
||||
pkgPath := filepath.Join(projectPath, "node_modules", pluginKey, "package.json")
|
||||
data, err := os.ReadFile(pkgPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package read.
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var pkg map[string]interface{}
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
return nil
|
||||
}
|
||||
peerDeps, ok := pkg["peerDependencies"].(map[string]interface{})
|
||||
if !ok || len(peerDeps) == 0 {
|
||||
return nil
|
||||
}
|
||||
var missing []string
|
||||
for dep := range peerDeps {
|
||||
depDir := filepath.Join(projectPath, "node_modules", dep)
|
||||
if !pluginDirExists(depDir) {
|
||||
missing = append(missing, dep)
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
// pluginInstalledVersion reads the version of an installed plugin from its
|
||||
// package.json in node_modules. Returns "" if not found or unreadable.
|
||||
func pluginInstalledVersion(projectPath, pluginKey string) string {
|
||||
path := filepath.Join(projectPath, "node_modules", pluginKey, "package.json")
|
||||
data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package read.
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
var pkg map[string]interface{}
|
||||
if err := json.Unmarshal(data, &pkg); err != nil {
|
||||
return ""
|
||||
}
|
||||
v, _ := pkg["version"].(string)
|
||||
return v
|
||||
}
|
||||
|
||||
// ── tgz extraction ──
|
||||
|
||||
// pluginExtractTGZ extracts a gzipped tar archive into destDir, stripping the
|
||||
// first path component (npm convention: tarballs contain a "package/" prefix).
|
||||
// Path traversal entries are silently skipped.
|
||||
func pluginExtractTGZ(r io.Reader, destDir string) error {
|
||||
gz, err := gzip.NewReader(r)
|
||||
if err != nil {
|
||||
return fmt.Errorf("gzip: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
cleanDest := filepath.Clean(destDir) + string(filepath.Separator)
|
||||
tr := tar.NewReader(gz)
|
||||
for {
|
||||
hdr, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("tar: %w", err)
|
||||
}
|
||||
|
||||
name := pluginStripFirstComponent(hdr.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(name, "..") {
|
||||
continue
|
||||
}
|
||||
|
||||
target := filepath.Join(destDir, name)
|
||||
if !strings.HasPrefix(filepath.Clean(target)+string(filepath.Separator), cleanDest) &&
|
||||
filepath.Clean(target) != filepath.Clean(destDir) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch hdr.Typeflag {
|
||||
case tar.TypeDir:
|
||||
if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction.
|
||||
return err
|
||||
}
|
||||
case tar.TypeReg:
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { //nolint:forbidigo
|
||||
return err
|
||||
}
|
||||
f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)&0o755) //nolint:forbidigo
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := io.Copy(f, tr); err != nil { //nolint:gosec // bounded by tar entry size
|
||||
f.Close()
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pluginStripFirstComponent removes the first path component ("package/foo" → "foo").
|
||||
func pluginStripFirstComponent(name string) string {
|
||||
name = filepath.ToSlash(name)
|
||||
if i := strings.Index(name, "/"); i >= 0 {
|
||||
return name[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
253
shortcuts/apps/plugin_common_test.go
Normal file
253
shortcuts/apps/plugin_common_test.go
Normal file
@@ -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() //nolint:forbidigo
|
||||
if got != cwd {
|
||||
t.Errorf("got %q, want cwd %q", got, cwd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveProjectPath_ExplicitPath(t *testing.T) {
|
||||
got, err := pluginResolveProjectPath("/tmp/myapp")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got != "/tmp/myapp" {
|
||||
t.Errorf("got %q, want /tmp/myapp", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- pluginCheckProjectDir ---
|
||||
|
||||
func TestPluginCheckProjectDir_OK(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := pluginCheckProjectDir(dir); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCheckProjectDir_Missing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
err := pluginCheckProjectDir(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", p.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
// --- pluginResolveCapDir ---
|
||||
|
||||
func TestPluginResolveCapDir_EnvVar(t *testing.T) {
|
||||
t.Setenv("MIAODA_CAPABILITIES_DIR", "envdir/caps")
|
||||
got, err := pluginResolveCapDir("/proj")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join("/proj", "envdir/caps"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_AppTypeEnv(t *testing.T) {
|
||||
t.Setenv("MIAODA_APP_TYPE", "2")
|
||||
got, err := pluginResolveCapDir("/proj")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join("/proj", "server", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_AppTypeEnvShared(t *testing.T) {
|
||||
t.Setenv("MIAODA_APP_TYPE", "6")
|
||||
got, err := pluginResolveCapDir("/proj")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join("/proj", "shared", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_EnvLocal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join(dir, "server", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_DetectServer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join(dir, "server", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_DetectShared(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join(dir, "shared", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_Ambiguous(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := pluginResolveCapDir(dir)
|
||||
if err == nil {
|
||||
t.Fatal("expected ambiguous error")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed error, got %T: %v", err, err)
|
||||
}
|
||||
if p.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %q, want failed_precondition", p.Subtype)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_NeitherExists_DefaultsToServer(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
got, err := pluginResolveCapDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("should default to server/capabilities, got error: %v", err)
|
||||
}
|
||||
if want := filepath.Join(dir, "server", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginResolveCapDir_AppType3_UsesServer(t *testing.T) {
|
||||
t.Setenv("MIAODA_APP_TYPE", "3")
|
||||
got, err := pluginResolveCapDir("/proj")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if want := filepath.Join("/proj", "server", "capabilities"); got != want {
|
||||
t.Errorf("got %q, want %q (appType=3 should use server)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// --- pluginListCapabilities ---
|
||||
|
||||
func TestPluginListCapabilities_Empty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
caps, err := pluginListCapabilities(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(caps) != 0 {
|
||||
t.Errorf("got %d caps, want 0", len(caps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginListCapabilities_DirNotExist(t *testing.T) {
|
||||
caps, err := pluginListCapabilities("/nonexistent/path")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if caps != nil {
|
||||
t.Errorf("got %v, want nil", caps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginListCapabilities_WithFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestCapJSON(t, dir, "cap1.json", map[string]interface{}{"id": "cap1", "name": "Cap One"})
|
||||
writeTestCapJSON(t, dir, "cap2.json", map[string]interface{}{"id": "cap2", "name": "Cap Two"})
|
||||
// non-JSON file should be skipped
|
||||
if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
caps, err := pluginListCapabilities(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(caps) != 2 {
|
||||
t.Fatalf("got %d caps, want 2", len(caps))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginListCapabilities_SkipsMalformed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestCapJSON(t, dir, "good.json", map[string]interface{}{"id": "good"})
|
||||
if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
caps, err := pluginListCapabilities(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(caps) != 1 {
|
||||
t.Fatalf("got %d caps, want 1", len(caps))
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func writeTestCapJSON(t *testing.T, dir, filename string, data map[string]interface{}) {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
393
shortcuts/apps/plugin_install.go
Normal file
393
shortcuts/apps/plugin_install.go
Normal file
@@ -0,0 +1,393 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsPluginInstall downloads a plugin package from the registry, extracts it
|
||||
// to node_modules, and updates package.json actionPlugins.
|
||||
//
|
||||
// Without --name it batch-installs all plugins declared in actionPlugins that
|
||||
// are not yet present in node_modules.
|
||||
var AppsPluginInstall = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+plugin-install",
|
||||
Description: "Install a plugin package (download, extract, update package.json)",
|
||||
Risk: "write",
|
||||
ConditionalScopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate",
|
||||
"Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0",
|
||||
"Example: lark-cli apps +plugin-install (install all declared plugins in package.json)",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"},
|
||||
{Name: "version", Desc: "plugin version (e.g. 1.0.0); omit to install latest"},
|
||||
{Name: "file", Desc: "install from a local .tgz file (dev/test only)", Hidden: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
key := strings.TrimSpace(rctx.Str("name"))
|
||||
if key == "" {
|
||||
return common.NewDryRunAPI().
|
||||
POST(apiBasePath+"/plugin/versions/batch_query").
|
||||
Desc("Batch-install all declared plugins from package.json actionPlugins").
|
||||
Set("request_body", `{"plugin_keys": [<from actionPlugins>], "latest_only": false}`)
|
||||
}
|
||||
version := strings.TrimSpace(rctx.Str("version"))
|
||||
isLatest := version == "" || version == "latest"
|
||||
desc := fmt.Sprintf("Query version for %s, then download .tgz", key)
|
||||
if isLatest {
|
||||
desc = fmt.Sprintf("Install latest version of %s (omit --version to install latest)", key)
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
POST(apiBasePath+"/plugin/versions/batch_query").
|
||||
Desc(desc).
|
||||
Set("request_body", fmt.Sprintf(`{"plugin_keys": ["%s"], "latest_only": %v}`, key, isLatest)).
|
||||
Set("download_body", fmt.Sprintf(`{"plugin_key": "%s", "plugin_version": "%s"}`, key, version))
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pluginCheckProjectDir(projectPath)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if localTgz := strings.TrimSpace(rctx.Str("file")); localTgz != "" {
|
||||
return pluginInstallLocal(rctx, projectPath, localTgz)
|
||||
}
|
||||
|
||||
key := strings.TrimSpace(rctx.Str("name"))
|
||||
if key == "" {
|
||||
return pluginInstallAll(ctx, rctx, projectPath)
|
||||
}
|
||||
version := strings.TrimSpace(rctx.Str("version"))
|
||||
return pluginInstallOne(ctx, rctx, projectPath, key, version)
|
||||
},
|
||||
}
|
||||
|
||||
// pluginInstallOne installs a single plugin by key and optional version.
|
||||
func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectPath, key, version string) error {
|
||||
if key == "" {
|
||||
return appsValidationParamError("--name", "--name is required")
|
||||
}
|
||||
|
||||
// Check if already installed with same version (pre-API fast path)
|
||||
if version != "" && version != "latest" {
|
||||
if installed := pluginInstalledVersion(projectPath, key); installed == version {
|
||||
pluginSyncActionPlugins(projectPath, key, version)
|
||||
result := map[string]interface{}{
|
||||
"key": key, "version": version, "status": "already_installed",
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ %s@%s is already installed\n", key, version)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve version via API
|
||||
resolvedVersion, err := pluginResolveVersion(ctx, rctx, key, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Post-API check: latest may resolve to the already-installed version
|
||||
if installed := pluginInstalledVersion(projectPath, key); installed == resolvedVersion {
|
||||
pluginSyncActionPlugins(projectPath, key, resolvedVersion)
|
||||
result := map[string]interface{}{
|
||||
"key": key, "version": resolvedVersion, "status": "already_installed",
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ %s@%s is already up to date\n", key, resolvedVersion)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Download tgz
|
||||
tgzData, err := pluginDownloadPackage(ctx, rctx, key, resolvedVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Extract to node_modules
|
||||
destDir := filepath.Join(projectPath, "node_modules", key)
|
||||
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract.
|
||||
return appsFileIOError(err, "cannot clean %s", destDir)
|
||||
}
|
||||
if err := os.MkdirAll(destDir, 0o755); err != nil { //nolint:forbidigo
|
||||
return appsFileIOError(err, "cannot create %s", destDir)
|
||||
}
|
||||
if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil {
|
||||
return appsFileIOError(err, "cannot extract plugin package for %s", key)
|
||||
}
|
||||
|
||||
// Check peer dependencies
|
||||
missingPeers := pluginCheckPeerDeps(projectPath, key)
|
||||
|
||||
// Update package.json
|
||||
pkg, err := pluginReadPackageJSON(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pluginSetActionPlugin(pkg, key, resolvedVersion)
|
||||
if err := pluginWritePackageJSON(projectPath, pkg); err != nil {
|
||||
return appsFileIOError(err, "cannot update package.json")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"key": key, "version": resolvedVersion, "status": "installed",
|
||||
}
|
||||
if len(missingPeers) > 0 {
|
||||
result["missing_peer_dependencies"] = missingPeers
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Installed %s@%s\n", key, resolvedVersion)
|
||||
if len(missingPeers) > 0 {
|
||||
fmt.Fprintf(w, "⚠ Missing peer dependencies: %s\n", strings.Join(missingPeers, ", "))
|
||||
fmt.Fprintln(w, " Run 'npm install' in the project directory to install them.")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// pluginInstallAll installs all plugins declared in actionPlugins that are
|
||||
// missing from node_modules.
|
||||
func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectPath string) error {
|
||||
pkg, err := pluginReadPackageJSON(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
declared := pluginGetActionPlugins(pkg)
|
||||
if len(declared) == 0 {
|
||||
rctx.OutFormat(map[string]interface{}{"installed": 0}, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var installed int
|
||||
for key, version := range declared {
|
||||
existing := pluginInstalledVersion(projectPath, key)
|
||||
if existing != "" && existing == version {
|
||||
continue
|
||||
}
|
||||
if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil {
|
||||
return fmt.Errorf("install %s: %w", key, err)
|
||||
}
|
||||
installed++
|
||||
}
|
||||
|
||||
if installed == 0 {
|
||||
rctx.OutFormat(map[string]interface{}{"installed": 0, "status": "all_up_to_date"}, nil, func(w io.Writer) {
|
||||
fmt.Fprintln(w, "All declared plugins are already installed.")
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pluginInstallLocal installs a plugin from a local .tgz file, skipping API calls.
|
||||
// Reads plugin key and version from the extracted package.json inside the tgz.
|
||||
func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string) error {
|
||||
tgzData, err := os.ReadFile(tgzPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local tgz read.
|
||||
if err != nil {
|
||||
return appsValidationParamError("--file", "cannot read tgz file %s: %v", tgzPath, err).WithCause(err)
|
||||
}
|
||||
|
||||
// Extract to a temp dir first to read package.json
|
||||
tmpDir, err := os.MkdirTemp("", "plugin-local-*") //nolint:forbidigo
|
||||
if err != nil {
|
||||
return appsFileIOError(err, "cannot create temp dir")
|
||||
}
|
||||
defer os.RemoveAll(tmpDir) //nolint:forbidigo
|
||||
|
||||
if err := pluginExtractTGZ(bytes.NewReader(tgzData), tmpDir); err != nil {
|
||||
return appsFileIOError(err, "cannot extract tgz")
|
||||
}
|
||||
|
||||
// Read key and version from extracted package.json
|
||||
pkgData, err := os.ReadFile(filepath.Join(tmpDir, "package.json")) //nolint:forbidigo
|
||||
if err != nil {
|
||||
return appsFileIOError(err, "tgz does not contain package.json")
|
||||
}
|
||||
var pkgMeta map[string]interface{}
|
||||
if err := json.Unmarshal(pkgData, &pkgMeta); err != nil {
|
||||
return appsFileIOError(err, "invalid package.json in tgz")
|
||||
}
|
||||
key, _ := pkgMeta["name"].(string)
|
||||
version, _ := pkgMeta["version"].(string)
|
||||
if key == "" {
|
||||
return appsValidationParamError("--file", "package.json in tgz missing 'name' field")
|
||||
}
|
||||
if version == "" {
|
||||
version = "0.0.0"
|
||||
}
|
||||
|
||||
// Move to node_modules
|
||||
destDir := filepath.Join(projectPath, "node_modules", key)
|
||||
if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo
|
||||
return appsFileIOError(err, "cannot clean %s", destDir)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil { //nolint:forbidigo
|
||||
return appsFileIOError(err, "cannot create parent dir for %s", destDir)
|
||||
}
|
||||
if err := os.Rename(tmpDir, destDir); err != nil { //nolint:forbidigo
|
||||
// rename may fail across filesystems; fall back to re-extract
|
||||
if err2 := os.MkdirAll(destDir, 0o755); err2 != nil { //nolint:forbidigo
|
||||
return appsFileIOError(err2, "cannot create %s", destDir)
|
||||
}
|
||||
if err2 := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err2 != nil {
|
||||
return appsFileIOError(err2, "cannot extract plugin to %s", destDir)
|
||||
}
|
||||
}
|
||||
|
||||
// Update package.json actionPlugins
|
||||
pkg, err := pluginReadPackageJSON(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pluginSetActionPlugin(pkg, key, version)
|
||||
if err := pluginWritePackageJSON(projectPath, pkg); err != nil {
|
||||
return appsFileIOError(err, "cannot update package.json")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"key": key, "version": version, "status": "installed", "source": "local",
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Installed %s@%s (from local %s)\n", key, version, tgzPath)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// pluginResolveVersion calls the batch_query API to resolve version info.
|
||||
func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, version string) (resolvedVersion string, err error) {
|
||||
isLatest := version == "" || version == "latest"
|
||||
body := map[string]interface{}{
|
||||
"plugin_keys": []interface{}{key},
|
||||
"latest_only": isLatest,
|
||||
}
|
||||
|
||||
data, err := rctx.CallAPITyped("POST", apiBasePath+"/plugin/versions/batch_query", nil, body)
|
||||
if err != nil {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if ok && p.Subtype == errs.SubtypeInvalidResponse {
|
||||
p.Message = fmt.Sprintf("plugin registry API is not available (returned non-JSON for %s)", key)
|
||||
p.Hint = "the plugin registry endpoint may not be registered yet; check with the backend team"
|
||||
return "", err
|
||||
}
|
||||
return "", withAppsHint(err, fmt.Sprintf("failed to fetch plugin version for %s; check plugin key spelling and network", key))
|
||||
}
|
||||
|
||||
// Response: data.items is a flat list of plugin_version objects
|
||||
match := pluginFindVersionInItems(data, key, version)
|
||||
if match == nil {
|
||||
hint := "check plugin key spelling"
|
||||
if !isLatest {
|
||||
hint = fmt.Sprintf("version %q not found for %s; omit --version to install latest", version, key)
|
||||
}
|
||||
return "", appsValidationError("no version found for plugin %q", key).
|
||||
WithHint(hint)
|
||||
}
|
||||
// API returns "version" (not "plugin_version")
|
||||
rv, _ := match["version"].(string)
|
||||
if rv == "" {
|
||||
return "", appsValidationError("incomplete version info for plugin %q", key).
|
||||
WithHint("API returned version info without version field; contact plugin maintainer")
|
||||
}
|
||||
return rv, nil
|
||||
}
|
||||
|
||||
// pluginFindVersionInItems extracts data.items and finds a matching version.
|
||||
func pluginFindVersionInItems(data map[string]interface{}, key, version string) map[string]interface{} {
|
||||
raw, ok := data["items"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
arr, ok := raw.([]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
isLatest := version == "" || version == "latest"
|
||||
for _, v := range arr {
|
||||
item, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// API returns "key" (not "plugin_key")
|
||||
pk, _ := item["key"].(string)
|
||||
if pk != key {
|
||||
continue
|
||||
}
|
||||
if isLatest {
|
||||
return item
|
||||
}
|
||||
pv, _ := item["version"].(string)
|
||||
if pv == version {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pluginDownloadPackage downloads a plugin .tgz via the download_package API.
|
||||
// The endpoint is POST with JSON body {plugin_key, plugin_version}.
|
||||
func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version string) ([]byte, error) {
|
||||
apiPath := apiBasePath + "/plugin/versions/download_package"
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"plugin_key": key,
|
||||
"plugin_version": version,
|
||||
})
|
||||
|
||||
resp, err := rctx.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: apiPath,
|
||||
Body: bytes.NewReader(body),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed for %s@%s: %v", key, version, err).
|
||||
WithHint("check network connectivity and retry").
|
||||
WithRetryable().
|
||||
WithCause(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 500 {
|
||||
return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed for %s@%s: HTTP %d", key, version, resp.StatusCode).
|
||||
WithHint("plugin registry returned a server error; retry after a short wait").
|
||||
WithRetryable()
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
hint := "check plugin key and version spelling"
|
||||
if resp.StatusCode == 403 {
|
||||
hint = "download token may have expired; retry the install to get a fresh token"
|
||||
} else if resp.StatusCode == 404 {
|
||||
hint = fmt.Sprintf("package %s@%s not found in registry; check plugin key and version", key, version)
|
||||
}
|
||||
return nil, errs.NewAPIError(errs.SubtypeUnknown, "download failed for %s@%s: HTTP %d: %s", key, version, resp.StatusCode, string(respBody)).
|
||||
WithHint(hint)
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
181
shortcuts/apps/plugin_install_test.go
Normal file
181
shortcuts/apps/plugin_install_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
)
|
||||
|
||||
func TestPluginInstall_SinglePlugin(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
|
||||
// Mock batch_query API (new protocol: plugin_keys array, response data.items flat list)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/plugin/versions/batch_query",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"key": "@test/my-plugin",
|
||||
"version": "1.0.0",
|
||||
"download_approach": "inner",
|
||||
"status": "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Mock download API (POST with JSON body, returns binary tgz)
|
||||
tgzData := buildTestTGZ(t, map[string]string{
|
||||
"manifest.json": `{"actions":[]}`,
|
||||
"package.json": `{"name":"@test/my-plugin","version":"1.0.0"}`,
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/plugin/versions/download_package",
|
||||
RawBody: tgzData,
|
||||
ContentType: "application/octet-stream",
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsPluginInstall, []string{
|
||||
"+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify file extracted
|
||||
manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json")
|
||||
if _, err := os.Stat(manifestPath); err != nil { //nolint:forbidigo
|
||||
t.Fatalf("manifest.json not extracted: %v", err)
|
||||
}
|
||||
|
||||
// Verify package.json updated
|
||||
pkg, _ := pluginReadPackageJSON(dir)
|
||||
ap := pluginGetActionPlugins(pkg)
|
||||
if v := ap["@test/my-plugin"]; v != "1.0.0" {
|
||||
t.Errorf("actionPlugins[@test/my-plugin] = %q, want 1.0.0", v)
|
||||
}
|
||||
|
||||
// Verify output
|
||||
var env map[string]interface{}
|
||||
json.Unmarshal(stdout.Bytes(), &env)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if data["status"] != "installed" {
|
||||
t.Errorf("status = %v, want installed", data["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginInstall_AlreadyInstalled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/my-plugin": "1.0.0",
|
||||
},
|
||||
})
|
||||
// Create an existing installed plugin with package.json containing version
|
||||
pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(pkgDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginInstall, []string{
|
||||
"+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
json.Unmarshal(stdout.Bytes(), &env)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if data["status"] != "already_installed" {
|
||||
t.Errorf("status = %v, want already_installed", data["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- tgz helpers ---
|
||||
|
||||
func TestPluginExtractTGZ(t *testing.T) {
|
||||
tgzData := buildTestTGZ(t, map[string]string{
|
||||
"manifest.json": `{"actions":[]}`,
|
||||
"README.md": "# Hello",
|
||||
})
|
||||
|
||||
destDir := t.TempDir()
|
||||
if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil {
|
||||
t.Fatalf("extract error: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) //nolint:forbidigo
|
||||
if err != nil {
|
||||
t.Fatalf("manifest.json not extracted: %v", err)
|
||||
}
|
||||
if string(data) != `{"actions":[]}` {
|
||||
t.Errorf("manifest.json content = %q", string(data))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginExtractTGZ_PathTraversal(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
tw.WriteHeader(&tar.Header{
|
||||
Name: "package/../../../etc/passwd",
|
||||
Size: 5,
|
||||
Mode: 0o644,
|
||||
Typeflag: tar.TypeReg,
|
||||
})
|
||||
tw.Write([]byte("evil!"))
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
|
||||
destDir := t.TempDir()
|
||||
if err := pluginExtractTGZ(&buf, destDir); err != nil {
|
||||
t.Fatalf("extract should not error, but skip bad entries: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { //nolint:forbidigo
|
||||
t.Error("path traversal should have been blocked")
|
||||
}
|
||||
}
|
||||
|
||||
// buildTestTGZ creates a .tgz in memory with files under a "package/" prefix.
|
||||
func buildTestTGZ(t *testing.T, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
gz := gzip.NewWriter(&buf)
|
||||
tw := tar.NewWriter(gz)
|
||||
|
||||
for name, content := range files {
|
||||
tw.WriteHeader(&tar.Header{
|
||||
Name: "package/" + name,
|
||||
Size: int64(len(content)),
|
||||
Mode: 0o644,
|
||||
Typeflag: tar.TypeReg,
|
||||
})
|
||||
tw.Write([]byte(content))
|
||||
}
|
||||
|
||||
tw.Close()
|
||||
gz.Close()
|
||||
return buf.Bytes()
|
||||
}
|
||||
80
shortcuts/apps/plugin_list.go
Normal file
80
shortcuts/apps/plugin_list.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsPluginList lists plugin packages declared in package.json actionPlugins,
|
||||
// cross-referencing with node_modules to report installation status.
|
||||
var AppsPluginList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+plugin-list",
|
||||
Description: "List declared plugin packages and their installation status",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +plugin-list",
|
||||
"Example: lark-cli apps +plugin-list --format pretty",
|
||||
},
|
||||
Flags: []common.Flag{},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("List declared plugin packages and installation status").
|
||||
Set("action", "list").
|
||||
Set("source", "package.json actionPlugins + node_modules")
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pluginCheckProjectDir(projectPath)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pkg, err := pluginReadPackageJSON(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
declared := pluginGetActionPlugins(pkg)
|
||||
plugins := make([]interface{}, 0, len(declared))
|
||||
for key, version := range declared {
|
||||
installed := pluginInstalledVersion(projectPath, key)
|
||||
status := "declared_not_installed"
|
||||
if installed != "" {
|
||||
status = "installed"
|
||||
}
|
||||
plugins = append(plugins, map[string]interface{}{
|
||||
"key": key,
|
||||
"version": version,
|
||||
"status": status,
|
||||
})
|
||||
}
|
||||
|
||||
data := map[string]interface{}{"plugins": plugins}
|
||||
rctx.OutFormat(data, &output.Meta{Count: len(plugins)}, func(w io.Writer) {
|
||||
if len(plugins) == 0 {
|
||||
fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.")
|
||||
return
|
||||
}
|
||||
rows := make([]map[string]interface{}, 0, len(plugins))
|
||||
for _, p := range plugins {
|
||||
rows = append(rows, p.(map[string]interface{}))
|
||||
}
|
||||
output.PrintTable(w, rows)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
121
shortcuts/apps/plugin_list_test.go
Normal file
121
shortcuts/apps/plugin_list_test.go
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPluginList_Empty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginList, []string{
|
||||
"+plugin-list", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
json.Unmarshal(stdout.Bytes(), &env)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
plugins, _ := data["plugins"].([]interface{})
|
||||
if len(plugins) != 0 {
|
||||
t.Errorf("expected 0 plugins, got %d", len(plugins))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginList_Installed(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/my-plugin": "1.0.0",
|
||||
},
|
||||
})
|
||||
manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(manifestDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) //nolint:forbidigo
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginList, []string{
|
||||
"+plugin-list", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
json.Unmarshal(stdout.Bytes(), &env)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
plugins, _ := data["plugins"].([]interface{})
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("expected 1 plugin, got %d", len(plugins))
|
||||
}
|
||||
p := plugins[0].(map[string]interface{})
|
||||
if p["status"] != "installed" {
|
||||
t.Errorf("status = %v, want installed", p["status"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginList_DeclaredNotInstalled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/missing": "1.0.0",
|
||||
},
|
||||
})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginList, []string{
|
||||
"+plugin-list", "--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
json.Unmarshal(stdout.Bytes(), &env)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
plugins, _ := data["plugins"].([]interface{})
|
||||
if len(plugins) != 1 {
|
||||
t.Fatalf("expected 1 plugin, got %d", len(plugins))
|
||||
}
|
||||
p := plugins[0].(map[string]interface{})
|
||||
if p["status"] != "declared_not_installed" {
|
||||
t.Errorf("status = %v, want declared_not_installed", p["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func chdirTest(t *testing.T, dir string) {
|
||||
t.Helper()
|
||||
prev, err := os.Getwd() //nolint:forbidigo
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(dir); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(prev) }) //nolint:forbidigo,errcheck
|
||||
}
|
||||
|
||||
func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(pkg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { //nolint:forbidigo
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
84
shortcuts/apps/plugin_uninstall.go
Normal file
84
shortcuts/apps/plugin_uninstall.go
Normal file
@@ -0,0 +1,84 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// AppsPluginUninstall removes a plugin package from node_modules and its
|
||||
// entry from package.json actionPlugins.
|
||||
var AppsPluginUninstall = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+plugin-uninstall",
|
||||
Description: "Uninstall a plugin package (remove from node_modules and package.json)",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate",
|
||||
},
|
||||
Flags: []common.Flag{
|
||||
{Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate)", Required: true},
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
key := strings.TrimSpace(rctx.Str("name"))
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Uninstall plugin package (remove from node_modules and package.json)").
|
||||
Set("action", "uninstall").
|
||||
Set("plugin_key", key).
|
||||
Set("remove_dir", fmt.Sprintf("node_modules/%s", key)).
|
||||
Set("update_file", "package.json actionPlugins")
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if strings.TrimSpace(rctx.Str("name")) == "" {
|
||||
return appsValidationParamError("--name", "--name is required")
|
||||
}
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return pluginCheckProjectDir(projectPath)
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
key := strings.TrimSpace(rctx.Str("name"))
|
||||
projectPath, err := pluginResolveProjectPath("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Block uninstall if any instances still reference this plugin package.
|
||||
if err := pluginCheckDependentInstances(projectPath, key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pkgDir := filepath.Join(projectPath, "node_modules", key)
|
||||
if err := os.RemoveAll(pkgDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; remove plugin directory.
|
||||
return appsFileIOError(err, "cannot remove %s", pkgDir)
|
||||
}
|
||||
|
||||
pkg, err := pluginReadPackageJSON(projectPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pluginRemoveActionPlugin(pkg, key)
|
||||
if err := pluginWritePackageJSON(projectPath, pkg); err != nil {
|
||||
return appsFileIOError(err, "cannot update package.json")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"key": key,
|
||||
"removed": true,
|
||||
}
|
||||
rctx.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "✓ Plugin uninstalled: %s\n", key)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
187
shortcuts/apps/plugin_uninstall_test.go
Normal file
187
shortcuts/apps/plugin_uninstall_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestPluginUninstall_Basic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/my-plugin": "1.0.0",
|
||||
},
|
||||
})
|
||||
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginUninstall, []string{
|
||||
"+plugin-uninstall", "--name", "@test/my-plugin",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify node_modules removed
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
|
||||
t.Error("node_modules plugin dir should be removed")
|
||||
}
|
||||
|
||||
// Verify package.json updated
|
||||
pkg, _ := pluginReadPackageJSON(dir)
|
||||
ap := pluginGetActionPlugins(pkg)
|
||||
if _, ok := ap["@test/my-plugin"]; ok {
|
||||
t.Error("actionPlugins should no longer contain @test/my-plugin")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginUninstall_NotInstalled(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginUninstall, []string{
|
||||
"+plugin-uninstall", "--name", "@test/not-here",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("uninstalling non-existent plugin should succeed: %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
json.Unmarshal(stdout.Bytes(), &env)
|
||||
data, _ := env["data"].(map[string]interface{})
|
||||
if data["removed"] != true {
|
||||
t.Errorf("removed = %v, want true", data["removed"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/my-plugin": "1.0.0",
|
||||
},
|
||||
})
|
||||
// Install plugin
|
||||
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
|
||||
|
||||
// Create a capability that references this plugin
|
||||
capDir := filepath.Join(dir, "server", "capabilities")
|
||||
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
|
||||
writeTestCapJSON(t, capDir, "my-instance.json", map[string]interface{}{
|
||||
"id": "my-instance",
|
||||
"pluginKey": "@test/my-plugin",
|
||||
"name": "My Instance",
|
||||
})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginUninstall, []string{
|
||||
"+plugin-uninstall", "--name", "@test/my-plugin",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when uninstalling a plugin with dependent instances, got nil")
|
||||
}
|
||||
|
||||
// Verify plugin directory still exists (blocked)
|
||||
if _, err := os.Stat(pluginDir); err != nil { //nolint:forbidigo
|
||||
t.Errorf("plugin directory should still exist after blocked uninstall: %v", err)
|
||||
}
|
||||
|
||||
// Verify error mentions the dependent instance
|
||||
prob, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed error, got %v", err)
|
||||
}
|
||||
if prob.Subtype != errs.SubtypeFailedPrecondition {
|
||||
t.Errorf("subtype = %s, want %s", prob.Subtype, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if prob.Hint == "" {
|
||||
t.Error("hint should be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/my-plugin": "1.0.0",
|
||||
},
|
||||
})
|
||||
pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin")
|
||||
os.MkdirAll(pluginDir, 0o755) //nolint:forbidigo
|
||||
os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) //nolint:forbidigo
|
||||
|
||||
// Create a capability that references a DIFFERENT plugin — should not block
|
||||
capDir := filepath.Join(dir, "server", "capabilities")
|
||||
os.MkdirAll(capDir, 0o755) //nolint:forbidigo
|
||||
writeTestCapJSON(t, capDir, "other-instance.json", map[string]interface{}{
|
||||
"id": "other-instance",
|
||||
"pluginKey": "@test/other-plugin",
|
||||
"name": "Other Instance",
|
||||
})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginUninstall, []string{
|
||||
"+plugin-uninstall", "--name", "@test/my-plugin",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("uninstall should succeed when instances reference different plugins: %v", err)
|
||||
}
|
||||
|
||||
// Verify plugin was removed
|
||||
if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { //nolint:forbidigo
|
||||
t.Error("plugin directory should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginUninstall_PreservesOtherPlugins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeTestPkgJSON(t, dir, map[string]interface{}{
|
||||
"name": "my-app",
|
||||
"actionPlugins": map[string]interface{}{
|
||||
"@test/remove-me": "1.0.0",
|
||||
"@test/keep-me": "2.0.0",
|
||||
},
|
||||
})
|
||||
chdirTest(t, dir)
|
||||
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsPluginUninstall, []string{
|
||||
"+plugin-uninstall", "--name", "@test/remove-me",
|
||||
"--format", "json", "--as", "user",
|
||||
}, factory, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
pkg, _ := pluginReadPackageJSON(dir)
|
||||
ap := pluginGetActionPlugins(pkg)
|
||||
if _, ok := ap["@test/remove-me"]; ok {
|
||||
t.Error("@test/remove-me should be removed from actionPlugins")
|
||||
}
|
||||
if v, ok := ap["@test/keep-me"]; !ok || v != "2.0.0" {
|
||||
t.Errorf("@test/keep-me should be preserved, got %q", v)
|
||||
}
|
||||
if name, _ := pkg["name"].(string); name != "my-app" {
|
||||
t.Errorf("other fields should be preserved, name = %q", name)
|
||||
}
|
||||
}
|
||||
@@ -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 <app_id> --environment online --key FOO --value <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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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 <name>` | [`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 <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 <app_id> --metric requests --since <range> --as user`(不传 `--series` 会同时返回 total/error);延迟用 `--metric latency`(不传 `--series` 会返回 p50/p99)。如果用户给了具体接口,再加 `--api <path-or-name>`;不要臆造 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 获取
|
||||
|
||||
|
||||
@@ -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` 重试。
|
||||
@@ -11,30 +11,34 @@
|
||||
- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。
|
||||
- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < <absolute-path>`(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 中取出来裸连。
|
||||
|
||||
@@ -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。
|
||||
@@ -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)。
|
||||
160
skills/lark-apps/references/lark-apps-db.md
Normal file
160
skills/lark-apps/references/lark-apps-db.md
Normal file
@@ -0,0 +1,160 @@
|
||||
# apps db 域命令
|
||||
|
||||
管理妙搭应用数据库:看表与结构、初始化与发布多环境、数据搬运、变更治理、时间点恢复、用量。逐条跑 SQL(SELECT/DML/DDL)走 [`+db-execute`](lark-apps-db-execute.md)(单独一篇)。运行时命令事实以 `lark-cli apps +<cmd> --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`。
|
||||
- **本地文件用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;路径在别处先 `cd` 过去或改成相对路径。
|
||||
- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。
|
||||
- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。
|
||||
|
||||
## 各命令
|
||||
|
||||
### 表与结构
|
||||
|
||||
**`+db-table-list`**:列出某环境的数据表。分页 `--page-size`(默认 20)/ `--page-token`(上一页 cursor)。每项给表名、描述、估算行数、大小、列数;要完整列定义 / 索引 / 约束用 `+db-table-get`。只知道业务对象名时,先用它定位可能的表名。
|
||||
|
||||
```bash
|
||||
lark-cli apps +db-table-list --app-id app_xxx
|
||||
lark-cli apps +db-table-list --app-id app_xxx --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`
|
||||
|
||||
## 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`,并明确告知用户会覆盖当前数据。
|
||||
@@ -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 <app_id>
|
||||
```
|
||||
|
||||
## 失败处理
|
||||
|
||||
48
skills/lark-apps/references/lark-apps-env.md
Normal file
48
skills/lark-apps/references/lark-apps-env.md
Normal file
@@ -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 <app_id>
|
||||
lark-cli apps +env-list --app-id <app_id> --environment online
|
||||
lark-cli apps +env-list --app-id <app_id> --include-values --jq '.data.items[] | select(.key == "FOO")'
|
||||
```
|
||||
|
||||
## 设置
|
||||
|
||||
dev 环境设置不需要 `--yes`。设置 online 环境需要人类确认并显式传 `--yes`;如果用户在同一轮已经明确说“确认/直接执行”,视为已确认,直接带 `--yes`,不要再次追问。`--dry-run` 可用于预览请求且不需要 `--yes`。变量值支持直接传 `<value>`,也支持 `@file` 或 stdin 输入。
|
||||
|
||||
回复中只说明 app/env/key 和执行结果;不要回显真实 value。需要举例时使用 `<value>`、`@file` 或 stdin。
|
||||
|
||||
```bash
|
||||
lark-cli apps +env-set --app-id <app_id> --key FOO --value <value>
|
||||
lark-cli apps +env-set --app-id <app_id> --key FOO --value @./secret.txt
|
||||
lark-cli apps +env-set --app-id <app_id> --environment online --key FOO --value <value> --dry-run
|
||||
lark-cli apps +env-set --app-id <app_id> --environment online --key FOO --value <value> --yes
|
||||
```
|
||||
|
||||
## 删除
|
||||
|
||||
`+env-delete` 是 high-risk-write。尊重 exit 10 confirmation protocol:先让用户确认 app/env/key 和删除后果,再传 `--yes`。不要自动补 `--yes`。如果只是认证失败后让用户重登,重登完成不等于删除确认;继续删除前仍需确认。
|
||||
|
||||
```bash
|
||||
lark-cli apps +env-delete --app-id <app_id> --key FOO --dry-run
|
||||
lark-cli apps +env-delete --app-id <app_id> --key FOO --yes
|
||||
lark-cli apps +env-delete --app-id <app_id> --environment online --key FOO --yes
|
||||
```
|
||||
|
||||
## 反模式
|
||||
|
||||
- 不要把 `+env-pull` 当成环境变量管理命令;它只是刷新本地 `.env.local` 的兜底工具。
|
||||
- 不要为了看一个变量臆造名为 env-get 的 apps shortcut;用 `+env-list --include-values` 加 `--jq`。
|
||||
- 不要把真实 secret 写进示例或对话输出;需要示例时使用 `<value>`、`@file` 或 stdin。
|
||||
94
skills/lark-apps/references/lark-apps-file.md
Normal file
94
skills/lark-apps/references/lark-apps-file.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# apps file 域命令(应用存储)
|
||||
|
||||
管理妙搭应用的文件存储:上传 / 下载本地文件、列出与查看已存文件、生成临时分享链接、批量删除、查看用量。运行时命令事实以 `lark-cli apps +<cmd> --help` 为准;认证、`--as user`、exit 码、`_notice` 等通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 与本域 [`SKILL.md`](../SKILL.md)。
|
||||
|
||||
## 何时用
|
||||
|
||||
用户要在某个妙搭应用里上传 / 下载 / 列出 / 删除文件、拿文件的临时分享链接、或看存储用量时。普通飞书云盘走 [`lark-drive`](../../lark-drive/SKILL.md);数据库里的表数据走 `+db-*`。
|
||||
|
||||
## 命令一览
|
||||
|
||||
| 命令 | 做什么 | 关键参数 |
|
||||
|---|---|---|
|
||||
| `+file-list` | 列出文件,可按名/路径/类型/大小/上传时间过滤 | `--app-id`、过滤器、`--page-size`/`--page-token` |
|
||||
| `+file-get` | 查单个文件的元数据 | `--app-id`、`--path` |
|
||||
| `+file-sign` | 生成有时效的下载链接(用于分享 / 直接下载) | `--app-id`、`--path`、`--expires-in` |
|
||||
| `+file-download` | 把远端文件保存到本地 | `--app-id`、`--path`、`--output` |
|
||||
| `+file-upload` | 上传本地文件到应用存储 | `--app-id`、`--file` |
|
||||
| `+file-delete` | 按路径批量删除文件 | `--app-id`、`--path`(可重复)、`--yes` |
|
||||
| `+file-quota-get` | 查应用的文件存储用量 | `--app-id` |
|
||||
|
||||
## 寻址与约定(先读)
|
||||
|
||||
- **远端文件统一用 `--path` 精确寻址**(远端路径,带前导 `/`)。只知道文件名时,先用 `+file-list --name <名>` 定位拿到 `path`,再做后续操作。
|
||||
- **本地文件 / 输出路径用工作目录内的相对路径**(如 `--file ./report.pdf`、`--output ./out.png`);路径在别处时先 `cd` 过去或改成相对路径。
|
||||
- 上传只接收本地 `--file`:文件名沿用本地文件名,远端路径由平台分配、全局唯一(无需也无法手填)。
|
||||
- file 域不区分环境,没有 `--env`。
|
||||
|
||||
## 各命令
|
||||
|
||||
### +file-list
|
||||
列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`(MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20)/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间(pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`。
|
||||
|
||||
```bash
|
||||
lark-cli apps +file-list --app-id app_xxx
|
||||
lark-cli apps +file-list --app-id app_xxx --type image/png --uploaded-since 7d
|
||||
```
|
||||
|
||||
### +file-get
|
||||
按 `--path` 查单个文件的元数据。路径不存在时返回明确的「文件不存在」错误。
|
||||
|
||||
```bash
|
||||
lark-cli apps +file-get --app-id app_xxx --path /1858537546760216.png
|
||||
```
|
||||
|
||||
### +file-sign
|
||||
为指定文件生成一个**有时效的下载链接**——适合发给用户分享、或直接下载。`--expires-in` 设有效期秒数(默认 1 天,最长 30 天)。`pretty` 模式只输出链接本身,便于复制 / 管道;要把到期时间一并告诉用户时用默认 JSON 输出(含到期时间)。
|
||||
|
||||
```bash
|
||||
lark-cli apps +file-sign --app-id app_xxx --path /1858537546760216.png --expires-in 3600
|
||||
```
|
||||
|
||||
### +file-download
|
||||
把远端文件保存到本地。`--output` 指定保存路径,缺省时按远端文件名保存到当前目录。
|
||||
|
||||
```bash
|
||||
lark-cli apps +file-download --app-id app_xxx --path /1858537546760216.png --output ./logo.png
|
||||
```
|
||||
|
||||
### +file-upload
|
||||
上传一个本地文件。文件名沿用本地文件名,远端路径由平台分配。单文件上限 100 MB。
|
||||
|
||||
```bash
|
||||
lark-cli apps +file-upload --app-id app_xxx --file ./report.pdf
|
||||
```
|
||||
|
||||
### +file-delete(高危)
|
||||
按路径批量删除,`--path` 可重复传多个。删除是高危操作,必须带 `--yes`;缺省会被确认关卡拦下。**逐项返回结果**:部分文件删除失败(如某个路径不存在)不影响其余文件,整体仍算成功,失败项在结果里单独标出原因。
|
||||
|
||||
```bash
|
||||
lark-cli apps +file-delete --app-id app_xxx --path /1858537546760216.png --yes
|
||||
lark-cli apps +file-delete --app-id app_xxx --path /a.png --path /b.png --yes
|
||||
```
|
||||
|
||||
### +file-quota-get
|
||||
查应用的文件存储用量(已用量、文件数;配额接入后还会给总配额与使用率)。
|
||||
|
||||
```bash
|
||||
lark-cli apps +file-quota-get --app-id app_xxx
|
||||
```
|
||||
|
||||
## 时间格式(`--uploaded-since` / `--uploaded-until`)
|
||||
|
||||
按用户口语自然传入即可,支持:
|
||||
- 相对时间 `7d` / `2h` / `30s`(从现在往前推)
|
||||
- 日期 `2026-04-15`
|
||||
- 日期时间 `2026-04-15T10:00:00`
|
||||
- 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00`
|
||||
|
||||
## Agent 规则
|
||||
|
||||
- 寻址一律用 `--path`;用户只给文件名时先 `+file-list --name <名>` 定位,多个同名再让用户确认。
|
||||
- 上传 / 下载的本地路径用工作目录内相对路径;不在当前目录就 `cd` 过去或改相对路径。
|
||||
- 用户要「分享链接 / 临时下载地址」时用 `+file-sign`,把返回的链接转述给用户。
|
||||
- 删除前判断意图:已明确要删且授权时可直接带 `--yes`;不确定删哪些时先 `+file-list` 给用户确认。批量删除部分失败不报错,按逐项结果向用户说明哪些成功、哪些没删掉及原因。
|
||||
@@ -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 <project-path>/.agents/skills/plugin-guide/SKILL.md`,读取仓库插件指引。该文件包含插件目录、实例配置规则和调用代码生成方式——不读就无法正确集成插件能力。文件不存在则跳过。
|
||||
|
||||
## 改完代码后部署上线
|
||||
|
||||
已拉到本地、改完代码,用户说"推上去""部署""上线""发布到云端"时,按此序列。
|
||||
|
||||
48
skills/lark-apps/references/lark-apps-observability.md
Normal file
48
skills/lark-apps/references/lark-apps-observability.md
Normal file
@@ -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 <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 <app_id> --level error --keyword timeout --since 0.5h
|
||||
lark-cli apps +log-get --app-id <app_id> --log-id <log_id>
|
||||
lark-cli apps +trace-list --app-id <app_id> --trace-id <trace_id>
|
||||
lark-cli apps +trace-get --app-id <app_id> --trace-id <trace_id>
|
||||
lark-cli apps +metric-list --app-id <app_id> --metric requests --series total --since 1d
|
||||
lark-cli apps +metric-list --app-id <app_id> --metric requests --since 1h
|
||||
lark-cli apps +metric-list --app-id <app_id> --metric latency --since 1h
|
||||
lark-cli apps +metric-list --app-id <app_id> --metric latency --series p99 --since 1d
|
||||
lark-cli apps +metric-list --app-id <app_id> --metric cpu --since 1h
|
||||
lark-cli apps +metric-list --app-id <app_id> --metric memory --since 1h
|
||||
lark-cli apps +analytics-list --app-id <app_id> --analytics users --series active-users --granularity day
|
||||
lark-cli apps +analytics-list --app-id <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 <path-or-name>`;当前没有 `group-by` 参数,不要臆造。
|
||||
- `+metric-list` 未显式传 `--down-sample` 时会按时间范围自动选择粒度:短范围用 `1m`,中等范围用 `1h`,长范围用 `1d`;显式传入时尊重用户指定。
|
||||
- 如果用户问“页面访问量、PV、UV、活跃用户”,优先走 `+analytics-list`。
|
||||
- 如果用户已有 `trace_id` 或 `log_id`,直接用对应 get 命令;不知道 ID 时先 list。
|
||||
79
skills/lark-apps/references/lark-apps-openapi-key.md
Normal file
79
skills/lark-apps/references/lark-apps-openapi-key.md
Normal file
@@ -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 '<raw request_scope JSON>'` | 高级逃生口,直传 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),不在此重复。
|
||||
34
skills/lark-apps/references/lark-apps-plugin-install.md
Normal file
34
skills/lark-apps/references/lark-apps-plugin-install.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# apps +plugin-install
|
||||
|
||||
安装插件包到项目。运行时命令事实以 `lark-cli apps +plugin-install --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
用户要接入 AI 能力或飞书平台能力,需要先安装对应的插件包。安装后才能创建插件实例。具体有哪些可用插件、该选哪个,读取创建的应用仓库 Skill:`.agents/skills/plugin-guide/SKILL.md`。
|
||||
|
||||
**插件包 ≠ npm 包**:插件包写入 `actionPlugins`,npm 写入 `dependencies`,两套独立机制。禁止用 `npm install` 代替本命令。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
- `--name <key>`:插件包 key(从仓库 Skill 的「AI 插件目录」获取)。不传则批量安装 `actionPlugins` 中声明的所有插件。
|
||||
- `--version <ver>`:指定版本(如 `1.0.0`)。不传则安装最新版。
|
||||
|
||||
在项目根目录下运行(和 npm 一样,无需指定路径)。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
# 安装最新版
|
||||
lark-cli apps +plugin-install --name <plugin-key>
|
||||
|
||||
# 安装指定版本
|
||||
lark-cli apps +plugin-install --name <plugin-key> --version 1.0.0
|
||||
|
||||
# 批量安装已声明的所有插件
|
||||
lark-cli apps +plugin-install
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- 已安装同版本会跳过(status=already_installed)。
|
||||
- 失败时 hint 指示原因(网络/版本不存在/package.json 缺失)。
|
||||
21
skills/lark-apps/references/lark-apps-plugin-list.md
Normal file
21
skills/lark-apps/references/lark-apps-plugin-list.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# apps +plugin-list
|
||||
|
||||
列出已声明的插件包及安装状态。运行时命令事实以 `lark-cli apps +plugin-list --help` 为准。
|
||||
|
||||
## 何时用
|
||||
|
||||
查看当前项目声明了哪些插件、是否已安装。`declared_not_installed` 状态表示需要运行 `+plugin-install` 安装。
|
||||
|
||||
## 命令骨架
|
||||
|
||||
在项目根目录下运行(和 npm 一样,无需指定路径)。
|
||||
|
||||
## 示例
|
||||
|
||||
```bash
|
||||
lark-cli apps +plugin-list --format json
|
||||
```
|
||||
|
||||
## 输出契约
|
||||
|
||||
- `data.plugins[]` 包含 `key`、`version`、`status`(`installed` / `declared_not_installed`)。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user