fix: upgrade observability and env

This commit is contained in:
qingniaotonghua
2026-06-25 21:09:44 +08:00
parent d2452b7f9c
commit 9efa8b3b69
22 changed files with 941 additions and 162 deletions

View File

@@ -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 <app_id>",
"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: "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 <app_id> --key FOO --value bar",
"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: "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 <app_id> --key FOO --yes",
"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: "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 {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,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 <app_id> --env online --key FOO --value <value> --yes"),
withExtraTips(AppsEnvVarDelete, "Tip: +envvar-delete is high-risk-write; only pass --yes after explicit confirmation."),
envSet,
envDelete,
AppsDBTableList,
AppsDBTableGet,
AppsDBExecute,

View File

@@ -11,7 +11,7 @@ import (
// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。
// 6 基础 + 1 init + 3 publish + 1 env-pull + 6 observability
// + 3 envvar + 4 dbtable-list/table-schema/sql/dev-init
// + 3 env + 4 dbtable-list/table-schema/sql/dev-init
// + 3 git-credential + 5 sessioncreate/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)
}
}
}

View File

@@ -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 <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 <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-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 云端)只看用户对"谁来写代码"的偏好,与应用复杂度、要不要数据库无关。

View File

@@ -8,7 +8,7 @@
## 何时别用(核心反模式)
**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并把用户刚改完的 `.env.local` 临时改动覆盖掉
**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并用服务端返回值覆盖 `.env.local` 里的同名 key本地无关行和注释会保留
只在这些兜底场景用:

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

View File

@@ -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 <app_id>
lark-cli apps +envvar-list --app-id <app_id> --env online
lark-cli apps +envvar-list --app-id <app_id> --include-values --jq '.data.items[] | select(.key == "FOO")'
```
## 设置
dev 环境设置不需要 `--yes`。设置 online 环境需要人类确认并显式传 `--yes``--dry-run` 可用于预览请求且不需要 `--yes`。变量值支持直接传 `<value>`,也支持 `@file` 或 stdin 输入。
```bash
lark-cli apps +envvar-set --app-id <app_id> --key FOO --value <value>
lark-cli apps +envvar-set --app-id <app_id> --key FOO --value @./secret.txt
lark-cli apps +envvar-set --app-id <app_id> --env online --key FOO --value <value> --dry-run
lark-cli apps +envvar-set --app-id <app_id> --env online --key FOO --value <value> --yes
```
## 删除
`+envvar-delete` 是 high-risk-write。尊重 exit 10 confirmation protocol先让用户确认要删除哪些 key再传 `--yes`。不要自动补 `--yes`
```bash
lark-cli apps +envvar-delete --app-id <app_id> --key FOO --dry-run
lark-cli apps +envvar-delete --app-id <app_id> --key FOO --yes
lark-cli apps +envvar-delete --app-id <app_id> --env online --key FOO --yes
```
## 反模式
- 不要把 `+env-pull` 当成环境变量管理命令;它只是刷新本地 `.env.local` 的兜底工具。
- 不要为了看一个变量臆造名为 envvar-get 的 apps shortcut`+envvar-list --include-values``--jq`
- 不要把真实 secret 写进示例或对话输出;需要示例时使用 `<value>``@file` 或 stdin。

View File

@@ -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 <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 <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-query --app-id <app_id> --metric requests --series total --since 1d
lark-cli apps +metric-query --app-id <app_id> --metric requests --since 1h
lark-cli apps +metric-query --app-id <app_id> --metric latency --since 1h
lark-cli apps +metric-query --app-id <app_id> --metric latency --series p99 --since 1d
lark-cli apps +metric-query --app-id <app_id> --metric cpu --since 1h
lark-cli apps +metric-query --app-id <app_id> --metric memory --since 1h
@@ -35,5 +41,8 @@ lark-cli apps +analytics-query --app-id <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 <path-or-name>`;当前没有 `group-by` 参数,不要臆造。
- `+metric-query` 未显式传 `--down-sample` 时会按时间范围自动选择粒度:短范围用 `1m`,中等范围用 `1h`,长范围用 `1d`;显式传入时尊重用户指定。
- 如果用户问“页面访问量、PV、UV、活跃用户”优先走 `+analytics-query`
- 如果用户已有 `trace_id``log_id`,直接用对应 get 命令;不知道 ID 时先 list。

View File

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