From 9efa8b3b696d8cab63f4f225b112e566ea6261a4 Mon Sep 17 00:00:00 2001 From: qingniaotonghua <1021281778@qq.com> Date: Thu, 25 Jun 2026 21:09:44 +0800 Subject: [PATCH] fix: upgrade observability and env --- .../apps/{apps_envvar.go => apps_env.go} | 68 +++- shortcuts/apps/apps_env_pull.go | 17 +- shortcuts/apps/apps_env_pull_test.go | 37 +- .../{apps_envvar_test.go => apps_env_test.go} | 133 ++++++- shortcuts/apps/apps_examples_test.go | 19 +- shortcuts/apps/apps_hints_test.go | 2 +- shortcuts/apps/apps_observability_common.go | 7 +- .../apps/apps_observability_common_test.go | 8 +- shortcuts/apps/apps_observability_logs.go | 363 ++++++++++++++++-- .../apps/apps_observability_logs_test.go | 256 +++++++++++- shortcuts/apps/apps_observability_metrics.go | 8 +- .../apps/apps_observability_metrics_test.go | 4 +- shortcuts/apps/apps_observability_traces.go | 8 +- .../apps/apps_observability_traces_test.go | 4 +- shortcuts/apps/shortcuts.go | 19 +- shortcuts/apps/shortcuts_test.go | 30 +- skills/lark-apps/SKILL.md | 12 +- .../references/lark-apps-env-pull.md | 2 +- skills/lark-apps/references/lark-apps-env.md | 48 +++ .../lark-apps/references/lark-apps-envvar.md | 44 --- .../references/lark-apps-observability.md | 11 +- .../cli_e2e/apps/apps_env_pull_dryrun_test.go | 3 + 22 files changed, 941 insertions(+), 162 deletions(-) rename shortcuts/apps/{apps_envvar.go => apps_env.go} (83%) rename shortcuts/apps/{apps_envvar_test.go => apps_env_test.go} (66%) create mode 100644 skills/lark-apps/references/lark-apps-env.md delete mode 100644 skills/lark-apps/references/lark-apps-envvar.md diff --git a/shortcuts/apps/apps_envvar.go b/shortcuts/apps/apps_env.go similarity index 83% rename from shortcuts/apps/apps_envvar.go rename to shortcuts/apps/apps_env.go index 1661b4ffe..57c05b5e0 100644 --- a/shortcuts/apps/apps_envvar.go +++ b/shortcuts/apps/apps_env.go @@ -5,6 +5,7 @@ package apps import ( "context" + "io" "sort" "strings" @@ -12,23 +13,26 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -const defaultAppsEnvVarEnv = "dev" +const ( + defaultAppsEnvVarEnv = "dev" + defaultAppsEnvVarScene = 2 +) // AppsEnvVarList lists app environment variables without values by default. var AppsEnvVarList = common.Shortcut{ Service: appsService, - Command: "+envvar-list", + Command: "+env-list", Description: "List app environment variables", Risk: "read", Tips: []string{ - "Example: lark-cli apps +envvar-list --app-id ", + "Example: lark-cli apps +env-list --app-id ", }, Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, - {Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, + {Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, {Name: "include-values", Type: "bool", Desc: "include environment variable values"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -57,7 +61,10 @@ var AppsEnvVarList = common.Shortcut{ if err != nil { return withAppsHint(err, appIDListHint) } - rctx.OutFormat(normalizeEnvVarListOutput(data, includeValues), nil, nil) + out := normalizeEnvVarListOutput(data, includeValues) + rctx.OutFormat(out, nil, func(w io.Writer) { + appsPrintSchemaTable(w, out.Items, envVarListSchema(includeValues)) + }) return nil }, } @@ -65,18 +72,18 @@ var AppsEnvVarList = common.Shortcut{ // AppsEnvVarSet sets one app environment variable. Values are never printed. var AppsEnvVarSet = common.Shortcut{ Service: appsService, - Command: "+envvar-set", + Command: "+env-set", Description: "Set an app environment variable", Risk: "write", Tips: []string{ - "Example: lark-cli apps +envvar-set --app-id --key FOO --value bar", + "Example: lark-cli apps +env-set --app-id --key FOO --value bar", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, - {Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, + {Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, {Name: "key", Desc: "environment variable key", Required: true}, {Name: "value", Desc: "environment variable value", Required: true, Input: []string{common.File, common.Stdin}}, {Name: "yes", Type: "bool", Desc: "confirm setting variables in online"}, @@ -113,8 +120,8 @@ var AppsEnvVarSet = common.Shortcut{ if env == "online" && !rctx.Bool("yes") { return errs.NewConfirmationRequiredError( errs.RiskWrite, - "apps +envvar-set --env online", - "apps +envvar-set --env online requires confirmation", + "apps +env-set --environment online", + "apps +env-set --environment online requires confirmation", ).WithHint("add --yes to confirm") } appID, err := requireAppID(rctx.Str("app-id")) @@ -131,7 +138,7 @@ var AppsEnvVarSet = common.Shortcut{ "value": rctx.Str("value"), }) if err != nil { - return withAppsHint(err, appIDListHint) + return withAppsHint(err, envVarMutationHint(err)) } action := envVarStringAny(data, "action") if action == "" { @@ -149,18 +156,18 @@ var AppsEnvVarSet = common.Shortcut{ // AppsEnvVarDelete deletes one or more app environment variables. var AppsEnvVarDelete = common.Shortcut{ Service: appsService, - Command: "+envvar-delete", + Command: "+env-delete", Description: "Delete app environment variables", Risk: "high-risk-write", Tips: []string{ - "Example: lark-cli apps +envvar-delete --app-id --key FOO --yes", + "Example: lark-cli apps +env-delete --app-id --key FOO --yes", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, - {Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, + {Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, {Name: "key", Type: "string_array", Desc: "environment variable key; repeatable", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -193,7 +200,7 @@ var AppsEnvVarDelete = common.Shortcut{ env := envVarEnv(rctx) data, err := rctx.CallAPITyped("POST", envVarDeletePath(appID), nil, buildEnvVarDeleteBody(env, keys)) if err != nil { - return withAppsHint(err, appIDListHint) + return withAppsHint(err, envVarMutationHint(err)) } deletedKeys := envVarStringSliceAny(data, "deleted_keys", "deletedKeys") if len(deletedKeys) == 0 { @@ -208,7 +215,7 @@ var AppsEnvVarDelete = common.Shortcut{ } func envVarEnv(rctx *common.RuntimeContext) string { - env := strings.TrimSpace(rctx.Str("env")) + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) if env == "" { return defaultAppsEnvVarEnv } @@ -229,7 +236,8 @@ func envVarDeletePath(appID string) string { func buildEnvVarListBody(rctx *common.RuntimeContext) map[string]interface{} { return map[string]interface{}{ - "env": envVarEnv(rctx), + "env": envVarEnv(rctx), + "scene": defaultAppsEnvVarScene, } } @@ -240,6 +248,21 @@ func buildEnvVarDeleteBody(env string, keys []string) map[string]interface{} { } } +func envVarMutationHint(err error) string { + if isEnvVarNotModifiableError(err) { + return "this environment variable is platform-managed and cannot be modified; remove protected keys from --key and retry only with user-defined variables" + } + return appIDListHint +} + +func isEnvVarNotModifiableError(err error) bool { + p, ok := errs.ProblemOf(err) + if !ok { + return false + } + return strings.Contains(strings.ToLower(p.Message), "not modifiable") +} + func requireEnvVarKey(raw string) (string, error) { key := strings.TrimSpace(raw) if key == "" { @@ -339,6 +362,17 @@ func filterEnvVarItem(item map[string]interface{}, includeValues bool) map[strin return out } +func envVarListSchema(includeValues bool) appsOutputSchema { + columns := []appsOutputColumn{ + {Key: "key"}, + {Key: "env"}, + } + if includeValues { + columns = append(columns, appsOutputColumn{Key: "value"}) + } + return appsOutputSchema{Columns: columns, Strict: true} +} + func envVarStringAny(data map[string]interface{}, keys ...string) string { for _, key := range keys { if value, ok := data[key].(string); ok { diff --git a/shortcuts/apps/apps_env_pull.go b/shortcuts/apps/apps_env_pull.go index 211b0b4b5..690b08e26 100644 --- a/shortcuts/apps/apps_env_pull.go +++ b/shortcuts/apps/apps_env_pull.go @@ -62,9 +62,9 @@ var AppsEnvPull = common.Shortcut{ projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path"))) appID := strings.TrimSpace(rctx.Str("app-id")) return common.NewDryRunAPI(). - POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))). + POST(envPullVarsPath(appID)). Desc("Pull app startup env vars into the local .env.local file"). - Body(map[string]interface{}{"env": "dev"}). + Body(envPullVarsBody()). Set("project_path", projectPath). Set("env_file", envFile) }, @@ -81,8 +81,7 @@ var AppsEnvPull = common.Shortcut{ return err } - path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID)) - data, err := rctx.CallAPITyped("POST", path, nil, map[string]interface{}{"env": "dev"}) + data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody()) if err != nil { return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`") } @@ -117,6 +116,16 @@ var AppsEnvPull = common.Shortcut{ }, } +func envPullVarsPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID)) +} + +func envPullVarsBody() map[string]interface{} { + return map[string]interface{}{ + "env": "dev", + } +} + func resolveEnvPullTarget(projectPath string) (string, string, error) { if strings.TrimSpace(projectPath) == "" { cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded. diff --git a/shortcuts/apps/apps_env_pull_test.go b/shortcuts/apps/apps_env_pull_test.go index 4df081645..590d14d31 100644 --- a/shortcuts/apps/apps_env_pull_test.go +++ b/shortcuts/apps/apps_env_pull_test.go @@ -32,6 +32,11 @@ func assertValidationError(t *testing.T, err error, wantSubstr string) { } } +func assertEnvPullBody(t *testing.T, req *http.Request) { + t.Helper() + assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"}) +} + func TestResolveEnvPullTarget_DefaultProjectPathUsesCWD(t *testing.T) { cwd := t.TempDir() oldwd, err := os.Getwd() @@ -288,7 +293,7 @@ func TestAppsEnvPull_PrettyOutput_WithDatabaseLine(t *testing.T) { Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", OnMatch: func(req *http.Request) { - assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"}) + assertEnvPullBody(t, req) }, Body: map[string]interface{}{ "code": 0, @@ -557,6 +562,36 @@ func TestAppsEnvPull_ExecuteUsesNestedDataEnvVars(t *testing.T) { } } +func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + RawBody: []byte("[]"), + OnMatch: func(req *http.Request) { + assertEnvPullBody(t, req) + }, + }) + + err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"}, + factory, stdout, + ) + if err == nil { + t.Fatalf("expected non-object JSON failure, got nil; stdout=%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype) + } + if strings.Contains(p.Hint, "apps +list") || strings.Contains(p.Hint, "--app-id") { + t.Fatalf("hint should not point to app-id/list recovery for malformed upstream JSON: %q", p.Hint) + } +} + func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() diff --git a/shortcuts/apps/apps_envvar_test.go b/shortcuts/apps/apps_env_test.go similarity index 66% rename from shortcuts/apps/apps_envvar_test.go rename to shortcuts/apps/apps_env_test.go index 533a72130..4913bf5c4 100644 --- a/shortcuts/apps/apps_envvar_test.go +++ b/shortcuts/apps/apps_env_test.go @@ -29,6 +29,10 @@ func assertEnvVarBody(t *testing.T, req *http.Request, want map[string]interface } } +func expectedEnvVarSceneJSON() float64 { + return float64(defaultAppsEnvVarScene) +} + func decodeEnvVarEnvelopeData(t *testing.T, stdout string) map[string]interface{} { t.Helper() var envelope struct { @@ -65,7 +69,7 @@ func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) { Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", OnMatch: func(req *http.Request) { - assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"}) + assertEnvVarBody(t, req, map[string]interface{}{"env": "dev", "scene": expectedEnvVarSceneJSON()}) }, Body: map[string]interface{}{ "code": 0, @@ -78,7 +82,7 @@ func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) { }) if err := runAppsShortcut(t, AppsEnvVarList, - []string{"+envvar-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + []string{"+env-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -106,7 +110,7 @@ func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) { Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", OnMatch: func(req *http.Request) { - assertEnvVarBody(t, req, map[string]interface{}{"env": "online"}) + assertEnvVarBody(t, req, map[string]interface{}{"env": "online", "scene": expectedEnvVarSceneJSON()}) }, Body: map[string]interface{}{ "code": 0, @@ -119,7 +123,7 @@ func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) { }) if err := runAppsShortcut(t, AppsEnvVarList, - []string{"+envvar-list", "--app-id", "app_x", "--env", "online", "--include-values", "--as", "user"}, factory, stdout); err != nil { + []string{"+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -129,10 +133,73 @@ func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) { } } +func TestAppsEnvVarList_DoesNotAcceptEnvironmentShorthand(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarList, + []string{"+env-list", "--app-id", "app_x", "-e", "online", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "unknown shorthand flag: 'e'") { + t.Fatalf("expected unknown -e shorthand, got %v", err) + } +} + +func TestAppsEnvVarList_DryRunIncludesScene(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsEnvVarList, []string{ + "+env-list", "--app-id", "app_x", "--include-values", "--dry-run", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var dryRun struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &dryRun); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if got := dryRun.API[0].Body["scene"]; got != expectedEnvVarSceneJSON() { + t.Fatalf("body.scene = %#v, want %v; stdout:\n%s", got, expectedEnvVarSceneJSON(), stdout.String()) + } +} + +func TestAppsEnvVarList_PrettyDisplaysTable(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"key": "API_HOST", "value": "https://example.com", "env": "online"}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvVarList, []string{ + "+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.HasPrefix(got, "key") { + t.Fatalf("pretty output should start with key column, got:\n%s", got) + } + for _, want := range []string{"API_HOST", "online", "https://example.com"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q:\n%s", want, got) + } + } + if strings.Contains(got, `"ok"`) || strings.Contains(got, `"data"`) { + t.Fatalf("pretty output should not fall back to JSON envelope:\n%s", got) + } +} + func TestAppsEnvVarSet_OnlineRequiresYesOutsideDryRun(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsEnvVarSet, - []string{"+envvar-set", "--app-id", "app_x", "--env", "online", + []string{"+env-set", "--app-id", "app_x", "--environment", "online", "--key", "SECRET_TOKEN", "--value", "super-secret", "--as", "user"}, factory, stdout) p := requireAppsProblem(t, err, errs.CategoryConfirmation) @@ -147,7 +214,7 @@ func TestAppsEnvVarSet_OnlineRequiresYesOutsideDryRun(t *testing.T) { func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsEnvVarSet, - []string{"+envvar-set", "--app-id", "app_x", "--env", "online", + []string{"+env-set", "--app-id", "app_x", "--environment", "online", "--key", "SECRET_TOKEN", "--value", "super-secret", "--dry-run", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } @@ -184,7 +251,7 @@ func TestAppsEnvVarSet_ExecutesWithYesAndDoesNotEchoValue(t *testing.T) { reg.Register(stub) if err := runAppsShortcut(t, AppsEnvVarSet, - []string{"+envvar-set", "--app-id", "app_x", "--env", "online", + []string{"+env-set", "--app-id", "app_x", "--environment", "online", "--key", "SECRET_TOKEN", "--value", "super-secret", "--yes", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -223,7 +290,7 @@ func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) { reg.Register(stub) if err := runAppsShortcut(t, AppsEnvVarDelete, - []string{"+envvar-delete", "--app-id", "app_x", "--env", "online", + []string{"+env-delete", "--app-id", "app_x", "--environment", "online", "--key", "SECRET_ONE", "--key", "SECRET_TWO", "--yes", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -247,10 +314,41 @@ func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) { } } +func TestAppsEnvVarDelete_NotModifiableHint(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/delete_env_vars", + Body: map[string]interface{}{ + "code": 400000072, + "msg": "Invalid Request: env var (INTEGRATION_TOKEN) is not modifiable", + }, + }) + + err := runAppsShortcut(t, AppsEnvVarDelete, + []string{"+env-delete", "--app-id", "app_x", "--key", "INTEGRATION_TOKEN", "--yes", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected not modifiable error, got nil; stdout=%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Code != 400000072 { + t.Fatalf("code = %d, want 400000072", p.Code) + } + if !strings.Contains(p.Hint, "platform-managed") || !strings.Contains(p.Hint, "user-defined") { + t.Fatalf("hint = %q, want platform-managed/user-defined guidance", p.Hint) + } + if strings.Contains(p.Hint, "apps +list") { + t.Fatalf("hint should not point at app listing for protected env vars: %q", p.Hint) + } +} + func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsEnvVarDelete, - []string{"+envvar-delete", "--app-id", "app_x", "--env", "online", + []string{"+env-delete", "--app-id", "app_x", "--environment", "online", "--key", "SECRET_ONE", "--key", "SECRET_TWO", "--dry-run", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } @@ -281,14 +379,23 @@ func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) { func TestAppsEnvVarList_InvalidEnvTypedValidation(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsEnvVarList, - []string{"+envvar-list", "--app-id", "app_x", "--env", "prod", "--as", "user"}, factory, stdout) - requireEnvVarValidationProblem(t, err, "--env") + []string{"+env-list", "--app-id", "app_x", "--environment", "prod", "--as", "user"}, factory, stdout) + requireEnvVarValidationProblem(t, err, "--environment") +} + +func TestAppsEnvVarList_OldEnvFlagIsNotAlias(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarList, + []string{"+env-list", "--app-id", "app_x", "--env", "online", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "unknown flag: --env") { + t.Fatalf("expected old --env to be rejected, got %v", err) + } } func TestAppsEnvVarSet_InvalidKeyTypedValidation(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsEnvVarSet, - []string{"+envvar-set", "--app-id", "app_x", "--key", "bad-key", + []string{"+env-set", "--app-id", "app_x", "--key", "bad-key", "--value", "super-secret", "--as", "user"}, factory, stdout) requireEnvVarValidationProblem(t, err, "--key") } @@ -296,7 +403,7 @@ func TestAppsEnvVarSet_InvalidKeyTypedValidation(t *testing.T) { func TestAppsEnvVarDelete_InvalidKeyTypedValidation(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsEnvVarDelete, - []string{"+envvar-delete", "--app-id", "app_x", "--key", "bad-key", + []string{"+env-delete", "--app-id", "app_x", "--key", "bad-key", "--yes", "--as", "user"}, factory, stdout) requireEnvVarValidationProblem(t, err, "--key") } diff --git a/shortcuts/apps/apps_examples_test.go b/shortcuts/apps/apps_examples_test.go index d2c1ed4fc..75576b6c7 100644 --- a/shortcuts/apps/apps_examples_test.go +++ b/shortcuts/apps/apps_examples_test.go @@ -14,6 +14,9 @@ func TestAppsShortcutsHaveExamples(t *testing.T) { email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`) phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`) for _, s := range Shortcuts() { + if s.Hidden { + continue + } hasExample := false for _, tip := range s.Tips { if strings.HasPrefix(tip, "Example: lark-cli apps +") { @@ -51,15 +54,15 @@ func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) { } } -func TestAppsEnvVarTipsCoverConfirmations(t *testing.T) { - envvarSet := requireShortcutForExamples(t, "+envvar-set") - if !tipsContainAll(envvarSet.Tips, "--env online", "--yes") { - t.Fatalf("+envvar-set tips must include an online write example with --env online --yes: %#v", envvarSet.Tips) +func TestAppsEnvTipsCoverConfirmations(t *testing.T) { + envSet := requireShortcutForExamples(t, "+env-set") + if !tipsContainAll(envSet.Tips, "--environment online", "--yes") { + t.Fatalf("+env-set tips must include an online write example with --environment online --yes: %#v", envSet.Tips) } - envvarDelete := requireShortcutForExamples(t, "+envvar-delete") - if !tipsContainAll(envvarDelete.Tips, "--yes") { - t.Fatalf("+envvar-delete tips must include --yes: %#v", envvarDelete.Tips) + envDelete := requireShortcutForExamples(t, "+env-delete") + if !tipsContainAll(envDelete.Tips, "--yes") { + t.Fatalf("+env-delete tips must include --yes: %#v", envDelete.Tips) } } @@ -73,7 +76,7 @@ func TestAppsObservabilityTipsMentionOnlineOnly(t *testing.T) { "+analytics-query", } { shortcut := requireShortcutForExamples(t, cmd) - if !tipsContainAll(shortcut.Tips, "online-only", "--env online") { + if !tipsContainAll(shortcut.Tips, "online-only", "--environment online") { t.Fatalf("%s tips should mention online-only env: %#v", cmd, shortcut.Tips) } } diff --git a/shortcuts/apps/apps_hints_test.go b/shortcuts/apps/apps_hints_test.go index c5ce0e3f3..a4af9254e 100644 --- a/shortcuts/apps/apps_hints_test.go +++ b/shortcuts/apps/apps_hints_test.go @@ -22,7 +22,7 @@ func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) { Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}, OnMatch: func(req *http.Request) { - assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"}) + assertEnvPullBody(t, req) }, }) diff --git a/shortcuts/apps/apps_observability_common.go b/shortcuts/apps/apps_observability_common.go index c2fcef335..22d2b7116 100644 --- a/shortcuts/apps/apps_observability_common.go +++ b/shortcuts/apps/apps_observability_common.go @@ -14,6 +14,7 @@ import ( const ( defaultAppsPageSize = 50 maxAppsPageSize = 100 + appsEnvironmentFlag = "environment" // The CLI exposes the user-facing online environment, while the // observability backend stores online app runtime telemetry under runtime. @@ -34,8 +35,8 @@ func validateObservabilityEnv(env string) error { case "", "online": return nil default: - return appsValidationParamError("--env", "observability commands only support online (got %q)", env). - WithHint("only online is supported; omit --env to use the default online environment") + return appsValidationParamError("--environment", "observability commands only support online (got %q)", env). + WithHint("only online is supported; omit --environment to use the default online environment") } } @@ -44,7 +45,7 @@ func validateEnvVarEnv(env string) error { case "dev", "online": return nil default: - return appsValidationParamError("--env", "env var commands only support --env dev or --env online (got %q)", env) + return appsValidationParamError("--environment", "env var commands only support --environment dev or --environment online (got %q)", env) } } diff --git a/shortcuts/apps/apps_observability_common_test.go b/shortcuts/apps/apps_observability_common_test.go index ced8140f1..9fed5e665 100644 --- a/shortcuts/apps/apps_observability_common_test.go +++ b/shortcuts/apps/apps_observability_common_test.go @@ -33,9 +33,9 @@ func TestAppsObservabilityValidateEnvOnlyOnline(t *testing.T) { t.Fatalf("online should pass: %v", err) } err := validateObservabilityEnv("dev") - p := requireAppsValidationParam(t, err, "--env") + p := requireAppsValidationParam(t, err, "--environment") if p.Subtype != errs.SubtypeInvalidArgument { - t.Fatalf("problem = %#v, want invalid_argument param --env", p) + t.Fatalf("problem = %#v, want invalid_argument param --environment", p) } if !strings.Contains(p.Hint, "only online is supported") { t.Fatalf("hint = %q, want only-online guidance", p.Hint) @@ -63,8 +63,8 @@ func TestAppsObservabilityCommonHelpers(t *testing.T) { t.Fatalf("validateEnvVarEnv(%q) err=%v", env, err) } } - requireAppsValidationParam(t, validateEnvVarEnv(""), "--env") - requireAppsValidationParam(t, validateEnvVarEnv("boe"), "--env") + requireAppsValidationParam(t, validateEnvVarEnv(""), "--environment") + requireAppsValidationParam(t, validateEnvVarEnv("boe"), "--environment") got := cleanRepeatedStrings([]string{" a ", "b", "a", "", "b", "c"}) want := []string{"a", "b", "c"} if len(got) != len(want) { diff --git a/shortcuts/apps/apps_observability_logs.go b/shortcuts/apps/apps_observability_logs.go index a60afc97f..0123ad530 100644 --- a/shortcuts/apps/apps_observability_logs.go +++ b/shortcuts/apps/apps_observability_logs.go @@ -8,18 +8,29 @@ import ( "encoding/json" "fmt" "io" + "regexp" + "strconv" "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) const ( - defaultAppsLogEnv = "online" - logSearchEndpoint = "search_logs" - resolveStackEndpoint = "resolve_stack_trace" - sourceStackStatusOK = "resolved" - sourceStackStatusError = "unresolved" + defaultAppsLogEnv = "online" + logSearchEndpoint = "search_logs" + resolveStackEndpoint = "resolve_stack_trace" + sourceStackStatusOK = "resolved" + sourceStackStatusError = "unresolved" + sourceStackMaxScanDepth = 8 + sourceStackMaxFrames = 2000 + defaultSourceMapPrefix = "client/assets/" +) + +var ( + jsStackFrameParenRe = regexp.MustCompile(`^\s*(?:at\s+(.+?)\s+)?\((.+):(\d+):(\d+)\)\s*$`) + jsStackFrameBareRe = regexp.MustCompile(`^\s*(?:at\s+)?(.+):(\d+):(\d+)\s*$`) ) // AppsLogList searches online app logs with observability filters. @@ -37,7 +48,7 @@ var AppsLogList = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true}, - {Name: "env", Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, + {Name: appsEnvironmentFlag, Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, {Name: "level", Type: "string_array", Desc: "log level filter; repeatable, one of DEBUG, INFO, WARN, ERROR (case-insensitive)"}, @@ -100,7 +111,7 @@ var AppsLogGet = common.Shortcut{ Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true}, {Name: "log-id", Desc: "log ID to fetch", Required: true}, - {Name: "env", Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, + {Name: appsEnvironmentFlag, Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { @@ -109,7 +120,7 @@ var AppsLogGet = common.Shortcut{ if strings.TrimSpace(rctx.Str("log-id")) == "" { return appsValidationParamError("--log-id", "--log-id is required") } - return validateObservabilityEnv(rctx.Str("env")) + return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag)) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). @@ -119,14 +130,14 @@ var AppsLogGet = common.Shortcut{ }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID, _ := requireAppID(rctx.Str("app-id")) - data, err := rctx.CallAPITyped("POST", logSearchPath(appID), nil, buildLogGetSearchBody(rctx)) + data, err := callLogGetSearch(rctx, appID, buildLogGetSearchBody(rctx)) if err != nil { return withAppsHint(err, appIDListHint) } out := normalizeLogSearchResponse(data) if len(out.Items) == 0 { return appsFailedPreconditionParamError("--log-id", "log not found"). - WithHint("verify --log-id and --env online") + WithHint("verify --log-id and --environment online") } log := out.Items[0] enrichLogSourceStack(rctx, appID, log) @@ -137,6 +148,25 @@ var AppsLogGet = common.Shortcut{ }, } +func callLogGetSearch(rctx *common.RuntimeContext, appID string, body map[string]interface{}) (map[string]interface{}, error) { + resp, err := rctx.DoAPI(&larkcore.ApiReq{ + HttpMethod: "POST", + ApiPath: logSearchPath(appID), + Body: body, + }) + if err != nil { + return nil, err + } + data, err := rctx.ClassifyAPIResponse(resp) + if err == nil && data != nil { + return data, nil + } + if flex, ok := flexibleLogSearchData(resp.RawBody); ok && (err == nil || isNonObjectInvalidResponse(err)) { + return flex, nil + } + return data, err +} + type logSearchOutput struct { Items []map[string]interface{} `json:"items"` PageToken string `json:"page_token,omitempty"` @@ -152,7 +182,7 @@ func resolveStackPath(appID string) string { } func buildLogSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { - env := strings.TrimSpace(rctx.Str("env")) + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) if env == "" { env = defaultAppsLogEnv } @@ -330,6 +360,40 @@ func firstMapSlice(data map[string]interface{}, keys ...string) []map[string]int return nil } +func flexibleLogSearchData(raw []byte) (map[string]interface{}, bool) { + var result interface{} + if err := json.Unmarshal(raw, &result); err != nil { + return nil, false + } + switch value := result.(type) { + case []interface{}: + return map[string]interface{}{"items": value}, true + case map[string]interface{}: + data, ok := value["data"] + if !ok { + return nil, false + } + items, ok := data.([]interface{}) + if !ok { + return nil, false + } + out := map[string]interface{}{"items": items} + for _, key := range []string{"page_token", "next_page_token", "pageToken", "nextPageToken", "has_more", "hasMore"} { + if v, present := value[key]; present { + out[key] = v + } + } + return out, true + default: + return nil, false + } +} + +func isNonObjectInvalidResponse(err error) bool { + p, ok := errs.ProblemOf(err) + return ok && p.Category == errs.CategoryInternal && p.Subtype == errs.SubtypeInvalidResponse +} + func firstLogString(data map[string]interface{}, keys ...string) string { for _, key := range keys { if s, ok := data[key].(string); ok && strings.TrimSpace(s) != "" { @@ -429,8 +493,7 @@ func enrichLogSourceStack(rctx *common.RuntimeContext, appID string, log map[str data, err := rctx.CallAPITyped("POST", resolveStackPath(appID), nil, body) if err != nil { if _, typed := errs.ProblemOf(err); typed { - log["source_stack_status"] = sourceStackStatusError - log["source_stack_reason"] = "resolve_stack_trace failed" + markSourceStackResolveError(log, err) } return } @@ -442,6 +505,20 @@ func enrichLogSourceStack(rctx *common.RuntimeContext, appID string, log map[str log["source_stack"] = stack } +func markSourceStackResolveError(log map[string]interface{}, err error) { + log["source_stack_status"] = sourceStackStatusError + log["source_stack_reason"] = "resolve_stack_trace failed" + if problem, ok := errs.ProblemOf(err); ok { + if problem.Code != 0 { + log["source_stack_error_code"] = problem.Code + log["source_stack_reason"] = fmt.Sprintf("resolve_stack_trace failed: code %d", problem.Code) + } + if problem.LogID != "" { + log["source_stack_log_id"] = problem.LogID + } + } +} + func shouldResolveSourceStack(log map[string]interface{}) bool { level := strings.ToUpper(firstItemString(log, "level", "severity_text", "severityText")) if level != "ERROR" { @@ -479,24 +556,78 @@ func isSourceMapSignal(value string) bool { } func extractSourceStackResolveBody(log map[string]interface{}) (map[string]interface{}, bool) { - sources := []map[string]interface{}{log} - if attrs, ok := log["attributes"].(map[string]interface{}); ok { - sources = append([]map[string]interface{}{attrs}, sources...) - } - if bodyMap, ok := log["body"].(map[string]interface{}); ok { - sources = append([]map[string]interface{}{bodyMap}, sources...) - } - commitID := firstStringInMaps(sources, "commit_id", "commitID", "commitId") + sources := collectSourceStackMaps(log) + commitID := firstStringInMaps(sources, "commit_id", "commitID", "commitId", "release_commit_id", "releaseCommitID", "releaseCommitId") prefix := firstStringInMaps(sources, "source_map_file_prefix", "sourceMapFilePrefix", "source_map_prefix", "sourceMapPrefix") - frames := firstFramesInMaps(sources, "frames", "stack_frames", "stackFrames", "source_stack_frames", "sourceStackFrames") + if prefix == "" && firstStringInMaps(sources, "release_commit_id", "releaseCommitID", "releaseCommitId") != "" { + prefix = defaultSourceMapPrefix + } + frames := firstFramesInMaps( + sources, + "frames", + "stack_frames", + "stackFrames", + "source_stack_frames", + "sourceStackFrames", + "stack", + "stack_trace", + "stackTrace", + "error_stack", + "errorStack", + "exception_stack", + "exceptionStack", + "message", + "body", + ) if commitID == "" || prefix == "" || len(frames) == 0 { return nil, false } - return map[string]interface{}{ + body := map[string]interface{}{ "commit_id": commitID, "source_map_file_prefix": prefix, "frames": frames, - }, true + } + if tenantID := firstStringInMaps(sources, "tenant_id", "tenantID", "tenantId"); tenantID != "" { + body["tenant_id"] = tenantID + } + return body, true +} + +func collectSourceStackMaps(value interface{}) []map[string]interface{} { + out := make([]map[string]interface{}, 0, 8) + collectSourceStackMapsInto(value, 0, &out) + return out +} + +func collectSourceStackMapsInto(value interface{}, depth int, out *[]map[string]interface{}) { + if depth > sourceStackMaxScanDepth || value == nil { + return + } + switch v := value.(type) { + case map[string]interface{}: + *out = append(*out, v) + for _, nested := range v { + collectSourceStackMapsInto(nested, depth+1, out) + } + case []interface{}: + if attrs := observabilityKVList(v); len(attrs) > 0 { + *out = append(*out, attrs) + for _, nested := range attrs { + collectSourceStackMapsInto(nested, depth+1, out) + } + } + for _, nested := range v { + collectSourceStackMapsInto(nested, depth+1, out) + } + case []map[string]interface{}: + for _, nested := range v { + collectSourceStackMapsInto(nested, depth+1, out) + } + case string: + if parsed := parseJSONObjectString(v); parsed != nil { + collectSourceStackMapsInto(parsed, depth+1, out) + } + } } func firstStringInMaps(sources []map[string]interface{}, keys ...string) string { @@ -509,8 +640,8 @@ func firstStringInMaps(sources []map[string]interface{}, keys ...string) string } func firstFramesInMaps(sources []map[string]interface{}, keys ...string) []interface{} { - for _, source := range sources { - for _, key := range keys { + for _, key := range keys { + for _, source := range sources { frames := normalizeFrames(source[key]) if len(frames) > 0 { return frames @@ -525,16 +656,22 @@ func normalizeFrames(raw interface{}) []interface{} { case []interface{}: out := make([]interface{}, 0, len(frames)) for _, frame := range frames { - if isNonEmptyFrame(frame) { - out = append(out, frame) + if normalized, ok := normalizeFrame(frame); ok { + out = append(out, normalized) + if len(out) >= sourceStackMaxFrames { + return out + } } } return out case []map[string]interface{}: out := make([]interface{}, 0, len(frames)) for _, frame := range frames { - if len(frame) > 0 { - out = append(out, frame) + if normalized, ok := normalizeFrame(frame); ok { + out = append(out, normalized) + if len(out) >= sourceStackMaxFrames { + return out + } } } return out @@ -545,19 +682,106 @@ func normalizeFrames(raw interface{}) []interface{} { } } -func isNonEmptyFrame(frame interface{}) bool { +func normalizeFrame(frame interface{}) (map[string]interface{}, bool) { switch f := frame.(type) { case map[string]interface{}: - return len(f) > 0 + return normalizeFrameMap(f) case map[string]string: - return len(f) > 0 + m := make(map[string]interface{}, len(f)) + for key, value := range f { + m[key] = value + } + return normalizeFrameMap(m) case string: - return strings.TrimSpace(f) != "" + parsed := parseJSStackFrameLine(f) + if _, ok := parsed["file_name"]; !ok { + return nil, false + } + return parsed, true default: - return frame != nil + 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 == "" { @@ -571,13 +795,78 @@ func parseFrameString(raw string) []interface{} { out := make([]interface{}, 0, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) - if line != "" { - out = append(out, map[string]interface{}{"raw": line}) + if line == "" { + continue + } + if frame, ok := normalizeFrame(parseJSStackFrameLine(line)); ok { + out = append(out, frame) + if len(out) >= sourceStackMaxFrames { + return out + } } } return out } +func parseJSStackFrameLine(line string) map[string]interface{} { + if frame := parseJSStackFrameMatch(line, jsStackFrameParenRe.FindStringSubmatch(line)); frame != nil { + return frame + } + if frame := parseJSStackFrameMatch(line, jsStackFrameBareRe.FindStringSubmatch(line)); frame != nil { + return frame + } + return map[string]interface{}{"raw": line} +} + +func parseJSStackFrameMatch(raw string, match []string) map[string]interface{} { + if match == nil { + return nil + } + switch len(match) { + case 4: + line, lineOK := parsePositiveInt(match[2]) + column, columnOK := parsePositiveInt(match[3]) + if lineOK && columnOK { + return map[string]interface{}{"file_name": normalizeSourceFrameFileName(match[1]), "line": line, "column": column} + } + case 5: + line, lineOK := parsePositiveInt(match[3]) + column, columnOK := parsePositiveInt(match[4]) + if lineOK && columnOK { + out := map[string]interface{}{ + "file_name": normalizeSourceFrameFileName(match[2]), + "line": line, + "column": column, + } + if fn := strings.TrimSpace(match[1]); fn != "" { + out["function"] = fn + } + return out + } + } + return map[string]interface{}{"raw": raw} +} + +func parseJSONObjectString(raw string) map[string]interface{} { + raw = strings.TrimSpace(raw) + if raw == "" || !strings.HasPrefix(raw, "{") { + return nil + } + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return nil + } + return parsed +} + +func parsePositiveInt(raw string) (int, bool) { + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || n < 1 { + return 0, false + } + return n, true +} + func firstLogValue(data map[string]interface{}, keys ...string) interface{} { for _, key := range keys { if value, ok := data[key]; ok { diff --git a/shortcuts/apps/apps_observability_logs_test.go b/shortcuts/apps/apps_observability_logs_test.go index c1082da20..e456aa2f6 100644 --- a/shortcuts/apps/apps_observability_logs_test.go +++ b/shortcuts/apps/apps_observability_logs_test.go @@ -74,8 +74,8 @@ func TestAppsLogList_DoesNotAcceptLogIDFlag(t *testing.T) { func TestAppsLogList_RejectsDevEnv(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, factory, stdout) - requireAppsValidationParam(t, err, "--env") + err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout) + requireAppsValidationParam(t, err, "--environment") } func TestAppsLogGet_SearchesByLogIDLimitOne(t *testing.T) { @@ -108,6 +108,49 @@ func TestAppsLogGet_SearchesByLogIDLimitOne(t *testing.T) { } } +func TestAppsLogGet_AcceptsDataArraySearchResponse(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + RawBody: []byte(`{ + "code": 0, + "data": [ + { + "log_id": "LOG7655249917057764881", + "level": "ERROR", + "attributes": { + "commit_id": "commit_array", + "source_map_file_prefix": "sourcemaps/array", + "frames": [{"file":"main.js","line":10,"column":20}] + } + } + ] + }`), + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/App.tsx", "line": 7, "column": 9}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") { + t.Fatalf("stdout missing resolved source stack from data array response: %s", got) + } +} + func TestAppsLogList_NormalizesResponseVariantsAndCanonicalLevel(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -297,6 +340,212 @@ func TestAppsLogGet_ResolvesSourceStackWhenFieldsPresent(t *testing.T) { } } +func TestAppsLogGet_ResolvesSourceStackFromNestedKVAttributes(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG7655249917057764881", + "severityText": "ERROR", + "attributes": []interface{}{ + map[string]interface{}{"key": "commit_id", "value": "commit_nested"}, + map[string]interface{}{"key": "source_map_file_prefix", "value": "sourcemaps/nested"}, + map[string]interface{}{ + "key": "exception", + "value": map[string]interface{}{ + "stackTrace": strings.Join([]string{ + "TypeError: failed to render", + " at render (https://cdn.example.com/assets/main.js:12:34)", + " at https://cdn.example.com/assets/chunk.js:56:78", + }, "\n"), + }, + }, + }, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/App.tsx", "line": 12, "column": 34}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil { + t.Fatal(err) + } + if sent["commit_id"] != "commit_nested" || sent["source_map_file_prefix"] != "sourcemaps/nested" { + t.Fatalf("resolve body missing nested source map fields: %#v", sent) + } + frames, ok := sent["frames"].([]interface{}) + if !ok || len(frames) != 2 { + t.Fatalf("resolve frames = %#v, want parsed stack frames", sent["frames"]) + } + frame, ok := frames[0].(map[string]interface{}) + if !ok { + t.Fatalf("parsed frame = %#v, want object", frames[0]) + } + if frame["function"] != "render" || frame["file_name"] != "main.js" || frame["line"] != float64(12) || frame["column"] != float64(34) { + t.Fatalf("parsed frame = %#v", frame) + } + bare, ok := frames[1].(map[string]interface{}) + if !ok { + t.Fatalf("bare frame = %#v, want object", frames[1]) + } + if bare["file_name"] != "chunk.js" || bare["line"] != float64(56) || bare["column"] != float64(78) { + t.Fatalf("bare frame = %#v", bare) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") { + t.Fatalf("stdout missing resolved source stack: %s", got) + } +} + +func TestAppsLogGet_ResolvesSourceStackFromReleaseCommitJSONStack(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG7655249917057764881", + "severityText": "ERROR", + "attributes": map[string]interface{}{ + "tenant_id": "110564", + "release_commit_id": "4b393e4e0ca9ca1a855ba4585bc6750a7db2266f", + "stack": `[{"fileName":"main.js","line":3348,"column":540585},` + + `{"fileName":"main.js","line":3107,"column":51935},` + + `{"fileName":"main.js","line":62,"column":12516}]`, + }, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/App.tsx", "line": 42, "column": 7}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil { + t.Fatal(err) + } + if sent["commit_id"] != "4b393e4e0ca9ca1a855ba4585bc6750a7db2266f" || sent["source_map_file_prefix"] != defaultSourceMapPrefix || sent["tenant_id"] != "110564" { + t.Fatalf("resolve body missing release source map fields: %#v", sent) + } + frames, ok := sent["frames"].([]interface{}) + if !ok || len(frames) != 3 { + t.Fatalf("resolve frames = %#v, want all valid generated frames", sent["frames"]) + } + first, ok := frames[0].(map[string]interface{}) + if !ok { + t.Fatalf("first frame = %#v, want object", frames[0]) + } + if first["file_name"] != "main.js" || first["line"] != float64(3348) || first["column"] != float64(540585) { + t.Fatalf("first frame = %#v", first) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") { + t.Fatalf("stdout missing resolved source stack: %s", got) + } +} + +func TestAppsLogGet_ResolvesSourceStackFromJSONBodyStack(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG_BODY_STACK", + "severityText": "ERROR", + "attributes": map[string]interface{}{ + "release_commit_id": "commit_body", + }, + "body": `{"error":{"stack":"AxiosError: failed\n at request (https://cdn.example.com/client/assets/body.js:9:88)"}}`, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/request.ts", "line": 9, "column": 88}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG_BODY_STACK", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil { + t.Fatal(err) + } + if sent["commit_id"] != "commit_body" || sent["source_map_file_prefix"] != defaultSourceMapPrefix { + t.Fatalf("resolve body missing body stack source map fields: %#v", sent) + } + frames, ok := sent["frames"].([]interface{}) + if !ok || len(frames) != 1 { + t.Fatalf("resolve frames = %#v, want parsed JSON body stack frame", sent["frames"]) + } + frame, ok := frames[0].(map[string]interface{}) + if !ok { + t.Fatalf("frame = %#v, want object", frames[0]) + } + if frame["function"] != "request" || frame["file_name"] != "body.js" || frame["line"] != float64(9) || frame["column"] != float64(88) { + t.Fatalf("frame = %#v", frame) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/request.ts") { + t.Fatalf("stdout missing resolved source stack: %s", got) + } +} + func TestAppsLogGet_SourceStackMissingFieldsDoesNotFail(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) search := &httpmock.Stub{ @@ -404,6 +653,9 @@ func TestAppsLogGet_SourceStackResolveFailureIsRedacted(t *testing.T) { if !strings.Contains(got, `"source_stack_status": "unresolved"`) { t.Fatalf("stdout missing unresolved status: %s", got) } + if !strings.Contains(got, `"source_stack_error_code": 999`) { + t.Fatalf("stdout missing resolve error code: %s", got) + } for _, banned := range []string{"secret", "token", "raw request payload"} { if strings.Contains(strings.ToLower(got), banned) { t.Fatalf("stdout leaked %q: %s", banned, got) diff --git a/shortcuts/apps/apps_observability_metrics.go b/shortcuts/apps/apps_observability_metrics.go index 8cb65c95d..be32d9ad7 100644 --- a/shortcuts/apps/apps_observability_metrics.go +++ b/shortcuts/apps/apps_observability_metrics.go @@ -40,7 +40,7 @@ var AppsMetricQuery = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online metrics should be queried", Required: true}, - {Name: "env", Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"}, + {Name: appsEnvironmentFlag, Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"}, {Name: "metric", Desc: "metric family to query", Required: true, Enum: []string{"requests", "latency", "cpu", "memory"}}, {Name: "series", Desc: "metric series within the family, such as total/error or p50/p99"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, @@ -102,7 +102,7 @@ var AppsAnalyticsQuery = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online analytics should be queried", Required: true}, - {Name: "env", Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"}, + {Name: appsEnvironmentFlag, Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"}, {Name: "analytics", Desc: "analytics family to query", Required: true, Enum: []string{"users", "page-view"}}, {Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"}, {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, @@ -163,7 +163,7 @@ func analyticsQueryPath(appID string) string { } func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, bool, error) { - env := strings.TrimSpace(rctx.Str("env")) + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) if env == "" { env = defaultAppsMetricEnv } @@ -221,7 +221,7 @@ func buildMetricQueryFilter(rctx *common.RuntimeContext) map[string]interface{} } func buildAnalyticsQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, error) { - env := strings.TrimSpace(rctx.Str("env")) + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) if env == "" { env = defaultAppsAnalyticsEnv } diff --git a/shortcuts/apps/apps_observability_metrics_test.go b/shortcuts/apps/apps_observability_metrics_test.go index d64ee386c..3209ebe96 100644 --- a/shortcuts/apps/apps_observability_metrics_test.go +++ b/shortcuts/apps/apps_observability_metrics_test.go @@ -108,9 +108,9 @@ func TestAppsMetricQuery_AutoDownSampleByRange(t *testing.T) { func TestAppsMetricQuery_RejectsDevEnv(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsMetricQuery, []string{ - "+metric-query", "--app-id", "app_x", "--metric", "requests", "--env", "dev", "--as", "user", + "+metric-query", "--app-id", "app_x", "--metric", "requests", "--environment", "dev", "--as", "user", }, factory, stdout) - requireAppsValidationParam(t, err, "--env") + requireAppsValidationParam(t, err, "--environment") } func TestAppsMetricQuery_FillsMissingRequestValuesWithZero(t *testing.T) { diff --git a/shortcuts/apps/apps_observability_traces.go b/shortcuts/apps/apps_observability_traces.go index 27aed3287..e22c95073 100644 --- a/shortcuts/apps/apps_observability_traces.go +++ b/shortcuts/apps/apps_observability_traces.go @@ -34,7 +34,7 @@ var AppsTraceList = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online traces should be searched", Required: true}, - {Name: "env", Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, + {Name: appsEnvironmentFlag, Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, {Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"}, {Name: "root-span", Desc: "root span keyword filter applied by the trace search backend"}, {Name: "user-id", Desc: "end user ID filter"}, @@ -90,7 +90,7 @@ var AppsTraceGet = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID whose online trace should be fetched", Required: true}, - {Name: "env", Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, + {Name: appsEnvironmentFlag, Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, {Name: "trace-id", Desc: "trace ID to fetch", Required: true}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { @@ -100,7 +100,7 @@ var AppsTraceGet = common.Shortcut{ if strings.TrimSpace(rctx.Str("trace-id")) == "" { return appsValidationParamError("--trace-id", "--trace-id is required") } - return validateObservabilityEnv(rctx.Str("env")) + return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag)) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). @@ -137,7 +137,7 @@ func traceGetPath(appID string) string { } func buildTraceSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { - env := strings.TrimSpace(rctx.Str("env")) + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) if env == "" { env = defaultAppsTraceEnv } diff --git a/shortcuts/apps/apps_observability_traces_test.go b/shortcuts/apps/apps_observability_traces_test.go index 8d8d11faf..4768b1f93 100644 --- a/shortcuts/apps/apps_observability_traces_test.go +++ b/shortcuts/apps/apps_observability_traces_test.go @@ -60,8 +60,8 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) { func TestAppsTraceList_RejectsDevEnv(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, factory, stdout) - requireAppsValidationParam(t, err, "--env") + err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout) + requireAppsValidationParam(t, err, "--environment") } func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) { diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index 62581bdbb..2cb939166 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -7,6 +7,9 @@ import "github.com/larksuite/cli/shortcuts/common" // Shortcuts returns all apps domain shortcuts. func Shortcuts() []common.Shortcut { + envSet := withExtraTips(AppsEnvVarSet, "Example: lark-cli apps +env-set --app-id --environment online --key FOO --value --yes") + envDelete := withExtraTips(AppsEnvVarDelete, "Tip: +env-delete is high-risk-write; only pass --yes after explicit confirmation.") + return []common.Shortcut{ AppsCreate, AppsUpdate, @@ -19,15 +22,15 @@ func Shortcuts() []common.Shortcut { AppsReleaseList, AppsReleaseGet, AppsEnvPull, - withExtraTips(AppsLogList, "Tip: logs are online-only; keep --env omitted or set --env online."), - withExtraTips(AppsLogGet, "Tip: logs are online-only; keep --env omitted or set --env online."), - withExtraTips(AppsTraceList, "Tip: traces are online-only; keep --env omitted or set --env online."), - withExtraTips(AppsTraceGet, "Tip: traces are online-only; keep --env omitted or set --env online."), - withExtraTips(AppsMetricQuery, "Tip: metrics are online-only; keep --env omitted or set --env online."), - withExtraTips(AppsAnalyticsQuery, "Tip: analytics are online-only; keep --env omitted or set --env online."), + withExtraTips(AppsLogList, "Tip: logs are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsLogGet, "Tip: logs are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsTraceList, "Tip: traces are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsTraceGet, "Tip: traces are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsMetricQuery, "Tip: metrics are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsAnalyticsQuery, "Tip: analytics are online-only; keep --environment omitted or set --environment online."), AppsEnvVarList, - withExtraTips(AppsEnvVarSet, "Example: lark-cli apps +envvar-set --app-id --env online --key FOO --value --yes"), - withExtraTips(AppsEnvVarDelete, "Tip: +envvar-delete is high-risk-write; only pass --yes after explicit confirmation."), + envSet, + envDelete, AppsDBTableList, AppsDBTableGet, AppsDBExecute, diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 5abfa1e26..3afb07cb0 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -11,7 +11,7 @@ import ( // 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 // 6 基础 + 1 init + 3 publish + 1 env-pull + 6 observability -// + 3 envvar + 4 db(table-list/table-schema/sql/dev-init) +// + 3 env + 4 db(table-list/table-schema/sql/dev-init) // + 3 git-credential + 5 session(create/list/get/stop/chat)+ 1 session-messages-list = 33。 func TestAppsShortcuts_Returns33(t *testing.T) { got := Shortcuts() @@ -20,10 +20,32 @@ func TestAppsShortcuts_Returns33(t *testing.T) { } } -func TestAppsShortcuts_DoesNotIncludeEnvVarGet(t *testing.T) { +func TestAppsShortcuts_DoesNotIncludeEnvGet(t *testing.T) { for _, sc := range Shortcuts() { - if sc.Command == "+envvar-get" { - t.Fatalf("Shortcuts() must not register +envvar-get") + switch sc.Command { + case "+env-get", "+envvar-get", "+envvar-list", "+envvar-set", "+envvar-delete": + t.Fatalf("Shortcuts() must not register %s", sc.Command) + } + } +} + +func TestAppsShortcuts_EnvCommandsUseCanonicalNames(t *testing.T) { + want := map[string]bool{ + "+env-list": false, + "+env-set": false, + "+env-delete": false, + } + for _, sc := range Shortcuts() { + if _, ok := want[sc.Command]; ok { + want[sc.Command] = true + if sc.Hidden { + t.Errorf("%s must be visible", sc.Command) + } + } + } + for cmd, found := range want { + if !found { + t.Errorf("Shortcuts() missing canonical %s", cmd) } } } diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 5a19b200a..83af9886d 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-apps version: 1.0.0 -description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" +description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" metadata: requires: bins: ["lark-cli"] @@ -24,7 +24,7 @@ metadata: | 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) | | 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | -| 管理应用环境变量(查看/设置/删除) | `+envvar-list`, `+envvar-set`, `+envvar-delete` | [`lark-apps-envvar.md`](references/lark-apps-envvar.md) | +| 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) | | 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-query`, `+analytics-query` | [`lark-apps-observability.md`](references/lark-apps-observability.md) | | 看表、看 schema、跑 SQL、初始化 dev/online 多环境 DB | `+db-table-list`, `+db-table-get`, `+db-execute`, `+db-env-create` | 对应 `lark-apps-db-*.md` | | **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) | @@ -32,6 +32,14 @@ metadata: | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | +## 高频路径 + +- **性能/监控/观测指标**:用户问“接口请求量、错误量、错误率、接口慢、延迟、CPU、内存、最近一小时/七天趋势”时,不要去当前工作区搜索监控文件,也不要询问“监控数据在哪”。先按「app_id 获取」解析应用:`lark-cli apps +list --keyword "<应用名>" --as user`;拿到 `app_id` 后读 [`lark-apps-observability.md`](references/lark-apps-observability.md),用 `+metric-query`。 +- **请求量 + 错误量 + 延迟**:请求量/错误量用 `lark-cli apps +metric-query --app-id --metric requests --since --as user`(不传 `--series` 会同时返回 total/error);延迟用 `--metric latency`(不传 `--series` 会返回 p50/p99)。如果用户给了具体接口,再加 `--api `;不要臆造 group-by 参数。 +- **PV/UV/访问量/活跃用户**:先解析 `app_id`,再用 `+analytics-query`,不要误用 `+metric-query`。 +- **设置环境变量**:如果用户只给应用名,仍先 `+list --keyword` 解析 app_id;设置 online 环境且用户已经明确说“确认/直接执行”时,调用 `+env-set --environment online ... --yes`,不要再次要求确认。回复和日志摘要里只提 key / env / app,不回显真实 value;需要传复杂值时优先用 `@file` 或 stdin。 +- **删除环境变量**:`+env-delete` 是破坏性操作。除非用户在同一轮已经明确确认删除这个 app/env/key,否则先向用户确认应用、环境、key 和删除后果;确认后再加 `--yes`。不要因为认证失败/重登完成就自动继续删除,必须保留确认门槛。 + ## 选择开发路径(进意图路由前先判这步) 新建必先定 **app_type** 和**开发方式**两件正交的事;修改已有先按「app_id 获取」指认到 app,指认不到就问用户,不擅自 `+create`。开发方式(本地 vs 云端)只看用户对"谁来写代码"的偏好,与应用复杂度、要不要数据库无关。 diff --git a/skills/lark-apps/references/lark-apps-env-pull.md b/skills/lark-apps/references/lark-apps-env-pull.md index 43eb1e3b5..148cefa60 100644 --- a/skills/lark-apps/references/lark-apps-env-pull.md +++ b/skills/lark-apps/references/lark-apps-env-pull.md @@ -8,7 +8,7 @@ ## 何时别用(核心反模式) -**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并把用户刚改完的 `.env.local` 临时改动覆盖掉。 +**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并用服务端返回值覆盖 `.env.local` 里的同名 key;本地无关行和注释会保留。 只在这些兜底场景用: diff --git a/skills/lark-apps/references/lark-apps-env.md b/skills/lark-apps/references/lark-apps-env.md new file mode 100644 index 000000000..b6c3aa1fa --- /dev/null +++ b/skills/lark-apps/references/lark-apps-env.md @@ -0,0 +1,48 @@ +# apps env + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 + +管理妙搭应用环境变量。查看用 `+env-list`,设置用 `+env-set`,删除用 `+env-delete`。没有单变量 get 命令;要确认某个 key 是否存在,使用 list 后用 `--jq` 过滤。 + +环境 flag 使用 `--environment`;不要使用旧的 `--env`,也不要使用短选项。 + +## 查看 + +`+env-list` 默认查 dev,且默认不返回 value。只有显式传 `--include-values` 后,响应中才可能出现变量值;不要在公开日志里展示带值输出。 + +接口契约:list 使用 `POST env_vars`,body 固定包含 `env` 和 CLI 场景 `scene=2`;set 使用 `POST create_or_update_env_var`;delete 使用 `POST delete_env_vars`。`--include-values` 只控制 CLI 输出是否展示 value,不作为服务端查询参数发送。 + +```bash +lark-cli apps +env-list --app-id +lark-cli apps +env-list --app-id --environment online +lark-cli apps +env-list --app-id --include-values --jq '.data.items[] | select(.key == "FOO")' +``` + +## 设置 + +dev 环境设置不需要 `--yes`。设置 online 环境需要人类确认并显式传 `--yes`;如果用户在同一轮已经明确说“确认/直接执行”,视为已确认,直接带 `--yes`,不要再次追问。`--dry-run` 可用于预览请求且不需要 `--yes`。变量值支持直接传 ``,也支持 `@file` 或 stdin 输入。 + +回复中只说明 app/env/key 和执行结果;不要回显真实 value。需要举例时使用 ``、`@file` 或 stdin。 + +```bash +lark-cli apps +env-set --app-id --key FOO --value +lark-cli apps +env-set --app-id --key FOO --value @./secret.txt +lark-cli apps +env-set --app-id --environment online --key FOO --value --dry-run +lark-cli apps +env-set --app-id --environment online --key FOO --value --yes +``` + +## 删除 + +`+env-delete` 是 high-risk-write。尊重 exit 10 confirmation protocol:先让用户确认 app/env/key 和删除后果,再传 `--yes`。不要自动补 `--yes`。如果只是认证失败后让用户重登,重登完成不等于删除确认;继续删除前仍需确认。 + +```bash +lark-cli apps +env-delete --app-id --key FOO --dry-run +lark-cli apps +env-delete --app-id --key FOO --yes +lark-cli apps +env-delete --app-id --environment online --key FOO --yes +``` + +## 反模式 + +- 不要把 `+env-pull` 当成环境变量管理命令;它只是刷新本地 `.env.local` 的兜底工具。 +- 不要为了看一个变量臆造名为 env-get 的 apps shortcut;用 `+env-list --include-values` 加 `--jq`。 +- 不要把真实 secret 写进示例或对话输出;需要示例时使用 ``、`@file` 或 stdin。 diff --git a/skills/lark-apps/references/lark-apps-envvar.md b/skills/lark-apps/references/lark-apps-envvar.md deleted file mode 100644 index 73fdba7ec..000000000 --- a/skills/lark-apps/references/lark-apps-envvar.md +++ /dev/null @@ -1,44 +0,0 @@ -# apps envvar - -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 - -管理妙搭应用环境变量。查看用 `+envvar-list`,设置用 `+envvar-set`,删除用 `+envvar-delete`。没有单变量 get 命令;要确认某个 key 是否存在,使用 list 后用 `--jq` 过滤。 - -## 查看 - -`+envvar-list` 默认查 dev,且默认不返回 value。只有显式传 `--include-values` 后,响应中才可能出现变量值;不要在公开日志里展示带值输出。 - -接口契约:list 使用 `POST env_vars`;set 使用 `POST create_or_update_env_var`;delete 使用 `POST delete_env_vars`。`--include-values` 只控制 CLI 输出是否展示 value,不作为服务端查询参数发送。 - -```bash -lark-cli apps +envvar-list --app-id -lark-cli apps +envvar-list --app-id --env online -lark-cli apps +envvar-list --app-id --include-values --jq '.data.items[] | select(.key == "FOO")' -``` - -## 设置 - -dev 环境设置不需要 `--yes`。设置 online 环境需要人类确认并显式传 `--yes`;`--dry-run` 可用于预览请求且不需要 `--yes`。变量值支持直接传 ``,也支持 `@file` 或 stdin 输入。 - -```bash -lark-cli apps +envvar-set --app-id --key FOO --value -lark-cli apps +envvar-set --app-id --key FOO --value @./secret.txt -lark-cli apps +envvar-set --app-id --env online --key FOO --value --dry-run -lark-cli apps +envvar-set --app-id --env online --key FOO --value --yes -``` - -## 删除 - -`+envvar-delete` 是 high-risk-write。尊重 exit 10 confirmation protocol:先让用户确认要删除哪些 key,再传 `--yes`。不要自动补 `--yes`。 - -```bash -lark-cli apps +envvar-delete --app-id --key FOO --dry-run -lark-cli apps +envvar-delete --app-id --key FOO --yes -lark-cli apps +envvar-delete --app-id --env online --key FOO --yes -``` - -## 反模式 - -- 不要把 `+env-pull` 当成环境变量管理命令;它只是刷新本地 `.env.local` 的兜底工具。 -- 不要为了看一个变量臆造名为 envvar-get 的 apps shortcut;用 `+envvar-list --include-values` 加 `--jq`。 -- 不要把真实 secret 写进示例或对话输出;需要示例时使用 ``、`@file` 或 stdin。 diff --git a/skills/lark-apps/references/lark-apps-observability.md b/skills/lark-apps/references/lark-apps-observability.md index 1498fc044..b0375b7ad 100644 --- a/skills/lark-apps/references/lark-apps-observability.md +++ b/skills/lark-apps/references/lark-apps-observability.md @@ -2,20 +2,24 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 -查询妙搭应用的线上运行观测和产品访问分析。所有 observability 命令只支持 `--env online`;省略 `--env` 时默认就是 online,传 dev 或其他环境是不支持的。 +查询妙搭应用的线上运行观测和产品访问分析。所有 observability 命令只支持 `--environment online`;省略 `--environment` 时默认就是 online,传 dev 或其他环境是不支持的。不要使用旧的 `--env`,也不要使用短选项。 日志和 trace 的用户侧环境仍然是 online;但 OpenAPI 请求体里的后端 `app_env` 固定发送 `runtime`,因为线上应用的运行时日志和 trace 存储在 runtime 观测环境下。dry-run 输出会展示这个后端参数。 +metric / analytics 的 `--environment` 只是 CLI 侧 online-only 校验:`+metric-query` 和 `+analytics-query` 不会向 OpenAPI body 发送 `env` 或 `app_env`。dry-run 里看不到环境字段是预期行为,不要补造参数。 + 时间过滤支持相对时间(如 `30s`、`5m`、`0.5h`、`2h`、`3d`、`1w`)、本地日期 / 时间和 RFC3339。 ## 命令选择 - 日志检索:用 `+log-list` 搜索日志,用 `+log-get` 按 log ID 取单条日志。 +- `+log-list` 不再支持 `--log-id`;已有 log ID 时直接用 `+log-get --log-id `。 - 前端 ERROR 日志详情:`+log-get` 可能补充 `source_stack`;没有独立的 source-stack 命令。 - Trace 检索:用 `+trace-list` 搜索 trace,用 `+trace-get` 按 trace ID 取详情。 - 运行时指标:请求数、错误、延迟、CPU、memory 用 `+metric-query`。 - 产品分析:PV、UV、访问量这类业务访问分析用 `+analytics-query`,不要放到 runtime metric 里混查。 - `+analytics-query` 按最新 OpenAPI 发送 `metric_types`、纳秒时间戳和 `need_pack_lack_point=false`;`group_by` 暂不支持。 +- 用户询问“最近一小时接口请求量、错误量、延迟、接口慢/报错多”时,这是平台运行时监控,不是本地项目文件。先用 `apps +list --keyword` 找 `app_id`,再查 `+metric-query`。 ## 示例 @@ -25,6 +29,8 @@ lark-cli apps +log-get --app-id --log-id lark-cli apps +trace-list --app-id --trace-id lark-cli apps +trace-get --app-id --trace-id lark-cli apps +metric-query --app-id --metric requests --series total --since 1d +lark-cli apps +metric-query --app-id --metric requests --since 1h +lark-cli apps +metric-query --app-id --metric latency --since 1h lark-cli apps +metric-query --app-id --metric latency --series p99 --since 1d lark-cli apps +metric-query --app-id --metric cpu --since 1h lark-cli apps +metric-query --app-id --metric memory --since 1h @@ -35,5 +41,8 @@ lark-cli apps +analytics-query --app-id --analytics page-view --granula ## 使用边界 - 如果用户问“接口慢、报错多、CPU/内存高”,优先走 `+metric-query`。 +- `+metric-query --metric requests` 不传 `--series` 会同时返回请求总量 total 和错误量 error;`--metric latency` 不传 `--series` 会同时返回 p50 和 p99。只想看单条曲线时再传 `--series total|error|p50|p99`。 +- 按接口收窄范围时使用 `--api `;当前没有 `group-by` 参数,不要臆造。 +- `+metric-query` 未显式传 `--down-sample` 时会按时间范围自动选择粒度:短范围用 `1m`,中等范围用 `1h`,长范围用 `1d`;显式传入时尊重用户指定。 - 如果用户问“页面访问量、PV、UV、活跃用户”,优先走 `+analytics-query`。 - 如果用户已有 `trace_id` 或 `log_id`,直接用对应 get 命令;不知道 ID 时先 list。 diff --git a/tests/cli_e2e/apps/apps_env_pull_dryrun_test.go b/tests/cli_e2e/apps/apps_env_pull_dryrun_test.go index 69ff6b117..513bdbf9f 100644 --- a/tests/cli_e2e/apps/apps_env_pull_dryrun_test.go +++ b/tests/cli_e2e/apps/apps_env_pull_dryrun_test.go @@ -35,6 +35,9 @@ func TestAppsEnvPullDryRun(t *testing.T) { assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String()) assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String()) + assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String()) + assert.False(t, gjson.Get(result.Stdout, "api.0.body.include_values").Exists()) + assert.False(t, gjson.Get(result.Stdout, "api.0.params").Exists()) assert.True(t, gjson.Get(result.Stdout, "project_path").Exists()) assert.Contains(t, gjson.Get(result.Stdout, "env_file").String(), ".env.local") assert.False(t, gjson.Get(result.Stdout, "env_keys").Exists())