From 02ebcca5319802664c41c2e6b7b04cad64ac4460 Mon Sep 17 00:00:00 2001 From: chengshihao Date: Wed, 21 Oct 2026 17:44:49 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=A9=B9=20fix(apps):=20forbid=20online=20e?= =?UTF-8?q?nv=20var=20delete=20and=20surface=20release-required=20note?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI could delete online env vars directly, bypassing the release-based change control, and both set/delete success looked instantaneous even though the online runtime keeps serving the pre-release snapshot. - `+env-delete --environment online` is now rejected at Validate (fails for --yes execution and --dry-run alike); users must delete in dev then run `+release-create`, or remove from the developer console. - `+env-set` and `+env-delete` success envelopes include a `note` field pointing at `+release-create`, so JSON and pretty consumers see the same reminder that online propagation requires a release. - Skill reference doc updated to document both constraints. --- shortcuts/apps/apps_env.go | 10 ++++ shortcuts/apps/apps_env_test.go | 59 +++++++++++--------- skills/lark-apps/references/lark-apps-env.md | 7 ++- 3 files changed, 48 insertions(+), 28 deletions(-) diff --git a/shortcuts/apps/apps_env.go b/shortcuts/apps/apps_env.go index 57c05b5e07..051797f51c 100644 --- a/shortcuts/apps/apps_env.go +++ b/shortcuts/apps/apps_env.go @@ -16,6 +16,10 @@ import ( const ( defaultAppsEnvVarEnv = "dev" defaultAppsEnvVarScene = 2 + // envVarReleaseNote tells users that mutations only propagate to the online + // runtime after a release — the config write succeeds immediately but the + // running app keeps serving the previously released snapshot until then. + envVarReleaseNote = "changes take effect online only after a release; run `lark-cli apps +release-create --app-id APP_ID`" ) // AppsEnvVarList lists app environment variables without values by default. @@ -148,6 +152,7 @@ var AppsEnvVarSet = common.Shortcut{ "key": key, "env": env, "action": action, + "note": envVarReleaseNote, }, nil, nil) return nil }, @@ -177,6 +182,10 @@ var AppsEnvVarDelete = common.Shortcut{ if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil { return err } + if envVarEnv(rctx) == "online" { + return appsFailedPreconditionParamError("--environment", + "deleting online env vars via CLI is disabled; delete the key in dev with --environment dev and run `lark-cli apps +release-create --app-id ` to apply, or remove it from the developer console") + } _, err := requireEnvVarKeys(rctx.StrArray("key")) return err }, @@ -209,6 +218,7 @@ var AppsEnvVarDelete = common.Shortcut{ rctx.OutFormat(map[string]interface{}{ "env": env, "deleted_keys": deletedKeys, + "note": envVarReleaseNote, }, nil, nil) return nil }, diff --git a/shortcuts/apps/apps_env_test.go b/shortcuts/apps/apps_env_test.go index aba1e400f8..ab20ca6050 100644 --- a/shortcuts/apps/apps_env_test.go +++ b/shortcuts/apps/apps_env_test.go @@ -264,6 +264,9 @@ func TestAppsEnvVarSet_ExecutesWithYesAndDoesNotEchoValue(t *testing.T) { t.Fatalf("stdout missing %q: %s", want, got) } } + if !strings.Contains(got, envVarReleaseNote) { + t.Fatalf("stdout missing release note %q: %s", envVarReleaseNote, got) + } } func TestAppsEnvVarDelete_IsHighRiskWrite(t *testing.T) { @@ -282,7 +285,7 @@ func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) { reg.Register(stub) if err := runAppsShortcut(t, AppsEnvVarDelete, - []string{"+env-delete", "--app-id", "app_x", "--environment", "online", + []string{"+env-delete", "--app-id", "app_x", "--environment", "dev", "--key", "SECRET_ONE", "--key", "SECRET_TWO", "--yes", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -291,19 +294,22 @@ func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) { if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { t.Fatalf("decode body: %v", err) } - if sent["env"] != "online" { - t.Fatalf("body.env = %v, want online", sent["env"]) + if sent["env"] != "dev" { + t.Fatalf("body.env = %v, want dev", sent["env"]) } keys, ok := sent["keys"].([]interface{}) if !ok || len(keys) != 2 || keys[0] != "SECRET_ONE" || keys[1] != "SECRET_TWO" { t.Fatalf("body.keys = %#v, want SECRET_ONE/SECRET_TWO", sent["keys"]) } got := stdout.String() - for _, want := range []string{`"env": "online"`, `"deleted_keys"`, `"SECRET_ONE"`, `"SECRET_TWO"`} { + for _, want := range []string{`"env": "dev"`, `"deleted_keys"`, `"SECRET_ONE"`, `"SECRET_TWO"`} { if !strings.Contains(got, want) { t.Fatalf("stdout missing %q: %s", want, got) } } + if !strings.Contains(got, envVarReleaseNote) { + t.Fatalf("stdout missing release note %q: %s", envVarReleaseNote, got) + } } func TestAppsEnvVarDelete_NotModifiableHint(t *testing.T) { @@ -337,28 +343,29 @@ func TestAppsEnvVarDelete_NotModifiableHint(t *testing.T) { } } -func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) { - factory, stdout, _ := newAppsExecuteFactory(t) - if err := runAppsShortcut(t, AppsEnvVarDelete, - []string{"+env-delete", "--app-id", "app_x", "--environment", "online", - "--key", "SECRET_ONE", "--key", "SECRET_TWO", "--dry-run", "--as", "user"}, factory, stdout); err != nil { - t.Fatalf("dry-run err=%v", err) - } - - var dryRun dryRunAPIEnvelope - got := stdout.String() - if err := json.Unmarshal([]byte(got), &dryRun); err != nil { - t.Fatalf("decode dry-run: %v\n%s", err, got) - } - if len(dryRun.API) != 1 || dryRun.API[0].Method != "POST" || dryRun.API[0].URL != "/open-apis/spark/v1/apps/app_x/delete_env_vars" { - t.Fatalf("dry-run api = %#v", dryRun.API) - } - if dryRun.API[0].Body["env"] != "online" { - t.Fatalf("dry-run body.env = %v, want online", dryRun.API[0].Body["env"]) - } - keys, ok := dryRun.API[0].Body["keys"].([]interface{}) - if !ok || len(keys) != 2 || keys[0] != "SECRET_ONE" || keys[1] != "SECRET_TWO" { - t.Fatalf("dry-run body.keys = %#v, want SECRET_ONE/SECRET_TWO", dryRun.API[0].Body["keys"]) +func TestAppsEnvVarDelete_RejectsOnlineEnvironment(t *testing.T) { + for _, args := range [][]string{ + {"+env-delete", "--app-id", "app_x", "--environment", "online", + "--key", "SECRET_ONE", "--yes", "--as", "user"}, + {"+env-delete", "--app-id", "app_x", "--environment", "online", + "--key", "SECRET_ONE", "--dry-run", "--as", "user"}, + } { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarDelete, args, factory, stdout) + requireAppsProblem(t, err, errs.CategoryValidation) + var validation *errs.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if validation.Param != "--environment" { + t.Fatalf("validation param = %q, want --environment", validation.Param) + } + if validation.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("validation subtype = %q, want %q", validation.Subtype, errs.SubtypeFailedPrecondition) + } + if !strings.Contains(err.Error(), "disabled") { + t.Fatalf("error should explain online delete is disabled: %v", err) + } } } diff --git a/skills/lark-apps/references/lark-apps-env.md b/skills/lark-apps/references/lark-apps-env.md index 72da7d4381..81d92305a3 100644 --- a/skills/lark-apps/references/lark-apps-env.md +++ b/skills/lark-apps/references/lark-apps-env.md @@ -22,6 +22,8 @@ lark-cli apps +env-list --app-id --include-values --jq '.data.items[] | dev 环境设置不需要 `--yes`。设置 online 环境需要人类确认并显式传 `--yes`;如果用户在同一轮已经明确说“确认/直接执行”,视为已确认,直接带 `--yes`,不要再次追问。`--dry-run` 可用于预览请求且不需要 `--yes`。变量值支持直接传 ``,也支持 `@file` 或 stdin 输入。 +成功响应会带 `data.note` 提示变更需要走 `+release-create` 才会在线上生效,转述给用户即可。 + 回复中只说明 app/env/key 和执行结果;不要回显真实 value。需要举例时使用 ``、`@file` 或 stdin。 ```bash @@ -33,12 +35,13 @@ lark-cli apps +env-set --app-id --environment online --key FOO --value ## 删除 -`+env-delete` 是 high-risk-write。尊重 exit 10 confirmation protocol:先让用户确认 app/env/key 和删除后果,再传 `--yes`。不要自动补 `--yes`。如果只是认证失败后让用户重登,重登完成不等于删除确认;继续删除前仍需确认。 +`+env-delete` 是 high-risk-write,且 **仅支持 dev 环境**。CLI 禁止删除 online 环境变量——传 `--environment online` 会被 Validate 拒绝(failed_precondition);若用户要清理 online 的 key,让其先在 dev 中删除,再 `+release-create` 推上线,或到开发者后台删除。 + +尊重 exit 10 confirmation protocol:先让用户确认 app/env/key 和删除后果,再传 `--yes`。不要自动补 `--yes`。如果只是认证失败后让用户重登,重登完成不等于删除确认;继续删除前仍需确认。成功响应同样会带 `data.note`,提醒删除需要发布后在线上生效。 ```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 ``` ## 反模式