diff --git a/README.md b/README.md index f6191084..5ea47838 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t | ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances | | 🎯 OKR | Query, create, update OKRs; manage objective & key results, alignments, indicators and progress. | | 📋 Project | Meegle — manage work items, schedules, and data via the standalone [meegle-cli](https://github.com/larksuite/meegle-cli) (install separately) | -| 🔗 Apps | Develop, deploy HTML, web pages and applications | +| 🔗 Apps | Create Spark/Miaoda apps, publish HTML/static sites, run cloud generation, and manage access scope | ## Installation & Quick Start diff --git a/README.zh.md b/README.zh.md index d0df8d4e..f597ca68 100644 --- a/README.zh.md +++ b/README.zh.md @@ -41,7 +41,7 @@ | ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 | | 🎯 OKR | 查询、创建、更新 OKR,管理目标、关键结果、对齐、指标和进展记录 | | 📋 飞书项目 | 管理工作项、排期与数据 — 由独立的 [meegle-cli](https://github.com/larksuite/meegle-cli) 提供(需单独安装) | -| 🔗 应用 | 开发、部署 HTML、Web 页面和应用 | +| 🔗 应用 | 创建妙搭(Spark/Miaoda)应用、发布 HTML/静态站点、云端生成迭代、管理可用范围 | ## 安装与快速开始 diff --git a/shortcuts/apps/apps_access_scope_get.go b/shortcuts/apps/apps_access_scope_get.go index df1a8cfe..390aee5d 100644 --- a/shortcuts/apps/apps_access_scope_get.go +++ b/shortcuts/apps/apps_access_scope_get.go @@ -21,9 +21,12 @@ var AppsAccessScopeGet = common.Shortcut{ Command: "+access-scope-get", Description: "Get Miaoda app access scope configuration", Risk: "read", - Scopes: []string{"spark:app:read"}, - AuthTypes: []string{"user"}, - HasFormat: true, + Tips: []string{ + "Example: lark-cli apps +access-scope-get --app-id ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, }, @@ -42,9 +45,9 @@ var AppsAccessScopeGet = common.Shortcut{ Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID := strings.TrimSpace(rctx.Str("app-id")) path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID)) - data, err := rctx.CallAPI("GET", path, nil, nil) + data, err := rctx.CallAPITyped("GET", path, nil, nil) if err != nil { - return err + return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`") } // 原样透传 — 保留服务端字符串枚举 (All/Tenant/Range),不合并 users/departments/chats。 rctx.OutFormat(data, nil, func(w io.Writer) { diff --git a/shortcuts/apps/apps_access_scope_set.go b/shortcuts/apps/apps_access_scope_set.go index f9f474fa..ad1b5595 100644 --- a/shortcuts/apps/apps_access_scope_set.go +++ b/shortcuts/apps/apps_access_scope_set.go @@ -27,9 +27,14 @@ var AppsAccessScopeSet = common.Shortcut{ Command: "+access-scope-set", Description: "Set Miaoda app access scope (specific / public / tenant)", Risk: "write", - Scopes: []string{"spark:app:write"}, - AuthTypes: []string{"user"}, - HasFormat: true, + Tips: []string{ + `Example: lark-cli apps +access-scope-set --app-id --scope tenant`, + `Example: lark-cli apps +access-scope-set --app-id --scope public --require-login`, + `Example: lark-cli apps +access-scope-set --app-id --scope specific --targets '[{"type":"user","id":""}]'`, + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "scope", Desc: "scope: specific | public | tenant", Required: true, Enum: []string{"specific", "public", "tenant"}}, @@ -64,9 +69,9 @@ var AppsAccessScopeSet = common.Shortcut{ } appID := strings.TrimSpace(rctx.Str("app-id")) path := fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID)) - data, err := rctx.CallAPI("PUT", path, nil, body) + data, err := rctx.CallAPITyped("PUT", path, nil, body) if err != nil { - return err + return withAppsHint(err, "verify --app-id is correct; for scope=specific, each --targets id must be a valid open_id/department_id/chat_id and --approver a valid open_id; review the current scope with `lark-cli apps +access-scope-get --app-id `") } rctx.OutFormat(data, nil, func(w io.Writer) { fmt.Fprintf(w, "access-scope set: %s\n", rctx.Str("scope")) diff --git a/shortcuts/apps/apps_access_scope_set_test.go b/shortcuts/apps/apps_access_scope_set_test.go index f5eede00..a6da187b 100644 --- a/shortcuts/apps/apps_access_scope_set_test.go +++ b/shortcuts/apps/apps_access_scope_set_test.go @@ -8,9 +8,62 @@ import ( "strings" "testing" + "github.com/spf13/cobra" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" ) +func testRuntimeAccessScope(t *testing.T, scope, targets, approver string, applyEnabled, requireLogin bool) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "access-scope-set"} + cmd.Flags().String("scope", scope, "") + cmd.Flags().String("targets", targets, "") + cmd.Flags().String("approver", approver, "") + cmd.Flags().Bool("apply-enabled", applyEnabled, "") + cmd.Flags().Bool("require-login", requireLogin, "") + return common.TestNewRuntimeContext(cmd, nil) +} + +func TestBuildAccessScopeBody_Branches(t *testing.T) { + t.Run("invalid scope", func(t *testing.T) { + if _, err := buildAccessScopeBody(testRuntimeAccessScope(t, "bogus", "", "", false, false)); err == nil { + t.Error("unknown scope must error") + } + }) + t.Run("specific with all target kinds and approver", func(t *testing.T) { + body, err := buildAccessScopeBody(testRuntimeAccessScope(t, + "specific", + `[{"type":"user","id":"u1"},{"type":"department","id":"d1"},{"type":"chat","id":"c1"}]`, + "ou_appr", true, false)) + if err != nil { + t.Fatalf("err=%v", err) + } + if body["scope"] != "Range" { + t.Errorf("scope=%v want Range", body["scope"]) + } + for _, k := range []string{"users", "departments", "chats", "apply_config"} { + if _, ok := body[k]; !ok { + t.Errorf("missing %q in body=%v", k, body) + } + } + }) + t.Run("specific with invalid targets JSON", func(t *testing.T) { + if _, err := buildAccessScopeBody(testRuntimeAccessScope(t, "specific", "{bad", "", false, false)); err == nil { + t.Error("invalid targets JSON must error") + } + }) + t.Run("public sets require_login", func(t *testing.T) { + body, err := buildAccessScopeBody(testRuntimeAccessScope(t, "public", "", "", false, true)) + if err != nil { + t.Fatalf("err=%v", err) + } + if body["scope"] != "All" || body["require_login"] != true { + t.Errorf("public body=%v", body) + } + }) +} + func TestAppsAccessScopeSet_Specific(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) stub := &httpmock.Stub{ @@ -201,3 +254,44 @@ func TestAppsAccessScopeSet_TrimsAppIDInPath(t *testing.T) { t.Fatalf("execute err=%v", err) } } + +func TestSplitAccessScopeTargets_Partitions(t *testing.T) { + users, departments, chats := splitAccessScopeTargets([]map[string]interface{}{ + {"type": "user", "id": "u1"}, + {"type": "department", "id": "d1"}, + {"type": "chat", "id": "c1"}, + {"type": "user", "id": " "}, // empty id skipped + {"type": "unknown", "id": "x"}, // unknown type skipped + }) + if len(users) != 1 || users[0] != "u1" { + t.Errorf("users=%v want [u1]", users) + } + if len(departments) != 1 || departments[0] != "d1" { + t.Errorf("departments=%v want [d1]", departments) + } + if len(chats) != 1 || chats[0] != "c1" { + t.Errorf("chats=%v want [c1]", chats) + } +} + +func TestValidateTargetsJSON_Cases(t *testing.T) { + cases := []struct { + name string + in string + wantErr bool + }{ + {"invalid json", "{not json", true}, + {"empty array", "[]", true}, + {"bad type", `[{"type":"role","id":"r1"}]`, true}, + {"empty id", `[{"type":"user","id":" "}]`, true}, + {"valid", `[{"type":"user","id":"u1"}]`, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + err := validateTargetsJSON(c.in) + if (err != nil) != c.wantErr { + t.Errorf("validateTargetsJSON(%q) err=%v wantErr=%v", c.in, err, c.wantErr) + } + }) + } +} diff --git a/shortcuts/apps/apps_callapi_typed_test.go b/shortcuts/apps/apps_callapi_typed_test.go new file mode 100644 index 00000000..90ae7001 --- /dev/null +++ b/shortcuts/apps/apps_callapi_typed_test.go @@ -0,0 +1,71 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +// TestAppsList_503IsRetryableTypedError pins the typed-error upgrade: a 5xx +// response from the apps list endpoint must surface as a typed errs.Problem with +// Retryable == true (via CallAPITyped → httpStatusError). The pre-migration +// CallAPI path produced a legacy *output.ExitError with no Retryable field, so +// this test fails until AppsList is migrated to CallAPITyped. +func TestAppsList_503IsRetryableTypedError(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps", + Status: 503, + // A gateway-style non-JSON body (text/html) forces the status-based + // classifier (httpStatusError) rather than the API-envelope path. + Headers: http.Header{"Content-Type": []string{"text/html"}}, + RawBody: []byte("503 Service Unavailable"), + }) + + err := runAppsShortcut(t, AppsList, + []string{"+list", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected an error on 503, got nil; stdout:\n%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.Problem on 503, got %T: %v", err, err) + } + if !p.Retryable { + t.Fatalf("expected Retryable == true on 503, got Problem=%+v", p) + } +} + +// TestAppsList_SuccessShapeUnchanged pins that the success path is +// output-shape-neutral after migration: a 200 envelope still yields a success +// stdout envelope carrying the app_id. +func TestAppsList_SuccessShapeUnchanged(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{"app_id": "a", "name": "n"}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsList, + []string{"+list", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"app_id": "a"`) { + t.Fatalf("stdout missing app_id: %s", got) + } +} diff --git a/shortcuts/apps/apps_chat.go b/shortcuts/apps/apps_chat.go new file mode 100644 index 00000000..a2932b36 --- /dev/null +++ b/shortcuts/apps/apps_chat.go @@ -0,0 +1,83 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsChat sends a user message to a session, starting/continuing a conversation. +// Async: the message is queued and the response carries no business payload (no +// turn_id, no next_poll_after_ms — the turn is not generated yet). Poll +// +session-get; it returns next_poll_after_ms, and once the turn runs its handle +// is in latest_turn.turn_id. + +// Turn cost varies sharply by init state: the first +chat on a not-initialized +// app runs a one-time design + first-generation pass server-side (~20-50 min); +// chat on an already-initialized app is incremental and finishes in minutes. +// The init-state check and matching polling cadence live in the lark-apps +// skill reference (references/lark-apps-cloud-dev.md) — the canonical source. +var AppsChat = common.Shortcut{ + Service: appsService, + Command: "+chat", + Description: "Send a message to a session to start/continue a conversation", + Risk: "write", + Tips: []string{ + `Example: lark-cli apps +chat --app-id --session-id --message "做一个待办清单页面"`, + `Example: lark-cli apps +chat --app-id --session-id --message "把首页标题改为 我的待办"`, + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "session-id", Desc: "session ID", Required: true}, + {Name: "message", Desc: "user message text", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + if strings.TrimSpace(rctx.Str("session-id")) == "" { + return output.ErrValidation("--session-id is required") + } + // Do not echo --message content in the error (spec §4 redaction). + if strings.TrimSpace(rctx.Str("message")) == "" { + return output.ErrValidation("--message is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST(chatPath(rctx.Str("app-id"), rctx.Str("session-id"))). + Desc("Send a message to a session"). + Body(buildChatBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("POST", chatPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, buildChatBody(rctx)) + if err != nil { + return withAppsHint(err, "if the session_id is unknown or invalid, list this app's sessions with `lark-cli apps +session-list --app-id "+strings.TrimSpace(rctx.Str("app-id"))+"`") + } + rctx.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "message sent; poll +session-get for turn status\n") + }) + return nil + }, +} + +func chatPath(appID, sessionID string) string { + return sessionPath(appID, sessionID) + "/chat" +} + +func buildChatBody(rctx *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "message": strings.TrimSpace(rctx.Str("message")), + } +} diff --git a/shortcuts/apps/apps_chat_test.go b/shortcuts/apps/apps_chat_test.go new file mode 100644 index 00000000..10a91567 --- /dev/null +++ b/shortcuts/apps/apps_chat_test.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsChat_Success(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat", + Body: map[string]interface{}{ + "code": 0, + // +chat is async and returns NO business payload (no turn_id, no + // next_poll_after_ms — the turn is not generated yet). turn_id and the + // poll interval are read later from +session-get. + "data": map[string]interface{}{}, + }, + } + reg.Register(stub) + if err := runAppsShortcut(t, AppsChat, + []string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "把首页表头改成蓝色", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["message"] != "把首页表头改成蓝色" { + t.Fatalf("body.message = %v", sent["message"]) + } + if _, present := sent["attachment_ids"]; present { + t.Fatalf("attachment_ids must not be sent this iteration: %v", sent) + } + // +chat carries no next_poll_after_ms; the CLI must not fabricate one. + if got := stdout.String(); strings.Contains(got, "next_poll_after_ms") { + t.Fatalf("stdout must not reference next_poll_after_ms (chat returns none): %s", got) + } +} + +func TestAppsChat_Pretty(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + if err := runAppsShortcut(t, AppsChat, + []string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "hi", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "message sent") || !strings.Contains(got, "+session-get") { + t.Fatalf("pretty wrong: %q", got) + } +} + +func TestAppsChat_RequiresMessage(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsChat, + []string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "message") { + t.Fatalf("expected --message required error, got %v", err) + } +} + +// Security: a non-blank message that fails for another reason must never be echoed. +// Here we assert the blank-message error names the field only (no content leak path). +func TestAppsChat_ValidationDoesNotEchoMessage(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // blank message triggers validation; the error must mention the flag, not any content. + err := runAppsShortcut(t, AppsChat, + []string{"+chat", "--app-id", "", "--session-id", "conv_x", "--message", "secret-content-xyz", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected validation error") + } + if strings.Contains(err.Error(), "secret-content-xyz") { + t.Fatalf("validation error must not echo --message content: %v", err) + } +} + +func TestAppsChat_DryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsChat, + []string{"+chat", "--app-id", "app_x", "--session-id", "conv_x", "--message", "hi", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x/chat") { + t.Fatalf("dry-run missing endpoint: %s", got) + } + if !strings.Contains(got, `"message": "hi"`) { + t.Fatalf("dry-run missing message body: %s", got) + } +} diff --git a/shortcuts/apps/apps_create.go b/shortcuts/apps/apps_create.go index 215b4ccd..c96f877c 100644 --- a/shortcuts/apps/apps_create.go +++ b/shortcuts/apps/apps_create.go @@ -13,18 +13,24 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +const createHint = "verify --app-type is html or full_stack and --name is non-empty; if this is a permission error, confirm your account can create Miaoda apps" + // AppsCreate creates a new Miaoda app. var AppsCreate = common.Shortcut{ Service: appsService, Command: "+create", Description: "Create a new Miaoda app", Risk: "write", - Scopes: []string{"spark:app:write"}, - AuthTypes: []string{"user"}, - HasFormat: true, + Tips: []string{ + `Example: lark-cli apps +create --name "审批系统" --app-type full_stack`, + `Example: lark-cli apps +create --name "活动页" --app-type html --description "活动报名"`, + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, Flags: []common.Flag{ {Name: "name", Desc: "app display name", Required: true}, - {Name: "app-type", Desc: "app type (currently only: HTML)", Required: true}, + {Name: "app-type", Desc: "app type", Required: true, Enum: []string{"html", "full_stack"}}, {Name: "description", Desc: "app description"}, {Name: "icon-url", Desc: "app icon URL (server uses default if omitted)"}, }, @@ -32,13 +38,6 @@ var AppsCreate = common.Shortcut{ if strings.TrimSpace(rctx.Str("name")) == "" { return output.ErrValidation("--name is required") } - appType := strings.TrimSpace(rctx.Str("app-type")) - if appType == "" { - return output.ErrValidation("--app-type is required") - } - if !validAppTypes[appType] { - return output.ErrValidation(fmt.Sprintf("--app-type %q is not supported (allowed: HTML)", appType)) - } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { @@ -48,9 +47,9 @@ var AppsCreate = common.Shortcut{ Body(buildAppsCreateBody(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - data, err := rctx.CallAPI("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx)) + data, err := rctx.CallAPITyped("POST", apiBasePath+"/apps", nil, buildAppsCreateBody(rctx)) if err != nil { - return err + return withAppsHint(err, createHint) } rctx.OutFormat(data, nil, func(w io.Writer) { fmt.Fprintf(w, "created: %s\n", common.GetString(data, "app", "app_id")) @@ -59,15 +58,13 @@ var AppsCreate = common.Shortcut{ }, } -// 应用类型枚举。当前只有 HTML,未来会扩展(SPA、NATIVE、...)。 -var validAppTypes = map[string]bool{ - "HTML": true, -} - func buildAppsCreateBody(rctx *common.RuntimeContext) map[string]interface{} { + // --app-type is constrained to the lowercase enum (html / full_stack) by the + // flag's Enum, so send it through verbatim. Legacy uppercase compatibility is + // a server concern and is intentionally not surfaced by the CLI. body := map[string]interface{}{ "name": strings.TrimSpace(rctx.Str("name")), - "app_type": strings.TrimSpace(rctx.Str("app-type")), + "app_type": rctx.Str("app-type"), } if desc := strings.TrimSpace(rctx.Str("description")); desc != "" { body["description"] = desc diff --git a/shortcuts/apps/apps_create_test.go b/shortcuts/apps/apps_create_test.go index 2cee3e58..249c507d 100644 --- a/shortcuts/apps/apps_create_test.go +++ b/shortcuts/apps/apps_create_test.go @@ -22,6 +22,7 @@ import ( func newAppsExecuteFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *httpmock.Registry) { t.Helper() + t.Setenv("HOME", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) cfg := &core.CliConfig{ AppID: "test-app-" + strings.ToLower(t.Name()), @@ -68,7 +69,7 @@ func TestAppsCreate_Success(t *testing.T) { reg.Register(stub) if err := runAppsShortcut(t, AppsCreate, - []string{"+create", "--name", "Demo", "--app-type", "HTML", "--description", "d", "--as", "user"}, + []string{"+create", "--name", "Demo", "--app-type", "html", "--description", "d", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -83,8 +84,8 @@ func TestAppsCreate_Success(t *testing.T) { if sent["name"] != "Demo" { t.Fatalf("body.name = %v", sent["name"]) } - if sent["app_type"] != "HTML" { - t.Fatalf("body.app_type = %v (want HTML)", sent["app_type"]) + if sent["app_type"] != "html" { + t.Fatalf("body.app_type = %v (want html)", sent["app_type"]) } if sent["description"] != "d" { t.Fatalf("body.description = %v", sent["description"]) @@ -108,7 +109,7 @@ func TestAppsCreate_WithIconURL(t *testing.T) { }) if err := runAppsShortcut(t, AppsCreate, - []string{"+create", "--name", "Demo", "--app-type", "HTML", "--icon-url", "https://example.com/icon.svg", "--as", "user"}, + []string{"+create", "--name", "Demo", "--app-type", "html", "--icon-url", "https://example.com/icon.svg", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -133,7 +134,7 @@ func TestAppsCreate_PrettyOutputReadsNestedAppID(t *testing.T) { }) if err := runAppsShortcut(t, AppsCreate, - []string{"+create", "--name", "Demo", "--app-type", "HTML", "--format", "pretty", "--as", "user"}, + []string{"+create", "--name", "Demo", "--app-type", "html", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -144,7 +145,7 @@ func TestAppsCreate_PrettyOutputReadsNestedAppID(t *testing.T) { func TestAppsCreate_RequiresName(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "HTML", "--as", "user"}, factory, stdout) + err := runAppsShortcut(t, AppsCreate, []string{"+create", "--app-type", "html", "--as", "user"}, factory, stdout) if err == nil || !strings.Contains(err.Error(), "name") { t.Fatalf("expected name required error, got %v", err) } @@ -159,20 +160,31 @@ func TestAppsCreate_RequiresAppType(t *testing.T) { } } +// TestAppsCreate_RejectsInvalidAppType pins that --app-type is a strict +// lowercase enum (html / full_stack). Unknown values and legacy uppercase are +// both rejected by the flag's Enum — the CLI does not normalize case; legacy +// uppercase compatibility is a server-side concern, not surfaced by the client. func TestAppsCreate_RejectsInvalidAppType(t *testing.T) { - factory, stdout, _ := newAppsExecuteFactory(t) - err := runAppsShortcut(t, AppsCreate, - []string{"+create", "--name", "Demo", "--app-type", "spa", "--as", "user"}, - factory, stdout) - if err == nil || !strings.Contains(err.Error(), "not supported") { - t.Fatalf("expected unsupported app-type error, got %v", err) + for _, appType := range []string{"spa", "HTML", "Full_Stack"} { + t.Run(appType, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", appType, "--as", "user"}, + factory, stdout) + if err == nil || !strings.Contains(err.Error(), "invalid value") { + t.Fatalf("expected invalid-enum error for %q, got %v", appType, err) + } + if !strings.Contains(err.Error(), "full_stack") { + t.Fatalf("expected enum error to list allowed values, got %v", err) + } + }) } } func TestAppsCreate_DryRun(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsCreate, - []string{"+create", "--name", "Demo", "--app-type", "HTML", "--dry-run", "--as", "user"}, + []string{"+create", "--name", "Demo", "--app-type", "html", "--dry-run", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } @@ -183,7 +195,55 @@ func TestAppsCreate_DryRun(t *testing.T) { if !strings.Contains(got, `"name": "Demo"`) { t.Fatalf("dry-run missing body: %s", got) } - if !strings.Contains(got, `"app_type": "HTML"`) { + if !strings.Contains(got, `"app_type": "html"`) { t.Fatalf("dry-run missing app_type: %s", got) } } + +func TestAppsCreate_FullstackSuccess(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "app": map[string]interface{}{"app_id": "app_fs", "name": "Demo"}, + }, + }, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", "full_stack", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["app_type"] != "full_stack" { + t.Fatalf("body.app_type = %v (want full_stack)", sent["app_type"]) + } + if _, present := sent["message"]; present { + t.Fatalf("message should never be sent: %v", sent) + } +} + +func TestAppsCreate_FullstackDryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsCreate, + []string{"+create", "--name", "Demo", "--app-type", "full_stack", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"app_type": "full_stack"`) { + t.Fatalf("dry-run missing app_type full_stack: %s", got) + } + if strings.Contains(got, `"message"`) { + t.Fatalf("dry-run should not contain message: %s", got) + } +} diff --git a/shortcuts/apps/apps_db_env_create.go b/shortcuts/apps/apps_db_env_create.go new file mode 100644 index 00000000..347a4567 --- /dev/null +++ b/shortcuts/apps/apps_db_env_create.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +const dbEnvCreateHint = "verify --app-id is correct; if the app is already multi-env this is a conflict — inspect current tables with `lark-cli apps +db-table-list --app-id --env dev`" + +// AppsDBEnvCreate creates a DB environment for a Miaoda app(拆分单库为 dev/online 多环境)。 +// +// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。 +// 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。 +var AppsDBEnvCreate = common.Shortcut{ + Service: appsService, + Command: "+db-env-create", + Description: "Create a DB environment (split single-env DB into dev/online, irreversible)", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +db-env-create --env dev --sync-data --app-id --yes", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "env", Default: "dev", Enum: []string{"dev"}, Desc: "environment to create (only dev supported for now)"}, + {Name: "sync-data", Type: "bool", Desc: "copy existing online data into the new environment (default off)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + _, err := requireAppID(rctx.Str("app-id")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appDbEnvCreatePath(appID)). + Desc("Create Miaoda app DB environment"). + Body(buildDBEnvCreateBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", appDbEnvCreatePath(appID), nil, buildDBEnvCreateBody(rctx)) + if err != nil { + return withAppsHint(err, dbEnvCreateHint) + } + rctx.OutFormat(data, nil, func(w io.Writer) { + renderEnvCreatePretty(w, data) + }) + return nil + }, +} + +// buildDBEnvCreateBody 构造 db 环境创建 body:sync_data(bool)。 +// --env 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。 +func buildDBEnvCreateBody(rctx *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "sync_data": rctx.Bool("sync-data"), + } +} + +// renderEnvCreatePretty 输出 4 行(pretty 模式): +// +// ✓ Multi-env initialized +// Environments: dev, online +// Data synced: yes +// Note: structure changes in dev now need to be released to online. +func renderEnvCreatePretty(w io.Writer, data map[string]interface{}) { + fmt.Fprintln(w, "✓ Multi-env initialized") + + if envs, ok := data["environments"].([]interface{}); ok && len(envs) > 0 { + names := make([]string, 0, len(envs)) + for _, e := range envs { + if s, ok := e.(string); ok { + names = append(names, s) + } + } + fmt.Fprintf(w, "Environments: %s\n", strings.Join(names, ", ")) + } + + synced := "no" + if ds, ok := data["data_synced"].(bool); ok && ds { + synced = "yes" + } + fmt.Fprintf(w, "Data synced: %s\n", synced) + + fmt.Fprintln(w, "Note: structure changes in dev now need to be released to online.") +} diff --git a/shortcuts/apps/apps_db_env_create_test.go b/shortcuts/apps/apps_db_env_create_test.go new file mode 100644 index 00000000..0b29bd45 --- /dev/null +++ b/shortcuts/apps/apps_db_env_create_test.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsDBEnvCreate_WithYesPostsSyncData(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/db_dev_init", // URL 仍走 db_dev_init,CLI 命令名 +db-env-create + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "status": "initialized", + "environments": []interface{}{"dev", "online"}, + "data_synced": true, + }, + }, + } + reg.Register(stub) + if err := runAppsShortcut(t, AppsDBEnvCreate, + []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["sync_data"] != true { + t.Fatalf("body.sync_data = %v (want true)", sent["sync_data"]) + } + if !strings.Contains(stdout.String(), "initialized") { + t.Fatalf("stdout should include status, got %s", stdout.String()) + } +} + +// 不传 --sync-data(默认)→ body.sync_data=false +func TestAppsDBEnvCreate_SyncDataFalseByDefault(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/db_dev_init", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "initialized"}}, + } + reg.Register(stub) + if err := runAppsShortcut(t, AppsDBEnvCreate, + []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["sync_data"] != false { + t.Fatalf("body.sync_data = %v (want false by default)", sent["sync_data"]) + } +} + +func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/db_dev_init", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "status": "initialized", + "environments": []interface{}{"dev", "online"}, + "data_synced": true, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBEnvCreate, + []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + wantLines := []string{ + "✓ Multi-env initialized", + "Environments: dev, online", + "Data synced: yes", + "Note: structure changes in dev now need to be released to online.", + } + for _, line := range wantLines { + if !strings.Contains(got, line) { + t.Errorf("pretty output missing line %q\ngot:\n%s", line, got) + } + } +} + +func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBEnvCreate, + []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/db_dev_init") { + t.Fatalf("dry-run missing endpoint: %s", got) + } +} + +// --env 只接受 dev:传 online 应被 enum 校验拒绝。 +func TestAppsDBEnvCreate_RejectsNonDevEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBEnvCreate, + []string{"+db-env-create", "--app-id", "app_x", "--env", "online", "--yes", "--as", "user"}, + factory, stdout) + if err == nil || !strings.Contains(err.Error(), "env") { + t.Fatalf("expected env enum rejection, got %v", err) + } +} diff --git a/shortcuts/apps/apps_db_execute.go b/shortcuts/apps/apps_db_execute.go new file mode 100644 index 00000000..9daaf4d2 --- /dev/null +++ b/shortcuts/apps/apps_db_execute.go @@ -0,0 +1,520 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "sort" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsDBExecute executes SQL against a Miaoda app database. +// +// POST /apps/{app_id}/sql_commands,CLI 永远带 ?transactional=false 进入 DBA 模式 +// (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON)。 +// +// pretty 渲染 6 种形态: +// - 单 SELECT:表格(列间两空格、列对齐填充) +// - 空 SELECT:`(0 rows)` +// - 单 DML:`✓ N row(s) `(verb 跟 sql_type:INSERT→inserted/UPDATE→updated/DELETE→deleted) +// - 单 DDL:`✓ DDL executed` +// - 多语句全部成功:逐条 `Statement K: ✓ ` + 末尾 `✓ N statements executed` +// - 多语句部分失败:`Statement K: ✗ []` + 末尾「前序语句已落地」提示 +// +// 失败语义:server 多语句失败仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。Execute 检测到哨兵 +// 后升级成 typed api_error(exit 非 0、detail 带 statement_index / completed / rolled_back), +// 避免 agent 误判 ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit +// 落地,故 rolled_back=false(真机 boe 实证)。 +// +// JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。 +// +// Risk: high-risk-write —— SQL 可含 DML/DDL,框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。 +// +// SQL 来源二选一:--sql(内联文本,或 - 读 stdin)/ --file(.sql 文件路径,受 CLI 相对路径约束)。 +// --file 在 Validate 阶段读出内容、归一化到 --sql,下游统一从 rctx.Str("sql") 取。 +var AppsDBExecute = common.Shortcut{ + Service: appsService, + Command: "+db-execute", + Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database", + Risk: "high-risk-write", + Tips: []string{ + `Example: lark-cli apps +db-execute --app-id --sql "SELECT * FROM orders LIMIT 10" --yes`, + `Example: lark-cli apps +db-execute --app-id --env dev --file ./migration.sql --yes`, + "Tip: filter fields with --jq, e.g. -q '.data.results[].sql_type'", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file", + Input: []string{common.Stdin}}, + {Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"}, + {Name: "env", Default: "dev", Enum: []string{"dev", "online"}, Desc: "target db environment (default dev; use --env online for the online environment)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + sql := strings.TrimSpace(rctx.Str("sql")) + file := strings.TrimSpace(rctx.Str("file")) + if sql != "" && file != "" { + return output.ErrValidation("--sql and --file are mutually exclusive") + } + if file != "" { + data, err := cmdutil.ReadInputFile(rctx.FileIO(), file) + if err != nil { + return output.ErrValidation("--file: %v", err) + } + // 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。 + rctx.Cmd.Flags().Set("sql", string(data)) + sql = strings.TrimSpace(string(data)) + } + if sql == "" { + return output.ErrValidation("one of --sql or --file is required (use --sql - to read stdin)") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appSQLPath(appID)). + Desc("Execute SQL on Miaoda app database"). + Params(buildDBSQLParams(rctx)). + Body(buildDBSQLBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + raw, err := rctx.CallAPITyped("POST", appSQLPath(appID), + buildDBSQLParams(rctx), + buildDBSQLBody(rctx)) + if err != nil { + return withAppsHint(err, "verify table/column names with `lark-cli apps +db-table-get --app-id "+appID+" --table `; for day-to-day debugging target the dev database with `--env dev`") + } + + // server `result: string` 内嵌结构化数组 —— CLI 解出来放进 envelope 的 data.results, + // 让 json/pretty 路径都基于同一份反序列化产物渲染。 + stmts := parseSQLResult(common.GetString(raw, "result")) + // 注意:data.results 在 json(默认)路径下原样透出全部行,CLI 侧不再二次截断。 + // 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出会直接 + // 返报错(而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。 + data := map[string]interface{}{"results": stmts} + + // 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。 + // 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。 + // pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→stderr)。 + if errIdx, errStmt, failed := findErrorSentinel(stmts); failed { + if rctx.Format == "pretty" { + renderSQLPretty(rctx.IO().Out, stmts) + } + return sqlStatementError(stmts, errIdx, errStmt) + } + + rctx.OutFormat(data, nil, func(w io.Writer) { + renderSQLPretty(w, stmts) + }) + return nil + }, +} + +// findErrorSentinel 在 statements 里找 ERROR 哨兵(server 失败时追加在失败语句位置)。 +// 返回失败语句下标(0-based)、该 ERROR statement、是否命中。 +func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interface{}, bool) { + for i, s := range stmts { + if common.GetString(s, "sql_type") == "ERROR" { + return i, s, true + } + } + return 0, nil, false +} + +// sqlStatementError 把 ERROR 哨兵升级成 typed api_error。 +// +// CLI 永远 DBA 模式(transactional=false),真机 boe 实证:失败语句之前的语句已逐条 auto-commit +// 落地,不存在外层事务回滚。因此 rolled_back=false、completed 列出已落地的前序语句,hint 提示用户 +// 别整批重跑(否则会重复写入)。 +func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error { + code, msg := parseErrorSentinel(common.GetString(errStmt, "data")) + stmtNo := errIdx + 1 // 1-based 给人看 + fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)) + + apiErr := output.ErrAPI(code, fullMsg, map[string]interface{}{ + "statement_index": errIdx, + "completed": stmts[:errIdx], + "rolled_back": false, + }) + if apiErr.Detail != nil { + if errIdx > 0 { + apiErr.Detail.Hint = fmt.Sprintf( + "statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.", + errIdx, stmtNo) + } else { + apiErr.Detail.Hint = "no statements were applied; fix the SQL and re-run." + } + } + return apiErr +} + +// parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。 +// code 兼容 int / "k_dl_1300002" / 数字字符串多形态(复用 codeString),解析失败回退 0 / 原文。 +func parseErrorSentinel(data string) (int, string) { + if data == "" { + return 0, "(unknown error)" + } + var e struct { + Code interface{} `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(data), &e); err != nil { + return 0, data + } + code := 0 + if cs := codeString(e.Code); cs != "" { + if n, convErr := strconv.Atoi(cs); convErr == nil { + code = n + } + } + if e.Message == "" { + return code, "(unknown error)" + } + return code, e.Message +} + +// buildDBSQLParams 构造 sql 接口的 query:env + 强制 transactional=false(DBA 模式)。 +// +// CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。 +func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "env": rctx.Str("env"), + "transactional": false, + } +} + +// buildDBSQLBody 构造 sql 接口的 body:仅 sql(来源由 Validate 归一化到 --sql)。 +func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "sql": rctx.Str("sql"), + } +} + +// parseSQLResult 从 server result 字符串反序列化出 statements 数组,兼容两种 wire 形态: +// +// 1. 结构化形态:`[{"sql_type":"SELECT","data":"[...]","record_count":N}, ...]` +// —— 每条 statement 含 sql_type / data / record_count / affected_rows 元数据。 +// +// 2. 字符串数组形态:`["[{...rows...}]", "", ...]` +// —— 每条 statement 一个字符串:SELECT 是 rows JSON、DML/DDL 是空串; +// 无 sql_type 元数据,CLI 端按内容形态推断(SELECT vs OK)。 +// +// 解析失败时返回单元素 fallback `{sql_type:"RAW", data:resultStr}`,pretty 路径原样打。 +func parseSQLResult(resultStr string) []map[string]interface{} { + if resultStr == "" { + return nil + } + + // 形态 1:结构化数组(每元素是 object) + var structured []map[string]interface{} + if err := json.Unmarshal([]byte(resultStr), &structured); err == nil && isStructuredResult(structured) { + return structured + } + + // 形态 2:字符串数组(每元素是 rows JSON 或 "") + var legacy []string + if err := json.Unmarshal([]byte(resultStr), &legacy); err == nil { + out := make([]map[string]interface{}, 0, len(legacy)) + for _, rowsJSON := range legacy { + out = append(out, normalizeLegacyStatement(rowsJSON)) + } + return out + } + + return []map[string]interface{}{{"sql_type": "RAW", "data": resultStr}} +} + +// isStructuredResult 判断反序列化出来的 []map 是不是新形态:第一条元素含 sql_type 字段。 +// 兼容场景:[]map 反序列化 legacy `[""]` 可能也能成(空 map),用 sql_type 存在性区分。 +func isStructuredResult(stmts []map[string]interface{}) bool { + if len(stmts) == 0 { + return false + } + _, ok := stmts[0]["sql_type"] + return ok +} + +// normalizeLegacyStatement 把 legacy wire 一个字符串元素转成跟新形态一致的 map。 +// 推断规则:data 是非空 rows 数组 → sql_type=SELECT;空串 / 空数组 → sql_type=OK(DML/DDL 老 wire 不可分)。 +func normalizeLegacyStatement(rowsJSON string) map[string]interface{} { + stmt := map[string]interface{}{ + "sql_type": "OK", + "data": rowsJSON, + } + trimmed := strings.TrimSpace(rowsJSON) + if trimmed == "" || trimmed == "null" { + return stmt + } + var rows []interface{} + if err := json.Unmarshal([]byte(trimmed), &rows); err != nil { + // 非 JSON 数组(理论上 server 不会返这种),按原样保留 sql_type=OK + return stmt + } + // 是 JSON 数组 → 视作 SELECT,含 record_count + stmt["sql_type"] = "SELECT" + stmt["record_count"] = float64(len(rows)) + return stmt +} + +// renderSQLPretty 按 statements 数量分单条 / 多条两种渲染路径。 +func renderSQLPretty(w io.Writer, stmts []map[string]interface{}) { + if len(stmts) == 0 { + fmt.Fprintln(w, "(empty result)") + return + } + if len(stmts) == 1 { + renderSingleStatementPretty(w, stmts[0]) + return + } + renderMultiStatementPretty(w, stmts) +} + +// renderSingleStatementPretty 单条 statement pretty(无 Statement header)。 +func renderSingleStatementPretty(w io.Writer, s map[string]interface{}) { + sqlType := common.GetString(s, "sql_type") + switch { + case sqlType == "SELECT": + renderSelectRowsAsTable(w, common.GetString(s, "data")) + case sqlType == "ERROR": + // 单条就挂的极端场景:直接打 ERROR 行(跟多语句失败的最后一行格式一致)。 + fmt.Fprintln(w, "✗ "+errorSummary(common.GetString(s, "data"))) + case isDMLType(sqlType): + // 结构化 wire 下 INSERT / UPDATE / DELETE / MERGE:✓ N row(s) + fmt.Fprintln(w, "✓ "+dmlSummary(sqlType, s["affected_rows"])) + case sqlType == "OK": + // legacy wire 下 DML / DDL 都映射成 OK(老 wire 不带 sql_type 元数据,无法区分动词 / 行数) + fmt.Fprintln(w, "✓ ok") + default: + // 其余皆 DDL:真机 boe 返细粒度动词 CREATE_TABLE / DROP_TABLE / ALTER_TABLE / TRUNCATE 等。 + fmt.Fprintln(w, "✓ DDL executed") + } +} + +// renderMultiStatementPretty 多条 statement pretty: +// - 每条用 "Statement K: ✓ " / "Statement K: ✗ []" +// - SELECT 用 "Statement K: SELECT (N row(s))" 头 + 紧跟表格 +// - 末尾汇总:全部成功 "✓ N statements executed";遇 ERROR 哨兵打「前序语句已落地」提示 +// (DBA 模式不回滚),失败本身由 Execute 升级成 typed error(exit 非 0) +func renderMultiStatementPretty(w io.Writer, stmts []map[string]interface{}) { + failedIdx := -1 + successCount := 0 + for i, s := range stmts { + sqlType := common.GetString(s, "sql_type") + idx := i + 1 + switch { + case sqlType == "ERROR": + fmt.Fprintf(w, "Statement %d: ✗ %s\n", idx, errorSummary(common.GetString(s, "data"))) + failedIdx = i + case sqlType == "SELECT": + rc := intOrZero(s["record_count"]) + fmt.Fprintf(w, "Statement %d: SELECT (%d row%s)\n", idx, rc, plural(rc)) + renderSelectRowsAsTable(w, common.GetString(s, "data")) + successCount++ + case isDMLType(sqlType): + fmt.Fprintf(w, "Statement %d: ✓ %s\n", idx, dmlSummary(sqlType, s["affected_rows"])) + successCount++ + case sqlType == "OK": + fmt.Fprintf(w, "Statement %d: ✓ ok\n", idx) + successCount++ + default: + // DDL 族:CREATE_TABLE / DROP_TABLE / ALTER_TABLE / TRUNCATE / CREATE_INDEX ... + fmt.Fprintf(w, "Statement %d: ✓ DDL executed\n", idx) + successCount++ + } + if i < len(stmts)-1 { + fmt.Fprintln(w) // statements 间留空行 + } + } + fmt.Fprintln(w) + if failedIdx >= 0 { + // CLI 永远 DBA 模式(transactional=false),失败语句之前的语句已 auto-commit 落地, + // 不存在整批回滚 —— 如实告诉用户,避免整批重跑导致重复写入。 + if successCount > 0 { + fmt.Fprintf(w, "(statement %d failed; %d statement%s before it already applied — DBA mode auto-commits each)\n", + failedIdx+1, successCount, plural(int64(successCount))) + } else { + fmt.Fprintf(w, "(statement %d failed; no statements applied)\n", failedIdx+1) + } + } else { + fmt.Fprintf(w, "✓ %d statements executed\n", successCount) + } +} + +// renderSelectRowsAsTable 把 SELECT 的 data(rows JSON 数组字符串)解析并渲染成对齐表格。 +// 空结果输出 "(0 rows)"。 +func renderSelectRowsAsTable(w io.Writer, dataJSON string) { + if dataJSON == "" || dataJSON == "[]" { + fmt.Fprintln(w, "(0 rows)") + return + } + var rows []map[string]interface{} + if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil { + // 数据不符合预期 schema —— 原样打 fallback。 + fmt.Fprintln(w, dataJSON) + return + } + if len(rows) == 0 { + fmt.Fprintln(w, "(0 rows)") + return + } + headers := collectColumns(rows) + cells := make([][]string, 0, len(rows)) + for _, row := range rows { + line := make([]string, 0, len(headers)) + for _, h := range headers { + line = append(line, cellString(row[h])) + } + cells = append(cells, line) + } + renderAlignedTable(w, headers, cells) +} + +// collectColumns 按首行字段顺序收集列名;首行 key 顺序由 encoding/json 反序列化决定(map 无序), +// 排序后保证输出稳定。列顺序在示例里跟 SQL SELECT 顺序一致——但 Go encoding/json 反序列化丢列序, +// 这里按字典序保证可重现,agent / 测试可稳定 assert。 +func collectColumns(rows []map[string]interface{}) []string { + set := map[string]struct{}{} + for _, r := range rows { + for k := range r { + set[k] = struct{}{} + } + } + cols := make([]string, 0, len(set)) + for k := range set { + cols = append(cols, k) + } + sort.Strings(cols) + return cols +} + +// cellString 把任意 JSON value 转字符串显示(null → 空串;非字符串/数字 → JSON 编码)。 +func cellString(v interface{}) string { + switch x := v.(type) { + case nil: + return "" + case string: + return x + case bool: + if x { + return "true" + } + return "false" + case float64: + // 整数值不输出小数(id=101 而不是 101.000000)。 + if x == float64(int64(x)) { + return fmt.Sprintf("%d", int64(x)) + } + return fmt.Sprintf("%g", x) + } + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(b) +} + +// dmlSummary 把 sql_type + affected_rows 渲染成 "N row(s) " 字符串。 +// +// 动词映射:INSERT → inserted / UPDATE → updated / DELETE → deleted / MERGE → merged。 +// 未知 sql_type 默认 "affected"。 +func dmlSummary(sqlType string, affectedRows interface{}) string { + n := intOrZero(affectedRows) + verb := dmlVerb(sqlType) + return fmt.Sprintf("%d row%s %s", n, plural(n), verb) +} + +// isDMLType 判断 sql_type 是否是行级 DML(带 affected_rows 语义)。 +// 真机 boe wire:SELECT 走表格、INSERT/UPDATE/DELETE/MERGE 走行数摘要、其余(CREATE_TABLE / +// DROP_TABLE / ALTER_TABLE / TRUNCATE / CREATE_INDEX ...)一律按 DDL 处理。 +func isDMLType(sqlType string) bool { + switch strings.ToUpper(sqlType) { + case "INSERT", "UPDATE", "DELETE", "MERGE": + return true + } + return false +} + +func dmlVerb(sqlType string) string { + switch strings.ToUpper(sqlType) { + case "INSERT": + return "inserted" + case "UPDATE": + return "updated" + case "DELETE": + return "deleted" + case "MERGE": + return "merged" + } + return "affected" +} + +func plural(n int64) string { + if n == 1 { + return "" + } + return "s" +} + +// errorSummary 从 ERROR 哨兵的 data 字段({code, message} JSON)解析出 "message [code]" 形态。 +// 解析失败时回退到原文。 +func errorSummary(data string) string { + if data == "" { + return "(unknown error)" + } + var e struct { + Code interface{} `json:"code"` + Message string `json:"message"` + } + if err := json.Unmarshal([]byte(data), &e); err != nil { + return data + } + codeStr := codeString(e.Code) + if codeStr != "" { + return fmt.Sprintf("%s [%s]", e.Message, codeStr) + } + return e.Message +} + +// codeString 处理 code 字段在 wire 上可能是 int / "k_dl_1300015" / 数字字符串等多形态。 +func codeString(c interface{}) string { + switch x := c.(type) { + case nil: + return "" + case string: + // "k_dl_1300015" → 抽 1300015;纯数字保持原样。 + if strings.HasPrefix(x, "k_dl_") { + return strings.TrimPrefix(x, "k_dl_") + } + return x + case float64: + return fmt.Sprintf("%d", int64(x)) + } + return "" +} + +// intOrZero 把 JSON number 转 int64;nil / 类型不匹配返回 0。 +func intOrZero(raw interface{}) int64 { + if n, ok := numericAsFloat(raw); ok { + return int64(n) + } + return 0 +} diff --git a/shortcuts/apps/apps_db_execute_test.go b/shortcuts/apps/apps_db_execute_test.go new file mode 100644 index 00000000..66e966ea --- /dev/null +++ b/shortcuts/apps/apps_db_execute_test.go @@ -0,0 +1,797 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + // DBA 模式 result:结构化数组 JSON 字符串 + "result": `[{"sql_type":"SELECT","data":"[{\"id\":101,\"total_cents\":2500}]","record_count":1}]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + // JSON envelope 应该把 result 字符串 parse 之后放进 data.results + var env struct { + Data struct { + Results []map[string]interface{} `json:"results"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode envelope: %v\n%s", err, stdout.String()) + } + if len(env.Data.Results) != 1 { + t.Fatalf("data.results = %d items (want 1)", len(env.Data.Results)) + } + if env.Data.Results[0]["sql_type"] != "SELECT" { + t.Fatalf("results[0].sql_type = %v", env.Data.Results[0]["sql_type"]) + } +} + +func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--env", "dev", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/sql_commands" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + if env.API[0].Body["sql"] != "select 1" { + t.Fatalf("body.sql = %v", env.API[0].Body["sql"]) + } + if env.API[0].Params["env"] != "dev" { + t.Fatalf("params.env = %v", env.API[0].Params["env"]) + } + if env.API[0].Params["transactional"] != false { + t.Fatalf("params.transactional = %v (want false, CLI is DBA mode)", env.API[0].Params["transactional"]) + } + if _, ok := env.API[0].Body["transactional"]; ok { + t.Fatalf("transactional should NOT be in body, got body=%v", env.API[0].Body) + } +} + +func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", " ", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "--sql or --file") { + t.Fatalf("expected empty-sql error, got %v", err) + } +} + +// --sql 与 --file 互斥 +func TestAppsDBExecute_RejectsSQLAndFileTogether(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1", "--file", "x.sql", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("expected mutual-exclusion error, got %v", err) + } +} + +// --file 读取相对路径 .sql 文件 → 内容进 body.sql(dry-run 验证) +func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) { + dir := t.TempDir() + sqlPath := filepath.Join(dir, "m.sql") + if err := os.WriteFile(sqlPath, []byte("SELECT 42 AS answer;\n"), 0o600); err != nil { + t.Fatal(err) + } + // 切到临时目录,使相对路径校验通过(CLI 仅接受 cwd 内相对路径)。 + // 用 os.Chdir + 还原而非 t.Chdir:后者要 Go 1.24,本仓库 go.mod 为 1.23。 + oldWD, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--app-id", "app_x", "--env", "dev", "--file", "m.sql", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) + } + if env.API[0].Body["sql"] != "SELECT 42 AS answer;\n" { + t.Fatalf("body.sql = %v, want file content", env.API[0].Body["sql"]) + } +} + +// ============================================================================ +// legacy wire 形态测试 —— BOE server 实测返这种 ["rows-json-string", ...] +// 形态而非 spec 里的 [{sql_type, data, ...}],CLI 端必须兼容。 +// 输入用 BOE 真实抓包数据(test_scripts/boe_e2e/run.log)。 +// ============================================================================ + +func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) { + // BOE 实测:SELECT 1 AS x → result: "[\"[{\\\"x\\\":1}]\"]" + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `["[{\"x\":1}]"]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1 AS x", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "x") { + t.Errorf("missing header 'x':\n%s", got) + } + if !strings.Contains(got, "1") { + t.Errorf("missing value row '1':\n%s", got) + } + // 不应回退到 RAW + if strings.Contains(got, "RAW") || strings.Contains(got, "[\\\"") { + t.Errorf("should not fall back to RAW or raw-string passthrough:\n%s", got) + } +} + +func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) { + // 验证 JSON envelope 也把 legacy result 正确归一化进 data.results + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `["[{\"x\":1}]"]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1 AS x", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var env struct { + Data struct { + Results []map[string]interface{} `json:"results"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) + } + if len(env.Data.Results) != 1 { + t.Fatalf("results length = %d, want 1; got: %v", len(env.Data.Results), env.Data.Results) + } + if env.Data.Results[0]["sql_type"] != "SELECT" { + t.Fatalf("results[0].sql_type = %v, want SELECT", env.Data.Results[0]["sql_type"]) + } + if env.Data.Results[0]["record_count"] != float64(1) { + t.Fatalf("results[0].record_count = %v, want 1", env.Data.Results[0]["record_count"]) + } +} + +func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) { + // BOE 实测:SELECT 1; SELECT 2 → result: "[\"[{\\\"?column?\\\":1}]\",\"[{\\\"?column?\\\":2}]\"]" + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `["[{\"?column?\":1}]","[{\"?column?\":2}]"]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT 1; SELECT 2;", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // 多语句应有 Statement N: header + if !strings.Contains(got, "Statement 1: SELECT") || !strings.Contains(got, "Statement 2: SELECT") { + t.Errorf("missing Statement headers:\n%s", got) + } + // 末尾应有 ✓ N statements executed + if !strings.Contains(got, "✓ 2 statements executed") { + t.Errorf("missing summary line:\n%s", got) + } +} + +func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) { + // BOE 实测:CREATE TABLE → result: "" (空字符串,无 rows) + // 老 wire 不区分 DDL/DML/无返回,统一标 "ok" + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": ``, // 空字符串 + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "CREATE TABLE foo (id INT)", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // result="" 触发 parseSQLResult 返 nil → renderSQLPretty 输出 "(empty result)" + if !strings.Contains(got, "(empty result)") { + t.Errorf("expected '(empty result)' for empty result string, got:\n%s", got) + } +} + +func TestAppsDBExecute_LegacyWireMultiSelectWithRealTable(t *testing.T) { + // BOE 实测真实表抓包(course 表第一行):复杂 JSON 含 CJK / timestamp / uuid 字段 + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `["[{\"id\":\"abc-123\",\"title\":\"高效沟通\",\"capacity\":30}]"]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "SELECT id,title,capacity FROM course LIMIT 1", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // 验证 CJK / uuid / int 都能正确显示在表格里 + for _, want := range []string{"id", "title", "capacity", "abc-123", "高效沟通", "30"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q in pretty output:\n%s", want, got) + } + } +} + +// pretty 单 SELECT:表格输出,列间两空格,无 Statement header。 +func TestAppsDBExecute_PrettySingleSelectTable(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `[{"sql_type":"SELECT","data":"[{\"id\":101,\"total_cents\":2500},{\"id\":102,\"total_cents\":1800}]","record_count":2}]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if strings.Contains(got, "Statement 1:") { + t.Errorf("single statement pretty should NOT have Statement header\noutput:\n%s", got) + } + // 列按字典序排序:id / total_cents + if !strings.Contains(got, "id total_cents") { + t.Errorf("missing header row\noutput:\n%s", got) + } + if !strings.Contains(got, "101 2500") || !strings.Contains(got, "102 1800") { + t.Errorf("missing data rows\noutput:\n%s", got) + } +} + +func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `[{"sql_type":"SELECT","data":"[]","record_count":0}]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "(0 rows)") { + t.Fatalf("empty SELECT should print (0 rows), got:\n%s", stdout.String()) + } +} + +func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) { + cases := []struct { + name string + result string + wantStr string + }{ + {"INSERT_1_row", `[{"sql_type":"INSERT","data":"","affected_rows":1}]`, "✓ 1 row inserted"}, + {"UPDATE_5_rows", `[{"sql_type":"UPDATE","data":"","affected_rows":5}]`, "✓ 5 rows updated"}, + {"DELETE_0_rows", `[{"sql_type":"DELETE","data":"","affected_rows":0}]`, "✓ 0 rows deleted"}, + {"DDL", `[{"sql_type":"DDL","data":"","affected_rows":0}]`, "✓ DDL executed"}, + // 真机 boe 实测:DDL 的 sql_type 是细粒度动词(CREATE_TABLE / DROP_TABLE / ALTER_TABLE...), + // data 是 "[]"、无 affected_rows。必须识别为 DDL,而不是落到 dmlSummary 渲染成 "0 rows affected"。 + {"CREATE_TABLE", `[{"sql_type":"CREATE_TABLE","data":"[]"}]`, "✓ DDL executed"}, + {"DROP_TABLE", `[{"sql_type":"DROP_TABLE","data":"[]"}]`, "✓ DDL executed"}, + {"ALTER_TABLE", `[{"sql_type":"ALTER_TABLE","data":"[]"}]`, "✓ DDL executed"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"result": c.result}}, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), c.wantStr) { + t.Errorf("want %q\ngot:\n%s", c.wantStr, stdout.String()) + } + }) + } +} + +func TestAppsDBExecute_PrettyMultiStatementsAllSuccess(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `[` + + `{"sql_type":"INSERT","data":"","affected_rows":1},` + + `{"sql_type":"UPDATE","data":"","affected_rows":1},` + + `{"sql_type":"SELECT","data":"[{\"id\":999}]","record_count":1}` + + `]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, line := range []string{ + "Statement 1: ✓ 1 row inserted", + "Statement 2: ✓ 1 row updated", + "Statement 3: SELECT (1 row)", + "✓ 3 statements executed", + } { + if !strings.Contains(got, line) { + t.Errorf("missing %q in pretty output\nfull:\n%s", line, got) + } + } +} + +// TestAppsDBExecute_PrettyMultiStatementsDDL 钉住真机 boe 多语句 DDL 的 wire: +// CREATE_TABLE / DROP_TABLE(data="[]"、无 affected_rows)须渲染成 "✓ DDL executed", +// 不能落到 dmlSummary 变成 "0 rows affected"。 +func TestAppsDBExecute_PrettyMultiStatementsDDL(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `[{"sql_type":"CREATE_TABLE","data":"[]"},{"sql_type":"DROP_TABLE","data":"[]"}]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, line := range []string{ + "Statement 1: ✓ DDL executed", + "Statement 2: ✓ DDL executed", + "✓ 2 statements executed", + } { + if !strings.Contains(got, line) { + t.Errorf("missing %q in pretty output\nfull:\n%s", line, got) + } + } + if strings.Contains(got, "rows affected") { + t.Errorf("DDL must not render as 'rows affected'\nfull:\n%s", got) + } +} + +func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `[` + + `{"sql_type":"INSERT","data":"","affected_rows":1},` + + `{"sql_type":"ERROR","data":"{\"code\":1300015,\"message\":\"syntax error at or near 'SELEC'\"}"}` + + `]`, + }, + }, + }) + // pretty 失败路径:逐条 ✓/✗ 摘要照打到 stdout(人看),同时返回 typed error(exit 非 0)。 + err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"}, + factory, stdout) + if err == nil { + t.Fatalf("pretty multi-statement failure must still return a typed error; stdout:\n%s", stdout.String()) + } + got := stdout.String() + for _, line := range []string{ + "Statement 1: ✓ 1 row inserted", + "Statement 2: ✗ syntax error at or near 'SELEC' [1300015]", + } { + if !strings.Contains(got, line) { + t.Errorf("missing %q in pretty output\nfull:\n%s", line, got) + } + } + // DBA 模式(transactional=false)前序语句已 auto-commit 落地,绝不能误报「rolled back」。 + if strings.Contains(got, "rolled back") { + t.Errorf("DBA mode must NOT claim rollback (prior statements persisted); got:\n%s", got) + } + if strings.Contains(got, "statements executed") { + t.Errorf("failed run should NOT print success summary; got:\n%s", got) + } +} + +// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed api_error」: +// json 默认不再打 ok:true 假成功,而是返回 *output.ExitError(type=api_error、非零 exit), +// detail 带 statement_index / completed / rolled_back。rolled_back=false 因 CLI 永远 DBA 模式 +// (真机 boe 实证:失败前的语句已落地)。 +func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `[` + + `{"sql_type":"INSERT","data":"","affected_rows":1},` + + `{"sql_type":"ERROR","data":"{\"code\":\"k_dl_1300002\",\"message\":\"duplicate key value violates unique constraint\"}"}` + + `]`, + }, + }, + }) + err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"}, + factory, stdout) + if err == nil { + t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String()) + } + // json 失败路径不得打成功 envelope。 + if strings.Contains(stdout.String(), `"ok": true`) { + t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err) + } + if exitErr.Detail.Type != "api_error" { + t.Errorf("error.type = %q, want api_error", exitErr.Detail.Type) + } + if exitErr.Detail.Code != 1300002 { + t.Errorf("error.code = %d, want 1300002", exitErr.Detail.Code) + } + if !strings.Contains(exitErr.Detail.Message, "(at statement 2 of 2)") { + t.Errorf("error.message missing statement locator: %q", exitErr.Detail.Message) + } + if output.ExitCodeOf(err) != output.ExitAPI { + t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI) + } + detail, ok := exitErr.Detail.Detail.(map[string]interface{}) + if !ok { + t.Fatalf("error.detail not a map: %T", exitErr.Detail.Detail) + } + if detail["statement_index"] != 1 { + t.Errorf("statement_index = %v, want 1", detail["statement_index"]) + } + if detail["rolled_back"] != false { + t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", detail["rolled_back"]) + } + if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 1 { + t.Errorf("completed = %v, want 1 persisted statement", detail["completed"]) + } +} + +// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败(server 也返 code:0 + ERROR 哨兵) +// 同样升级成 typed error:statement_index=0、completed 空、message 标注 (at statement 1 of 1)。 +func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error at or near 'SELEC'\"}"}]`, + }, + }, + }) + err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"}, + factory, stdout) + if err == nil { + t.Fatalf("single ERROR sentinel must return a typed error; stdout:\n%s", stdout.String()) + } + var exitErr *output.ExitError + if !errors.As(err, &exitErr) || exitErr.Detail == nil { + t.Fatalf("want *output.ExitError with detail, got %T: %v", err, err) + } + if !strings.Contains(exitErr.Detail.Message, "(at statement 1 of 1)") { + t.Errorf("error.message missing locator: %q", exitErr.Detail.Message) + } + detail, _ := exitErr.Detail.Detail.(map[string]interface{}) + if detail["statement_index"] != 0 { + t.Errorf("statement_index = %v, want 0", detail["statement_index"]) + } + if completed, ok := detail["completed"].([]map[string]interface{}); !ok || len(completed) != 0 { + t.Errorf("completed = %v, want empty", detail["completed"]) + } +} + +func TestCellString_AllKinds(t *testing.T) { + cases := []struct { + name string + in interface{} + want string + }{ + {"nil", nil, ""}, + {"string", "hello", "hello"}, + {"bool true", true, "true"}, + {"bool false", false, "false"}, + {"int float", float64(101), "101"}, + {"fractional", float64(1.25), "1.25"}, + {"object", map[string]interface{}{"a": float64(1)}, `{"a":1}`}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := cellString(c.in); got != c.want { + t.Errorf("cellString(%v)=%q want %q", c.in, got, c.want) + } + }) + } +} + +func TestCodeString_Forms(t *testing.T) { + cases := []struct { + name string + in interface{} + want string + }{ + {"nil", nil, ""}, + {"k_dl prefix", "k_dl_1300015", "1300015"}, + {"plain string", "1300015", "1300015"}, + {"float64", float64(42), "42"}, + {"unsupported", []int{1}, ""}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := codeString(c.in); got != c.want { + t.Errorf("codeString(%v)=%q want %q", c.in, got, c.want) + } + }) + } +} + +func TestDmlVerb_AllVerbs(t *testing.T) { + cases := map[string]string{ + "INSERT": "inserted", + "update": "updated", + "DELETE": "deleted", + "Merge": "merged", + "CREATE_TABLE": "affected", + } + for in, want := range cases { + if got := dmlVerb(in); got != want { + t.Errorf("dmlVerb(%q)=%q want %q", in, got, want) + } + } +} + +func TestIntOrZero_Cases(t *testing.T) { + if got := intOrZero(float64(5)); got != 5 { + t.Errorf("intOrZero(5)=%d want 5", got) + } + if got := intOrZero("x"); got != 0 { + t.Errorf("intOrZero(non-numeric)=%d want 0", got) + } + if got := intOrZero(nil); got != 0 { + t.Errorf("intOrZero(nil)=%d want 0", got) + } +} + +func TestErrorSummary_Cases(t *testing.T) { + cases := []struct { + name, in, want string + }{ + {"empty", "", "(unknown error)"}, + {"malformed json", "not json", "not json"}, + {"with code", `{"code":"k_dl_1300015","message":"boom"}`, "boom [1300015]"}, + {"no code", `{"message":"plain"}`, "plain"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := errorSummary(c.in); got != c.want { + t.Errorf("errorSummary(%q)=%q want %q", c.in, got, c.want) + } + }) + } +} + +func TestParseErrorSentinel_Cases(t *testing.T) { + cases := []struct { + name, in string + wantCode int + wantMsg string + }{ + {"empty", "", 0, "(unknown error)"}, + {"malformed", "xyz", 0, "xyz"}, + {"code+msg", `{"code":"1300015","message":"boom"}`, 1300015, "boom"}, + {"empty msg", `{"code":"1300015","message":""}`, 1300015, "(unknown error)"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + code, msg := parseErrorSentinel(c.in) + if code != c.wantCode || msg != c.wantMsg { + t.Errorf("parseErrorSentinel(%q)=%d,%q want %d,%q", c.in, code, msg, c.wantCode, c.wantMsg) + } + }) + } +} + +func TestIsStructuredResult_Cases(t *testing.T) { + if !isStructuredResult([]map[string]interface{}{{"sql_type": "SELECT"}}) { + t.Error("expected structured=true when sql_type present") + } + if isStructuredResult([]map[string]interface{}{{}}) { + t.Error("expected structured=false when sql_type absent") + } + if isStructuredResult(nil) { + t.Error("expected structured=false for empty") + } +} + +func TestNormalizeLegacyStatement_Cases(t *testing.T) { + t.Run("empty -> OK", func(t *testing.T) { + got := normalizeLegacyStatement("") + if got["sql_type"] != "OK" { + t.Errorf("got sql_type=%v want OK", got["sql_type"]) + } + }) + t.Run("null -> OK", func(t *testing.T) { + got := normalizeLegacyStatement("null") + if got["sql_type"] != "OK" { + t.Errorf("got sql_type=%v want OK", got["sql_type"]) + } + }) + t.Run("rows -> SELECT", func(t *testing.T) { + got := normalizeLegacyStatement(`[{"id":1}]`) + if got["sql_type"] != "SELECT" { + t.Errorf("got sql_type=%v want SELECT", got["sql_type"]) + } + if got["record_count"] != float64(1) { + t.Errorf("got record_count=%v want 1", got["record_count"]) + } + }) + t.Run("non-json kept as OK", func(t *testing.T) { + got := normalizeLegacyStatement(`notjson`) + if got["sql_type"] != "OK" { + t.Errorf("got sql_type=%v want OK", got["sql_type"]) + } + }) +} + +func TestCellString_MarshalFallback(t *testing.T) { + // complex128 is not switch-handled and json.Marshal rejects it → + // falls back to fmt.Sprintf("%v", v), which is deterministic for complex. + if got := cellString(complex(1, 2)); got != "(1+2i)" { + t.Errorf("cellString(complex)=%q want (1+2i)", got) + } +} + +func TestRenderSingleStatementPretty_Branches(t *testing.T) { + cases := []struct { + name string + stmt map[string]interface{} + substr string + }{ + {"select empty", map[string]interface{}{"sql_type": "SELECT", "data": "[]"}, "(0 rows)"}, + {"error", map[string]interface{}{"sql_type": "ERROR", "data": `{"message":"boom"}`}, "✗ boom"}, + {"dml insert", map[string]interface{}{"sql_type": "INSERT", "affected_rows": float64(3)}, "✓ 3 rows inserted"}, + {"legacy ok", map[string]interface{}{"sql_type": "OK"}, "✓ ok"}, + {"ddl default", map[string]interface{}{"sql_type": "CREATE_TABLE"}, "✓ DDL executed"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var b strings.Builder + renderSingleStatementPretty(&b, c.stmt) + if !strings.Contains(b.String(), c.substr) { + t.Errorf("output %q does not contain %q", b.String(), c.substr) + } + }) + } +} + +func TestRenderSelectRowsAsTable_Branches(t *testing.T) { + cases := []struct { + name string + data string + substr string + }{ + {"empty string", "", "(0 rows)"}, + {"empty array", "[]", "(0 rows)"}, + {"malformed fallback", "{bad", "{bad"}, + {"rows", `[{"id":1}]`, "id"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + var b strings.Builder + renderSelectRowsAsTable(&b, c.data) + if !strings.Contains(b.String(), c.substr) { + t.Errorf("output %q does not contain %q", b.String(), c.substr) + } + }) + } +} diff --git a/shortcuts/apps/apps_db_table_get.go b/shortcuts/apps/apps_db_table_get.go new file mode 100644 index 00000000..4041d414 --- /dev/null +++ b/shortcuts/apps/apps_db_table_get.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const dbTableGetHint = "verify --app-id and --table are correct; list tables with `lark-cli apps +db-table-list --app-id `; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id --env dev`" + +// AppsDBTableGet gets one table's structure (动词对齐 +db-table-list)。 +// +// GET /apps/{app_id}/tables/{table_name}。 +// +// `--format` 同时驱动 CLI 渲染和 server 请求形态: +// - `--format json`(默认)/ table / ndjson / csv:CLI 不传 format query,response 含结构化 +// columns / indexes / constraints / stats,envelope 化输出。 +// - `--format pretty`:CLI 给 server 带 ?format=ddl,response 含 ddl 字符串,stdout 直接打 +// ddl 内容(无 envelope / 无表格包装)。 +var AppsDBTableGet = common.Shortcut{ + Service: appsService, + Command: "+db-table-get", + Description: "Get a table's structure: columns, indexes and constraints", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-table-get --app-id --table
", + "Tip: filter fields with --jq (json format), e.g. -q '.data.columns[].name'", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Desc: "table name", Required: true}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("table")) == "" { + return output.ErrValidation("--table is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appTablePath(appID, strings.TrimSpace(rctx.Str("table")))). + Desc("Get Miaoda app db table schema"). + Params(buildDBTableGetParams(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + path := appTablePath(appID, strings.TrimSpace(rctx.Str("table"))) + data, err := rctx.CallAPITyped("GET", path, buildDBTableGetParams(rctx), nil) + if err != nil { + return withAppsHint(err, dbTableGetHint) + } + rctx.OutFormat(data, nil, func(w io.Writer) { + // pretty 模式:stdout 直接打 ddl 文本(无 trailing newline,由 server 返回的字符串决定)。 + io.WriteString(w, common.GetString(data, "ddl")) + }) + return nil + }, +} + +// buildDBTableGetParams 构造 schema 接口的 query。 +// +// CLI 检测 rctx.Format == "pretty" 时给 server 带 format=ddl,要求返 CREATE 语句文本; +// 其他 format(含默认 json)不传该参数,让 server 返默认结构化字段。 +func buildDBTableGetParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{"env": rctx.Str("env")} + if rctx.Format == "pretty" { + params["format"] = "ddl" + } + return params +} diff --git a/shortcuts/apps/apps_db_table_get_test.go b/shortcuts/apps/apps_db_table_get_test.go new file mode 100644 index 00000000..bab56a84 --- /dev/null +++ b/shortcuts/apps/apps_db_table_get_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsDBTableGet_DefaultJSONReturnsStructuredFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/tables/orders", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "name": "orders", + "description": "订单表", + "columns": []interface{}{ + map[string]interface{}{ + "name": "id", "data_type": "int8", + "is_primary_key": true, "is_unique": true, + "is_allow_null": false, "default_value": "", + }, + }, + "indexes": []interface{}{ + map[string]interface{}{"name": "orders_pkey", "type": "btree", "columns": []interface{}{"id"}, "definition": "..."}, + }, + "constraints": []interface{}{ + map[string]interface{}{"type": "primary_key", "name": "orders_pkey", "columns": []interface{}{"id"}}, + }, + "estimated_row_count": 1200, + "size_bytes": 81920, + }, + }, + }) + + if err := runAppsShortcut(t, AppsDBTableGet, + []string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"name": "orders"`) { + t.Fatalf("stdout missing schema name: %s", got) + } +} + +// --format pretty 是触发 DDL 模式的唯一开关。 +// 用 --format json + --dry-run 走 JSON envelope 路径方便 parse,但 query 形态由代码内部 +// 根据 rctx.Format 决定 —— 这里我们直接传 --format pretty + --dry-run,pretty 模式下 dry-run +// 输出是 plain text 列表,用 substring 校验 format=ddl 出现在 URL query 中。 +func TestAppsDBTableGet_PrettyFormatSendsFormatDDLQuery(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBTableGet, + []string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/tables/orders") { + t.Fatalf("missing URL in dry-run output:\n%s", got) + } + if !strings.Contains(got, "format=ddl") { + t.Fatalf("--format=pretty should trigger ?format=ddl, got:\n%s", got) + } +} + +func TestAppsDBTableGet_NonPrettyFormatsOmitFormatQuery(t *testing.T) { + // 默认 json / table / ndjson / csv 都走 schema 路径 —— CLI 不传 format query。 + for _, format := range []string{"json", "table", "ndjson", "csv"} { + t.Run(format, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + args := []string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", format, "--dry-run", "--as", "user"} + if err := runAppsShortcut(t, AppsDBTableGet, args, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if _, ok := env.API[0].Params["format"]; ok { + t.Fatalf("--format=%s should omit format query, got %v", format, env.API[0].Params) + } + }) + } +} + +func TestAppsDBTableGet_PrettyOutputIsDDLTextOnly(t *testing.T) { + // pretty 模式 stdout 直接打 ddl 字段文本,无 envelope / 表格包装。 + factory, stdout, reg := newAppsExecuteFactory(t) + ddl := "CREATE TABLE orders (\n id bigint NOT NULL,\n PRIMARY KEY (id)\n);" + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/tables/orders", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"ddl": ddl}, + }, + }) + + if err := runAppsShortcut(t, AppsDBTableGet, + []string{"+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "CREATE TABLE orders") { + t.Fatalf("pretty output should contain raw DDL, got:\n%s", got) + } + if strings.Contains(got, `"data":`) || strings.Contains(got, `"ddl":`) { + t.Fatalf("pretty output should not be JSON envelope, got:\n%s", got) + } +} + +func TestAppsDBTableGet_RequiresTable(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBTableGet, + []string{"+db-table-get", "--app-id", "app_x", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "table") { + t.Fatalf("expected table required error, got %v", err) + } +} diff --git a/shortcuts/apps/apps_db_table_list.go b/shortcuts/apps/apps_db_table_list.go new file mode 100644 index 00000000..cc89f1db --- /dev/null +++ b/shortcuts/apps/apps_db_table_list.go @@ -0,0 +1,301 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +const dbTableListHint = "verify --app-id is correct; if targeting --env dev, create it first with `lark-cli apps +db-env-create --app-id --env dev`" + +// AppsDBTableList lists tables in a Miaoda app's database. +// +// GET /apps/{app_id}/tables(cursor 分页),response items[] 含 estimated_row_count / +// size_bytes optional 字段,默认返回,不必额外传 query。 +// +// 输出裁剪:server 给每张表回完整 columns[](与 +db-table-get 同源、内容一致)。CLI 用白名单 +// 投影(dbTableListItem)只组装产品要求字段、把 columns[] 折算成 column_count,避免逐表重复列定义 +// 放大 token、并与 +db-table-get 职责区分。完整列定义 / 索引 / 约束 / DDL 用 +db-table-get。 +// +// pretty 渲染 5 列:name / description / estimated_row_count / size / columns(即 column_count); +// 列间两空格、列对齐填充、空 description 用 "—" 占位、size 按 KB/MB/GB 友好格式化。 +var AppsDBTableList = common.Shortcut{ + Service: appsService, + Command: "+db-table-list", + Description: "List tables in a Miaoda app database (cursor pagination)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-table-list --app-id ", + "Tip: filter fields with --jq, e.g. -q '.data.items[].name'", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "env", Default: "online", Enum: []string{"dev", "online"}, Desc: "target db environment"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, + {Name: "page-token", Desc: "pagination cursor from previous response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + _, err := requireAppID(rctx.Str("app-id")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appTablesPath(appID)). + Desc("List Miaoda app db tables"). + Params(buildDBTableListParams(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("GET", appTablesPath(appID), buildDBTableListParams(rctx), nil) + if err != nil { + return withAppsHint(err, dbTableListHint) + } + // 白名单投影:只把产品要求的字段组装进 dbTableListItem,替换 server 原始 items[]。 + // server 给每张表回完整 columns[](与 +db-table-get 同源、逐字节一致),在 list 里逐表 + // 重复既放大 token 又与 schema 职责重叠。这里用白名单而非 delete 黑名单 —— server 后续新增 + // 字段不会自动泄漏进 CLI 输出。需要完整列定义 / 索引 / 约束 / DDL 用 +db-table-get。 + items := projectTableListItems(data["items"]) + data["items"] = items + rctx.OutFormat(data, nil, func(w io.Writer) { + renderTableListPretty(w, items) + }) + return nil + }, +} + +// dbTableListItem 是 +db-table-list 对外输出的「产品要求字段」白名单。 +// 改字段在此处增删即可,无需在 Execute 里逐个 delete server 返回的多余字段。 +type dbTableListItem struct { + Name string `json:"name"` + Description string `json:"description"` + EstimatedRowCount interface{} `json:"estimated_row_count,omitempty"` + SizeBytes interface{} `json:"size_bytes,omitempty"` + ColumnCount int `json:"column_count"` +} + +// projectTableListItems 把 server 原始 items[](map)投影成白名单 dbTableListItem 切片。 +// column_count 由 server 返回的 columns[] 长度派生(随后 columns[] 不再透出)。 +func projectTableListItems(raw interface{}) []dbTableListItem { + arr, _ := raw.([]interface{}) + out := make([]dbTableListItem, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + out = append(out, dbTableListItem{ + Name: common.GetString(m, "name"), + Description: common.GetString(m, "description"), + EstimatedRowCount: m["estimated_row_count"], + SizeBytes: m["size_bytes"], + ColumnCount: deriveColumnCount(m), + }) + } + return out +} + +func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{ + "env": rctx.Str("env"), + "page_size": rctx.Int("page-size"), + } + if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { + params["page_token"] = token + } + return params +} + +// renderTableListPretty 5 列输出,列间两空格、列对齐填充。 +// +// 列名:name / description / estimated_row_count / size / columns。 +// 空 description 用 "—" 占位;size 由 size_bytes 经 humanBytes 友好格式化; +// columns 列取白名单投影的 column_count。 +func renderTableListPretty(w io.Writer, items []dbTableListItem) { + headers := []string{"name", "description", "estimated_row_count", "size", "columns"} + rows := make([][]string, 0, len(items)) + for _, item := range items { + desc := item.Description + if desc == "" { + desc = "—" + } + rows = append(rows, []string{ + item.Name, + desc, + intString(item.EstimatedRowCount), + humanBytes(item.SizeBytes), + fmt.Sprintf("%d", item.ColumnCount), + }) + } + renderAlignedTable(w, headers, rows) +} + +// renderAlignedTable 输出列对齐表格:列间两空格、列宽按每列最长 cell 填充、 +// 不画 `|` 和 `-` 分隔线、不依赖 TTY 着色。 +func renderAlignedTable(w io.Writer, headers []string, rows [][]string) { + if len(headers) == 0 { + return + } + widths := make([]int, len(headers)) + for i, h := range headers { + widths[i] = displayWidth(h) + } + for _, row := range rows { + for i, cell := range row { + if i >= len(widths) { + break + } + if dw := displayWidth(cell); dw > widths[i] { + widths[i] = dw + } + } + } + writeRow := func(cells []string) { + for i, cell := range cells { + if i >= len(widths) { + continue + } + if i > 0 { + io.WriteString(w, " ") + } + io.WriteString(w, cell) + if i < len(widths)-1 { + pad := widths[i] - displayWidth(cell) + if pad > 0 { + io.WriteString(w, strings.Repeat(" ", pad)) + } + } + } + io.WriteString(w, "\n") + } + writeRow(headers) + for _, r := range rows { + writeRow(r) + } +} + +// displayWidth 估算字符串在 monospace 终端下的显示宽度。 +// ASCII 占 1 列;CJK / 全角字符占 2 列;其他多字节字符按 rune 数算(保守)。 +func displayWidth(s string) int { + w := 0 + for _, r := range s { + switch { + case r < 0x80: + w++ + case isWide(r): + w += 2 + default: + w++ + } + } + return w +} + +func isWide(r rune) bool { + switch { + case r >= 0x1100 && r <= 0x115F: // Hangul Jamo + case r >= 0x2E80 && r <= 0x303E: // CJK Radicals / Kangxi + case r >= 0x3041 && r <= 0x33FF: // Hiragana / Katakana / Bopomofo / CJK Symbols + case r >= 0x3400 && r <= 0x4DBF: // CJK Extension A + case r >= 0x4E00 && r <= 0x9FFF: // CJK Unified Ideographs + case r >= 0xA000 && r <= 0xA4CF: // Yi + case r >= 0xAC00 && r <= 0xD7A3: // Hangul Syllables + case r >= 0xF900 && r <= 0xFAFF: // CJK Compatibility Ideographs + case r >= 0xFE30 && r <= 0xFE4F: // CJK Compatibility Forms + case r >= 0xFF00 && r <= 0xFF60: // Fullwidth Forms + case r >= 0xFFE0 && r <= 0xFFE6: // Fullwidth Signs + case r >= 0x20000 && r <= 0x2FFFD: // CJK Extension B-F + case r >= 0x30000 && r <= 0x3FFFD: // CJK Extension G + default: + return false + } + return true +} + +// humanBytes 把 size_bytes 数值转 KB / MB / GB 友好字符串。 +// 1 KiB = 1024 B;与 PG / 操作系统约定一致。 +func humanBytes(raw interface{}) string { + n, ok := numericAsFloat(raw) + if !ok { + return "—" + } + const unit = 1024.0 + switch { + case n < unit: + return fmt.Sprintf("%d B", int64(n)) + case n < unit*unit: + return fmt.Sprintf("%.0f KB", n/unit) + case n < unit*unit*unit: + return formatFloat(n/(unit*unit)) + " MB" + default: + return formatFloat(n/(unit*unit*unit)) + " GB" + } +} + +// formatFloat 一位小数;整数值省略小数(24 KB 而不是 24.0 KB;1.5 MB 而不是 1 MB)。 +func formatFloat(f float64) string { + if f == float64(int64(f)) { + return fmt.Sprintf("%d", int64(f)) + } + return fmt.Sprintf("%.1f", f) +} + +// intString 把 JSON 反序列化进来的 number 转为整数字符串显示(estimated_row_count)。 +func intString(raw interface{}) string { + if n, ok := numericAsFloat(raw); ok { + return fmt.Sprintf("%d", int64(n)) + } + return "—" +} + +func numericAsFloat(raw interface{}) (float64, bool) { + switch v := raw.(type) { + case float64: + return v, true + case float32: + return float64(v), true + case int: + return float64(v), true + case int32: + return float64(v), true + case int64: + return float64(v), true + case uint: + return float64(v), true + case uint32: + return float64(v), true + case uint64: + return float64(v), true + case json.Number: + f, err := v.Float64() + if err != nil { + return 0, false + } + return f, true + case nil: + return 0, false + } + return 0, false +} + +// deriveColumnCount 从 items[i].columns 数组长度派生 column_count。 +func deriveColumnCount(m map[string]interface{}) int { + cols, ok := m["columns"].([]interface{}) + if !ok { + return 0 + } + return len(cols) +} diff --git a/shortcuts/apps/apps_db_table_list_test.go b/shortcuts/apps/apps_db_table_list_test.go new file mode 100644 index 00000000..0f3c5485 --- /dev/null +++ b/shortcuts/apps/apps_db_table_list_test.go @@ -0,0 +1,309 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +// TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope 验证 server 业务错误 +// (code != 0,如单环境 app 查 env=dev 返 "Invalid DB Branch")被 CLI 透出成 +// typed error —— 用 BOE 实测的错误码 / 文案做输入。 +// +// 迁移到 runtime.CallAPITyped 后,非零 code 的业务错误由 errclass.BuildAPIError +// 归类为 typed errs.* error(wire type 为 "api" 类别,不再是 legacy 的 +// *output.ExitError / "api_error"),但仍保留 code 与 message。与 drive/okr 等 +// 已迁移域一致:用 errs.ProblemOf 读 typed envelope,断言不弱化。 +func TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/tables", + Body: map[string]interface{}{ + "code": 500002511, + "msg": "k_dl_1600000:Invalid DB Branch:dev", + }, + }) + + err := runAppsShortcut(t, AppsDBTableList, + []string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, + factory, stdout) + if err == nil { + t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed errs.Problem, got %T: %v", err, err) + } + if p.Category != errs.CategoryAPI { + t.Fatalf("error.type = %q, want %q", p.Category, errs.CategoryAPI) + } + if p.Code != 500002511 { + t.Fatalf("error.code = %d, want 500002511", p.Code) + } + if !strings.Contains(p.Message, "Invalid DB Branch") { + t.Fatalf("error.message missing 'Invalid DB Branch': %q", p.Message) + } +} + +func TestAppsDBTableList_SuccessReturnsItemsWithStats(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{ + map[string]interface{}{ + "name": "orders", + "description": "订单表", + "columns": []interface{}{map[string]interface{}{"name": "id"}, map[string]interface{}{"name": "user_id"}}, + "estimated_row_count": 1200, + "size_bytes": 81920, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsDBTableList, + []string{"+db-table-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"name": "orders"`) { + t.Fatalf("stdout missing table name: %s", got) + } + if !strings.Contains(got, `"estimated_row_count": 1200`) { + t.Fatalf("stdout missing estimated_row_count: %s", got) + } + // CLI 裁剪:json 默认不透出每表 columns[],折算成 column_count(mock 给了 2 列)。 + if !strings.Contains(got, `"column_count": 2`) { + t.Fatalf("stdout missing column_count (should replace columns[]): %s", got) + } + if strings.Contains(got, `"columns"`) { + t.Fatalf("stdout should NOT contain raw columns[] (stripped to column_count): %s", got) + } +} + +// pretty 5 列 + 列名 (size / columns,不是 size_bytes / column_count) + size 友好格式(KB) + +// 空 description 用 "—" 占位。 +func TestAppsDBTableList_PrettyRendersFiveColumnsHumanReadable(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "name": "orders", + "description": "Order entries", + "columns": []interface{}{map[string]interface{}{"name": "id"}, map[string]interface{}{"name": "user_id"}}, + "estimated_row_count": 1200, + "size_bytes": 81920, // 80 KB + }, + map[string]interface{}{ + "name": "customers", + "description": "", + "columns": []interface{}{map[string]interface{}{"name": "id"}}, + "estimated_row_count": 350, + "size_bytes": 24576, // 24 KB + }, + }, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBTableList, + []string{"+db-table-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // Header 行 5 列命名。 + wantHeader := "name description estimated_row_count size columns" + // rows + wantOrders := "orders Order entries 1200 80 KB 2" + wantCustomers := "customers — 350 24 KB 1" + for _, want := range []string{wantHeader, wantOrders, wantCustomers} { + if !strings.Contains(got, want) { + t.Errorf("missing line %q\nactual output:\n%s", want, got) + } + } + // 禁止出现旧列名 / 原始字节。 + for _, banned := range []string{"size_bytes", "column_count", "81920", "24576"} { + if strings.Contains(got, banned) { + t.Errorf("pretty output contains %q (must be human-formatted)\noutput:\n%s", banned, got) + } + } +} + +func TestAppsDBTableList_RequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBTableList, + []string{"+db-table-list", "--app-id", " ", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "app-id") { + t.Fatalf("expected app-id required error, got %v", err) + } +} + +func TestAppsDBTableList_DryRunSendsPaginationAndEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBTableList, + []string{"+db-table-list", "--app-id", "app_x", "--env", "dev", + "--page-size", "50", "--page-token", "cursor-abc", + "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "GET" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/tables" { + t.Fatalf("dry-run method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + if env.API[0].Params["env"] != "dev" { + t.Fatalf("dry-run params.env = %v (want dev)", env.API[0].Params["env"]) + } + if pz, _ := env.API[0].Params["page_size"].(float64); int(pz) != 50 { + t.Fatalf("dry-run params.page_size = %v (want 50)", env.API[0].Params["page_size"]) + } + if env.API[0].Params["page_token"] != "cursor-abc" { + t.Fatalf("dry-run params.page_token = %v (want cursor-abc)", env.API[0].Params["page_token"]) + } +} + +func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBTableList, + []string{"+db-table-list", "--app-id", "app_x", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var env struct { + API []struct { + Params map[string]interface{} `json:"params"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil { + t.Fatalf("decode: %v", err) + } + if _, ok := env.API[0].Params["include_stats"]; ok { + t.Fatalf("CLI should not send include_stats query, but got params=%v", env.API[0].Params) + } +} + +func TestAppsDBTableList_RejectsBadEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBTableList, + []string{"+db-table-list", "--app-id", "app_x", "--env", "prod", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "env") { + t.Fatalf("expected env enum rejection, got %v", err) + } +} + +func TestNumericAsFloat_AllTypes(t *testing.T) { + cases := []struct { + name string + in interface{} + want float64 + ok bool + }{ + {"float64", float64(3.5), 3.5, true}, + {"float32", float32(2), 2, true}, + {"int", int(7), 7, true}, + {"int32", int32(8), 8, true}, + {"int64", int64(9), 9, true}, + {"uint", uint(10), 10, true}, + {"uint32", uint32(11), 11, true}, + {"uint64", uint64(12), 12, true}, + {"json.Number valid", json.Number("13.5"), 13.5, true}, + {"json.Number invalid", json.Number("abc"), 0, false}, + {"nil", nil, 0, false}, + {"unsupported string", "x", 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got, ok := numericAsFloat(c.in) + if ok != c.ok || got != c.want { + t.Fatalf("numericAsFloat(%v) = %v,%v want %v,%v", c.in, got, ok, c.want, c.ok) + } + }) + } +} + +func TestFormatFloat_IntegerVsFractional(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {24, "24"}, + {1.5, "1.5"}, + {2.04, "2.0"}, + {0, "0"}, + } + for _, c := range cases { + if got := formatFloat(c.in); got != c.want { + t.Errorf("formatFloat(%v)=%q want %q", c.in, got, c.want) + } + } +} + +func TestHumanBytes_UnitBoundaries(t *testing.T) { + cases := []struct { + name string + in interface{} + want string + }{ + {"non-numeric", "x", "—"}, + {"bytes", float64(512), "512 B"}, + {"kb", float64(2048), "2 KB"}, + {"mb fractional", float64(1572864), "1.5 MB"}, + {"gb integer", float64(2 * 1024 * 1024 * 1024), "2 GB"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := humanBytes(c.in); got != c.want { + t.Errorf("humanBytes(%v)=%q want %q", c.in, got, c.want) + } + }) + } +} + +func TestIntString_Cases(t *testing.T) { + if got := intString(float64(42)); got != "42" { + t.Errorf("intString(42)=%q want 42", got) + } + if got := intString("x"); got != "—" { + t.Errorf("intString(non-numeric)=%q want —", got) + } +} + +func TestDeriveColumnCount_Cases(t *testing.T) { + if got := deriveColumnCount(map[string]interface{}{"columns": []interface{}{1, 2, 3}}); got != 3 { + t.Errorf("deriveColumnCount=%d want 3", got) + } + if got := deriveColumnCount(map[string]interface{}{}); got != 0 { + t.Errorf("deriveColumnCount(missing)=%d want 0", got) + } + if got := deriveColumnCount(map[string]interface{}{"columns": "notarray"}); got != 0 { + t.Errorf("deriveColumnCount(wrongtype)=%d want 0", got) + } +} diff --git a/shortcuts/apps/apps_env_pull.go b/shortcuts/apps/apps_env_pull.go new file mode 100644 index 00000000..21c17afb --- /dev/null +++ b/shortcuts/apps/apps_env_pull.go @@ -0,0 +1,380 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// envKeyPattern matches valid environment variable names: [A-Za-z_][A-Za-z0-9_]* +var envKeyPattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +type envPullDatabaseInfo struct { + Detected bool + ExpiresAtRaw string + ExpiresAtText string +} + +// AppsEnvPull pulls startup env vars for an app into the local .env.local file. +var AppsEnvPull = common.Shortcut{ + Service: appsService, + Command: "+env-pull", + Description: "Pull app startup env vars into the local project .env.local", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +env-pull --app-id ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID"}, + {Name: "project-path", Desc: "local project root path (defaults to current directory)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: "--app-id is required"}, Param: "app-id"} + } + _, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path"))) + if err != nil { + return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err} + } + if err := checkEnvPullTarget(envFile); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + 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))). + Desc("Pull app startup env vars into the local .env.local file"). + Set("project_path", projectPath). + Set("env_file", envFile) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + _, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path"))) + if err != nil { + return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("--project-path: %v", err)}, Param: "project-path", Cause: err} + } + if err := checkEnvPullTarget(envFile); err != nil { + return err + } + if err := rctx.EnsureScopes([]string{"spark:app:read"}); err != nil { + return err + } + + path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID)) + data, err := rctx.CallAPITyped("POST", path, nil, nil) + 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`") + } + + envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data) + if err != nil { + return err + } + if envVars == nil { + envVars = map[string]string{} + } + envVars["FORCE_DB_BRANCH"] = "dev" + original, err := readEnvPullFile(envFile) + if err != nil { + return err + } + merged, updated, created := mergeEnvPullFileContent(original, envVars) + if err := ensureEnvPullParentDir(envFile); err != nil { + return err + } + if err := validate.AtomicWrite(envFile, []byte(merged), 0o600); err != nil { + return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot write %s: %v", envFile, err)}, Cause: err} + } + + result := buildEnvPullSuccessData(appID, envFile, databaseInfo) + rctx.OutFormat(result, nil, func(w io.Writer) { + writeEnvPullPretty(w, appID, envFile, databaseInfo, skippedKeys) + }) + _ = updated + _ = created + return nil + }, +} + +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. + if err != nil { + return "", "", fmt.Errorf("cannot determine working directory: %w", err) + } + projectPath = cwd + } + if err := validate.RejectControlChars(projectPath, "--project-path"); err != nil { + return "", "", err + } + projectPath = filepath.Clean(projectPath) + return projectPath, filepath.Join(projectPath, ".env.local"), nil +} + +func checkEnvPullTarget(envFile string) error { + info, err := os.Lstat(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; direct lstat is needed to reject symlinks before write. + if err != nil { + if os.IsNotExist(err) { + return nil + } + return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("cannot inspect %s: %v", envFile, err)}, Param: "project-path", Cause: err} + } + if info.Mode()&os.ModeSymlink != 0 { + return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file, not a symlink", envFile)}, Param: "project-path"} + } + if !info.Mode().IsRegular() { + return &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidArgument, Message: fmt.Sprintf("target %s must be a regular file", envFile)}, Param: "project-path"} + } + return nil +} + +func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPullDatabaseInfo, []string, error) { + raw := data["env_vars"] + if raw == nil { + if nested, ok := data["data"].(map[string]interface{}); ok { + raw = nested["env_vars"] + } + } + if raw == nil { + return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}} + } + + var skippedKeys []string + switch typed := raw.(type) { + case map[string]interface{}: + out := make(map[string]string, len(typed)) + for key, value := range typed { + if !envKeyPattern.MatchString(key) { + skippedKeys = append(skippedKeys, key) + continue + } + s, ok := value.(string) + if !ok { + continue + } + out[key] = s + } + return out, envPullDatabaseInfo{Detected: hasEnvPullDatabase(out)}, skippedKeys, nil + case []interface{}: + out := make(map[string]string, len(typed)) + info := envPullDatabaseInfo{} + for _, item := range typed { + entry, ok := item.(map[string]interface{}) + if !ok { + continue + } + key, ok := entry["key"].(string) + if !ok || strings.TrimSpace(key) == "" { + continue + } + if !envKeyPattern.MatchString(key) { + skippedKeys = append(skippedKeys, key) + continue + } + value, ok := entry["value"].(string) + if !ok { + continue + } + out[key] = value + if key == "SUDA_DATABASE_URL" { + info.Detected = true + info.ExpiresAtRaw, info.ExpiresAtText = extractEnvPullDatabaseExpiry(entry["extras"]) + } + } + return out, info, skippedKeys, nil + default: + return nil, envPullDatabaseInfo{}, nil, &errs.ValidationError{Problem: errs.Problem{Category: errs.CategoryValidation, Subtype: errs.SubtypeInvalidResponse, Message: "response field env_vars must be an object or array of key/value entries"}} + } +} + +func readEnvPullFile(envFile string) (string, error) { + data, err := os.ReadFile(envFile) //nolint:forbidigo // shortcuts cannot import internal/vfs; validated local file read for a single env file. + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot read %s: %v", envFile, err)}, Cause: err} + } + return string(data), nil +} + +func ensureEnvPullParentDir(envFile string) error { + dir := filepath.Dir(envFile) + if err := os.MkdirAll(dir, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; local mkdir for target env parent dir. + return &errs.InternalError{Problem: errs.Problem{Category: errs.CategoryInternal, Subtype: errs.SubtypeUnknown, Message: fmt.Sprintf("cannot create %s: %v", dir, err)}, Cause: err} + } + return nil +} + +func mergeEnvPullFileContent(original string, envVars map[string]string) (string, []string, []string) { + if len(envVars) == 0 { + if original == "" { + return "", nil, nil + } + return ensureTrailingNewline(original), nil, nil + } + + normalized := strings.ReplaceAll(original, "\r\n", "\n") + lines := []string{} + if normalized != "" { + lines = strings.Split(normalized, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + } + + used := make(map[string]bool, len(envVars)) + updated := make([]string, 0, len(envVars)) + for i, line := range lines { + key, ok := parseEnvPullAssignmentLine(line) + if !ok { + continue + } + value, exists := envVars[key] + if !exists { + continue + } + lines[i] = formatEnvPullAssignment(key, value) + updated = append(updated, key) + used[key] = true + } + + created := make([]string, 0, len(envVars)) + pending := make([]string, 0, len(envVars)) + for key := range envVars { + if used[key] { + continue + } + pending = append(pending, key) + } + sort.Strings(pending) + for _, key := range pending { + lines = append(lines, formatEnvPullAssignment(key, envVars[key])) + created = append(created, key) + } + + sort.Strings(updated) + content := strings.Join(lines, "\n") + if content != "" { + content += "\n" + } + return content, updated, created +} + +func parseEnvPullAssignmentLine(line string) (string, bool) { + trimmed := strings.TrimSpace(line) + if trimmed == "" || strings.HasPrefix(trimmed, "#") { + return "", false + } + if strings.HasPrefix(trimmed, "export ") || strings.HasPrefix(trimmed, "export\t") { + remainder := strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(trimmed, "export "), "export\t")) + if remainder == "" || strings.HasPrefix(remainder, "=") { + return "", false + } + trimmed = remainder + } + idx := strings.Index(trimmed, "=") + if idx <= 0 { + return "", false + } + key := strings.TrimSpace(trimmed[:idx]) + if key == "" || strings.ContainsAny(key, " \t") { + return "", false + } + return key, true +} + +func formatEnvPullAssignment(key, value string) string { + return fmt.Sprintf("%s=%s", key, strconv.Quote(value)) +} + +func buildEnvPullSuccessData(appID, envFile string, databaseInfo envPullDatabaseInfo) map[string]interface{} { + result := map[string]interface{}{ + "app_id": appID, + "env_file": envFile, + } + if databaseInfo.ExpiresAtRaw != "" { + result["database_url_expires_at"] = databaseInfo.ExpiresAtRaw + } + return result +} + +func hasEnvPullDatabase(envVars map[string]string) bool { + _, ok := envVars["SUDA_DATABASE_URL"] + return ok +} + +func extractEnvPullDatabaseExpiry(rawExtras interface{}) (string, string) { + extras, ok := rawExtras.([]interface{}) + if !ok { + return "", "" + } + for _, raw := range extras { + entry, ok := raw.(map[string]interface{}) + if !ok { + continue + } + key, _ := entry["key"].(string) + if key != "expiresAt" { + continue + } + switch value := entry["value"].(type) { + case string: + rawValue := strings.TrimSpace(value) + ts, err := strconv.ParseInt(rawValue, 10, 64) + if err != nil { + return "", "" + } + return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST") + case float64: + ts := int64(value) + rawValue := strconv.FormatInt(ts, 10) + return rawValue, time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST") + } + } + return "", "" +} + +func writeEnvPullPretty(w io.Writer, appID, envFile string, databaseInfo envPullDatabaseInfo, skippedKeys []string) { + fmt.Fprintf(w, "✓ App detected: %s\n", appID) + if databaseInfo.Detected { + fmt.Fprintln(w, "✓ Development database detected") + } + fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile) + if databaseInfo.ExpiresAtText != "" { + fmt.Fprintln(w) + fmt.Fprintf(w, "DATABASE_URL is valid until %s.\n", databaseInfo.ExpiresAtText) + } + if len(skippedKeys) > 0 { + fmt.Fprintln(w) + fmt.Fprintf(w, "⚠ Skipped %d invalid key(s): %s (key names must match [A-Za-z_][A-Za-z0-9_]*)\n", len(skippedKeys), strings.Join(skippedKeys, ", ")) + } + fmt.Fprintf(w, "Run `lark-cli apps +env-pull --app-id ` again to refresh it.\n") +} + +func ensureTrailingNewline(s string) string { + if s == "" || strings.HasSuffix(s, "\n") { + return s + } + return s + "\n" +} diff --git a/shortcuts/apps/apps_env_pull_test.go b/shortcuts/apps/apps_env_pull_test.go new file mode 100644 index 00000000..f80a80a4 --- /dev/null +++ b/shortcuts/apps/apps_env_pull_test.go @@ -0,0 +1,1081 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" +) + +func assertValidationError(t *testing.T, err error, wantSubstr string) { + t.Helper() + if err == nil { + t.Fatal("expected a validation error, got nil") + } + if !errs.IsValidation(err) && output.ExitCodeOf(err) != output.ExitValidation { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + if wantSubstr != "" && !strings.Contains(err.Error(), wantSubstr) { + t.Fatalf("expected validation message containing %q, got %v", wantSubstr, err) + } +} + +func TestResolveEnvPullTarget_DefaultProjectPathUsesCWD(t *testing.T) { + cwd := t.TempDir() + oldwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() err=%v", err) + } + t.Cleanup(func() { _ = os.Chdir(oldwd) }) + if err := os.Chdir(cwd); err != nil { + t.Fatalf("Chdir() err=%v", err) + } + wantProject, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() after Chdir err=%v", err) + } + + gotProject, gotFile, err := resolveEnvPullTarget("") + if err != nil { + t.Fatalf("resolveEnvPullTarget() err=%v", err) + } + if gotProject != wantProject { + t.Fatalf("project path = %q, want %q", gotProject, wantProject) + } + wantFile := filepath.Join(wantProject, ".env.local") + if gotFile != wantFile { + t.Fatalf("env file = %q, want %q", gotFile, wantFile) + } +} + +func TestResolveEnvPullTarget_CustomProjectPath(t *testing.T) { + root := t.TempDir() + gotProject, gotFile, err := resolveEnvPullTarget(root) + if err != nil { + t.Fatalf("resolveEnvPullTarget() err=%v", err) + } + if gotProject != root { + t.Fatalf("project path = %q, want %q", gotProject, root) + } + wantFile := filepath.Join(root, ".env.local") + if gotFile != wantFile { + t.Fatalf("env file = %q, want %q", gotFile, wantFile) + } +} + +func TestCheckEnvPullTargetRejectsSymlink(t *testing.T) { + dir := t.TempDir() + realFile := filepath.Join(dir, "real.env") + if err := os.WriteFile(realFile, []byte("A = \"1\"\n"), 0o600); err != nil { + t.Fatalf("WriteFile() err=%v", err) + } + link := filepath.Join(dir, ".env.local") + if err := os.Symlink(realFile, link); err != nil { + t.Fatalf("Symlink() err=%v", err) + } + + err := checkEnvPullTarget(link) + assertValidationError(t, err, "must be a regular file") +} + +func TestCheckEnvPullTargetRejectsDirectory(t *testing.T) { + dir := filepath.Join(t.TempDir(), ".env.local") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll() err=%v", err) + } + + err := checkEnvPullTarget(dir) + assertValidationError(t, err, "must be a regular file") +} + +func TestParseEnvPullAssignmentLine(t *testing.T) { + tests := map[string]string{ + `FOO = "bar"`: "FOO", + `FOO=bar`: "FOO", + `FOO = bar`: "FOO", + `FOO='bar'`: "FOO", + `export FOO=bar`: "FOO", + `FOO=`: "FOO", + `FOO=a=b=c`: "FOO", + } + for line, want := range tests { + key, ok := parseEnvPullAssignmentLine(line) + if !ok { + t.Fatalf("expected line to parse: %q", line) + } + if key != want { + t.Fatalf("key for %q = %q, want %q", line, key, want) + } + } +} + +func TestParseEnvPullAssignmentLineRejectsComment(t *testing.T) { + if _, ok := parseEnvPullAssignmentLine("# FOO = \"bar\""); ok { + t.Fatalf("commented line should not be treated as active assignment") + } +} + +func TestParseEnvPullAssignmentLineRejectsInvalidExport(t *testing.T) { + if _, ok := parseEnvPullAssignmentLine("export =bar"); ok { + t.Fatalf("invalid export line should not be treated as active assignment") + } +} + +func TestParseEnvPullAssignmentLineTreatsExportPrefixWithoutDelimiterAsKey(t *testing.T) { + key, ok := parseEnvPullAssignmentLine("exportFOO=bar") + if !ok { + t.Fatalf("expected export-prefixed key to parse") + } + if key != "exportFOO" { + t.Fatalf("key = %q, want exportFOO", key) + } +} + +func TestFormatEnvPullAssignmentEscapesQuotesAndBackslashes(t *testing.T) { + got := formatEnvPullAssignment("TOKEN", `a"b\c`) + want := `TOKEN="a\"b\\c"` + if got != want { + t.Fatalf("formatEnvPullAssignment() = %q, want %q", got, want) + } +} + +func TestMergeEnvPullFileContentPreservesCommentsAndMalformedLines(t *testing.T) { + original := strings.Join([]string{ + "# FOO = \"old\"", + "FOO=old", + "BROKEN LINE", + "KEEP = \"stay\"", + "", + }, "\n") + + merged, updated, created := mergeEnvPullFileContent(original, map[string]string{ + "FOO": "new", + "BAR": "added", + }) + + if !strings.Contains(merged, "# FOO = \"old\"") { + t.Fatalf("comment line must be preserved: %q", merged) + } + if !strings.Contains(merged, `FOO="new"`) { + t.Fatalf("active key must be updated: %q", merged) + } + if !strings.Contains(merged, "BROKEN LINE") { + t.Fatalf("malformed line must be preserved: %q", merged) + } + if !strings.Contains(merged, "KEEP = \"stay\"") { + t.Fatalf("unrelated key must be preserved: %q", merged) + } + if !strings.Contains(merged, `BAR="added"`) { + t.Fatalf("missing key must be appended: %q", merged) + } + if len(updated) != 1 || updated[0] != "FOO" { + t.Fatalf("updated = %v, want [FOO]", updated) + } + if len(created) != 1 || created[0] != "BAR" { + t.Fatalf("created = %v, want [BAR]", created) + } +} + +func TestMergeEnvPullFileContentUpdatesCommonAssignmentStylesWithoutDuplicateKeys(t *testing.T) { + original := strings.Join([]string{ + `FOO=old`, + `BAR = old`, + `export BAZ=old`, + `QUX='old'`, + "", + }, "\n") + + merged, updated, created := mergeEnvPullFileContent(original, map[string]string{ + "FOO": "new-foo", + "BAR": "new-bar", + "BAZ": "new-baz", + "QUX": "new-qux", + }) + + for _, want := range []string{ + `FOO="new-foo"`, + `BAR="new-bar"`, + `BAZ="new-baz"`, + `QUX="new-qux"`, + } { + if strings.Count(merged, want) != 1 { + t.Fatalf("expected exactly one canonical assignment %q in %q", want, merged) + } + } + for _, legacy := range []string{`FOO=old`, `BAR = old`, `export BAZ=old`, `QUX='old'`} { + if strings.Contains(merged, legacy) { + t.Fatalf("legacy assignment should be replaced, still found %q in %q", legacy, merged) + } + } + if len(updated) != 4 { + t.Fatalf("updated = %v, want 4 items", updated) + } + if len(created) != 0 { + t.Fatalf("created = %v, want empty", created) + } +} + +func TestBuildEnvPullSuccessDataSuppressesEnvKeysAndValues(t *testing.T) { + data := buildEnvPullSuccessData("app_x", "/repo/.env.local", envPullDatabaseInfo{Detected: true, ExpiresAtRaw: "1780389006", ExpiresAtText: "2026-06-02 16:30:06 CST"}) + + if _, ok := data["updated"]; ok { + t.Fatalf("success data must not expose updated key names: %v", data) + } + if _, ok := data["created"]; ok { + t.Fatalf("success data must not expose created key names: %v", data) + } + if _, ok := data["project_path"]; ok { + t.Fatalf("success data must not expose project_path: %v", data) + } + if _, ok := data["updated_count"]; ok { + t.Fatalf("success data must not expose updated_count: %v", data) + } + if _, ok := data["created_count"]; ok { + t.Fatalf("success data must not expose created_count: %v", data) + } + if got := data["app_id"]; got != "app_x" { + t.Fatalf("app_id = %v, want app_x", got) + } + if got := data["env_file"]; got != "/repo/.env.local" { + t.Fatalf("env_file = %v, want /repo/.env.local", got) + } + if got := data["database_url_expires_at"]; got != "1780389006" { + t.Fatalf("database_url_expires_at = %v, want 1780389006", got) + } +} + +func TestAppsEnvPull_DryRunUsesPostAndResolvedEnvFile(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + projectDir := t.TempDir() + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + + got := stdout.String() + if !strings.Contains(got, `"method": "POST"`) { + t.Fatalf("dry-run must use POST: %s", got) + } + if !strings.Contains(got, `/open-apis/spark/v1/apps/app_x/env_vars`) { + t.Fatalf("dry-run missing endpoint: %s", got) + } + if !strings.Contains(got, filepath.Join(projectDir, ".env.local")) { + t.Fatalf("dry-run must include resolved env file path: %s", got) + } +} + +func TestAppsEnvPull_PrettyOutput_WithDatabaseLine(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": []interface{}{ + map[string]interface{}{"key": "SUDA_DATABASE_URL", "value": "postgres://db", "extras": []interface{}{map[string]interface{}{"key": "expiresAt", "value": "1780389006"}}}, + map[string]interface{}{"key": "APP_ID", "value": "app_x"}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + got := stdout.String() + if !strings.Contains(got, "App detected: app_x") { + t.Fatalf("missing app summary: %q", got) + } + if !strings.Contains(got, "Development database detected") { + t.Fatalf("missing database line: %q", got) + } + if !strings.Contains(got, "✓ Local environment written to "+filepath.Join(projectDir, ".env.local")) { + t.Fatalf("missing env file write line in pretty output: %q", got) + } + wantExpiry := time.Unix(1780389006, 0).Local().Format("2006-01-02 15:04:05 MST") + if !strings.Contains(got, "\n\nDATABASE_URL is valid until "+wantExpiry+".\n") { + t.Fatalf("missing blank-line separated expiry block: %q", got) + } + if !strings.Contains(got, "Run `lark-cli apps +env-pull --app-id ` again to refresh it.") { + t.Fatalf("missing refresh hint line: %q", got) + } + if strings.Contains(got, "postgres://db") { + t.Fatalf("pretty output must not print env values: %q", got) + } +} + +func TestAppsEnvPull_JSONOutput_UsesSummaryFieldsOnly(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": []interface{}{ + map[string]interface{}{"key": "AAA", "value": "value-a"}, + map[string]interface{}{"key": "SUDA_DATABASE_URL", "value": "postgres://db", "extras": []interface{}{map[string]interface{}{"key": "expiresAt", "value": "1780389006"}}}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + got := stdout.String() + if !strings.Contains(got, `"app_id": "app_x"`) { + t.Fatalf("json output must expose app_id: %s", got) + } + if !strings.Contains(got, `"env_file": "`+filepath.Join(projectDir, ".env.local")+`"`) { + t.Fatalf("json output must expose env_file: %s", got) + } + if !strings.Contains(got, `"database_url_expires_at": "1780389006"`) { + t.Fatalf("json output must expose raw database_url_expires_at: %s", got) + } + if strings.Contains(got, `"project_path"`) { + t.Fatalf("json output must not expose project_path: %s", got) + } + if strings.Contains(got, `"updated_count"`) || strings.Contains(got, `"created_count"`) { + t.Fatalf("json output must not expose count fields: %s", got) + } + if strings.Contains(got, `"AAA"`) || strings.Contains(got, `"value-a"`) || strings.Contains(got, `"postgres://db"`) { + t.Fatalf("json output must not expose env keys or env values: %s", got) + } +} + +func TestAppsEnvPull_MalformedPayloadSkipsInvalidEntries(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": []interface{}{"bad"}, + }, + }, + }) + + err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--format", "pretty", "--as", "user"}, + factory, stdout) + if err != nil { + t.Fatalf("malformed entries should be skipped, not fail; err=%v", err) + } +} + +func TestAppsEnvPull_TargetSymlinkIsRejectedBeforeAPI(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + projectDir := t.TempDir() + linkTarget := filepath.Join(projectDir, "real.env") + if err := os.WriteFile(linkTarget, []byte("KEEP = \"1\"\n"), 0o600); err != nil { + t.Fatalf("WriteFile() err=%v", err) + } + if err := os.Symlink(linkTarget, filepath.Join(projectDir, ".env.local")); err != nil { + t.Fatalf("Symlink() err=%v", err) + } + + err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"}, + factory, stdout) + assertValidationError(t, err, "must be a regular file") +} + +func TestReadEnvPullFile_MissingFileReturnsEmpty(t *testing.T) { + got, err := readEnvPullFile(filepath.Join(t.TempDir(), "missing.env")) + if err != nil { + t.Fatalf("readEnvPullFile() err=%v", err) + } + if got != "" { + t.Fatalf("readEnvPullFile() = %q, want empty string", got) + } +} + +func TestAppsEnvPull_WritesCanonicalEnvFile(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": map[string]interface{}{ + "AAA": "new", + "BBB": `quote"and\\slash`, + }, + }, + }, + }) + if err := os.WriteFile(filepath.Join(projectDir, ".env.local"), []byte(strings.Join([]string{ + "# AAA = \"commented\"", + "AAA=old", + "KEEP = \"stay\"", + "BROKEN LINE", + "", + }, "\n")), 0o600); err != nil { + t.Fatalf("WriteFile() err=%v", err) + } + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + data, err := os.ReadFile(filepath.Join(projectDir, ".env.local")) + if err != nil { + t.Fatalf("ReadFile() err=%v", err) + } + got := string(data) + if !strings.Contains(got, "# AAA = \"commented\"") { + t.Fatalf("comment must be preserved: %q", got) + } + if !strings.Contains(got, `AAA="new"`) { + t.Fatalf("active value must be updated: %q", got) + } + if !strings.Contains(got, `BBB="quote\"and\\\\slash"`) { + t.Fatalf("new key must be appended canonically: %q", got) + } + if !strings.Contains(got, "KEEP = \"stay\"") { + t.Fatalf("unrelated key must be preserved: %q", got) + } + if !strings.Contains(got, "BROKEN LINE") { + t.Fatalf("malformed line must be preserved: %q", got) + } +} + +func TestAppsEnvPull_DryRunDoesNotWriteFile(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + projectDir := t.TempDir() + target := filepath.Join(projectDir, ".env.local") + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("dry-run must not create target file, stat err=%v", err) + } +} + +func TestAppsEnvPull_JSONOutputOmitsDatabaseLineText(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": map[string]interface{}{ + "SUDA_DATABASE_URL": "short-lived-db-token", + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if strings.Contains(stdout.String(), "Development database detected") { + t.Fatalf("json output must not include pretty text: %s", stdout.String()) + } +} + +func TestAppsEnvPull_ValidationRequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--project-path", t.TempDir(), "--as", "user"}, + factory, stdout) + if err == nil || !strings.Contains(err.Error(), "app-id") { + t.Fatalf("expected missing app-id error, got %v", err) + } +} + +func TestAppsEnvPull_ExecuteUsesNestedDataEnvVars(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": map[string]interface{}{ + "AAA": "value-a", + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + data, err := os.ReadFile(filepath.Join(projectDir, ".env.local")) + if err != nil { + t.Fatalf("ReadFile() err=%v", err) + } + if !strings.Contains(string(data), `AAA="value-a"`) { + t.Fatalf("expected nested data env vars to be written, got %q", string(data)) + } +} + +func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": []interface{}{ + map[string]interface{}{"key": "AAA", "value": "value-a"}, + map[string]interface{}{"key": "SUDA_DATABASE_URL", "value": "postgres://db", "extras": []interface{}{map[string]interface{}{"key": "expiresAt", "value": "1780389006"}}}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + data, err := os.ReadFile(filepath.Join(projectDir, ".env.local")) + if err != nil { + t.Fatalf("ReadFile() err=%v", err) + } + gotFile := string(data) + if !strings.Contains(gotFile, `AAA="value-a"`) { + t.Fatalf("expected array env_vars entry to be written, got %q", gotFile) + } + if !strings.Contains(gotFile, `SUDA_DATABASE_URL="postgres://db"`) { + t.Fatalf("expected SUDA_DATABASE_URL array entry to be written, got %q", gotFile) + } + gotOut := stdout.String() + if !strings.Contains(gotOut, "Development database detected") { + t.Fatalf("expected database line in pretty output, got %q", gotOut) + } + wantExpiry := time.Unix(1780389006, 0).Local().Format("2006-01-02 15:04:05 MST") + if !strings.Contains(gotOut, "DATABASE_URL is valid until "+wantExpiry+".") { + t.Fatalf("expected expiry line in pretty output, got %q", gotOut) + } + if strings.Contains(gotOut, "expiresAt") { + t.Fatalf("extras metadata must not leak to output, got %q", gotOut) + } +} + +func TestAppsEnvPull_JSONOutputCanBeDecoded(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": map[string]interface{}{ + "AAA": "value-a", + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var envelope struct { + OK bool `json:"ok"` + Data struct { + AppID string `json:"app_id"` + EnvFile string `json:"env_file"` + DatabaseURLExpiresAt string `json:"database_url_expires_at"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("json.Unmarshal() err=%v; stdout=%s", err, stdout.String()) + } + if !envelope.OK { + t.Fatalf("expected ok=true envelope, got %+v", envelope) + } + if envelope.Data.AppID != "app_x" { + t.Fatalf("app_id = %q, want app_x", envelope.Data.AppID) + } + if envelope.Data.EnvFile != filepath.Join(projectDir, ".env.local") { + t.Fatalf("env_file = %q, want %q", envelope.Data.EnvFile, filepath.Join(projectDir, ".env.local")) + } + if envelope.Data.DatabaseURLExpiresAt != "" { + t.Fatalf("database_url_expires_at = %q, want empty for payload without SUDA_DATABASE_URL extras", envelope.Data.DatabaseURLExpiresAt) + } +} + +func TestAppsEnvPull_PrettyOutputWithoutDatabaseLine(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": map[string]interface{}{ + "AAA": "value-a", + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if strings.Contains(stdout.String(), "Development database detected") { + t.Fatalf("unexpected database line in pretty output: %q", stdout.String()) + } +} + +func TestMergeEnvPullFileContentEmptyEnvVarsPreservesOriginalNewline(t *testing.T) { + original := "KEEP = \"stay\"" + merged, updated, created := mergeEnvPullFileContent(original, map[string]string{}) + if merged != "KEEP = \"stay\"\n" { + t.Fatalf("merged = %q, want trailing newline preserved", merged) + } + if len(updated) != 0 || len(created) != 0 { + t.Fatalf("updated=%v created=%v, want both empty", updated, created) + } +} + +func TestParseEnvPullAssignmentLineRejectsInvalidKey(t *testing.T) { + if _, ok := parseEnvPullAssignmentLine("FOO BAR=baz"); ok { + t.Fatalf("assignment with whitespace in key should not be treated as active assignment") + } +} + +func TestResolveEnvPullTargetCleansCustomPath(t *testing.T) { + root := filepath.Join(t.TempDir(), "demo") + input := filepath.Join(root, ".", "sub", "..") + gotProject, gotFile, err := resolveEnvPullTarget(input) + if err != nil { + t.Fatalf("resolveEnvPullTarget() err=%v", err) + } + wantProject := filepath.Clean(input) + if gotProject != wantProject { + t.Fatalf("project path = %q, want %q", gotProject, wantProject) + } + if gotFile != filepath.Join(wantProject, ".env.local") { + t.Fatalf("env file = %q, want %q", gotFile, filepath.Join(wantProject, ".env.local")) + } +} + +func TestAppsEnvPull_DatabaseExtrasWithoutExpiresAtDoesNotFail(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": []interface{}{ + map[string]interface{}{"key": "SUDA_DATABASE_URL", "value": "postgres://db", "extras": []interface{}{map[string]interface{}{"key": "notExpiresAt", "value": "1780389006"}}}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + got := stdout.String() + if !strings.Contains(got, "Development database detected") { + t.Fatalf("expected database detection line, got %q", got) + } + if strings.Contains(got, "DATABASE_URL is valid until") { + t.Fatalf("did not expect expiry line when expiresAt is absent, got %q", got) + } +} + +func TestWriteEnvPullPretty(t *testing.T) { + var buf bytes.Buffer + writeEnvPullPretty(&buf, "app_x", "/repo/.env.local", envPullDatabaseInfo{Detected: true, ExpiresAtText: "2026-06-02 16:30:06 CST"}, nil) + got := buf.String() + if !strings.Contains(got, "App detected: app_x") { + t.Fatalf("missing app line: %q", got) + } + if !strings.Contains(got, "Development database detected") { + t.Fatalf("missing database line: %q", got) + } + if !strings.Contains(got, "✓ Local environment written to /repo/.env.local") { + t.Fatalf("missing env file write line: %q", got) + } + if !strings.Contains(got, "\n\nDATABASE_URL is valid until 2026-06-02 16:30:06 CST.\n") { + t.Fatalf("missing blank-line separated expiry block: %q", got) + } + if strings.Contains(got, "Skipped") { + t.Fatalf("no skipped warning when skippedKeys is nil: %q", got) + } + if !strings.Contains(got, "Run `lark-cli apps +env-pull --app-id ` again to refresh it.") { + t.Fatalf("missing refresh hint line: %q", got) + } +} + +func TestWriteEnvPullPretty_SkippedKeys(t *testing.T) { + var buf bytes.Buffer + writeEnvPullPretty(&buf, "app_x", "/repo/.env.local", envPullDatabaseInfo{}, []string{"bad key", "=eq"}) + got := buf.String() + if !strings.Contains(got, "⚠ Skipped 2 invalid key(s): bad key, =eq") { + t.Fatalf("missing skipped keys warning: %q", got) + } +} + +func TestExtractEnvPullVars_SkipsInvalidKeys(t *testing.T) { + data := map[string]interface{}{ + "env_vars": map[string]interface{}{ + "VALID_KEY": "ok", + "also_valid_123": "ok2", + "has space": "skip1", + "has\nnewline": "skip2", + "=starts-eq": "skip3", + "": "skip4", + "has=equals": "skip5", + }, + } + got, _, skipped, err := extractEnvPullVars(data) + if err != nil { + t.Fatalf("extractEnvPullVars() err=%v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 valid keys, got %d: %v", len(got), got) + } + if len(skipped) != 5 { + t.Fatalf("expected 5 skipped keys, got %d: %v", len(skipped), skipped) + } + if got["VALID_KEY"] != "ok" { + t.Fatalf("VALID_KEY = %q, want ok", got["VALID_KEY"]) + } + if got["also_valid_123"] != "ok2" { + t.Fatalf("also_valid_123 = %q, want ok2", got["also_valid_123"]) + } +} + +func TestExtractEnvPullVars_ArraySkipsInvalidKeys(t *testing.T) { + data := map[string]interface{}{ + "env_vars": []interface{}{ + map[string]interface{}{"key": "GOOD_KEY", "value": "val1"}, + map[string]interface{}{"key": "bad key", "value": "val2"}, + }, + } + got, _, skipped, err := extractEnvPullVars(data) + if err != nil { + t.Fatalf("extractEnvPullVars() err=%v", err) + } + if len(got) != 1 { + t.Fatalf("expected 1 valid key, got %d: %v", len(got), got) + } + if len(skipped) != 1 || skipped[0] != "bad key" { + t.Fatalf("expected 1 skipped key 'bad key', got %v", skipped) + } + if got["GOOD_KEY"] != "val1" { + t.Fatalf("GOOD_KEY = %q, want val1", got["GOOD_KEY"]) + } +} + +func TestAppsEnvPull_InjectsForceDBBranchWhenAbsent(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": map[string]interface{}{ + "AAA": "value-a", + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + data, err := os.ReadFile(filepath.Join(projectDir, ".env.local")) + if err != nil { + t.Fatalf("ReadFile() err=%v", err) + } + got := string(data) + if !strings.Contains(got, `FORCE_DB_BRANCH="dev"`) { + t.Fatalf("expected FORCE_DB_BRANCH to be injected, got %q", got) + } + if !strings.Contains(got, `AAA="value-a"`) { + t.Fatalf("expected upstream env vars to remain, got %q", got) + } +} + +func TestAppsEnvPull_InjectsForceDBBranchAlongsideArrayEnvVars(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": []interface{}{ + map[string]interface{}{"key": "AAA", "value": "value-a"}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + data, err := os.ReadFile(filepath.Join(projectDir, ".env.local")) + if err != nil { + t.Fatalf("ReadFile() err=%v", err) + } + if !strings.Contains(string(data), `FORCE_DB_BRANCH="dev"`) { + t.Fatalf("expected FORCE_DB_BRANCH to be injected for array env_vars, got %q", string(data)) + } +} + +func TestAppsEnvPull_ForceDBBranchOverwritesExistingLocalValue(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": map[string]interface{}{ + "AAA": "value-a", + }, + }, + }, + }) + if err := os.WriteFile(filepath.Join(projectDir, ".env.local"), []byte(strings.Join([]string{ + `FORCE_DB_BRANCH="prod"`, + "", + }, "\n")), 0o600); err != nil { + t.Fatalf("WriteFile() err=%v", err) + } + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + data, err := os.ReadFile(filepath.Join(projectDir, ".env.local")) + if err != nil { + t.Fatalf("ReadFile() err=%v", err) + } + got := string(data) + if !strings.Contains(got, `FORCE_DB_BRANCH="dev"`) { + t.Fatalf("expected FORCE_DB_BRANCH to be overwritten with dev, got %q", got) + } + if strings.Contains(got, `FORCE_DB_BRANCH="prod"`) { + t.Fatalf("expected stale FORCE_DB_BRANCH=\"prod\" to be replaced, got %q", got) + } + if strings.Count(got, "FORCE_DB_BRANCH=") != 1 { + t.Fatalf("expected exactly one FORCE_DB_BRANCH assignment, got %q", got) + } +} + +func TestAppsEnvPull_ForceDBBranchInjectedEvenWhenUpstreamReturnsEmptyMap(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + projectDir := t.TempDir() + 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{}{ + "env_vars": map[string]interface{}{}, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", projectDir, "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + data, err := os.ReadFile(filepath.Join(projectDir, ".env.local")) + if err != nil { + t.Fatalf("ReadFile() err=%v", err) + } + if !strings.Contains(string(data), `FORCE_DB_BRANCH="dev"`) { + t.Fatalf("expected FORCE_DB_BRANCH to be injected even with empty upstream map, got %q", string(data)) + } +} + +func TestEnsureTrailingNewline_Cases(t *testing.T) { + cases := []struct { + in, want string + }{ + {"", ""}, + {"abc", "abc\n"}, + {"abc\n", "abc\n"}, + } + for _, c := range cases { + if got := ensureTrailingNewline(c.in); got != c.want { + t.Errorf("ensureTrailingNewline(%q)=%q want %q", c.in, got, c.want) + } + } +} + +func TestExtractEnvPullDatabaseExpiry_Cases(t *testing.T) { + t.Run("not a slice", func(t *testing.T) { + raw, text := extractEnvPullDatabaseExpiry("nope") + if raw != "" || text != "" { + t.Errorf("got %q,%q want empty", raw, text) + } + }) + t.Run("no expiresAt key", func(t *testing.T) { + raw, text := extractEnvPullDatabaseExpiry([]interface{}{ + map[string]interface{}{"key": "other", "value": "1"}, + }) + if raw != "" || text != "" { + t.Errorf("got %q,%q want empty", raw, text) + } + }) + t.Run("non-map element skipped", func(t *testing.T) { + raw, text := extractEnvPullDatabaseExpiry([]interface{}{"not-a-map"}) + if raw != "" || text != "" { + t.Errorf("got %q,%q want empty", raw, text) + } + }) + t.Run("string timestamp", func(t *testing.T) { + ts := int64(1700000000) + raw, text := extractEnvPullDatabaseExpiry([]interface{}{ + map[string]interface{}{"key": "expiresAt", "value": "1700000000"}, + }) + want := time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST") + if raw != "1700000000" || text != want { + t.Errorf("got %q,%q want 1700000000,%q", raw, text, want) + } + }) + t.Run("float timestamp", func(t *testing.T) { + ts := int64(1700000000) + raw, text := extractEnvPullDatabaseExpiry([]interface{}{ + map[string]interface{}{"key": "expiresAt", "value": float64(ts)}, + }) + want := time.Unix(ts, 0).Local().Format("2006-01-02 15:04:05 MST") + if raw != "1700000000" || text != want { + t.Errorf("got %q,%q want 1700000000,%q", raw, text, want) + } + }) + t.Run("invalid string timestamp", func(t *testing.T) { + raw, text := extractEnvPullDatabaseExpiry([]interface{}{ + map[string]interface{}{"key": "expiresAt", "value": "notanumber"}, + }) + if raw != "" || text != "" { + t.Errorf("got %q,%q want empty", raw, text) + } + }) +} + +func TestExtractEnvPullVars_EdgeCases(t *testing.T) { + t.Run("missing env_vars", func(t *testing.T) { + _, _, _, err := extractEnvPullVars(map[string]interface{}{}) + if err == nil { + t.Fatal("expected error for missing env_vars") + } + }) + t.Run("nested under data", func(t *testing.T) { + vars, _, _, err := extractEnvPullVars(map[string]interface{}{ + "data": map[string]interface{}{ + "env_vars": map[string]interface{}{"FOO": "bar"}, + }, + }) + if err != nil || vars["FOO"] != "bar" { + t.Fatalf("got vars=%v err=%v", vars, err) + } + }) + t.Run("array form skips non-string value", func(t *testing.T) { + vars, _, _, err := extractEnvPullVars(map[string]interface{}{ + "env_vars": []interface{}{ + map[string]interface{}{"key": "K1", "value": "v1"}, + map[string]interface{}{"key": "K2", "value": 5}, + }, + }) + if err != nil { + t.Fatalf("err=%v", err) + } + if vars["K1"] != "v1" { + t.Errorf("K1 missing: %v", vars) + } + if _, ok := vars["K2"]; ok { + t.Errorf("K2 should be skipped (non-string value)") + } + }) +} + +func TestResolveEnvPullTarget_RejectsControlChars(t *testing.T) { + if _, _, err := resolveEnvPullTarget("bad\x01path"); err == nil { + t.Error("control char in --project-path must be rejected") + } +} + +func TestReadEnvPullFile_ReadErrorOnDirectory(t *testing.T) { + // Reading a directory as a file is a non-ENOENT error path. + if _, err := readEnvPullFile(t.TempDir()); err == nil { + t.Error("reading a directory as env file must surface an error") + } +} + +func TestEnsureEnvPullParentDir_MkdirError(t *testing.T) { + // A file occupying the would-be parent component makes MkdirAll fail. + base := t.TempDir() + blocker := filepath.Join(base, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := ensureEnvPullParentDir(filepath.Join(blocker, "child", ".env.local")); err == nil { + t.Error("MkdirAll over a file component must surface an error") + } +} diff --git a/shortcuts/apps/apps_examples_test.go b/shortcuts/apps/apps_examples_test.go new file mode 100644 index 00000000..5b977d0f --- /dev/null +++ b/shortcuts/apps/apps_examples_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "regexp" + "strings" + "testing" +) + +func TestAppsShortcutsHaveExamples(t *testing.T) { + realAppID := regexp.MustCompile(`app_[a-z0-9]{6,}`) + 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() { + hasExample := false + for _, tip := range s.Tips { + if strings.HasPrefix(tip, "Example: lark-cli apps +") { + hasExample = true + } + if realAppID.MatchString(tip) { + t.Errorf("%s tip leaks real-looking app id (use ): %q", s.Command, tip) + } + if email.MatchString(tip) || phone.MatchString(tip) { + t.Errorf("%s tip leaks PII: %q", s.Command, tip) + } + } + if !hasExample { + t.Errorf("%s has no \"Example: lark-cli apps +...\" tip", s.Command) + } + } +} + +func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) { + want := map[string]int{"+chat": 2, "+access-scope-set": 2} + for _, s := range Shortcuts() { + min, ok := want[s.Command] + if !ok { + continue + } + n := 0 + for _, tip := range s.Tips { + if strings.HasPrefix(tip, "Example: lark-cli apps +") { + n++ + } + } + if n < min { + t.Errorf("%s has %d Example tips, want >= %d", s.Command, n, min) + } + } +} diff --git a/shortcuts/apps/apps_hint_leak_test.go b/shortcuts/apps/apps_hint_leak_test.go new file mode 100644 index 00000000..a06f9803 --- /dev/null +++ b/shortcuts/apps/apps_hint_leak_test.go @@ -0,0 +1,67 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "regexp" + "testing" +) + +// TestAppsErrorHintsCarryNoSecretsOrPII guards the actionable error hints added +// for the apps command-governance task. Those hints are inline string literals +// spread across several files (apps_env_pull.go, apps_access_scope_set.go, +// apps_access_scope_get.go, apps_init.go git-push path, and the +// gitCredentialIssueHint const in git_credential.go). They are stable English +// strings, so we assert the verbatim copies here: a real app_id, an email, or a +// phone number must never appear in a hint. Placeholders like are +// expected and must NOT trip the real-app-id regex. +func TestAppsErrorHintsCarryNoSecretsOrPII(t *testing.T) { + // These are copied verbatim from the source. If a hint changes, copy the new + // text here so this leak guard keeps tracking the real production string. + hints := []string{ + // apps_env_pull.go:86 and apps_access_scope_get.go:50 (identical literals) + "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`", + // apps_access_scope_set.go:74 + "verify --app-id is correct; for scope=specific, each --targets id must be a valid open_id/department_id/chat_id and --approver a valid open_id; review the current scope with `lark-cli apps +access-scope-get --app-id `", + // apps_init.go:483 (git push rejection) + "the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded", + // git_credential.go gitCredentialIssueHint const (referenced directly so a + // rename or text change breaks the build instead of silently drifting) + gitCredentialIssueHint, + // command-governance hints added for this task (referenced by const, no drift) + appIDListHint, + sessionStopHint, + createHint, + dbEnvCreateHint, + dbTableGetHint, + dbTableListHint, + } + + realAppID := regexp.MustCompile(`app_[a-z0-9]{6,}`) + email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`) + phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`) + // An obvious secret: a PAT-like token or a "secret=..." / "token=..." pair. + secret := regexp.MustCompile(`(?i)(pat-[a-z0-9]+|secret\s*[=:]\s*\S|token\s*[=:]\s*\S)`) + + for _, h := range hints { + if realAppID.MatchString(h) { + t.Errorf("hint leaks a real-looking app id (use ): %q", h) + } + if email.MatchString(h) { + t.Errorf("hint leaks an email address: %q", h) + } + if phone.MatchString(h) { + t.Errorf("hint leaks a phone number: %q", h) + } + if secret.MatchString(h) { + t.Errorf("hint leaks an obvious secret/token: %q", h) + } + } + + // Sanity: the placeholder must NOT match the real-app-id regex, + // otherwise the guard above would be a false positive on legitimate hints. + if realAppID.MatchString("") { + t.Fatal("realAppID regex incorrectly matches the placeholder") + } +} diff --git a/shortcuts/apps/apps_hints_more_test.go b/shortcuts/apps/apps_hints_more_test.go new file mode 100644 index 00000000..2fed3cd6 --- /dev/null +++ b/shortcuts/apps/apps_hints_more_test.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func assertHintContains(t *testing.T, sc common.Shortcut, args []string, stub *httpmock.Stub, want string) { + t.Helper() + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(stub) + err := runAppsShortcut(t, sc, args, factory, stdout) + if err == nil { + t.Fatalf("expected failure, got nil; stdout=%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed errs.Problem, got %T: %v", err, err) + } + if !strings.Contains(p.Hint, want) { + t.Fatalf("hint %q does not contain %q", p.Hint, want) + } +} + +func TestAppsSessionCreate_4xxFailureCarriesListHint(t *testing.T) { + assertHintContains(t, AppsSessionCreate, + []string{"+session-create", "--app-id", "app_x", "--as", "user"}, + &httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/sessions", + Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "app not found"}}, + "apps +list") +} + +func TestAppsSessionList_4xxFailureCarriesListHint(t *testing.T) { + assertHintContains(t, AppsSessionList, + []string{"+session-list", "--app-id", "app_x", "--as", "user"}, + &httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/sessions", + Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}}, + "apps +list") +} + +func TestAppsUpdate_4xxFailureCarriesListHint(t *testing.T) { + assertHintContains(t, AppsUpdate, + []string{"+update", "--app-id", "app_x", "--name", "n", "--as", "user"}, + &httpmock.Stub{Method: "PATCH", URL: "/open-apis/spark/v1/apps/app_x", + Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "app not found"}}, + "apps +list") +} + +func TestAppsReleaseList_4xxFailureCarriesListHint(t *testing.T) { + assertHintContains(t, AppsReleaseList, + []string{"+release-list", "--app-id", "app_x", "--as", "user"}, + &httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases", + Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}}, + "apps +list") +} + +func TestAppsSessionStop_4xxFailureCarriesSessionHint(t *testing.T) { + assertHintContains(t, AppsSessionStop, + []string{"+session-stop", "--app-id", "app_x", "--session-id", "s1", "--turn-id", "t1", "--as", "user"}, + &httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/sessions/s1/stop", + Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "session not found"}}, + "+session-list") +} + +func TestAppsCreate_4xxFailureCarriesTypeHint(t *testing.T) { + assertHintContains(t, AppsCreate, + []string{"+create", "--name", "n", "--app-type", "html", "--as", "user"}, + &httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps", + Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}}, + "full_stack") +} + +func TestAppsDBEnvCreate_4xxFailureCarriesHint(t *testing.T) { + assertHintContains(t, AppsDBEnvCreate, + []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"}, + &httpmock.Stub{Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/db_dev_init", + Status: http.StatusConflict, Body: map[string]interface{}{"msg": "already multi-env"}}, + "+db-table-list") +} + +func TestAppsDBTableGet_4xxFailureCarriesHint(t *testing.T) { + assertHintContains(t, AppsDBTableGet, + []string{"+db-table-get", "--app-id", "app_x", "--table", "users", "--as", "user"}, + &httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables/users", + Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "table not found"}}, + "+db-table-list") +} + +func TestAppsDBTableList_4xxFailureCarriesHint(t *testing.T) { + assertHintContains(t, AppsDBTableList, + []string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, + &httpmock.Stub{Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/tables", + Status: http.StatusNotFound, Body: map[string]interface{}{"msg": "dev env not found"}}, + "+db-env-create") +} + +// withAppsHint must only fill an EMPTY hint; an upstream-provided hint wins. +func TestWithAppsHint_DoesNotOverrideUpstreamHint(t *testing.T) { + upstream := &errs.Problem{Message: "boom", Hint: "upstream specific hint"} + got := withAppsHint(upstream, appIDListHint) + p, ok := errs.ProblemOf(got) + if !ok { + t.Fatalf("expected typed problem, got %T", got) + } + if p.Hint != "upstream specific hint" { + t.Fatalf("upstream hint was overridden: %q", p.Hint) + } +} + +// withAppsHint fills the hint when empty and leaves Message untouched. +func TestWithAppsHint_FillsEmptyHintKeepsMessage(t *testing.T) { + p0 := &errs.Problem{Message: "boom"} + got := withAppsHint(p0, appIDListHint) + p, _ := errs.ProblemOf(got) + if p.Hint != appIDListHint { + t.Fatalf("hint not filled: %q", p.Hint) + } + if p.Message != "boom" { + t.Fatalf("message mutated: %q", p.Message) + } +} diff --git a/shortcuts/apps/apps_hints_test.go b/shortcuts/apps/apps_hints_test.go new file mode 100644 index 00000000..6c959f49 --- /dev/null +++ b/shortcuts/apps/apps_hints_test.go @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +// TestAppsEnvPull_4xxFailureCarriesListHint verifies that a 4xx failure from the +// env_vars endpoint surfaces an actionable hint pointing at `lark-cli apps +list`. +func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + Status: http.StatusForbidden, + Body: map[string]interface{}{"msg": "permission denied"}, + }) + + 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 failure, got nil; stdout=%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed errs.Problem, got %T: %v", err, err) + } + if !strings.Contains(p.Hint, "apps +list") { + t.Fatalf("hint missing `apps +list`: %q", p.Hint) + } +} + +// TestAppsAccessScopeGet_4xxFailureCarriesListHint verifies the access-scope-get +// 4xx failure points at `lark-cli apps +list`. +func TestAppsAccessScopeGet_4xxFailureCarriesListHint(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/access-scope", + Status: http.StatusNotFound, + Body: map[string]interface{}{"msg": "app not found"}, + }) + + err := runAppsShortcut(t, AppsAccessScopeGet, + []string{"+access-scope-get", "--app-id", "app_x", "--as", "user"}, + factory, stdout) + if err == nil { + t.Fatalf("expected failure, got nil; stdout=%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed errs.Problem, got %T: %v", err, err) + } + if !strings.Contains(p.Hint, "apps +list") { + t.Fatalf("hint missing `apps +list`: %q", p.Hint) + } +} + +// TestAppsAccessScopeSet_4xxFailureCarriesScopeGetHint verifies the +// access-scope-set 4xx failure points at `+access-scope-get`. +func TestAppsAccessScopeSet_4xxFailureCarriesScopeGetHint(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/spark/v1/apps/app_x/access-scope", + Status: http.StatusBadRequest, + Body: map[string]interface{}{"msg": "invalid target id"}, + }) + + err := runAppsShortcut(t, AppsAccessScopeSet, + []string{"+access-scope-set", "--app-id", "app_x", "--scope", "tenant", "--as", "user"}, + factory, stdout) + if err == nil { + t.Fatalf("expected failure, got nil; stdout=%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed errs.Problem, got %T: %v", err, err) + } + if !strings.Contains(p.Hint, "+access-scope-get") { + t.Fatalf("hint missing `+access-scope-get`: %q", p.Hint) + } +} diff --git a/shortcuts/apps/apps_html_publish.go b/shortcuts/apps/apps_html_publish.go index 64cc5ae7..6c6f58f9 100644 --- a/shortcuts/apps/apps_html_publish.go +++ b/shortcuts/apps/apps_html_publish.go @@ -21,9 +21,13 @@ var AppsHTMLPublish = common.Shortcut{ Command: "+html-publish", Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)", Risk: "write", - Scopes: []string{"spark:app:write"}, - AuthTypes: []string{"user"}, - HasFormat: true, + Tips: []string{ + "Example: lark-cli apps +html-publish --app-id --path ./dist", + "Example: lark-cli apps +html-publish --app-id --path ./site --dry-run", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "Miaoda app ID", Required: true}, {Name: "path", Desc: "path to HTML file or directory", Required: true}, diff --git a/shortcuts/apps/apps_init.go b/shortcuts/apps/apps_init.go new file mode 100644 index 00000000..a20b8184 --- /dev/null +++ b/shortcuts/apps/apps_init.go @@ -0,0 +1,674 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "unicode" + + "github.com/larksuite/cli/internal/charcheck" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// defaultInitBranch is the fixed remote branch +init checks out after clone. +const defaultInitBranch = "sprint/default" + +// Fixed init commit subjects. Constants — never interpolate user input. The +// empty-repo (`app init`) path splits the scaffolded tree into two commits; +// the non-empty (`app sync`) path stays a single commit. +const ( + commitMsgAppCode = "chore: initialize app project code" + commitMsgAppConfig = "chore: initialize miaoda app config" + commitMsgUpgrade = "chore: initialize miaoda app repository" +) + +// scaffold kinds returned by runScaffold and consumed by commitAndPushIfDirty. +const ( + scaffoldKindInit = "init" + scaffoldKindUpgrade = "upgrade" +) + +const ( + miaodaCLIPkg = "@lark-apaas/miaoda-cli@latest" + defaultTemplate = "nestjs-react-fullstack" + metaRelPath = ".spark/meta.json" + steeringRelPath = ".agent/skills/steering" + seedReadme = "README.md" +) + +// initRunner is the commandRunner used by +init. Package-level so unit tests +// can swap in a fakeCommandRunner. Production uses execCommandRunner. +var initRunner commandRunner = execCommandRunner{} + +// AppsInit initializes a Miaoda app's code and local development environment. +var AppsInit = common.Shortcut{ + Service: appsService, + Command: "+init", + Description: "Initialize a Miaoda app's code and local development environment", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +init --app-id --dir ", + "Example: lark-cli apps +init --app-id --dir --dry-run", + }, + // +init makes no direct lark API calls (it shells out to the + // +git-credential-init subprocess, which enforces its own scopes), so it + // declares no scopes of its own. Explicit []string{} (not nil) per the + // convention enforced by TestAllShortcutsScopesNotNil. + Scopes: []string{}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + // NOTE: --app-id is intentionally NOT Required:true. The framework maps + // Required:true to cobra's MarkFlagRequired, whose error is plain-text + // exit-1 (root.go handleRootError case 4), bypassing the structured + // envelope. The spec and the E2E assert exit-2 + a structured + // {"ok":false,"error":{...}} envelope for missing --app-id, so the empty + // check lives in Validate (output.ErrValidation -> ExitValidation=2). + {Name: "app-id", Desc: "Miaoda app ID"}, + {Name: "dir", Desc: "clone target directory; absolute or relative path (default ./)"}, + {Name: "template", Desc: "code-init template for an empty repo; optional — if omitted, derived from the app's tech stack"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + template := resolveTemplate(rctx, appID) + dry := common.NewDryRunAPI(). + Desc("Initialize Miaoda app code (credential-init, clone, checkout, npx code-init, optional commit/push)"). + Set("credential_init", fmt.Sprintf("apps +git-credential-init --app-id %s --format json", appID)). + Set("checkout", "git checkout "+defaultInitBranch). + Set("scaffold", fmt.Sprintf("empty repo: npx -y --prefer-online %s app init --template %s --app-id %s; non-empty: npx -y --prefer-online %s app sync + .spark/meta.json app_id patch + conditional skills sync --local", miaodaCLIPkg, template, appID, miaodaCLIPkg)). + Set("commit_push", "conditional: git add -A + commit + push origin "+defaultInitBranch+" when the working tree has changes"). + Set("template", template). + Set("env_pull", fmt.Sprintf("apps +env-pull --app-id %s --project-path --format json (after successful init)", appID)) + dir, err := resolveTargetPath(rctx, appID) + if err != nil { + dry.Set("dir_error", err.Error()) + dir = defaultCloneDir(appID) + } else if isAlreadyInitialized(dir) { + dry.Set("already_initialized", true) + } else if e := ensureEmptyDir(dir); e != nil { + dry.Set("dir_error", e.Error()) + } + dry.Set("clone", fmt.Sprintf("git clone -- %s", dir)) + dry.Set("clone_path", dir) + return dry + }, + Execute: appsInitExecute, +} + +// defaultCloneDir returns the default clone target (./) for an app ID. +func defaultCloneDir(appID string) string { + return filepath.Join(".", appID) +} + +// resolveTemplate returns the scaffold template for an empty-repo `app init`. +// An explicit --template wins. When omitted, it should be derived from the +// app's tech stack. +// TODO(apps-init): look up the app by appID via the apps API (e.g. `apps +list` +// or a get-app endpoint), read its tech stack, and map tech-stack -> template +// through a (future) enum. Until that lands, fall back to defaultTemplate. +func resolveTemplate(rctx *common.RuntimeContext, appID string) string { + if t := strings.TrimSpace(rctx.Str("template")); t != "" { + return t + } + // TODO(apps-init): derive from app tech stack (apps API + enum mapping). + return defaultTemplate +} + +// initLogf writes a one-line progress message to stderr. stdout stays reserved +// for the structured JSON envelope, so progress never pollutes it. Callers must +// never pass a raw repository_url (it may embed a token) — pass step names, +// clone_path, branch, or scaffold kind, and route any URL through +// redactURLCredentials first. +func initLogf(rctx *common.RuntimeContext, format string, args ...interface{}) { + fmt.Fprintf(rctx.IO().ErrOut, "→ "+format+"\n", args...) +} + +// resolveTargetPath computes the absolute clone target from --dir (or the +// ./ default). Unlike the prior SafeInputPath approach it does NOT +// confine to cwd — the clone destination is user-chosen (the skill prompts for +// it). It rejects empty input and control characters; symlink/no-clobber +// guarding happens in ensureEmptyDir. +func resolveTargetPath(rctx *common.RuntimeContext, appID string) (string, error) { + raw := strings.TrimSpace(rctx.Str("dir")) + if raw == "" { + raw = defaultCloneDir(appID) + } + // Reject ALL control characters (incl. tab/newline — a newline in an echoed + // path is a log-injection vector); charcheck additionally rejects dangerous + // Unicode (bidi overrides, zero-width) that IsControl does not. + if strings.IndexFunc(raw, unicode.IsControl) >= 0 { + return "", output.ErrValidation("--dir must not contain control characters") + } + if err := charcheck.RejectControlChars(raw, "--dir"); err != nil { + return "", output.ErrValidation("%v", err) + } + abs, err := filepath.Abs(raw) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); raw is control-char-validated above, and FileIO.ResolvePath cannot resolve a clone target (it rejects absolute paths). + if err != nil { + return "", output.ErrValidation("--dir cannot be resolved: %v", err) + } + return abs, nil +} + +// ensureEmptyDir refuses to clone into an existing non-empty dir, a symlink, or +// a non-directory. A non-existent path is fine (git clone creates it). Uses +// Lstat so a symlinked target is rejected rather than followed. +func ensureEmptyDir(dir string) error { + info, err := os.Lstat(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and lstat is required to reject a symlink (FileIO has no Lstat; its Stat follows symlinks). + if os.IsNotExist(err) { + return nil + } + if err != nil { + return output.ErrValidation("--dir cannot be read: %v", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return output.ErrValidation("--dir must not be a symlink: %q", dir) + } + if !info.IsDir() { + return output.ErrValidation("--dir exists and is not a directory: %q", dir) + } + entries, err := os.ReadDir(dir) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); dir is the validated clone target, and FileIO has no ReadDir. + if err != nil { + return output.ErrValidation("--dir cannot be read: %v", err) + } + if len(entries) > 0 { + return output.ErrValidation("target directory %q already exists and is not empty", dir) + } + return nil +} + +// isAlreadyInitialized reports whether dir is an already-initialized Miaoda app +// repo, detected by the presence of /.spark/meta.json (regardless of its +// app_id value). Used to short-circuit +init into a friendly no-op. +func isAlreadyInitialized(dir string) bool { + info, err := os.Stat(filepath.Join(dir, metaRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths. + return err == nil && !info.IsDir() +} + +// ensureMetaAppID patches /.spark/meta.json to include app_id when the file +// exists but lacks (or has an empty) app_id. Other fields are preserved. When +// the file does not exist, this is a no-op (we never create it). +func ensureMetaAppID(dir, appID string) error { + path := filepath.Join(dir, metaRelPath) + b, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Open rejects absolute paths. + if os.IsNotExist(err) { + return nil + } + if err != nil { + return output.Errorf(output.ExitAPI, "meta_write", "read %s failed: %v", metaRelPath, err) + } + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + return output.Errorf(output.ExitAPI, "meta_write", "parse %s failed: %v", metaRelPath, err) + } + if cur, _ := m["app_id"].(string); strings.TrimSpace(cur) != "" { + return nil + } + if m == nil { + m = map[string]interface{}{} + } + m["app_id"] = appID + out, err := json.MarshalIndent(m, "", " ") + if err != nil { + return output.Errorf(output.ExitAPI, "meta_write", "marshal %s failed: %v", metaRelPath, err) + } + if err := os.WriteFile(path, append(out, '\n'), 0o644); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Save rejects absolute paths. + return output.Errorf(output.ExitAPI, "meta_write", "write %s failed: %v", metaRelPath, err) + } + return nil +} + +// hasSteeringSkills reports whether /.agent/skills/steering exists as a dir. +func hasSteeringSkills(dir string) bool { + info, err := os.Stat(filepath.Join(dir, steeringRelPath)) //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); path is under the validated clone dir, and FileIO.Stat rejects absolute paths. + return err == nil && info.IsDir() +} + +// isEmptyRepo reports whether the checked-out branch has no tracked files +// other than the backend's default seed README.md. `git ls-files` listing +// nothing — or only README.md — counts as empty (→ scaffold via `app init`). +func isEmptyRepo(ctx context.Context, dir string) (bool, error) { + stdout, stderr, err := initRunner.Run(ctx, dir, "git", "ls-files") + if err != nil { + return false, output.Errorf(output.ExitAPI, "git_ls_files", "git ls-files failed: %s", gitErr(stderr, err)) + } + for _, line := range strings.Split(strings.TrimSpace(stdout), "\n") { + f := strings.TrimSpace(line) + // Match the seed exactly (case- and path-sensitive): only a root-level + // "README.md" is the backend's default seed. A docs/README.md or readme.md + // is treated as real content (→ non-empty), which is the safe direction + // (skip scaffolding rather than risk overwriting). Extend this allow-list + // here if the backend's seed set grows. + if f == "" || f == seedReadme { + continue + } + return false, nil // a non-README tracked file → non-empty repo + } + return true, nil +} + +// runScaffold runs the npx scaffolding step inside the cloned repo (cwd=dir). +// Empty repo -> `app init`; non-empty -> `app sync` + meta app_id patch + +// conditional `skills sync`. Returns "init" or "upgrade". +func runScaffold(ctx context.Context, dir, appID, template string) (string, error) { + empty, err := isEmptyRepo(ctx, dir) + if err != nil { + return "", err + } + if empty { + // isEmptyRepo treats a repo with no tracked files — or only the backend's + // seed README.md — as empty. If other seed files (e.g. .gitignore) can + // appear, extend isEmptyRepo's allow-list accordingly. + if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", template, "--app-id", appID); err != nil { + return "", output.Errorf(output.ExitAPI, "npx_app_init", "npx app init failed: %s", gitErr(stderr, err)) + } + return scaffoldKindInit, nil + } + if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "app", "sync"); err != nil { + return "", output.Errorf(output.ExitAPI, "npx_app_sync", "npx app sync failed: %s", gitErr(stderr, err)) + } + if err := ensureMetaAppID(dir, appID); err != nil { + return "", err + } + if !hasSteeringSkills(dir) { + if _, stderr, err := initRunner.Run(ctx, dir, "npx", "-y", "--prefer-online", miaodaCLIPkg, "skills", "sync", "--local"); err != nil { + return "", output.Errorf(output.ExitAPI, "npx_skills_sync", "npx skills sync failed: %s", gitErr(stderr, err)) + } + } + return scaffoldKindUpgrade, nil +} + +// parseRepoURLFromEnvelope extracts data.repository_url from a lark-cli JSON +// envelope ({"ok":true,"data":{"repository_url":"..."}}). The field name +// matches the contract emitted by `apps +git-credential-init`. +func parseRepoURLFromEnvelope(stdout string) (string, error) { + var env struct { + OK bool `json:"ok"` + Data struct { + RepositoryURL string `json:"repository_url"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &env); err != nil { + return "", output.Errorf(output.ExitInternal, "credential_init", "could not parse +git-credential-init output as JSON: %v", err) + } + if !env.OK { + return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init reported failure") + } + if strings.TrimSpace(env.Data.RepositoryURL) == "" { + return "", output.Errorf(output.ExitInternal, "credential_init", "+git-credential-init returned no repository_url") + } + return env.Data.RepositoryURL, nil +} + +// parseEnvFileFromEnvelope extracts data.env_file from a `+env-pull` success +// envelope ({"ok":true,"data":{"env_file":"..."}}) on stdout. +func parseEnvFileFromEnvelope(stdout string) (string, error) { + var env struct { + OK bool `json:"ok"` + Data struct { + EnvFile string `json:"env_file"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &env); err != nil { + return "", output.Errorf(output.ExitInternal, "env_pull", "could not parse +env-pull output as JSON: %v", err) + } + if !env.OK { + return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull reported failure") + } + if strings.TrimSpace(env.Data.EnvFile) == "" { + return "", output.Errorf(output.ExitInternal, "env_pull", "+env-pull returned no env_file") + } + return env.Data.EnvFile, nil +} + +// parseEnvPullErrorEnvelope extracts a single-line reason from a `+env-pull` +// error envelope ({"ok":false,"error":{"type":...,"message":...}}) on stderr. +// Returns "" when stderr is not a parseable error envelope (caller falls back). +func parseEnvPullErrorEnvelope(stderr string) string { + var env struct { + Error struct { + Type string `json:"type"` + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal([]byte(strings.TrimSpace(stderr)), &env); err != nil { + return "" + } + msg := strings.TrimSpace(env.Error.Message) + if msg == "" { + return "" + } + if t := strings.TrimSpace(env.Error.Type); t != "" { + return t + ": " + msg + } + return msg +} + +// validateRepoURLScheme rejects any repository_url that is not http(s):// to +// block git's dangerous transports (ext::, file://, ssh://) and option injection. +func validateRepoURLScheme(repoURL string) error { + if strings.HasPrefix(repoURL, "http://") || strings.HasPrefix(repoURL, "https://") { + return nil + } + return output.Errorf(output.ExitValidation, "validation", + "repository_url from +git-credential-init must be http(s); refusing %q", redactURLCredentials(repoURL)) +} + +func appsInitExecute(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + + dir, err := resolveTargetPath(rctx, appID) + if err != nil { + return err + } + + // Already-initialized short-circuit: a dir containing .spark/meta.json is an + // initialized Miaoda app repo -> skip clone/scaffold/commit, but still refresh + // the local env so a re-run picks up the latest startup env vars. + if isAlreadyInitialized(dir) { + initLogf(rctx, "Already initialized at %s — refreshing local environment", dir) + out := map[string]interface{}{ + "app_id": appID, + "clone_path": dir, + "scaffold": "already_initialized", + "committed": false, + "pushed": false, + } + initLogf(rctx, "Pulling local environment variables...") + envFile, envPullErr := pullEnv(ctx, rctx, appID, dir) + envPulled := envPullErr == "" + out["env_pulled"] = envPulled + if envPulled { + initLogf(rctx, "Local environment written to %s", envFile) + out["env_file"] = envFile + out["message"] = "Repository already initialized. Local env refreshed — you can start developing." + } else { + initLogf(rctx, "Could not pull local env vars: %s", envPullErr) + out["env_pull_error"] = envPullErr + out["message"] = fmt.Sprintf("Repository already initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID) + } + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Already initialized at %s\n", dir) + if envPulled { + fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile) + } else { + fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr) + fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID) + } + fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。") + }) + return nil + } + + if _, err := exec.LookPath("git"); err != nil { + return output.ErrWithHint(output.ExitInternal, "dependency", + "git executable not found on PATH", "install git and ensure it is on your PATH") + } + if _, err := exec.LookPath("npx"); err != nil { + return output.ErrWithHint(output.ExitInternal, "dependency", + "npx executable not found on PATH", "install Node.js (which provides npx) and ensure it is on your PATH") + } + + if err := ensureEmptyDir(dir); err != nil { + return err + } + + initLogf(rctx, "Issuing repository credentials for %s...", appID) + repoURL, err := issueCredentials(ctx, rctx, appID) + if err != nil { + return err + } + if err := validateRepoURLScheme(repoURL); err != nil { + return err + } + + initLogf(rctx, "Cloning into %s...", dir) + if _, stderr, err := initRunner.Run(ctx, "", "git", "clone", "--", repoURL, dir); err != nil { + return output.Errorf(output.ExitAPI, "git_clone", "git clone failed: %s", gitErr(stderr, err)) + } + initLogf(rctx, "Checking out %s...", defaultInitBranch) + if _, stderr, err := initRunner.Run(ctx, dir, "git", "checkout", defaultInitBranch); err != nil { + return output.Errorf(output.ExitAPI, "git_checkout", "git checkout %s failed: %s", defaultInitBranch, gitErr(stderr, err)) + } + + initLogf(rctx, "Initializing app code (running miaoda-cli)...") + scaffold, err := runScaffold(ctx, dir, appID, resolveTemplate(rctx, appID)) + if err != nil { + return err + } + + committed, pushed, err := commitAndPushIfDirty(ctx, dir, scaffold) + if err != nil { + return err + } + if pushed { + initLogf(rctx, "Committed and pushed to %s", defaultInitBranch) + } else { + initLogf(rctx, "Working tree clean — skipped commit/push") + } + + initLogf(rctx, "Pulling local environment variables...") + envFile, envPullErr := pullEnv(ctx, rctx, appID, dir) + envPulled := envPullErr == "" + if envPulled { + initLogf(rctx, "Local environment written to %s", envFile) + } else { + initLogf(rctx, "Could not pull local env vars: %s", envPullErr) + } + + out := map[string]interface{}{ + "app_id": appID, + "repository_url": redactURLCredentials(repoURL), + "branch": defaultInitBranch, + "clone_path": dir, + "scaffold": scaffold, + "committed": committed, + "pushed": pushed, + "env_pulled": envPulled, + "message": "Repository initialized. You can start developing.", + } + if envPulled { + out["env_file"] = envFile + } else { + out["env_pull_error"] = envPullErr + out["message"] = fmt.Sprintf("Repository initialized. Could not pull local env vars automatically — run `lark-cli apps +env-pull --app-id %s` to retry.", appID) + } + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Repository initialized at %s\n", dir) + fmt.Fprintf(w, " branch: %s\n scaffold: %s\n", defaultInitBranch, scaffold) + if envPulled { + fmt.Fprintf(w, "✓ Local environment written to %s\n", envFile) + } else { + fmt.Fprintf(w, "⚠ Could not pull local env vars: %s\n", envPullErr) + fmt.Fprintf(w, " run `lark-cli apps +env-pull --app-id %s` to retry\n", appID) + } + fmt.Fprintln(w, "仓库已初始化完成,可以开始开发了。") + }) + return nil +} + +// pullEnv runs ` apps +env-pull --app-id --project-path +// --format json`, forwarding --as when set. Returns (envFile, "") on success or +// ("", reason) on failure. Non-fatal by contract: the caller logs a warning and +// continues. The success envelope is read from stdout, the error envelope from +// stderr (lark-cli writes structured errors to stderr; see cmd/root.go +// handleRootError). The reason is always redacted. +func pullEnv(ctx context.Context, rctx *common.RuntimeContext, appID, dir string) (envFile, reason string) { + self, err := os.Executable() + if err != nil { + return "", redactURLCredentials(fmt.Sprintf("cannot locate lark-cli executable: %v", err)) + } + args := []string{"apps", "+env-pull", "--app-id", appID, "--project-path", dir, "--format", "json"} + if as := strings.TrimSpace(rctx.Str("as")); as != "" { + args = append(args, "--as", as) + } + stdout, stderr, runErr := initRunner.Run(ctx, "", self, args...) + if runErr != nil { + r := parseEnvPullErrorEnvelope(stderr) + if r == "" { + r = gitErr(stderr, runErr) + } + return "", redactURLCredentials(r) + } + envFile, perr := parseEnvFileFromEnvelope(stdout) + if perr != nil { + return "", redactURLCredentials(perr.Error()) + } + return envFile, "" +} + +// issueCredentials runs ` apps +git-credential-init --app-id --format json` +// and returns the repo_url it reports. Forwards --as when set. +func issueCredentials(ctx context.Context, rctx *common.RuntimeContext, appID string) (string, error) { + self, err := os.Executable() + if err != nil { + return "", output.Errorf(output.ExitInternal, "internal", "cannot locate lark-cli executable: %v", err) + } + args := []string{"apps", "+git-credential-init", "--app-id", appID, "--format", "json"} + if as := strings.TrimSpace(rctx.Str("as")); as != "" { + args = append(args, "--as", as) + } + stdout, stderr, err := initRunner.Run(ctx, "", self, args...) + if err != nil { + return "", output.ErrWithHint(output.ExitAPI, "credential_init", + fmt.Sprintf("apps +git-credential-init failed: %s", gitErr(stderr, err)), + "ensure apps +git-credential-init is available and you are logged in") + } + return parseRepoURLFromEnvelope(stdout) +} + +// commitAndPushIfDirty commits and pushes only when the working tree has +// changes; a clean tree is a no-op (returns false,false). For the empty-repo +// init path (scaffoldKind == "init") it splits the scaffolded tree into two +// commits — app project code, then Miaoda config (.spark/.agent) — skipping +// either commit when that group has no changes (no empty commits). Other paths +// commit once. Push is a single `git push origin ` for all commits. +func commitAndPushIfDirty(ctx context.Context, dir, scaffoldKind string) (committed, pushed bool, err error) { + status, stderr, runErr := initRunner.Run(ctx, dir, "git", "status", "--porcelain") + if runErr != nil { + return false, false, output.Errorf(output.ExitAPI, "git_status", "git status failed: %s", gitErr(stderr, runErr)) + } + if strings.TrimSpace(status) == "" { + return false, false, nil + } + + if scaffoldKind == scaffoldKindInit { + // Stage each group by its exact porcelain paths (never gitignored files), + // so neither `git add` errors on an ignored path like .agent. + appPaths, configPaths := classifyPorcelain(status) + if len(appPaths) > 0 { + if e := stageAndCommit(ctx, dir, commitMsgAppCode, appPaths...); e != nil { + return committed, false, e + } + committed = true + } + if len(configPaths) > 0 { + if e := stageAndCommit(ctx, dir, commitMsgAppConfig, configPaths...); e != nil { + return committed, false, e + } + committed = true + } + } else { + if e := stageAndCommit(ctx, dir, commitMsgUpgrade, "."); e != nil { + return false, false, e + } + committed = true + } + + if !committed { + return false, false, nil + } + + if _, se, e := initRunner.Run(ctx, dir, "git", "push", "origin", defaultInitBranch); e != nil { + return true, false, withAppsHint( + output.Errorf(output.ExitAPI, "git_push", "git push failed: %s", gitErr(se, e)), + "the push was rejected — the git output is in the message above; if it is a non-fast-forward (remote has new commits), sync the remote and retry; if it is an auth failure, make sure `lark-cli apps +git-credential-init` has succeeded") + } + return true, true, nil +} + +// stageAndCommit stages the given pathspecs (`git add -A -- `) and +// makes one `git commit --no-verify -m message`. --no-verify skips the scaffold +// repo's local pre-commit / commit-msg hooks (local only; the later push is not +// --no-verify). Callers gate this on classifyPorcelain so the group is non-empty +// and the commit never hits "nothing to commit". +func stageAndCommit(ctx context.Context, dir, message string, pathspecs ...string) error { + addArgs := append([]string{"add", "-A", "--"}, pathspecs...) + if _, se, e := initRunner.Run(ctx, dir, "git", addArgs...); e != nil { + return output.Errorf(output.ExitAPI, "git_add", "git add failed: %s", gitErr(se, e)) + } + if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil { + return output.Errorf(output.ExitAPI, "git_commit", "git commit failed: %s", gitErr(se, e)) + } + return nil +} + +// classifyPorcelain parses `git status --porcelain` output and partitions the +// changed paths into the "app code" group (anything outside .spark/ and .agent/) +// and the "Miaoda config" group (.spark/ and .agent/). It returns the exact +// porcelain paths so callers can stage them verbatim: porcelain never lists +// gitignored files, so `git add -- ` never trips git's ignored-path +// error. (Naming an ignored dir explicitly — or combining a "." pathspec with +// :(exclude) magic — DOES error when a scaffold template gitignores e.g. .agent, +// which is why we stage exact paths instead of pathspecs.) +func classifyPorcelain(status string) (appPaths, configPaths []string) { + for _, line := range strings.Split(status, "\n") { + p := porcelainPath(line) + if p == "" { + continue + } + if isConfigPath(p) { + configPaths = append(configPaths, p) + } else { + appPaths = append(appPaths, p) + } + } + return appPaths, configPaths +} + +// porcelainPath extracts the path from a `git status --porcelain` v1 line. +// Format is "XY " (2 status chars + space); rename/copy lines are +// "XY -> " (dest is what matters). Quoted paths are unquoted. +func porcelainPath(line string) string { + if len(line) < 4 { + return "" + } + p := line[3:] + if i := strings.Index(p, " -> "); i >= 0 { + p = p[i+len(" -> "):] + } + p = strings.TrimSpace(p) + p = strings.Trim(p, `"`) + return p +} + +// isConfigPath reports whether p is the Miaoda app-config group: the .spark or +// .agent directory itself, or anything under them. ".sparkrc" is NOT config. +func isConfigPath(p string) bool { + return p == ".spark" || p == ".agent" || + strings.HasPrefix(p, ".spark/") || strings.HasPrefix(p, ".agent/") +} + +// gitErr builds a redacted, single-line error detail from stderr (falling back +// to the exec error). Always redacts embedded credentials. +func gitErr(stderr string, err error) string { + s := strings.TrimSpace(stderr) + if s == "" && err != nil { + s = err.Error() + } + return redactURLCredentials(s) +} diff --git a/shortcuts/apps/apps_init_test.go b/shortcuts/apps/apps_init_test.go new file mode 100644 index 00000000..f8415d61 --- /dev/null +++ b/shortcuts/apps/apps_init_test.go @@ -0,0 +1,1468 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/shortcuts/common" +) + +// testRuntimeWithDir builds a *common.RuntimeContext whose backing cobra command +// has string flags "dir" (=dirFlag) and "template" (=defaultTemplate) registered, +// mirroring how +init reads them at runtime via rctx.Str. +func testRuntimeWithDir(t *testing.T, dirFlag string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "init"} + cmd.Flags().String("dir", dirFlag, "") + cmd.Flags().String("template", defaultTemplate, "") + return common.TestNewRuntimeContext(cmd, nil) +} + +// testRuntimeWithTemplate builds a *common.RuntimeContext with "dir" and +// "template" string flags registered, mirroring +init's runtime flag set. The +// template flag is registered with an empty default (matching the real flag, +// which no longer carries Default: defaultTemplate); pass tpl="" to model an +// omitted --template and a non-empty tpl to model an explicit one. +func testRuntimeWithTemplate(t *testing.T, dirFlag, tpl string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "init"} + cmd.Flags().String("dir", dirFlag, "") + cmd.Flags().String("template", tpl, "") + return common.TestNewRuntimeContext(cmd, nil) +} + +func TestResolveTemplate(t *testing.T) { + if got := resolveTemplate(testRuntimeWithTemplate(t, "", "foo"), "app_x"); got != "foo" { + t.Errorf("explicit --template = %q, want foo", got) + } + if got := resolveTemplate(testRuntimeWithTemplate(t, "", ""), "app_x"); got != defaultTemplate { + t.Errorf("omitted --template = %q, want fallback %q", got, defaultTemplate) + } + // Whitespace-only --template is treated as omitted -> fallback. + if got := resolveTemplate(testRuntimeWithTemplate(t, "", " "), "app_x"); got != defaultTemplate { + t.Errorf("whitespace --template = %q, want fallback %q", got, defaultTemplate) + } +} + +func TestResolveTargetPath(t *testing.T) { + got, err := resolveTargetPath(testRuntimeWithDir(t, ""), "app_x") + if err != nil { + t.Fatalf("unexpected: %v", err) + } + want, _ := filepath.Abs(filepath.Join(".", "app_x")) + if got != want { + t.Errorf("default dir = %q, want %q", got, want) + } + abs := t.TempDir() + "/work" + if got, err := resolveTargetPath(testRuntimeWithDir(t, abs), "app_x"); err != nil || got != filepath.Clean(abs) { + t.Errorf("absolute --dir = %q, err=%v; want %q", got, err, filepath.Clean(abs)) + } + for _, bad := range []string{"bad\tdir", "bad\ndir", "bad\x01dir", "a\rb"} { + if _, err := resolveTargetPath(testRuntimeWithDir(t, bad), "app_x"); err == nil { + t.Errorf("control char %q in --dir should be rejected", bad) + } + } +} + +func TestEnsureEmptyDir_SymlinkRejected(t *testing.T) { + base := t.TempDir() + target := filepath.Join(base, "real") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(base, "link") + if err := os.Symlink(target, link); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if err := ensureEmptyDir(link); err == nil { + t.Error("symlink target must be rejected") + } +} + +func TestIsAlreadyInitialized(t *testing.T) { + dir := t.TempDir() + if isAlreadyInitialized(dir) { + t.Error("empty dir must not be already-initialized") + } + if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".spark", "meta.json"), []byte(`{"app_id":"app_y"}`), 0o644); err != nil { + t.Fatal(err) + } + if !isAlreadyInitialized(dir) { + t.Error("dir with .spark/meta.json must be already-initialized (regardless of app_id)") + } +} + +func TestAppsInit_Declaration(t *testing.T) { + if AppsInit.Command != "+init" { + t.Errorf("Command = %q, want +init", AppsInit.Command) + } + if AppsInit.Service != appsService { + t.Errorf("Service = %q, want %q", AppsInit.Service, appsService) + } + if AppsInit.Risk != "write" { + t.Errorf("Risk = %q, want write", AppsInit.Risk) + } + if !AppsInit.HasFormat { + t.Error("HasFormat = false, want true") + } +} + +func TestDefaultCloneDir(t *testing.T) { + got := defaultCloneDir("app_xyz") + if got != filepath.Join(".", "app_xyz") { + t.Errorf("defaultCloneDir = %q, want ./app_xyz", got) + } +} + +// --- pure-function tests --- + +func TestParseRepoURL(t *testing.T) { + url, err := parseRepoURLFromEnvelope(`{"ok":true,"data":{"repository_url":"http://u:t@h/app_x.git"}}`) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if url != "http://u:t@h/app_x.git" { + t.Errorf("got %q", url) + } +} + +func TestParseRepoURL_Errors(t *testing.T) { + for _, in := range []string{`not json`, `{"ok":false,"data":{}}`, `{"ok":true,"data":{}}`, `{"ok":true,"data":{"repository_url":""}}`} { + if _, err := parseRepoURLFromEnvelope(in); err == nil { + t.Errorf("expected error for %q", in) + } + } +} + +func TestValidateRepoURLScheme(t *testing.T) { + for _, ok := range []string{"http://h/r.git", "https://h/r.git"} { + if err := validateRepoURLScheme(ok); err != nil { + t.Errorf("%q should be valid: %v", ok, err) + } + } + for _, bad := range []string{"ext::sh -c id", "file:///etc/passwd", "ssh://h/r", "-oProxyCommand=x", "git@h:r"} { + if err := validateRepoURLScheme(bad); err == nil { + t.Errorf("%q should be rejected", bad) + } + } +} + +// --- orchestration test helpers --- + +func withFakeRunner(t *testing.T, f *fakeCommandRunner) { + t.Helper() + orig := initRunner + initRunner = f + t.Cleanup(func() { initRunner = orig }) +} + +func credInitOK(repoURL string) fakeCallResult { + return fakeCallResult{stdout: `{"ok":true,"data":{"repository_url":"` + repoURL + `"}}`} +} + +// relCloneDir returns a relative, cwd-contained, not-yet-existing directory +// name suitable for --dir. SafeInputPath rejects absolute paths (so +// t.TempDir() cannot be used directly) and requires the path stay under cwd. +// The fake runner never creates the dir, so ensureEmptyDir sees a missing path +// and passes. Cleanup removes it in case anything materializes it. +func relCloneDir(t *testing.T) string { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + rel := "init-clone-" + strings.ReplaceAll(t.Name(), "/", "_") + t.Cleanup(func() { os.RemoveAll(filepath.Join(cwd, rel)) }) + return rel +} + +// parseEnvelopeData parses the JSON envelope's data object from stdout. +func parseEnvelopeData(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { + t.Helper() + var env struct { + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode envelope: %v (raw=%q)", err, stdout.String()) + } + return env.Data +} + +// findCall returns the recorded call whose name (element[1]) and first arg +// (element[2]) match, or nil if none. +func findCall(calls [][]string, name, firstArg string) []string { + for _, c := range calls { + if len(c) >= 3 && c[1] == name && c[2] == firstArg { + return c + } + } + return nil +} + +// findCallArg returns the first recorded call whose name (element[1]) matches +// and whose args contain the given ordered subsequence anywhere after the name. +func findCallArg(calls [][]string, name string, wantArgs ...string) []string { + for _, c := range calls { + if len(c) < 2 || c[1] != name { + continue + } + args := c[2:] + i := 0 + for _, a := range args { + if i < len(wantArgs) && a == wantArgs[i] { + i++ + } + } + if i == len(wantArgs) { + return c + } + } + return nil +} + +func containsAll(call []string, subs ...string) bool { + set := map[string]bool{} + for _, c := range call { + set[c] = true + } + for _, s := range subs { + if !set[s] { + return false + } + } + return true +} + +// --- orchestration tests --- + +func TestRunScaffold_EmptyRepo(t *testing.T) { + // Both a truly empty tree and a tree carrying only the seed README.md count + // as empty and must take the `app init` path. + for _, ls := range []string{"", "README.md\n"} { + t.Run("ls="+ls, func(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: ls}}} + withFakeRunner(t, f) + kind, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack") + if err != nil || kind != "init" { + t.Fatalf("ls=%q kind=%q err=%v, want init", ls, kind, err) + } + c := findCall(f.calls, "npx", "-y") + if c == nil || !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", "nestjs-react-fullstack", "--app-id", "app_x") { + t.Errorf("app init not invoked with expected args: %v", f.calls) + } + if c != nil && containsAll(c, "--local") { + t.Errorf("app init must NOT carry --local: %v", c) + } + }) + } +} + +func TestRunScaffold_NonEmpty_SyncsWhenNoSteering(t *testing.T) { + dir := t.TempDir() // no steering dir, no meta.json + f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}} + withFakeRunner(t, f) + kind, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack") + if err != nil || kind != "upgrade" { + t.Fatalf("kind=%q err=%v, want upgrade", kind, err) + } + if c := findCallArg(f.calls, "npx", "app", "sync"); c == nil || !containsAll(c, "-y", "--prefer-online") { + t.Error("app sync not invoked with --prefer-online") + } else if containsAll(c, "--local") { + t.Errorf("app sync must NOT carry --local: %v", c) + } + if c := findCallArg(f.calls, "npx", "skills", "sync"); c == nil || !containsAll(c, "-y", "--prefer-online", "--local") { + t.Error("skills sync should run with --prefer-online and --local when steering dir absent") + } +} + +func TestRunScaffold_NonEmpty_SkipsSyncWhenSteeringExists(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(filepath.Join(dir, steeringRelPath), 0o755) + f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: "src/x.ts\n"}}} + withFakeRunner(t, f) + if _, err := runScaffold(context.Background(), dir, "app_x", "nestjs-react-fullstack"); err != nil { + t.Fatal(err) + } + if findCallArg(f.calls, "npx", "skills", "sync") != nil { + t.Error("skills sync must be skipped when steering dir exists") + } +} + +func TestRunScaffold_AppInitFailure(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "git ls-files": {stdout: ""}, + "npx -y": {stderr: "boom", err: errors.New("exit 1")}, + }} + withFakeRunner(t, f) + if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack"); err == nil { + t.Error("app init failure must propagate") + } +} + +func TestAppsInit_EmptyRepo_EndToEnd(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + "git clone": {}, + "git checkout": {}, + "git ls-files": {stdout: ""}, // empty repo -> app init + "git status": {stdout: " M src/app.ts\n"}, // scaffold produced changes + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["scaffold"] != "init" { + t.Errorf("scaffold=%v, want init", data["scaffold"]) + } + if data["committed"] != true || data["pushed"] != true { + t.Errorf("committed/pushed = %v/%v, want true/true", data["committed"], data["pushed"]) + } + if _, ok := data["npx_skipped"]; ok { + t.Error("npx_skipped must be removed") + } + // --template is omitted here, so resolveTemplate falls back to + // defaultTemplate and `app init` must still receive --template nestjs-react-fullstack. + c := findCall(f.calls, "npx", "-y") + if c == nil { + t.Error("npx scaffold not invoked") + } else if !containsAll(c, "-y", "--prefer-online", miaodaCLIPkg, "app", "init", "--template", defaultTemplate, "--app-id", "app_x") { + t.Errorf("app init missing expected --template fallback args: %v", c) + } else if containsAll(c, "--local") { + t.Errorf("app init must NOT carry --local: %v", c) + } +} + +func TestAppsInit_AlreadyInitialized_ShortCircuit(t *testing.T) { + dir := relCloneDir(t) + abs, err := filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte(`{"app_id":"whatever"}`), 0o644); err != nil { + t.Fatal(err) + } + f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(filepath.Join(abs, ".env.local"))}} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["scaffold"] != "already_initialized" { + t.Errorf("scaffold=%v, want already_initialized", data["scaffold"]) + } + // short-circuit must still skip clone/checkout/scaffold/commit ... + for _, c := range f.calls { + if containsAll(c, "git", "clone") || containsAll(c, "git", "checkout") || containsAll(c, "git", "status") { + t.Errorf("short-circuit must not run git clone/checkout/status; got %v", f.calls) + } + } + // ... but now refreshes local env exactly once. + envPullCalls := 0 + for _, c := range f.calls { + if containsAll(c, "+env-pull") { + envPullCalls++ + } + } + if envPullCalls != 1 { + t.Errorf("short-circuit must call +env-pull exactly once; got %d (%v)", envPullCalls, f.calls) + } +} + +func TestAppsInit_HappyPathCleanTree(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + "git clone": {}, + "git checkout": {}, + "git ls-files": {stdout: ""}, // empty repo -> app init scaffold + "git status": {}, // clean tree after scaffold -> no commit/push + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + + err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["committed"] != false { + t.Errorf("committed = %v, want false", data["committed"]) + } + if data["pushed"] != false { + t.Errorf("pushed = %v, want false", data["pushed"]) + } + if data["scaffold"] != "init" { + t.Errorf("scaffold = %v, want init", data["scaffold"]) + } + if _, ok := data["npx_skipped"]; ok { + t.Error("npx_skipped must be removed") + } + if data["repository_url"] != "http://***@h/app_x.git" { + t.Errorf("repository_url = %v, want redacted http://***@h/app_x.git", data["repository_url"]) + } + clone := findCall(f.calls, "git", "clone") + if clone == nil { + t.Fatalf("git clone not recorded; calls=%v", f.calls) + } + // clone == [dir, "git", "clone", "--", repoURL, dir]; "--" must precede the URL. + found := false + for i := 0; i+1 < len(clone); i++ { + if clone[i] == "--" && strings.HasPrefix(clone[i+1], "http") { + found = true + break + } + } + if !found { + t.Errorf("git clone args missing \"--\" immediately before URL: %v", clone) + } +} + +func TestAppsInit_DirtyTreeCommitPush(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + "git clone": {}, + "git checkout": {}, + "git ls-files": {stdout: "src/x.ts\n"}, // non-empty repo -> app sync scaffold + "git status": {stdout: " M file.txt"}, + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + + err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if findCall(f.calls, "git", "add") == nil { + t.Errorf("git add not recorded; calls=%v", f.calls) + } + if commit := findCall(f.calls, "git", "commit"); commit == nil { + t.Errorf("git commit not recorded; calls=%v", f.calls) + } else if !containsAll(commit, "--no-verify") { + t.Errorf("git commit missing --no-verify; got %v", commit) + } + if findCall(f.calls, "git", "push") == nil { + t.Errorf("git push not recorded; calls=%v", f.calls) + } + data := parseEnvelopeData(t, stdout) + if data["committed"] != true { + t.Errorf("committed = %v, want true", data["committed"]) + } + if data["pushed"] != true { + t.Errorf("pushed = %v, want true", data["pushed"]) + } + if data["scaffold"] != "upgrade" { + t.Errorf("scaffold = %v, want upgrade", data["scaffold"]) + } +} + +func TestAppsInit_CredentialInitFailure(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": {stderr: "boom", err: errors.New("exit 1")}, + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + + err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected error, got nil") + } + if strings.Contains(err.Error(), ":t@") { + t.Errorf("error leaks token: %v", err) + } +} + +func TestAppsInit_BadRepoURLScheme(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("ext::sh -c id"), + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + + err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected error, got nil") + } + if findCall(f.calls, "git", "clone") != nil { + t.Errorf("git clone should not be recorded for bad scheme; calls=%v", f.calls) + } +} + +func TestAppsInit_CloneFailure(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/r.git"), + "git clone": {stderr: "fatal: unable to access 'http://u:t@h/r.git'", err: errors.New("exit 128")}, + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + + err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected error, got nil") + } + if strings.Contains(err.Error(), "u:t@") { + t.Errorf("error leaks credentials: %v", err) + } + if !strings.Contains(err.Error(), "***") { + t.Errorf("error should be redacted with ***: %v", err) + } +} + +func TestAppsInit_PushFailure(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + "git clone": {}, + "git checkout": {}, + "git ls-files": {stdout: ""}, + "git status": {stdout: " M file.txt"}, + "git push": {err: errors.New("exit 1")}, + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + + err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected error, got nil") + } +} + +func TestAppsInit_DirNonEmpty(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + + // Create a non-empty directory under cwd (SafeInputPath requires relative, + // cwd-contained paths), then pass it as --dir. + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + nonEmpty, err := os.MkdirTemp(cwd, "init-nonempty-") + if err != nil { + t.Fatalf("mkdirtemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(nonEmpty) }) + if err := os.WriteFile(filepath.Join(nonEmpty, "x.txt"), []byte("x"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + err = runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", filepath.Base(nonEmpty), "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected validation error, got nil") + } + if len(f.calls) != 0 { + t.Errorf("no runner calls expected before dir rejection; calls=%v", f.calls) + } +} + +func TestAppsInit_AsPassthrough(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + "git clone": {}, + "git checkout": {}, + "git ls-files": {stdout: ""}, + "git status": {}, + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + + // AppsInit.AuthTypes is ["user"], so the framework rejects --as bot. Use + // --as user and assert it is forwarded to the self-invoked credential-init. + err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var cred []string + for _, c := range f.calls { + if len(c) >= 3 && c[2] == "apps" { + cred = c + break + } + } + if cred == nil { + t.Fatalf("credential-init call not recorded; calls=%v", f.calls) + } + hasAs, hasUser := false, false + for _, a := range cred { + if a == "--as" { + hasAs = true + } + if a == "user" { + hasUser = true + } + } + if !hasAs || !hasUser { + t.Errorf("credential-init args missing --as user: %v", cred) + } +} + +func TestEnsureMetaAppID(t *testing.T) { + // no meta.json -> no-op, must not create + dir := t.TempDir() + if err := ensureMetaAppID(dir, "app_x"); err != nil { + t.Fatalf("missing meta should be no-op: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, metaRelPath)); !os.IsNotExist(err) { + t.Error("must not create meta.json when absent") + } + // exists without app_id -> add, preserve other fields + dir2 := t.TempDir() + os.MkdirAll(filepath.Join(dir2, ".spark"), 0o755) + os.WriteFile(filepath.Join(dir2, metaRelPath), []byte(`{"name":"keep"}`), 0o644) + if err := ensureMetaAppID(dir2, "app_x"); err != nil { + t.Fatal(err) + } + var m map[string]interface{} + b, _ := os.ReadFile(filepath.Join(dir2, metaRelPath)) + json.Unmarshal(b, &m) + if m["app_id"] != "app_x" || m["name"] != "keep" { + t.Errorf("merge failed: %v", m) + } + // exists with app_id -> untouched + dir3 := t.TempDir() + os.MkdirAll(filepath.Join(dir3, ".spark"), 0o755) + os.WriteFile(filepath.Join(dir3, metaRelPath), []byte(`{"app_id":"orig"}`), 0o644) + if err := ensureMetaAppID(dir3, "app_x"); err != nil { + t.Fatal(err) + } + b, _ = os.ReadFile(filepath.Join(dir3, metaRelPath)) + m = nil + json.Unmarshal(b, &m) + if m["app_id"] != "orig" { + t.Errorf("existing app_id overwritten: %v", m) + } +} + +func TestHasSteeringSkills(t *testing.T) { + dir := t.TempDir() + if hasSteeringSkills(dir) { + t.Error("absent steering dir -> false") + } + os.MkdirAll(filepath.Join(dir, steeringRelPath), 0o755) + if !hasSteeringSkills(dir) { + t.Error("present steering dir -> true") + } +} + +func TestIsEmptyRepo(t *testing.T) { + cases := []struct { + name, ls string + want bool + }{ + {"zero files", "", true}, + {"only README.md", "README.md\n", true}, + {"README + business file", "README.md\nsrc/x.ts\n", false}, + {"business file only", "src/x.ts\n", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{"git ls-files": {stdout: c.ls}}} + withFakeRunner(t, f) + got, err := isEmptyRepo(context.Background(), t.TempDir()) + if err != nil || got != c.want { + t.Errorf("ls=%q -> empty=%v err=%v, want %v", c.ls, got, err, c.want) + } + }) + } +} + +// newAppsExecuteFactoryWithStderr mirrors newAppsExecuteFactory but also returns +// the stderr buffer, so tests can assert on the +init progress log lines that +// initLogf writes to IO().ErrOut. +func newAppsExecuteFactoryWithStderr(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { + t.Helper() + t.Setenv("HOME", t.TempDir()) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + cfg := &core.CliConfig{ + AppID: "test-app-" + strings.ToLower(t.Name()), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_test", + } + factory, stdout, stderr, _ := cmdutil.TestFactory(t, cfg) + return factory, stdout, stderr +} + +func TestAppsInit_Req1_Wording(t *testing.T) { + var tmpl *common.Flag + for i := range AppsInit.Flags { + if AppsInit.Flags[i].Name == "template" { + tmpl = &AppsInit.Flags[i] + } + } + if tmpl == nil { + t.Fatal("--template flag missing") + } + if strings.Contains(strings.ToLower(tmpl.Desc), "scaffold") { + t.Errorf("--template Desc still mentions scaffold: %q", tmpl.Desc) + } + if !strings.Contains(strings.ToLower(tmpl.Desc), "code-init") { + t.Errorf("--template Desc should use code-init wording: %q", tmpl.Desc) + } + + // The --dry-run output is a flat object (DryRunAPI marshals to top-level keys + // description/scaffold/api/...), NOT wrapped in {"data":...}, so parse stdout + // directly rather than via parseEnvelopeData. + factory, stdout, _ := newAppsExecuteFactoryWithStderr(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--as", "user", "--dry-run"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var data map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &data); err != nil { + t.Fatalf("decode dry-run output: %v (raw=%q)", err, stdout.String()) + } + desc, _ := data["description"].(string) + if strings.Contains(strings.ToLower(desc), "scaffold") { + t.Errorf("dry-run description still mentions scaffold: %q", desc) + } + scaffold, ok := data["scaffold"].(string) + if !ok { + t.Error("dry-run must keep machine-contract key `scaffold`") + } else if !strings.Contains(scaffold, "skills sync --local") { + t.Errorf("dry-run scaffold string must show --local on skills sync: %q", scaffold) + } else if strings.Contains(scaffold, "app init --template nestjs-react-fullstack --app-id app_x --local") || + strings.Contains(scaffold, "app sync --local") { + t.Errorf("dry-run scaffold string must NOT show --local on app init / app sync: %q", scaffold) + } + + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + "git clone": {}, + "git checkout": {}, + "git ls-files": {stdout: ""}, + "git status": {}, + }} + withFakeRunner(t, f) + factory2, stdout2, stderr2 := newAppsExecuteFactoryWithStderr(t) + dir := relCloneDir(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory2, stdout2); err != nil { + t.Fatalf("run err=%v", err) + } + if strings.Contains(stderr2.String(), "Scaffolding") { + t.Errorf("progress log still says Scaffolding: %q", stderr2.String()) + } + if !strings.Contains(stderr2.String(), "Initializing app code") { + t.Errorf("progress log should say 'Initializing app code': %q", stderr2.String()) + } +} + +func TestClassifyPorcelain(t *testing.T) { + cases := []struct { + name, status string + wantAppCode, wantConfig bool + }{ + {"empty", "", false, false}, + {"app code only", " M src/x.ts\n?? package.json\n", true, false}, + {"config only", "?? .spark/meta.json\n?? .agent/skills/steering/x.md\n", false, true}, + {"both", " M src/x.ts\n?? .spark/meta.json\n", true, true}, + {"rename to config", "R old.txt -> .spark/meta.json\n", false, true}, + {"rename to app code", "R .spark/old -> src/new.ts\n", true, false}, + {"quoted config path", "?? \".spark/with space.json\"\n", false, true}, + {"spark prefix lookalike not config", "?? .sparkrc\n", true, false}, + {"exact .spark dir", "?? .spark\n", false, true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + gotApp, gotCfg := classifyPorcelain(c.status) + if (len(gotApp) > 0) != c.wantAppCode || (len(gotCfg) > 0) != c.wantConfig { + t.Errorf("classifyPorcelain(%q) = (app=%v,cfg=%v), want app=%v cfg=%v", + c.status, gotApp, gotCfg, c.wantAppCode, c.wantConfig) + } + }) + } +} + +// commitMessages returns the -m messages of all recorded `git commit` calls. +func commitMessages(calls [][]string) []string { + var msgs []string + for _, c := range calls { + if len(c) >= 3 && c[1] == "git" && c[2] == "commit" { + for i := 3; i+1 < len(c); i++ { + if c[i] == "-m" { + msgs = append(msgs, c[i+1]) + } + } + } + } + return msgs +} + +func TestAppsInit_EmptyRepo_TwoCommits(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + "git clone": {}, + "git checkout": {}, + "git ls-files": {stdout: ""}, + "git status": {stdout: " A src/app.ts\n A .spark/meta.json\n A .agent/skills/steering/x.md\n"}, + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + msgs := commitMessages(f.calls) + want := []string{"chore: initialize app project code", "chore: initialize miaoda app config"} + if len(msgs) != 2 || msgs[0] != want[0] || msgs[1] != want[1] { + t.Fatalf("commit messages = %v, want %v", msgs, want) + } + // The split's core invariant: each commit stages its own group's exact + // porcelain paths (no :(exclude) magic, no explicitly-named ignored dirs — + // see TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir). The app-code commit + // stages src/app.ts and not .spark/meta.json; the config commit, the reverse. + appAdd := findCallArg(f.calls, "git", "add", "-A", "--", "src/app.ts") + if appAdd == nil { + t.Errorf("app-code git add missing src/app.ts; calls=%v", f.calls) + } else if containsAll(appAdd, ".spark/meta.json") { + t.Errorf("app-code commit must not stage config paths; got %v", appAdd) + } + cfgAdd := findCallArg(f.calls, "git", "add", "-A", "--", ".spark/meta.json") + if cfgAdd == nil { + t.Errorf("config git add missing .spark/meta.json; calls=%v", f.calls) + } else if containsAll(cfgAdd, "src/app.ts") { + t.Errorf("config commit must not stage app code; got %v", cfgAdd) + } + data := parseEnvelopeData(t, stdout) + if data["committed"] != true || data["pushed"] != true { + t.Errorf("committed/pushed = %v/%v, want true/true", data["committed"], data["pushed"]) + } +} + +func TestAppsInit_EmptyRepo_AppCodeOnly_SingleCommit(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + "git clone": {}, + "git checkout": {}, + "git ls-files": {stdout: ""}, + "git status": {stdout: " A src/app.ts\n"}, + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + msgs := commitMessages(f.calls) + if len(msgs) != 1 || msgs[0] != "chore: initialize app project code" { + t.Fatalf("commit messages = %v, want one app-code commit", msgs) + } +} + +func TestAppsInit_EmptyRepo_ConfigOnly_SingleCommit(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + "git clone": {}, + "git checkout": {}, + "git ls-files": {stdout: ""}, + "git status": {stdout: " A .spark/meta.json\n"}, + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + msgs := commitMessages(f.calls) + if len(msgs) != 1 || msgs[0] != "chore: initialize miaoda app config" { + t.Fatalf("commit messages = %v, want one config commit", msgs) + } +} + +func TestAppsInit_NonEmpty_SingleInitCommit(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + "git clone": {}, + "git checkout": {}, + "git ls-files": {stdout: "src/x.ts\n"}, + "git status": {stdout: " M file.txt\n M .spark/meta.json\n"}, + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected: %v", err) + } + msgs := commitMessages(f.calls) + if len(msgs) != 1 || msgs[0] != "chore: initialize miaoda app repository" { + t.Fatalf("commit messages = %v, want one upgrade commit", msgs) + } + for _, c := range f.calls { + if len(c) >= 3 && c[1] == "git" && c[2] == "commit" && !containsAll(c, "--no-verify") { + t.Errorf("commit missing --no-verify: %v", c) + } + } +} + +// gitMust runs a git command in dir with a real binary, failing the test on error. +func gitMust(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v in %s failed: %v\n%s", args, dir, err, out) + } + return string(out) +} + +// TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir exercises the empty-repo +// commit split against a REAL git repo whose scaffold gitignores .agent. This +// reproduces the production failure where `git add -- .spark .agent` errored on +// the ignored .agent path; the fix stages the config remainder with ".". +func TestCommitAndPushIfDirty_RealGit_IgnoredAgentDir(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + // Bare remote so `git push origin sprint/default` succeeds. + remote := t.TempDir() + gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch) + + dir := t.TempDir() + gitMust(t, dir, "init", "-q", "--initial-branch", defaultInitBranch) + gitMust(t, dir, "config", "user.email", "t@example.com") + gitMust(t, dir, "config", "user.name", "Test") + gitMust(t, dir, "remote", "add", "origin", remote) + + // Scaffold: app code + .spark config + an IGNORED .agent dir. + mustWrite(t, filepath.Join(dir, ".gitignore"), ".agent\n") + if err := os.MkdirAll(filepath.Join(dir, "src"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "src", "x.ts"), "export const x = 1\n") + if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, ".spark", "meta.json"), `{"app_id":"app_x"}`) + if err := os.MkdirAll(filepath.Join(dir, ".agent"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, ".agent", "skill.md"), "ignored\n") + + // Use the real exec runner (not the fake) so gitignore semantics apply. + orig := initRunner + initRunner = execCommandRunner{} + t.Cleanup(func() { initRunner = orig }) + + committed, pushed, err := commitAndPushIfDirty(context.Background(), dir, scaffoldKindInit) + if err != nil { + t.Fatalf("commitAndPushIfDirty returned error: %v", err) + } + if !committed || !pushed { + t.Fatalf("committed=%v pushed=%v, want true/true", committed, pushed) + } + + // Two commits, newest first: config then app code. + subjects := strings.Split(strings.TrimSpace(gitMust(t, dir, "log", "--format=%s", "-2")), "\n") + want := []string{commitMsgAppConfig, commitMsgAppCode} + if len(subjects) != 2 || subjects[0] != want[0] || subjects[1] != want[1] { + t.Fatalf("commit subjects = %v, want %v", subjects, want) + } + + // .agent must NOT be tracked; .spark and src must be. + tracked := gitMust(t, dir, "ls-files") + if strings.Contains(tracked, ".agent") { + t.Errorf("ignored .agent must not be committed; tracked=%q", tracked) + } + if !strings.Contains(tracked, ".spark/meta.json") { + t.Errorf(".spark/meta.json should be committed; tracked=%q", tracked) + } + if !strings.Contains(tracked, "src/x.ts") { + t.Errorf("src/x.ts should be committed; tracked=%q", tracked) + } +} + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func envPullOK(envFile string) fakeCallResult { + return fakeCallResult{stdout: `{"ok":true,"data":{"env_file":"` + envFile + `"}}`} +} + +// testRuntimeForEnvPull builds a minimal RuntimeContext exposing the --as flag, +// which is all pullEnv reads. +func testRuntimeForEnvPull(t *testing.T, as string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "init"} + cmd.Flags().String("as", as, "") + return common.TestNewRuntimeContext(cmd, nil) +} + +func TestPullEnv(t *testing.T) { + // success: stdout envelope parsed; subprocess invoked with expected args + f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK("/abs/app_x/.env.local")}} + withFakeRunner(t, f) + rctx := testRuntimeForEnvPull(t, "") + envFile, reason := pullEnv(context.Background(), rctx, "app_x", "/abs/app_x") + if reason != "" || envFile != "/abs/app_x/.env.local" { + t.Fatalf("success: envFile=%q reason=%q", envFile, reason) + } + // findCallArg matches c[1] against name; for self-invocations c[1] is the + // test binary path (unknown at compile time), so search the args slice + // directly for the expected ordered subsequence. + var c []string + for _, call := range f.calls { + if findCallArg([][]string{call}, call[1], "apps", "+env-pull", "--app-id", "app_x", "--project-path", "/abs/app_x", "--format", "json") != nil { + c = call + break + } + } + if c == nil { + t.Errorf("+env-pull not invoked with expected args: %v", f.calls) + } + + // failure: non-zero exit + stderr error envelope -> reason, env_file empty + f2 := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": { + stderr: `{"ok":false,"error":{"type":"missing_scope","message":"need spark:app:read"}}`, + err: fmt.Errorf("exit status 2"), + }}} + withFakeRunner(t, f2) + envFile2, reason2 := pullEnv(context.Background(), testRuntimeForEnvPull(t, ""), "app_x", "/abs/app_x") + if envFile2 != "" || reason2 != "missing_scope: need spark:app:read" { + t.Fatalf("failure: envFile=%q reason=%q", envFile2, reason2) + } +} + +// TestCommitAndPushIfDirty_RealGit_NonEmptyUpgrade pins down that the non-empty +// (upgrade) path is unaffected by the commit-split / exact-path changes: it must +// stay a SINGLE commit using `git add -A -- .`, which silently skips a gitignored +// .agent (no ignored-path error), with the upgrade subject. +func TestCommitAndPushIfDirty_RealGit_NonEmptyUpgrade(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + remote := t.TempDir() + gitMust(t, remote, "init", "--bare", "-q", "--initial-branch", defaultInitBranch) + + dir := t.TempDir() + gitMust(t, dir, "init", "-q", "--initial-branch", defaultInitBranch) + gitMust(t, dir, "config", "user.email", "t@example.com") + gitMust(t, dir, "config", "user.name", "Test") + gitMust(t, dir, "remote", "add", "origin", remote) + + // Existing (non-empty) repo: a committed baseline with .agent already ignored. + mustWrite(t, filepath.Join(dir, ".gitignore"), ".agent\n") + if err := os.MkdirAll(filepath.Join(dir, "src"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, "src", "old.ts"), "export const old = 0\n") + gitMust(t, dir, "add", "-A") + gitMust(t, dir, "commit", "-q", "-m", "baseline") + baseline := strings.TrimSpace(gitMust(t, dir, "rev-parse", "HEAD")) + + // Simulate `app sync`: a modified app file, a patched .spark config, and an + // IGNORED .agent dir produced by `skills sync`. + mustWrite(t, filepath.Join(dir, "src", "old.ts"), "export const old = 1\n") + if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, ".spark", "meta.json"), `{"app_id":"app_x"}`) + if err := os.MkdirAll(filepath.Join(dir, ".agent"), 0o755); err != nil { + t.Fatal(err) + } + mustWrite(t, filepath.Join(dir, ".agent", "skill.md"), "ignored\n") + + orig := initRunner + initRunner = execCommandRunner{} + t.Cleanup(func() { initRunner = orig }) + + committed, pushed, err := commitAndPushIfDirty(context.Background(), dir, scaffoldKindUpgrade) + if err != nil { + t.Fatalf("commitAndPushIfDirty returned error: %v", err) + } + if !committed || !pushed { + t.Fatalf("committed=%v pushed=%v, want true/true", committed, pushed) + } + + // Exactly ONE commit added, with the upgrade subject (not a split). + added := strings.TrimSpace(gitMust(t, dir, "rev-list", "--count", baseline+"..HEAD")) + if added != "1" { + t.Fatalf("upgrade path added %s commits, want exactly 1 (no split)", added) + } + if subj := strings.TrimSpace(gitMust(t, dir, "log", "--format=%s", "-1")); subj != commitMsgUpgrade { + t.Errorf("upgrade commit subject = %q, want %q", subj, commitMsgUpgrade) + } + + // .agent stays ignored; the real changes are committed. + tracked := gitMust(t, dir, "ls-files") + if strings.Contains(tracked, ".agent") { + t.Errorf("ignored .agent must not be committed; tracked=%q", tracked) + } + if !strings.Contains(tracked, ".spark/meta.json") { + t.Errorf(".spark/meta.json should be committed; tracked=%q", tracked) + } +} + +func TestEnsureEmptyDir_RejectsNonDirAndNonEmpty(t *testing.T) { + t.Run("non-existent is ok", func(t *testing.T) { + if err := ensureEmptyDir(filepath.Join(t.TempDir(), "nope")); err != nil { + t.Errorf("non-existent dir should be ok, got %v", err) + } + }) + t.Run("file is rejected", func(t *testing.T) { + f := filepath.Join(t.TempDir(), "afile") + if err := os.WriteFile(f, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := ensureEmptyDir(f); err == nil { + t.Error("a regular file must be rejected") + } + }) + t.Run("non-empty dir is rejected", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "child"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := ensureEmptyDir(dir); err == nil { + t.Error("a non-empty dir must be rejected") + } + }) + t.Run("empty dir is ok", func(t *testing.T) { + if err := ensureEmptyDir(t.TempDir()); err != nil { + t.Errorf("empty dir should be ok, got %v", err) + } + }) +} + +func TestParseEnvFileFromEnvelope(t *testing.T) { + got, err := parseEnvFileFromEnvelope(`{"ok":true,"data":{"env_file":"/abs/app_x/.env.local"}}`) + if err != nil || got != "/abs/app_x/.env.local" { + t.Fatalf("got %q err %v", got, err) + } + for _, in := range []string{``, `not json`, `{"ok":false,"data":{}}`, `{"ok":true,"data":{}}`, `{"ok":true,"data":{"env_file":""}}`} { + if _, err := parseEnvFileFromEnvelope(in); err == nil { + t.Errorf("expected error for %q", in) + } + } +} + +func TestParseEnvPullErrorEnvelope(t *testing.T) { + cases := []struct{ in, want string }{ + {`{"ok":false,"error":{"type":"missing_scope","message":"need spark:app:read"}}`, "missing_scope: need spark:app:read"}, + {`{"ok":false,"error":{"message":"boom"}}`, "boom"}, + {`not json`, ""}, + {`{"ok":false,"error":{}}`, ""}, + {``, ""}, + } + for _, c := range cases { + if got := parseEnvPullErrorEnvelope(c.in); got != c.want { + t.Errorf("parseEnvPullErrorEnvelope(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestEnsureMetaAppID_MalformedJSON(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, ".spark"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, metaRelPath), []byte("{not json"), 0o644); err != nil { + t.Fatal(err) + } + if err := ensureMetaAppID(dir, "app_x"); err == nil { + t.Error("malformed meta.json must return a parse error") + } +} + +func TestIsEmptyRepo_GitError(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "git ls-files": {err: errors.New("fatal: not a git repository")}, + }} + withFakeRunner(t, f) + if _, err := isEmptyRepo(context.Background(), t.TempDir()); err == nil { + t.Error("git ls-files failure must surface as an error") + } +} + +func TestRunScaffold_NonEmpty_SyncFailure(t *testing.T) { + // Non-empty repo takes the `app sync` path; make that npx call fail. + withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{ + "git ls-files": {stdout: "src/x.ts\n"}, + "npx -y": {err: errors.New("sync boom")}, + }}) + if _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "tpl"); err == nil { + t.Error("npx app sync failure must surface as an error") + } +} + +func TestStageAndCommit_Errors(t *testing.T) { + t.Run("git add fails", func(t *testing.T) { + withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{ + "git add": {err: errors.New("boom")}, + }}) + if err := stageAndCommit(context.Background(), t.TempDir(), "msg", "."); err == nil { + t.Error("git add failure must surface as an error") + } + }) + t.Run("git commit fails", func(t *testing.T) { + withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{ + "git commit": {err: errors.New("boom")}, + }}) + if err := stageAndCommit(context.Background(), t.TempDir(), "msg", "."); err == nil { + t.Error("git commit failure must surface as an error") + } + }) +} + +func TestCommitAndPushIfDirty_Branches(t *testing.T) { + t.Run("clean tree is a no-op", func(t *testing.T) { + withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{ + "git status": {stdout: " "}, + }}) + committed, pushed, err := commitAndPushIfDirty(context.Background(), t.TempDir(), scaffoldKindUpgrade) + if err != nil || committed || pushed { + t.Errorf("clean tree: got committed=%v pushed=%v err=%v, want false,false,nil", committed, pushed, err) + } + }) + t.Run("status error", func(t *testing.T) { + withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{ + "git status": {err: errors.New("boom")}, + }}) + if _, _, err := commitAndPushIfDirty(context.Background(), t.TempDir(), scaffoldKindUpgrade); err == nil { + t.Error("git status failure must surface as an error") + } + }) + t.Run("upgrade path commits and pushes", func(t *testing.T) { + withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{ + "git status": {stdout: " M src/app.ts\n"}, + }}) + committed, pushed, err := commitAndPushIfDirty(context.Background(), t.TempDir(), scaffoldKindUpgrade) + if err != nil || !committed || !pushed { + t.Errorf("dirty upgrade: got committed=%v pushed=%v err=%v, want true,true,nil", committed, pushed, err) + } + }) + t.Run("push failure", func(t *testing.T) { + withFakeRunner(t, &fakeCommandRunner{results: map[string]fakeCallResult{ + "git status": {stdout: " M src/app.ts\n"}, + "git push": {err: errors.New("rejected")}, + }}) + committed, pushed, err := commitAndPushIfDirty(context.Background(), t.TempDir(), scaffoldKindUpgrade) + if err == nil || !committed || pushed { + t.Errorf("push failure: got committed=%v pushed=%v err=%v, want true,false,err", committed, pushed, err) + } + }) +} + +func TestAppsInit_EnvPull_Success(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + "git clone": {}, + "git checkout": {}, + "git ls-files": {stdout: ""}, + "git status": {}, + "env-pull": envPullOK("/abs/app_x/.env.local"), + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["env_pulled"] != true { + t.Errorf("env_pulled = %v, want true", data["env_pulled"]) + } + if data["env_file"] != "/abs/app_x/.env.local" { + t.Errorf("env_file = %v", data["env_file"]) + } + // env-pull invoked with forwarded --as user and the expected flags + var ep []string + for _, c := range f.calls { + if containsAll(c, "+env-pull") { + ep = c + break + } + } + if ep == nil || !containsAll(ep, "--app-id", "app_x", "--project-path", "--as", "user", "--format", "json") { + t.Errorf("+env-pull not invoked with expected args: %v", f.calls) + } +} + +func TestAppsInit_EnvPull_NonFatal(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "credential-init": credInitOK("http://u:t@h/app_x.git"), + "git clone": {}, + "git checkout": {}, + "git ls-files": {stdout: ""}, + "git status": {}, + "env-pull": { + stderr: `{"ok":false,"error":{"type":"missing_scope","message":"need spark:app:read"}}`, + err: fmt.Errorf("exit status 2"), + }, + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("env-pull failure must be non-fatal, got: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["env_pulled"] != false { + t.Errorf("env_pulled = %v, want false", data["env_pulled"]) + } + if data["env_pull_error"] != "missing_scope: need spark:app:read" { + t.Errorf("env_pull_error = %v", data["env_pull_error"]) + } + if _, ok := data["env_file"]; ok { + t.Errorf("env_file must be absent on failure: %v", data["env_file"]) + } + msg, _ := data["message"].(string) + if !strings.Contains(msg, "+env-pull --app-id app_x") { + t.Errorf("message missing retry hint: %q", msg) + } + if strings.Contains(stdout.String(), "u:t@h") { + t.Errorf("raw credential leaked: %s", stdout.String()) + } +} + +func TestAppsInit_AlreadyInitialized_RunsEnvPull(t *testing.T) { + dir := relCloneDir(t) + abs, err := filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil { + t.Fatal(err) + } + envFile := filepath.Join(abs, ".env.local") + f := &fakeCommandRunner{results: map[string]fakeCallResult{"env-pull": envPullOK(envFile)}} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + called := false + for _, c := range f.calls { + if containsAll(c, "+env-pull") { + called = true + } + } + if !called { + t.Errorf("already-initialized path must call +env-pull: %v", f.calls) + } + data := parseEnvelopeData(t, stdout) + if data["scaffold"] != "already_initialized" { + t.Errorf("scaffold=%v, want already_initialized", data["scaffold"]) + } + if data["env_pulled"] != true { + t.Errorf("env_pulled=%v, want true", data["env_pulled"]) + } + if data["env_file"] != envFile { + t.Errorf("env_file=%v, want %v", data["env_file"], envFile) + } + if data["committed"] != false || data["pushed"] != false { + t.Errorf("committed/pushed must stay false: %v", data) + } +} + +func TestAppsInit_AlreadyInitialized_EnvPullFailure_NonFatal(t *testing.T) { + dir := relCloneDir(t) + abs, err := filepath.Abs(dir) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(abs, ".spark"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(abs, metaRelPath), []byte(`{"app_id":"app_x"}`), 0o644); err != nil { + t.Fatal(err) + } + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "env-pull": { + stderr: `{"ok":false,"error":{"type":"missing_scope","message":"need spark:app:read"}}`, + err: fmt.Errorf("exit status 2"), + }, + }} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("env-pull failure must be non-fatal, got: %v", err) + } + data := parseEnvelopeData(t, stdout) + if data["scaffold"] != "already_initialized" { + t.Errorf("scaffold=%v, want already_initialized", data["scaffold"]) + } + if data["env_pulled"] != false { + t.Errorf("env_pulled=%v, want false", data["env_pulled"]) + } + if data["env_pull_error"] != "missing_scope: need spark:app:read" { + t.Errorf("env_pull_error=%v", data["env_pull_error"]) + } + if _, ok := data["env_file"]; ok { + t.Errorf("env_file must be absent on failure: %v", data["env_file"]) + } + msg, _ := data["message"].(string) + if !strings.Contains(msg, "+env-pull --app-id app_x") { + t.Errorf("message missing retry hint: %q", msg) + } +} + +func TestAppsInit_DryRun_DescribesEnvPull(t *testing.T) { + f := &fakeCommandRunner{results: map[string]fakeCallResult{}} + withFakeRunner(t, f) + factory, stdout, _ := newAppsExecuteFactory(t) + dir := relCloneDir(t) + if err := runAppsShortcut(t, AppsInit, []string{"+init", "--app-id", "app_x", "--dir", dir, "--as", "user", "--dry-run"}, factory, stdout); err != nil { + t.Fatalf("unexpected error: %v", err) + } + var m map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &m); err != nil { + t.Fatalf("decode dry-run: %v (raw=%q)", err, stdout.String()) + } + ep, _ := m["env_pull"].(string) + if !strings.Contains(ep, "+env-pull") { + t.Errorf("dry-run missing env_pull step: %v", m) + } + for _, c := range f.calls { + if containsAll(c, "+env-pull") { + t.Errorf("dry-run must not execute +env-pull: %v", f.calls) + } + } +} + +func TestAppsInit_Description_IsAboutCode(t *testing.T) { + if strings.Contains(strings.ToLower(AppsInit.Description), "local development repository") { + t.Errorf("Description should describe initializing app code, not a local dev repo: %q", AppsInit.Description) + } + if !strings.Contains(strings.ToLower(AppsInit.Description), "code") { + t.Errorf("Description should mention app code: %q", AppsInit.Description) + } +} diff --git a/shortcuts/apps/apps_jq_tips_test.go b/shortcuts/apps/apps_jq_tips_test.go new file mode 100644 index 00000000..5c0c8db0 --- /dev/null +++ b/shortcuts/apps/apps_jq_tips_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" +) + +func TestListyCommandsHaveJqTip(t *testing.T) { + wantCmds := map[string]bool{ + "+list": true, "+db-table-list": true, "+db-table-schema": true, + "+db-sql": true, "+release-list": true, "+session-list": true, + } + for _, s := range Shortcuts() { + if !wantCmds[s.Command] { + continue + } + has := false + for _, tip := range s.Tips { + if strings.Contains(tip, "--jq") || strings.Contains(tip, "-q '") { + has = true + } + } + if !has { + t.Errorf("%s should have a --jq filter tip", s.Command) + } + } +} diff --git a/shortcuts/apps/apps_list.go b/shortcuts/apps/apps_list.go index edfcca54..e98de1aa 100644 --- a/shortcuts/apps/apps_list.go +++ b/shortcuts/apps/apps_list.go @@ -12,23 +12,30 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -// AppsList lists Miaoda apps owned by the calling user (cursor pagination). +// AppsList lists Miaoda apps visible to the calling user (cursor pagination). // -// Hidden from --help / tab completion (Hidden: true) so agents do not discover it -// as a way to enumerate / search applications. Direct invocation still works for -// humans who know the command. When agents need an existing app_id, they should -// ask the user to provide either the Miaoda app URL (extract app_id from the -// path segment after /app/) or the app_id string directly; see lark-apps SKILL.md. +// Supports name fuzzy match (--keyword), ownership-dimension filter +// (--ownership: all / mine / shared), and app-type filter (--app-type). See +// lark-apps SKILL.md for when an agent should use this to resolve an app_id +// from a user-supplied name (only when the user named an app and a downstream +// op needs its app_id — never unconditional enumeration). var AppsList = common.Shortcut{ Service: appsService, Command: "+list", - Description: "List Miaoda apps owned by the calling user (cursor pagination)", + Description: "List Miaoda apps visible to the calling user (cursor pagination)", Risk: "read", - Scopes: []string{"spark:app:read"}, - AuthTypes: []string{"user"}, - HasFormat: true, - Hidden: true, + Tips: []string{ + "Example: lark-cli apps +list", + "Example: lark-cli apps +list --keyword ", + "Tip: filter fields with --jq, e.g. -q '.data.items[].app_id'", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, Flags: []common.Flag{ + {Name: "keyword", Desc: "fuzzy match on app name"}, + {Name: "ownership", Desc: "ownership filter: all (created by me + shared with me) | mine | shared", Enum: []string{"all", "mine", "shared"}}, + {Name: "app-type", Desc: "app type filter (html or full_stack)", Enum: []string{"html", "full_stack"}}, {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, {Name: "page-token", Desc: "pagination cursor from previous response"}, }, @@ -39,18 +46,42 @@ var AppsList = common.Shortcut{ Params(buildAppsListParams(rctx)) }, Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { - data, err := rctx.CallAPI("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil) + data, err := rctx.CallAPITyped("GET", apiBasePath+"/apps", buildAppsListParams(rctx), nil) if err != nil { return err } - items, _ := data["items"].([]interface{}) + // Project away icon_url (an image URL agents can't render) and created_at + // (redundant with updated_at) from every item BEFORE OutFormat, so json / + // table / pretty are all lean. Every other field (description, etc.) is kept. + rawItems, _ := data["items"].([]interface{}) + items := make([]interface{}, 0, len(rawItems)) + for _, item := range rawItems { + m, ok := item.(map[string]interface{}) + if !ok { + items = append(items, item) + continue + } + out := make(map[string]interface{}, len(m)) + for k, v := range m { + if k == "icon_url" || k == "created_at" { + continue + } + out[k] = v + } + items = append(items, out) + } + data["items"] = items rctx.OutFormat(data, nil, func(w io.Writer) { - // Table view (--format table) intentionally shows only the columns - // most useful for visual scanning: app_id (to copy-paste downstream), - // name (to match what the user sees in the UI), and updated_at (to - // pick the most recent variant). description / icon_url / created_at - // stay in the underlying JSON (--format json) but would make the - // table too wide for a terminal. + // Curated pretty view (--format pretty) shows the columns most useful + // for visual scanning: app_id (to copy-paste downstream), name (to match + // what the user sees in the UI), is_published / online_url (publish state + // and post-publish access link — the actionable fields after a deploy), + // and updated_at (to pick the most recent variant). online_url can be long + // but is the key value once published; the renderer clamps column width. + // Unpublished apps carry no online_url, so that cell renders empty. + // description stays in the underlying data (--format json / table) but + // would make the curated view too wide. icon_url / created_at are trimmed + // from the data entirely above (not useful to an agent). rows := make([]map[string]interface{}, 0, len(items)) for _, item := range items { m, ok := item.(map[string]interface{}) @@ -58,9 +89,11 @@ var AppsList = common.Shortcut{ continue } rows = append(rows, map[string]interface{}{ - "app_id": m["app_id"], - "name": m["name"], - "updated_at": m["updated_at"], + "app_id": m["app_id"], + "name": m["name"], + "is_published": m["is_published"], + "online_url": m["online_url"], + "updated_at": m["updated_at"], }) } output.PrintTable(w, rows) @@ -76,5 +109,14 @@ func buildAppsListParams(rctx *common.RuntimeContext) map[string]interface{} { if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { params["page_token"] = token } + if kw := strings.TrimSpace(rctx.Str("keyword")); kw != "" { + params["keyword"] = kw + } + if ownership := strings.TrimSpace(rctx.Str("ownership")); ownership != "" { + params["ownership"] = ownership + } + if at := strings.TrimSpace(rctx.Str("app-type")); at != "" { + params["app_type"] = at + } return params } diff --git a/shortcuts/apps/apps_list_test.go b/shortcuts/apps/apps_list_test.go index 3b34ec21..cdac719d 100644 --- a/shortcuts/apps/apps_list_test.go +++ b/shortcuts/apps/apps_list_test.go @@ -63,6 +63,56 @@ func TestAppsList_WithPageToken(t *testing.T) { } } +func TestAppsList_WithKeywordOwnershipAppType(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps?app_type=html&keyword=%E9%97%AE%E5%8D%B7&ownership=mine&page_size=20", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"items": []interface{}{}, "has_more": false}, + }, + }) + if err := runAppsShortcut(t, AppsList, + []string{"+list", "--keyword", "问卷", "--ownership", "mine", "--app-type", "html", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } +} + +func TestAppsList_InvalidOwnership(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsList, + []string{"+list", "--ownership", "bogus", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected enum validation error for --ownership bogus") + } +} + +func TestAppsList_InvalidAppType(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsList, + []string{"+list", "--app-type", "HTML", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected enum validation error for --app-type HTML (hard cut to lowercase)") + } +} + +func TestAppsList_DryRunWithFilters(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsList, + []string{"+list", "--keyword", "q", "--ownership", "all", "--app-type", "full_stack", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + got := stdout.String() + for _, want := range []string{"keyword", "ownership", "app_type", "full_stack"} { + if !strings.Contains(got, want) { + t.Fatalf("dry-run missing %q: %s", want, got) + } + } +} + func TestAppsList_DryRun(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) if err := runAppsShortcut(t, AppsList, @@ -78,3 +128,86 @@ func TestAppsList_DryRun(t *testing.T) { t.Fatalf("dry-run missing page_size param: %s", got) } } + +func TestAppsList_TrimsIconURLAndCreatedAt(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps?page_size=20", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "app_id": "app_x", + "name": "Trim Me", + "is_published": true, + "online_url": "https://example.com/spark/faas/app_x", + "updated_at": "2026-05-28T10:05:16Z", + "created_at": "2026-05-01T08:00:00Z", + "icon_url": "https://example.com/icon.png", + "description": "An app to test trimming", + }, + }, + "page_token": "next_cursor", + "has_more": true, + }, + }, + }) + + if err := runAppsShortcut(t, AppsList, []string{"+list", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, drop := range []string{"icon_url", "created_at"} { + if strings.Contains(got, drop) { + t.Fatalf("default output should not contain %q:\n%s", drop, got) + } + } + for _, keep := range []string{"app_id", "name", "is_published", "online_url", "updated_at", "description"} { + if !strings.Contains(got, keep) { + t.Fatalf("default output missing %q:\n%s", keep, got) + } + } +} + +func TestAppsList_PrettyShowsPublishFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps?page_size=20", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "app_id": "app_pub", + "name": "Published App", + "is_published": true, + "online_url": "https://example.com/spark/faas/app_pub", + "updated_at": "2026-05-28T10:05:16Z", + }, + map[string]interface{}{ + "app_id": "app_draft", + "name": "Draft App", + "is_published": false, + "updated_at": "2026-05-31T12:31:27Z", + }, + }, + "has_more": false, + }, + }, + }) + + if err := runAppsShortcut(t, AppsList, + []string{"+list", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"is_published", "online_url", "https://example.com/spark/faas/app_pub", "true", "false"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q:\n%s", want, got) + } + } +} diff --git a/shortcuts/apps/apps_release_common.go b/shortcuts/apps/apps_release_common.go new file mode 100644 index 00000000..694a82d1 --- /dev/null +++ b/shortcuts/apps/apps_release_common.go @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "io" + + "github.com/larksuite/cli/internal/output" +) + +// Gateway paths for the spark app.release OpenAPI methods. +// Prefix reuses apiBasePath = "/open-apis/spark/v1" (same package). +// Each path contains %s placeholders; use fmt.Sprintf to build the final URL. +const ( + releaseCreatePath = apiBasePath + "/apps/%s/releases" + releaseGetPath = apiBasePath + "/apps/%s/releases/%s" + releaseListPath = apiBasePath + "/apps/%s/releases" +) + +// writeReleaseErrorLogTable renders a release's error_logs (a slice of +// {step, error_log} maps from the gateway) as a two-column step/error_log +// table via output.PrintTable. Used by +release-get to render a failed +// release's error_logs. A nil/non-slice or +// empty value yields an empty table (PrintTable prints "(no data)"). +func writeReleaseErrorLogTable(w io.Writer, raw interface{}) { + logs, _ := raw.([]interface{}) + rows := make([]map[string]interface{}, 0, len(logs)) + for _, l := range logs { + m, ok := l.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, map[string]interface{}{ + "step": m["step"], + "error_log": m["error_log"], + }) + } + output.PrintTable(w, rows) +} diff --git a/shortcuts/apps/apps_release_create.go b/shortcuts/apps/apps_release_create.go new file mode 100644 index 00000000..30654430 --- /dev/null +++ b/shortcuts/apps/apps_release_create.go @@ -0,0 +1,76 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsReleaseCreate creates a release for a Miaoda app. +var AppsReleaseCreate = common.Shortcut{ + Service: appsService, + Command: "+release-create", + Description: "Create a release for a Miaoda app (returns release_id for status polling)", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +release-create --app-id ", + "Example: lark-cli apps +release-create --app-id --branch sprint/default --dry-run", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app ID", Required: true}, + {Name: "branch", Desc: "release branch (server uses default if omitted)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + branch := strings.TrimSpace(rctx.Str("branch")) + dry := common.NewDryRunAPI() + dry.POST(fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID))). + Desc("Create a release"). + Body(buildPublishBody(branch)) + return dry + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + branch := strings.TrimSpace(rctx.Str("branch")) + path := fmt.Sprintf(releaseCreatePath, validate.EncodePathSegment(appID)) + data, err := rctx.CallAPITyped("POST", path, nil, buildPublishBody(branch)) + if err != nil { + return withAppsHint(err, "if the push was rejected (non-fast-forward), sync first with `git pull --rebase origin sprint/default` then retry; inspect the failure via `lark-cli apps +release-get --app-id "+appID+" --release-id `") + } + out := map[string]interface{}{ + "release_id": common.GetString(data, "release_id"), + "status": common.GetString(data, "status"), + } + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "release_id: %s\nstatus: %s\n", out["release_id"], out["status"]) + }) + return nil + }, +} + +// buildPublishBody builds the create-release request body. app_id is in the +// path, not the body. branch is included only when non-empty. +func buildPublishBody(branch string) map[string]interface{} { + body := map[string]interface{}{} + if branch != "" { + body["branch"] = branch + } + return body +} diff --git a/shortcuts/apps/apps_release_create_test.go b/shortcuts/apps/apps_release_create_test.go new file mode 100644 index 00000000..cf606009 --- /dev/null +++ b/shortcuts/apps/apps_release_create_test.go @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestBuildPublishBody(t *testing.T) { + // branch included when non-empty; app_id is NOT in body (it's in the path) + b := buildPublishBody("feat/devops") + if b["branch"] != "feat/devops" { + t.Errorf("body = %v", b) + } + if _, ok := b["app_id"]; ok { + t.Errorf("app_id must not be in body, got %v", b) + } + // branch omitted when empty + b2 := buildPublishBody("") + if _, ok := b2["branch"]; ok { + t.Errorf("branch should be omitted when empty, got %v", b2) + } +} + +func TestAppsReleaseCreateMeta(t *testing.T) { + if AppsReleaseCreate.Command != "+release-create" || AppsReleaseCreate.Risk != "write" { + t.Errorf("meta mismatch: %+v", AppsReleaseCreate) + } + if len(AppsReleaseCreate.Scopes) != 1 || AppsReleaseCreate.Scopes[0] != "spark:app:write" { + t.Errorf("scopes = %v", AppsReleaseCreate.Scopes) + } +} + +// newReleaseCreateRuntimeContext builds a RuntimeContext whose cobra.Command has the +// flags that AppsReleaseCreate.Execute reads (app-id, branch). Flag values are set +// via the returned setter helper. +func newReleaseCreateRuntimeContext(t *testing.T, appID, branch string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + cfg := &core.CliConfig{ + AppID: "test-app-" + strings.ToLower(t.Name()), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_test", + } + factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg) + + cmd := &cobra.Command{Use: "test-release-create"} + cmd.SetContext(context.Background()) + cmd.Flags().String("app-id", "", "") + cmd.Flags().String("branch", "", "") + _ = cmd.Flags().Set("app-id", appID) + if branch != "" { + _ = cmd.Flags().Set("branch", branch) + } + + rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + return rctx, stdoutBuf, reg +} + +func TestAppsReleaseCreateExecute_Success(t *testing.T) { + rctx, stdoutBuf, reg := newReleaseCreateRuntimeContext(t, "app_x", "main") + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/releases", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "release_id": "123", + "status": "publishing", + }, + }, + }) + + err := AppsReleaseCreate.Execute(context.Background(), rctx) + if err != nil { + t.Fatalf("Execute() = %v", err) + } + + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil { + t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String()) + } + if !env.OK { + t.Fatalf("expected ok=true, got: %s", stdoutBuf.String()) + } + if env.Data["release_id"] != "123" { + t.Errorf("release_id = %v, want 123", env.Data["release_id"]) + } + if env.Data["status"] != "publishing" { + t.Errorf("status = %v, want publishing", env.Data["status"]) + } +} diff --git a/shortcuts/apps/apps_release_get.go b/shortcuts/apps/apps_release_get.go new file mode 100644 index 00000000..4e7ac914 --- /dev/null +++ b/shortcuts/apps/apps_release_get.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsReleaseGet fetches a single release's detail by release ID. +var AppsReleaseGet = common.Shortcut{ + Service: appsService, + Command: "+release-get", + Description: "Get a single release's status/detail by release ID", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +release-get --app-id --release-id ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app ID", Required: true}, + {Name: "release-id", Desc: "release ID (the release_id returned by +release-create)", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + if strings.TrimSpace(rctx.Str("release-id")) == "" { + return output.ErrValidation("--release-id is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + releaseID := strings.TrimSpace(rctx.Str("release-id")) + dry := common.NewDryRunAPI() + dry.GET(fmt.Sprintf(releaseGetPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(releaseID))). + Desc("Get release detail") + return dry + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + releaseID := strings.TrimSpace(rctx.Str("release-id")) + path := fmt.Sprintf(releaseGetPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(releaseID)) + data, err := rctx.CallAPITyped("GET", path, nil, nil) + if err != nil { + return withAppsHint(err, "if the release_id is unknown or invalid, list this app's releases with `lark-cli apps +release-list --app-id "+appID+"`") + } + out := data + if release, ok := data["release"].(map[string]interface{}); ok { + out = release + } + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "release_id: %v\nstatus: %v\ncreated_at: %v\nupdated_at: %v\n", + out["release_id"], out["status"], out["created_at"], out["updated_at"]) + if commitID, ok := out["commit_id"].(string); ok && commitID != "" { + fmt.Fprintf(w, "commit_id: %s\n", commitID) + } + status, _ := out["status"].(string) + switch status { + case "finished": + if url, ok := out["online_url"].(string); ok && url != "" { + fmt.Fprintf(w, "online_url: %s\n", url) + } + case "failed": + writeReleaseErrorLogTable(w, out["error_logs"]) + } + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_release_get_test.go b/shortcuts/apps/apps_release_get_test.go new file mode 100644 index 00000000..0d7d2d0f --- /dev/null +++ b/shortcuts/apps/apps_release_get_test.go @@ -0,0 +1,300 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestAppsReleaseGetMeta(t *testing.T) { + if AppsReleaseGet.Command != "+release-get" || AppsReleaseGet.Risk != "read" { + t.Errorf("meta mismatch: %+v", AppsReleaseGet) + } + if len(AppsReleaseGet.Scopes) != 1 || AppsReleaseGet.Scopes[0] != "spark:app:read" { + t.Errorf("scopes = %v", AppsReleaseGet.Scopes) + } + // both --app-id and --release-id must be required + req := map[string]bool{} + for _, f := range AppsReleaseGet.Flags { + req[f.Name] = f.Required + } + if !req["app-id"] || !req["release-id"] { + t.Errorf("app-id and release-id must be Required; flags=%+v", AppsReleaseGet.Flags) + } +} + +// newStatusRuntimeContext builds a RuntimeContext for AppsReleaseGet.Execute tests. +func newStatusRuntimeContext(t *testing.T, appID, releaseID string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + cfg := &core.CliConfig{ + AppID: "test-app-" + strings.ToLower(t.Name()), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_test", + } + factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg) + + cmd := &cobra.Command{Use: "test-release-get"} + cmd.SetContext(context.Background()) + cmd.Flags().String("app-id", "", "") + cmd.Flags().String("release-id", "", "") + _ = cmd.Flags().Set("app-id", appID) + _ = cmd.Flags().Set("release-id", releaseID) + + rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + return rctx, stdoutBuf, reg +} + +func TestAppsReleaseGetExecute_Success(t *testing.T) { + rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5") + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/releases/5", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "release": map[string]interface{}{ + "release_id": "5", + "status": "finished", + "created_at": "1700000000000", + "updated_at": "1700000000001", + }, + }, + }, + }) + + err := AppsReleaseGet.Execute(context.Background(), rctx) + if err != nil { + t.Fatalf("Execute() = %v", err) + } + + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil { + t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String()) + } + if !env.OK { + t.Fatalf("expected ok=true, got: %s", stdoutBuf.String()) + } + // Execute unwraps the nested "release" object + if env.Data["release_id"] != "5" { + t.Errorf("release_id = %v, want 5", env.Data["release_id"]) + } + if env.Data["status"] != "finished" { + t.Errorf("status = %v, want finished", env.Data["status"]) + } +} + +func TestAppsReleaseGetPrettyFinishedOnlineURL(t *testing.T) { + rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5") + rctx.Format = "pretty" + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/releases/5", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{"release": map[string]interface{}{ + "release_id": "5", "status": "finished", + "created_at": "1700000000000", "updated_at": "1700000000001", + "online_url": "https://example.feishu.cn/spark/faas/app_x", + }}, + }, + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + out := stdoutBuf.String() + if !strings.Contains(out, "status: finished") { + t.Errorf("missing base fields:\n%s", out) + } + if !strings.Contains(out, "online_url: https://example.feishu.cn/spark/faas/app_x") { + t.Errorf("expected online_url line, got:\n%s", out) + } +} + +func TestAppsReleaseGetPrettyFailedErrorLogs(t *testing.T) { + rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "6") + rctx.Format = "pretty" + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/releases/6", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{"release": map[string]interface{}{ + "release_id": "6", "status": "failed", + "created_at": "1700000000000", "updated_at": "1700000000050", + "error_logs": []interface{}{ + map[string]interface{}{"step": "build", "error_log": "compile error"}, + }, + }}, + }, + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + out := stdoutBuf.String() + if !strings.Contains(out, "status: failed") { + t.Errorf("missing base fields:\n%s", out) + } + if !strings.Contains(out, "build") || !strings.Contains(out, "compile error") { + t.Errorf("expected error_logs table with step/error_log, got:\n%s", out) + } +} + +func TestAppsReleaseGetPrettyPublishingNoExtra(t *testing.T) { + rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "7") + rctx.Format = "pretty" + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/7", + Body: map[string]interface{}{"code": 0, "msg": "", + "data": map[string]interface{}{"release": map[string]interface{}{ + "release_id": "7", "status": "publishing", + "created_at": "1700000000000", "updated_at": "1700000000000", + }}}, + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + out := stdoutBuf.String() + if strings.Contains(out, "online_url:") || strings.Contains(out, "error_log") { + t.Errorf("publishing must not add extra fields, got:\n%s", out) + } +} + +func TestAppsReleaseGetPrettyFinishedNoURL(t *testing.T) { + rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "8") + rctx.Format = "pretty" + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/8", + Body: map[string]interface{}{"code": 0, "msg": "", + "data": map[string]interface{}{"release": map[string]interface{}{ + "release_id": "8", "status": "finished", + "created_at": "1700000000000", "updated_at": "1700000000001", + }}}, + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if strings.Contains(stdoutBuf.String(), "online_url:") { + t.Errorf("finished without online_url must not print the line, got:\n%s", stdoutBuf.String()) + } +} + +func TestAppsReleaseGetPrettyFailedEmptyLogs(t *testing.T) { + rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "9") + rctx.Format = "pretty" + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/9", + Body: map[string]interface{}{"code": 0, "msg": "", + "data": map[string]interface{}{"release": map[string]interface{}{ + "release_id": "9", "status": "failed", + "created_at": "1700000000000", "updated_at": "1700000000050", + "error_logs": []interface{}{}, + }}}, + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if strings.Contains(stdoutBuf.String(), "compile error") { + t.Errorf("empty error_logs must not render row content, got:\n%s", stdoutBuf.String()) + } +} + +func TestAppsReleaseGetPrettyCommitID(t *testing.T) { + rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "10") + rctx.Format = "pretty" + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/10", + Body: map[string]interface{}{"code": 0, "msg": "", + "data": map[string]interface{}{"release": map[string]interface{}{ + "release_id": "10", "status": "publishing", + "created_at": "1700000000000", "updated_at": "1700000000000", + "commit_id": "1230aisdkjah9123913hi193", + }}}, + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if !strings.Contains(stdoutBuf.String(), "commit_id: 1230aisdkjah9123913hi193") { + t.Errorf("expected commit_id line, got:\n%s", stdoutBuf.String()) + } +} + +func TestAppsReleaseGetPrettyNoCommitID(t *testing.T) { + rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "11") + rctx.Format = "pretty" + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/11", + Body: map[string]interface{}{"code": 0, "msg": "", + "data": map[string]interface{}{"release": map[string]interface{}{ + "release_id": "11", "status": "publishing", + "created_at": "1700000000000", "updated_at": "1700000000000", + }}}, + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if strings.Contains(stdoutBuf.String(), "commit_id:") { + t.Errorf("absent commit_id must not print commit_id line, got:\n%s", stdoutBuf.String()) + } +} + +func TestAppsReleaseGetPrettyEmptyCommitID(t *testing.T) { + rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "12") + rctx.Format = "pretty" + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/12", + Body: map[string]interface{}{"code": 0, "msg": "", + "data": map[string]interface{}{"release": map[string]interface{}{ + "release_id": "12", "status": "publishing", + "created_at": "1700000000000", "updated_at": "1700000000000", + "commit_id": "", + }}}, + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if strings.Contains(stdoutBuf.String(), "commit_id:") { + t.Errorf("empty commit_id must not print commit_id line, got:\n%s", stdoutBuf.String()) + } +} + +func TestAppsReleaseGetJSONOnlineURLPassthrough(t *testing.T) { + rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "5") + reg.Register(&httpmock.Stub{ + Method: "GET", URL: "/open-apis/spark/v1/apps/app_x/releases/5", + Body: map[string]interface{}{"code": 0, "msg": "", + "data": map[string]interface{}{"release": map[string]interface{}{ + "release_id": "5", "status": "finished", + "created_at": "1700000000000", "updated_at": "1700000000001", + "online_url": "https://example.feishu.cn/spark/faas/app_x", + }}}, + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil { + t.Fatalf("unmarshal: %v\nraw: %s", err, stdoutBuf.String()) + } + if env.Data["online_url"] != "https://example.feishu.cn/spark/faas/app_x" { + t.Errorf("JSON must passthrough online_url, got: %v", env.Data["online_url"]) + } +} diff --git a/shortcuts/apps/apps_release_list.go b/shortcuts/apps/apps_release_list.go new file mode 100644 index 00000000..8cc53630 --- /dev/null +++ b/shortcuts/apps/apps_release_list.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsReleaseList lists a Miaoda app's release history (most recent first). +var AppsReleaseList = common.Shortcut{ + Service: appsService, + Command: "+release-list", + Description: "List a Miaoda app's release history (most recent first)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +release-list --app-id ", + "Tip: filter fields with --jq, e.g. -q '.data.releases[].release_id'", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app ID", Required: true}, + {Name: "status", Enum: []string{"publishing", "finished", "failed"}, Desc: "filter by release status: publishing | finished | failed"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size (max 500)"}, + {Name: "page-token", Desc: "pagination cursor from a previous response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + status := strings.TrimSpace(rctx.Str("status")) + pageSize := rctx.Int("page-size") + pageToken := strings.TrimSpace(rctx.Str("page-token")) + dry := common.NewDryRunAPI() + dry.GET(fmt.Sprintf(releaseListPath, validate.EncodePathSegment(appID))). + Desc("List release history"). + Params(buildReleaseListQuery(status, pageSize, pageToken)) + return dry + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + status := strings.TrimSpace(rctx.Str("status")) + pageSize := rctx.Int("page-size") + pageToken := strings.TrimSpace(rctx.Str("page-token")) + path := fmt.Sprintf(releaseListPath, validate.EncodePathSegment(appID)) + data, err := rctx.CallAPITyped("GET", path, buildReleaseListQuery(status, pageSize, pageToken), nil) + if err != nil { + return withAppsHint(err, appIDListHint) + } + releases, _ := data["releases"].([]interface{}) + rctx.OutFormat(data, nil, func(w io.Writer) { + rows := make([]map[string]interface{}, 0, len(releases)) + for _, it := range releases { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, map[string]interface{}{ + "release_id": m["release_id"], + "status": m["status"], + "created_at": m["created_at"], + "updated_at": m["updated_at"], + }) + } + output.PrintTable(w, rows) + }) + return nil + }, +} + +// buildReleaseListQuery builds the list-releases query parameters. app_id is in +// the path. page_size is always sent; status and page_token (snake) are included +// only when non-empty. +func buildReleaseListQuery(status string, pageSize int, pageToken string) map[string]interface{} { + q := map[string]interface{}{ + "page_size": pageSize, + } + if status != "" { + q["status"] = status + } + if pageToken != "" { + q["page_token"] = pageToken + } + return q +} diff --git a/shortcuts/apps/apps_release_list_test.go b/shortcuts/apps/apps_release_list_test.go new file mode 100644 index 00000000..d8c7c56f --- /dev/null +++ b/shortcuts/apps/apps_release_list_test.go @@ -0,0 +1,129 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestBuildReleaseListQuery(t *testing.T) { + // page_size always present; status/page_token omitted when empty; app_id is in the path + q := buildReleaseListQuery("", 0, "") + if q["page_size"] != 0 { + t.Errorf("page_size should always be present, got %v", q) + } + if _, ok := q["status"]; ok { + t.Errorf("status should be omitted when empty, got %v", q) + } + if _, ok := q["page_token"]; ok { + t.Errorf("page_token should be omitted when empty, got %v", q) + } + q2 := buildReleaseListQuery("finished", 30, "tok") + if q2["page_size"] != 30 { + t.Errorf("page_size = %v, want 30", q2["page_size"]) + } + if q2["status"] != "finished" { + t.Errorf("status = %v, want finished", q2["status"]) + } + if q2["page_token"] != "tok" { + t.Errorf("page_token = %v, want tok", q2["page_token"]) + } + if _, ok := q2["app_id"]; ok { + t.Errorf("app_id must not be in query params, got %v", q2) + } +} + +// newReleaseListRuntimeContext builds a RuntimeContext for AppsReleaseList.Execute tests. +func newReleaseListRuntimeContext(t *testing.T, appID string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) { + t.Helper() + cfg := &core.CliConfig{ + AppID: "test-app-" + strings.ToLower(t.Name()), + AppSecret: "test-secret", + Brand: core.BrandFeishu, + UserOpenId: "ou_test", + } + factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg) + + cmd := &cobra.Command{Use: "test-release-list"} + cmd.SetContext(context.Background()) + cmd.Flags().String("app-id", "", "") + cmd.Flags().String("status", "", "") + cmd.Flags().Int("page-size", 20, "") + cmd.Flags().String("page-token", "", "") + _ = cmd.Flags().Set("app-id", appID) + + rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + return rctx, stdoutBuf, reg +} + +func TestAppsReleaseListExecute_Success(t *testing.T) { + rctx, stdoutBuf, reg := newReleaseListRuntimeContext(t, "app_x") + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/releases", + Body: map[string]interface{}{ + "code": 0, + "msg": "", + "data": map[string]interface{}{ + "releases": []interface{}{ + map[string]interface{}{ + "release_id": "1", + "status": "finished", + "created_at": "1700000000000", + "updated_at": "1700000000000", + }, + }, + "next_page_token": "tok", + "has_more": true, + }, + }, + }) + + err := AppsReleaseList.Execute(context.Background(), rctx) + if err != nil { + t.Fatalf("Execute() = %v", err) + } + + var env struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdoutBuf.Bytes(), &env); err != nil { + t.Fatalf("unmarshal output: %v\nraw: %s", err, stdoutBuf.String()) + } + if !env.OK { + t.Fatalf("expected ok=true, got: %s", stdoutBuf.String()) + } + + // releases passthrough + releases, ok := env.Data["releases"].([]interface{}) + if !ok || len(releases) != 1 { + t.Fatalf("releases = %v", env.Data["releases"]) + } + r0 := releases[0].(map[string]interface{}) + if r0["release_id"] != "1" { + t.Errorf("releases[0].release_id = %v, want 1", r0["release_id"]) + } + if r0["status"] != "finished" { + t.Errorf("releases[0].status = %v, want finished", r0["status"]) + } + + // pagination fields passthrough + if env.Data["next_page_token"] != "tok" { + t.Errorf("next_page_token = %v, want tok", env.Data["next_page_token"]) + } + if env.Data["has_more"] != true { + t.Errorf("has_more = %v, want true", env.Data["has_more"]) + } +} diff --git a/shortcuts/apps/apps_session_create.go b/shortcuts/apps/apps_session_create.go new file mode 100644 index 00000000..48eace6a --- /dev/null +++ b/shortcuts/apps/apps_session_create.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsSessionCreate creates a new session under an existing Miaoda app. +var AppsSessionCreate = common.Shortcut{ + Service: appsService, + Command: "+session-create", + Description: "Create a session under a Miaoda app", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +session-create --app-id ", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST(sessionsPath(rctx.Str("app-id"))). + Desc("Create a session under a Miaoda app") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("POST", sessionsPath(rctx.Str("app-id")), nil, nil) + if err != nil { + return withAppsHint(err, appIDListHint) + } + rctx.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "session created: %s\n", common.GetString(data, "session_id")) + }) + return nil + }, +} + +// sessionsPath builds the collection path for an app's sessions. +func sessionsPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/sessions", apiBasePath, validate.EncodePathSegment(strings.TrimSpace(appID))) +} diff --git a/shortcuts/apps/apps_session_create_test.go b/shortcuts/apps/apps_session_create_test.go new file mode 100644 index 00000000..09a157c6 --- /dev/null +++ b/shortcuts/apps/apps_session_create_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsSessionCreate_Success(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sessions", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"session_id": "conv_new"}, + }, + } + reg.Register(stub) + if err := runAppsShortcut(t, AppsSessionCreate, + []string{"+session-create", "--app-id", "app_x", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"session_id": "conv_new"`) { + t.Fatalf("stdout missing session_id: %s", got) + } + if len(stub.CapturedBody) != 0 { + t.Fatalf("+session-create must POST with no body, got: %s", stub.CapturedBody) + } +} + +func TestAppsSessionCreate_Pretty(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sessions", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"session_id": "conv_new"}}, + }) + if err := runAppsShortcut(t, AppsSessionCreate, + []string{"+session-create", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "session created: conv_new") { + t.Fatalf("pretty output wrong: %q", got) + } +} + +func TestAppsSessionCreate_RequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // present-but-blank --app-id passes cobra MarkFlagRequired, caught by Validate hook. + err := runAppsShortcut(t, AppsSessionCreate, []string{"+session-create", "--app-id", "", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "app-id") { + t.Fatalf("expected --app-id required error, got %v", err) + } +} + +func TestAppsSessionCreate_DryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsSessionCreate, + []string{"+session-create", "--app-id", "app_x", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions") { + t.Fatalf("dry-run missing endpoint: %s", got) + } +} + +func TestAppsSessionCreate_EncodesAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsSessionCreate, + []string{"+session-create", "--app-id", "a/b", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + if got := stdout.String(); strings.Contains(got, "apps/a/b/sessions") { + t.Fatalf("app_id must be path-encoded, got raw slash: %s", got) + } +} diff --git a/shortcuts/apps/apps_session_get.go b/shortcuts/apps/apps_session_get.go new file mode 100644 index 00000000..7f4a642d --- /dev/null +++ b/shortcuts/apps/apps_session_get.go @@ -0,0 +1,73 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsSessionGet reads a session's current status, queued turns, and latest turn. +// Single-shot: the caller drives polling using next_poll_after_ms. +var AppsSessionGet = common.Shortcut{ + Service: appsService, + Command: "+session-get", + Description: "Read a session's current status, queued turns, and latest turn", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +session-get --app-id --session-id ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "session-id", Desc: "session ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + if strings.TrimSpace(rctx.Str("session-id")) == "" { + return output.ErrValidation("--session-id is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET(sessionPath(rctx.Str("app-id"), rctx.Str("session-id"))). + Desc("Read a session's status") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("GET", sessionPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, nil) + if err != nil { + return withAppsHint(err, "if the session_id is unknown or invalid, list this app's sessions with `lark-cli apps +session-list --app-id "+strings.TrimSpace(rctx.Str("app-id"))+"`") + } + rctx.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintf(w, "session: %s\n", common.GetString(data, "session_id")) + fmt.Fprintf(w, "active: %v streaming: %v\n", data["is_active"], data["is_streaming"]) + if lt, ok := data["latest_turn"].(map[string]interface{}); ok { + fmt.Fprintf(w, "latest turn: %v (%v)\n", lt["turn_id"], lt["status"]) + } + fmt.Fprintf(w, "queued: %v\n", data["queued_count"]) + fmt.Fprintf(w, "next poll after: %vms\n", data["next_poll_after_ms"]) + }) + return nil + }, +} + +// sessionPath builds the single-session path under an app. Defined here (first +// consumer) so it never sits unused. Reused by Task 4 (+session-stop) and Task 5 (+chat). +func sessionPath(appID, sessionID string) string { + return fmt.Sprintf("%s/apps/%s/sessions/%s", + apiBasePath, + validate.EncodePathSegment(strings.TrimSpace(appID)), + validate.EncodePathSegment(strings.TrimSpace(sessionID))) +} diff --git a/shortcuts/apps/apps_session_get_test.go b/shortcuts/apps/apps_session_get_test.go new file mode 100644 index 00000000..c5b528cd --- /dev/null +++ b/shortcuts/apps/apps_session_get_test.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func sessionGetStub() *httpmock.Stub { + return &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "session_id": "conv_x", + "is_active": true, + "is_streaming": true, + "summary": "正在补充...", + "queued_count": 1, + "latest_turn": map[string]interface{}{"turn_id": "8421374923", "status": "running"}, + "next_poll_after_ms": 30000, + }, + }, + } +} + +func TestAppsSessionGet_Success(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(sessionGetStub()) + if err := runAppsShortcut(t, AppsSessionGet, + []string{"+session-get", "--app-id", "app_x", "--session-id", "conv_x", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"is_streaming": true`) { + t.Fatalf("stdout missing is_streaming: %s", got) + } +} + +func TestAppsSessionGet_PrettyReadsNestedSnakeCase(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(sessionGetStub()) + if err := runAppsShortcut(t, AppsSessionGet, + []string{"+session-get", "--app-id", "app_x", "--session-id", "conv_x", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "8421374923") || !strings.Contains(got, "running") { + t.Fatalf("pretty must read latest_turn.turn_id/status: %s", got) + } + if !strings.Contains(got, "30000") { + t.Fatalf("pretty must show next_poll_after_ms: %s", got) + } +} + +func TestAppsSessionGet_RequiresFlags(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsSessionGet, []string{"+session-get", "--app-id", "app_x", "--session-id", "", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "session-id") { + t.Fatalf("expected --session-id required error, got %v", err) + } +} + +func TestAppsSessionGet_DryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsSessionGet, + []string{"+session-get", "--app-id", "app_x", "--session-id", "conv_x", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x") { + t.Fatalf("dry-run missing endpoint: %s", got) + } +} diff --git a/shortcuts/apps/apps_session_list.go b/shortcuts/apps/apps_session_list.go new file mode 100644 index 00000000..310baac8 --- /dev/null +++ b/shortcuts/apps/apps_session_list.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsSessionList lists sessions under a Miaoda app (cursor pagination, single page). +var AppsSessionList = common.Shortcut{ + Service: appsService, + Command: "+session-list", + Description: "List sessions under a Miaoda app (cursor pagination)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +session-list --app-id ", + "Tip: filter fields with --jq, e.g. -q '.data.sessions[].session_id'", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size (max 50)"}, + {Name: "page-token", Desc: "pagination cursor from previous response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET(sessionsPath(rctx.Str("app-id"))). + Desc("List sessions under a Miaoda app"). + Params(buildSessionListParams(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("GET", sessionsPath(rctx.Str("app-id")), buildSessionListParams(rctx), nil) + if err != nil { + return withAppsHint(err, appIDListHint) + } + sessions, _ := data["sessions"].([]interface{}) + rctx.OutFormat(data, nil, func(w io.Writer) { + rows := make([]map[string]interface{}, 0, len(sessions)) + for _, item := range sessions { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + rows = append(rows, map[string]interface{}{ + "session_id": m["session_id"], + "name": m["name"], + "is_active": m["is_active"], + "updated_at": m["updated_at"], + }) + } + output.PrintTable(w, rows) + }) + return nil + }, +} + +func buildSessionListParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{ + "page_size": rctx.Int("page-size"), + } + if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { + params["page_token"] = token + } + return params +} diff --git a/shortcuts/apps/apps_session_list_test.go b/shortcuts/apps/apps_session_list_test.go new file mode 100644 index 00000000..41b49cbe --- /dev/null +++ b/shortcuts/apps/apps_session_list_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsSessionList_Success(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/sessions", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "sessions": []interface{}{ + map[string]interface{}{ + "session_id": "conv_a", "name": "建后台", "is_active": true, + "created_at": "2026-05-28T10:00:00Z", "updated_at": "2026-05-28T11:00:00Z", + }, + }, + "next_page_token": "", + "has_more": false, + }, + }, + }) + if err := runAppsShortcut(t, AppsSessionList, + []string{"+session-list", "--app-id", "app_x", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"session_id": "conv_a"`) { + t.Fatalf("stdout missing session: %s", got) + } +} + +func TestAppsSessionList_TableShowsKeyColumns(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/sessions", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "sessions": []interface{}{ + map[string]interface{}{"session_id": "conv_a", "name": "建后台", "is_active": true, "updated_at": "2026-05-28T11:00:00Z"}, + }, + }, + }, + }) + if err := runAppsShortcut(t, AppsSessionList, + []string{"+session-list", "--app-id", "app_x", "--format", "table", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "conv_a") || !strings.Contains(got, "建后台") { + t.Fatalf("table missing key columns: %s", got) + } +} + +func TestAppsSessionList_PassesPagination(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsSessionList, + []string{"+session-list", "--app-id", "app_x", "--page-size", "50", "--page-token", "tok1", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "page_size") || !strings.Contains(got, "50") { + t.Fatalf("dry-run missing page_size: %s", got) + } + if !strings.Contains(got, "tok1") { + t.Fatalf("dry-run missing page_token: %s", got) + } +} + +func TestAppsSessionList_RequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsSessionList, []string{"+session-list", "--app-id", "", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "app-id") { + t.Fatalf("expected --app-id required error, got %v", err) + } +} diff --git a/shortcuts/apps/apps_session_stop.go b/shortcuts/apps/apps_session_stop.go new file mode 100644 index 00000000..449328a0 --- /dev/null +++ b/shortcuts/apps/apps_session_stop.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +const sessionStopHint = "verify --app-id and --session-id are correct (list sessions with `lark-cli apps +session-list --app-id `); --turn-id must be the latest turn from `lark-cli apps +session-get --app-id --session-id `" + +// AppsSessionStop interrupts the RUNNING turn of a session. No-op if the turn +// is queued or already finished. Does not close the session. +var AppsSessionStop = common.Shortcut{ + Service: appsService, + Command: "+session-stop", + Description: "Stop (interrupt) the running turn of a session", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +session-stop --app-id --session-id --turn-id ", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "session-id", Desc: "session ID", Required: true}, + {Name: "turn-id", Desc: "turn ID to stop (from +session-get latest_turn.turn_id)", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + if strings.TrimSpace(rctx.Str("session-id")) == "" { + return output.ErrValidation("--session-id is required") + } + if strings.TrimSpace(rctx.Str("turn-id")) == "" { + return output.ErrValidation("--turn-id is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST(stopPath(rctx.Str("app-id"), rctx.Str("session-id"))). + Desc("Stop the running turn of a session"). + Body(buildStopBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("POST", stopPath(rctx.Str("app-id"), rctx.Str("session-id")), nil, buildStopBody(rctx)) + if err != nil { + return withAppsHint(err, sessionStopHint) + } + turnID := strings.TrimSpace(rctx.Str("turn-id")) + rctx.OutFormat(data, nil, func(w io.Writer) { + stopped, _ := data["stopped"].(bool) + if stopped { + fmt.Fprintf(w, "stopped turn %s. %v\n", turnID, data["message"]) + } else { + fmt.Fprintf(w, "no-op: turn %s not stopped. %v\n", turnID, data["message"]) + } + }) + return nil + }, +} + +func stopPath(appID, sessionID string) string { + return sessionPath(appID, sessionID) + "/stop" +} + +func buildStopBody(rctx *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "turn_id": strings.TrimSpace(rctx.Str("turn-id")), + } +} diff --git a/shortcuts/apps/apps_session_stop_test.go b/shortcuts/apps/apps_session_stop_test.go new file mode 100644 index 00000000..7887b520 --- /dev/null +++ b/shortcuts/apps/apps_session_stop_test.go @@ -0,0 +1,110 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsSessionStop_Success(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"stopped": true, "message": "running turn stopped"}, + }, + } + reg.Register(stub) + if err := runAppsShortcut(t, AppsSessionStop, + []string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "8421374923", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["turn_id"] != "8421374923" { + t.Fatalf("body.turn_id = %v", sent["turn_id"]) + } +} + +func TestAppsSessionStop_PrettyStopped(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"stopped": true, "message": "running turn stopped"}}, + }) + if err := runAppsShortcut(t, AppsSessionStop, + []string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "8421374923", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "stopped turn 8421374923") { + t.Fatalf("pretty stopped wrong: %q", got) + } +} + +func TestAppsSessionStop_PrettyNoOp(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"stopped": false, "message": "turn already completed"}}, + }) + if err := runAppsShortcut(t, AppsSessionStop, + []string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "t1", "--format", "pretty", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "no-op") || !strings.Contains(got, "completed") { + t.Fatalf("pretty no-op wrong: %q", got) + } +} + +func TestAppsSessionStop_RequiresTurnID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsSessionStop, + []string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "turn-id") { + t.Fatalf("expected --turn-id required error, got %v", err) + } +} + +func TestAppsSessionStop_DryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsSessionStop, + []string{"+session-stop", "--app-id", "app_x", "--session-id", "conv_x", "--turn-id", "t1", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "/open-apis/spark/v1/apps/app_x/sessions/conv_x/stop") { + t.Fatalf("dry-run missing endpoint: %s", got) + } + if !strings.Contains(got, `"turn_id": "t1"`) { + t.Fatalf("dry-run missing turn_id body: %s", got) + } +} + +// Encoding safeguard for the shared sessionPath helper (reused from Task 3). +func TestAppsSessionStop_EncodesPathSegments(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsSessionStop, + []string{"+session-stop", "--app-id", "a/b", "--session-id", "c/d", "--turn-id", "t1", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + got := stdout.String() + if strings.Contains(got, "apps/a/b/sessions") || strings.Contains(got, "sessions/c/d/stop") { + t.Fatalf("path segments must be encoded, got raw slash: %s", got) + } +} diff --git a/shortcuts/apps/apps_skill_consistency_test.go b/shortcuts/apps/apps_skill_consistency_test.go new file mode 100644 index 00000000..b0a4c985 --- /dev/null +++ b/shortcuts/apps/apps_skill_consistency_test.go @@ -0,0 +1,327 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// frameworkGlobalFlags are injected by shortcuts/common/runner.go for every (or +// many) shortcuts, so they are always allowed in skill docs regardless of which +// command they are attached to. See registerShortcutFlagsWithContext in +// shortcuts/common/runner.go: --dry-run, --format, --json, --jq/-q are injected +// unconditionally; --as via the identity flag; --yes for high-risk-write; +// --print-schema/--flag-name for shortcuts that opt into schema introspection; +// --help/-h are cobra built-ins. +var frameworkGlobalFlags = map[string]bool{ + "dry-run": true, "format": true, "json": true, "yes": true, + "jq": true, "q": true, "as": true, + "print-schema": true, "flag-name": true, "help": true, "h": true, +} + +// cmdRef is one apps command invocation extracted from a skill doc. +type cmdRef struct { + cmd string // registered command form, includes the leading '+' + flags []string // long flag names without '--', short flags without '-' +} + +var ( + // cmdTokenRe matches a shortcut command token. The leading '+' is the + // reliable signal; the body is a-z plus digits/hyphens. A real command + // never ends in '-', so a trailing hyphen (from a glob like `+db-*`) is + // stripped/rejected separately. + cmdTokenRe = regexp.MustCompile(`\+[a-z][a-z0-9-]*`) + longFlagRe = regexp.MustCompile(`^--([a-z][a-z0-9-]*)`) + shortFlagRe = regexp.MustCompile(`^-([a-z])$`) + // bareWordRe matches a plain lowercase word (a CLI service/qualifier token + // like "apps", "contact", "im", "code") with no markdown decoration. + bareWordRe = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) +) + +// documentedNonexistentExamples are apps-prefixed command tokens the lark-apps +// docs deliberately cite as NOT being apps commands (negative examples that +// warn agents away from inventing them). They are intentionally absent from +// Shortcuts(); excluding them here is narrow and explicit, unlike skipping any +// line containing "不存在" (a common Chinese word meaning "does not exist") +// which would mask real drift on unrelated lines. +// +// Source lines (both files carry the identical sentence): +// - skills/lark-apps/references/lark-apps-local-dev.md:52 +// - skills/lark-apps/references/lark-apps-git-credential.md:35 +// "...不存在 `apps +pull` / `apps +push` / `apps code +read` 这类...shortcut..." +// +// Only `+pull` and `+push` are apps-prefixed and thus need an explicit entry +// here. `apps code +read` is preceded by the bare qualifier word "code" (not +// "apps"), so the cross-service filter already rejects it; adding it would be +// redundant double-coverage, so it is intentionally omitted. +var documentedNonexistentExamples = map[string]bool{ + "+pull": true, + "+push": true, +} + +// extractCmdRefs joins backslash-continued lines, then for each `+` token +// captures the --flags/-q that follow it, stopping at the next `+` token, a +// shell separator (| && ;), or the end of the inline-code span the command +// appears in. Flags only attach within the same backtick-delimited segment as +// the command, because skill docs write a real invocation inside one code span +// (`lark-cli apps +create --name x`) while a stray `--flag` discussed in prose +// (e.g. "`+git-credential-list` ... 不需要 `--app-id`") lives in a separate +// span and must not attach. +// +// To avoid false positives it also: +// - skips a `+token` immediately preceded by a bare service/qualifier word +// other than "apps" (e.g. `contact +search-user`, `im +chat-search`, +// `apps code +read`) — those are not apps shortcuts; +// - rejects a token that ends in '-' (a wildcard family like `+db-*`, +// `+release-*`), since no registered command ends in a hyphen. +// +// Deliberate negative examples (documentedNonexistentExamples, e.g. `+pull`) +// are still extracted here; the consistency gate skips them explicitly when an +// unregistered command turns out to be one of those documented examples. +func extractCmdRefs(doc string) []cmdRef { + var refs []cmdRef + for _, logical := range logicalLines(doc) { + // Split the logical line into backtick-delimited segments. A command and + // its flags only travel together within one segment; crossing a backtick + // boundary resets the capture context. Code-block lines (no backticks) + // are a single segment and behave like a normal command line. + var cur *cmdRef + var prevClean string + for _, seg := range strings.Split(logical, "`") { + cur = nil // a new inline span never inherits the previous command + prevClean = "" + for _, tok := range strings.Fields(seg) { + clean := strings.Trim(tok, ",'\"()*") + if tok == "|" || tok == "&&" || tok == ";" { + cur = nil + prevClean = clean + continue + } + if strings.HasPrefix(clean, "+") { + m := cmdTokenRe.FindString(clean) + if m == "" || strings.HasSuffix(m, "-") { + // Not a real command shape (e.g. "+1") or a wildcard + // family like "+db-" from `+db-*`. No capture context. + cur = nil + prevClean = clean + continue + } + // Cross-service reference: nearest preceding bare word is a + // service/qualifier other than "apps". + if bareWordRe.MatchString(prevClean) && prevClean != "apps" && prevClean != "lark-cli" { + cur = nil + prevClean = clean + continue + } + refs = append(refs, cmdRef{cmd: m}) + cur = &refs[len(refs)-1] + prevClean = clean + continue + } + if cur != nil { + if m := longFlagRe.FindStringSubmatch(clean); m != nil { + cur.flags = append(cur.flags, m[1]) + } else if m := shortFlagRe.FindStringSubmatch(clean); m != nil { + cur.flags = append(cur.flags, m[1]) + } + } + prevClean = clean + } + } + } + return refs +} + +// logicalLines merges lines ending with a backslash into one logical line. +func logicalLines(doc string) []string { + raw := strings.Split(strings.ReplaceAll(doc, "\r\n", "\n"), "\n") + var out []string + var buf strings.Builder + carrying := false + for _, ln := range raw { + t := strings.TrimRight(ln, " \t") + if strings.HasSuffix(t, "\\") { + buf.WriteString(strings.TrimSuffix(t, "\\")) + buf.WriteString(" ") + carrying = true + continue + } + buf.WriteString(ln) + out = append(out, buf.String()) + buf.Reset() + carrying = false + } + if carrying || buf.Len() > 0 { + out = append(out, buf.String()) + } + return out +} + +func TestExtractCmdRefs_Unit(t *testing.T) { + doc := "`lark-cli apps +create --name x --app-type html`\n" + + "`+db-table-list`, `+db-table-get`\n" + + "lark-cli apps +session-list --app-id x | jq '.y --post-pipe-flag'\n" + + "lark-cli apps +foo --bar baz \\\n --qux 1\n" + + "人名→`ou_` 用 `lark-cli contact +search-user --query <名字>`,群名→`oc_` 用 `lark-cli im +chat-search --query <群名>`\n" + + "改库走 `+db-*`;发布走 `+release-*`\n" + + "不存在 `apps +pull` / `apps +push` / `apps code +read` 这类 shortcut,不要臆造。\n" + + "`+git-credential-list` 列出本地凭证,不需要 `--app-id`。\n" + + refs := extractCmdRefs(doc) + got := map[string][]string{} + for _, r := range refs { + got[r.cmd] = append(got[r.cmd], r.flags...) + } + + // Full invocation: command + both flags captured. + if _, ok := got["+create"]; !ok { + t.Fatalf("missing +create; got %+v", refs) + } + if !contains(got["+create"], "name") || !contains(got["+create"], "app-type") { + t.Errorf("+create flags wrong: %v", got["+create"]) + } + + // Comma-separated command list: no flags attach to either command. + if _, ok := got["+db-table-list"]; !ok { + t.Errorf("missing +db-table-list; got %+v", refs) + } + if len(got["+db-table-list"]) != 0 || len(got["+db-table-get"]) != 0 { + t.Errorf("comma-separated commands must carry no flags: %v", got) + } + + // Pipe stops capture within a SINGLE span (no surrounding backticks), so the + // pipe `|` is the only boundary that can stop flag capture here: --app-id + // (before the pipe) attaches, but the post-pipe --post-pipe-flag must NOT. + if !contains(got["+session-list"], "app-id") { + t.Errorf("pre-pipe flag should attach to +session-list: %v", got["+session-list"]) + } + if contains(got["+session-list"], "post-pipe-flag") { + t.Errorf("pipe did not stop flag capture: %v", got["+session-list"]) + } + + // Backslash continuation joins --qux onto +foo (same logical line). + if !contains(got["+foo"], "bar") || !contains(got["+foo"], "qux") { + t.Errorf("continuation join failed: %v", got["+foo"]) + } + + // Cross-service commands must NOT be attributed to apps. + if _, ok := got["+search-user"]; ok { + t.Errorf("contact +search-user must not be extracted as an apps command: %+v", refs) + } + if _, ok := got["+chat-search"]; ok { + t.Errorf("im +chat-search must not be extracted as an apps command: %+v", refs) + } + + // Wildcard family references must NOT be extracted as commands. + if _, ok := got["+db-"]; ok { + t.Errorf("`+db-*` wildcard must not be extracted as a command: %+v", refs) + } + if _, ok := got["+release-"]; ok { + t.Errorf("`+release-*` wildcard must not be extracted as a command: %+v", refs) + } + + // Deliberate negative examples are no longer line-skipped: the apps-prefixed + // `+pull` / `+push` ARE extracted here (the consistency gate later excludes + // them via documentedNonexistentExamples). `apps code +read` is preceded by + // the bare qualifier "code", so the cross-service filter still drops it. + for _, tok := range []string{"+pull", "+push"} { + if _, ok := got[tok]; !ok { + t.Errorf("negative example %s should still be extracted (gate excludes it, not the extractor): %+v", tok, refs) + } + if !documentedNonexistentExamples[tok] { + t.Errorf("%s must be in documentedNonexistentExamples allowlist", tok) + } + } + if _, ok := got["+read"]; ok { + t.Errorf("`apps code +read` is cross-service (preceded by `code`) and must not be extracted: %+v", refs) + } + + // A --flag discussed in prose, in a separate inline-code span from the + // command, must NOT attach to the command (backtick-span boundary stops + // capture). + if contains(got["+git-credential-list"], "app-id") { + t.Errorf("prose flag in a separate backtick span must not attach: %v", got["+git-credential-list"]) + } +} + +func TestSkillDocsCommandsConsistentWithShortcuts(t *testing.T) { + // Source of truth: the registered shortcuts and their flags. + validCmd := map[string]map[string]bool{} + for _, s := range Shortcuts() { + fl := map[string]bool{} + for _, f := range s.Flags { + fl[f.Name] = true + } + validCmd[s.Command] = fl + } + + docs := skillDocFiles(t) + if len(docs) == 0 { + t.Fatal("no lark-apps skill docs found; gate cannot run") + } + + for _, path := range docs { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + rel := filepath.Base(path) + for _, ref := range extractCmdRefs(string(raw)) { + flags, ok := validCmd[ref.cmd] + if !ok { + // A deliberate negative example (documented as NOT existing) is + // expected to be absent from Shortcuts(); skip only those. + if documentedNonexistentExamples[ref.cmd] { + continue + } + t.Errorf("%s: references `apps %s` which is not a registered shortcut", rel, ref.cmd) + continue + } + for _, fl := range ref.flags { + if flags[fl] || frameworkGlobalFlags[fl] { + continue + } + t.Errorf("%s: `apps %s --%s`: --%s is not a flag of %s (have: %s)", + rel, ref.cmd, fl, fl, ref.cmd, sortedFlags(flags)) + } + } + } +} + +// skillDocFiles returns SKILL.md + references/*.md for lark-apps, relative to +// this package dir (go test cwd = shortcuts/apps/). +func skillDocFiles(t *testing.T) []string { + t.Helper() + base := filepath.Join("..", "..", "skills", "lark-apps") + var out []string + if _, err := os.Stat(filepath.Join(base, "SKILL.md")); err == nil { + out = append(out, filepath.Join(base, "SKILL.md")) + } + refs, _ := filepath.Glob(filepath.Join(base, "references", "*.md")) + out = append(out, refs...) + return out +} + +func sortedFlags(m map[string]bool) string { + names := make([]string, 0, len(m)) + for n := range m { + names = append(names, n) + } + sort.Strings(names) + return strings.Join(names, ", ") +} + +func contains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} diff --git a/shortcuts/apps/apps_update.go b/shortcuts/apps/apps_update.go index 16ea4f36..3b4e0e39 100644 --- a/shortcuts/apps/apps_update.go +++ b/shortcuts/apps/apps_update.go @@ -20,9 +20,13 @@ var AppsUpdate = common.Shortcut{ Command: "+update", Description: "Partially update a Miaoda app (only provided fields are sent)", Risk: "write", - Scopes: []string{"spark:app:write"}, - AuthTypes: []string{"user"}, - HasFormat: true, + Tips: []string{ + `Example: lark-cli apps +update --app-id --name "新名称"`, + `Example: lark-cli apps +update --app-id --description "..."`, + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, Flags: []common.Flag{ {Name: "app-id", Desc: "app ID", Required: true}, {Name: "name", Desc: "new app display name"}, @@ -48,9 +52,9 @@ var AppsUpdate = common.Shortcut{ Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { appID := strings.TrimSpace(rctx.Str("app-id")) path := fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID)) - data, err := rctx.CallAPI("PATCH", path, nil, buildAppsUpdateBody(rctx)) + data, err := rctx.CallAPITyped("PATCH", path, nil, buildAppsUpdateBody(rctx)) if err != nil { - return err + return withAppsHint(err, appIDListHint) } rctx.OutFormat(data, nil, func(w io.Writer) { fmt.Fprintf(w, "updated: %s\n", common.GetString(data, "app", "app_id")) diff --git a/shortcuts/apps/apps_update_test.go b/shortcuts/apps/apps_update_test.go index f187e273..01276c3f 100644 --- a/shortcuts/apps/apps_update_test.go +++ b/shortcuts/apps/apps_update_test.go @@ -8,9 +8,46 @@ import ( "strings" "testing" + "github.com/spf13/cobra" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" ) +func testRuntimeWithNameDesc(t *testing.T, name, desc string) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "update"} + cmd.Flags().String("name", name, "") + cmd.Flags().String("description", desc, "") + return common.TestNewRuntimeContext(cmd, nil) +} + +func TestBuildAppsUpdateBody_FieldCombos(t *testing.T) { + t.Run("both empty -> empty body", func(t *testing.T) { + if body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, " ", "")); len(body) != 0 { + t.Errorf("empty inputs should yield empty body, got %v", body) + } + }) + t.Run("name only", func(t *testing.T) { + body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, "App", "")) + if body["name"] != "App" || len(body) != 1 { + t.Errorf("name-only body=%v", body) + } + }) + t.Run("description only", func(t *testing.T) { + body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, "", "desc")) + if body["description"] != "desc" || len(body) != 1 { + t.Errorf("desc-only body=%v", body) + } + }) + t.Run("both set and trimmed", func(t *testing.T) { + body := buildAppsUpdateBody(testRuntimeWithNameDesc(t, " App ", " d ")) + if body["name"] != "App" || body["description"] != "d" { + t.Errorf("both body=%v", body) + } + }) +} + func TestAppsUpdate_PartialFields(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) stub := &httpmock.Stub{ diff --git a/shortcuts/apps/command_runner.go b/shortcuts/apps/command_runner.go new file mode 100644 index 00000000..1f24c233 --- /dev/null +++ b/shortcuts/apps/command_runner.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "os/exec" + "regexp" +) + +// commandRunner abstracts external process execution so apps +init's +// orchestration can be unit-tested without a real git binary or network. +// dir == "" runs in the current working directory; a non-empty dir runs the +// command with that working directory (git -C semantics). +type commandRunner interface { + Run(ctx context.Context, dir, name string, args ...string) (stdout, stderr string, err error) +} + +// execCommandRunner is the production commandRunner backed by os/exec. +type execCommandRunner struct{} + +func (execCommandRunner) Run(ctx context.Context, dir, name string, args ...string) (string, string, error) { + cmd := exec.CommandContext(ctx, name, args...) + if dir != "" { + cmd.Dir = dir + } + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err := cmd.Run() + return stdout.String(), stderr.String(), err +} + +// credentialURLRe matches the userinfo segment of an http(s) URL (the +// "user:token@" part) so it can be redacted before any output or logging. The +// negated class excludes only "/" and whitespace (not "@"), so the match +// greedily consumes up to the LAST "@" before the host/path — this ensures a +// literal "@" inside the userinfo (e.g. "user:p@ss@host") is fully redacted. +var credentialURLRe = regexp.MustCompile(`(?i)(https?://)[^/\s]+@`) + +// redactURLCredentials replaces the userinfo segment of any http(s) URL in s +// with "***". Safe to call on both a bare repo_url and free-form text such as +// git stderr (which echoes the full remote URL on failure). +func redactURLCredentials(s string) string { + return credentialURLRe.ReplaceAllString(s, "${1}***@") +} diff --git a/shortcuts/apps/command_runner_test.go b/shortcuts/apps/command_runner_test.go new file mode 100644 index 00000000..81b4cadd --- /dev/null +++ b/shortcuts/apps/command_runner_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "testing" +) + +func TestRedactURLCredentials(t *testing.T) { + cases := []struct{ name, in, want string }{ + {"http with userinfo", "http://x-token:PAT_abc@git.host/app_x.git", "http://***@git.host/app_x.git"}, + {"https with userinfo", "https://u:p@h/r.git", "https://***@h/r.git"}, + {"no userinfo unchanged", "http://git.host/app_x.git", "http://git.host/app_x.git"}, + {"embedded in stderr text", "fatal: unable to access 'http://u:t@h/r.git/': 401", "fatal: unable to access 'http://***@h/r.git/': 401"}, + {"empty", "", ""}, + {"non-url unchanged", "some error message", "some error message"}, + {"uppercase scheme", "HTTP://u:t@h/r.git", "HTTP://***@h/r.git"}, + {"multiple @ in userinfo", "https://user:p@ss@host/r.git", "https://***@host/r.git"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := redactURLCredentials(c.in); got != c.want { + t.Errorf("redactURLCredentials(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +// fakeCommandRunner records calls and returns scripted results keyed by the +// command + first arg (e.g. "git clone", "git checkout", "git status"), or +// "credential-init" for the self-invoked `apps +git-credential-init` call. +type fakeCallResult struct { + stdout, stderr string + err error +} + +type fakeCommandRunner struct { + results map[string]fakeCallResult + calls [][]string // each entry: [dir, name, args...] +} + +func (f *fakeCommandRunner) Run(ctx context.Context, dir, name string, args ...string) (string, string, error) { + rec := append([]string{dir, name}, args...) + f.calls = append(f.calls, rec) + key := name + if len(args) > 0 { + key = name + " " + args[0] + } + if name != "git" && len(args) >= 2 && args[0] == "apps" { + switch args[1] { + case "+env-pull": + key = "env-pull" + default: + key = "credential-init" + } + } + if r, ok := f.results[key]; ok { + return r.stdout, r.stderr, r.err + } + return "", "", nil +} diff --git a/shortcuts/apps/common.go b/shortcuts/apps/common.go index 7616e14d..de2e7cee 100644 --- a/shortcuts/apps/common.go +++ b/shortcuts/apps/common.go @@ -3,8 +3,50 @@ package apps +import ( + "errors" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" +) + // appsService 是 CLI 命令的 service 前缀(lark-cli apps ...)。 const appsService = "apps" // apiBasePath is the registered OAPI prefix for the Miaoda apps domain. const apiBasePath = "/open-apis/spark/v1" + +// appIDListHint is the shared recovery hint for commands whose most likely +// failure cause is a wrong/inaccessible --app-id. It points at +list to find +// the correct Miaoda app id. The app_/cli_ format rule is taught in +// lark-apps SKILL.md ("app_id 获取"); the hint stays lean and does not repeat it. +const appIDListHint = "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`" + +// withAppsHint attaches an actionable next-step hint to a failure returned by +// CallAPI, preserving its original classification (typed subtype/code/log_id or +// legacy detail). A hint already present on the error is kept (the upstream +// wording wins); only an empty hint is filled in. Mirrors +// drive.appendDriveExportRecoveryHint. err==nil passes through. +func withAppsHint(err error, hint string) error { + if err == nil { + return nil + } + // p points at the embedded Problem, so the mutation is reflected in err. + if p, ok := errs.ProblemOf(err); ok { + if strings.TrimSpace(p.Hint) == "" { + p.Hint = hint + } + return err + } + // Legacy *output.ExitError fallback: fill the hint in place, preserving the + // original class / exit code rather than downgrading the error. + var exitErr *output.ExitError + if errors.As(err, &exitErr) && exitErr.Detail != nil { + if strings.TrimSpace(exitErr.Detail.Hint) == "" { + exitErr.Detail.Hint = hint + } + return err + } + return err +} diff --git a/shortcuts/apps/common_test.go b/shortcuts/apps/common_test.go new file mode 100644 index 00000000..092874e9 --- /dev/null +++ b/shortcuts/apps/common_test.go @@ -0,0 +1,66 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/internal/output" +) + +func TestWithAppsHint(t *testing.T) { + t.Run("nil error stays nil", func(t *testing.T) { + if got := withAppsHint(nil, "do x"); got != nil { + t.Fatalf("withAppsHint(nil) = %v, want nil", got) + } + }) + + t.Run("empty hint gets filled, code/type preserved", func(t *testing.T) { + in := &output.ExitError{Code: 1, Detail: &output.ErrDetail{Type: "api_error", Message: "boom"}} + out := withAppsHint(in, "run +release-list") + var exitErr *output.ExitError + if !errors.As(out, &exitErr) { + t.Fatalf("returned error is not *output.ExitError: %T", out) + } + if exitErr.Detail.Hint != "run +release-list" { + t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "run +release-list") + } + if exitErr.Code != 1 || exitErr.Detail.Type != "api_error" || exitErr.Detail.Message != "boom" { + t.Errorf("code/type/message mutated: code=%d type=%q msg=%q", exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message) + } + }) + + t.Run("existing hint is preserved, not clobbered", func(t *testing.T) { + in := output.ErrWithHint(1, "api_error", "boom", "original hint") + out := withAppsHint(in, "new hint") + var exitErr *output.ExitError + if !errors.As(out, &exitErr) { + t.Fatalf("returned error is not *output.ExitError: %T", out) + } + if exitErr.Detail.Hint != "original hint" { + t.Errorf("Hint = %q, want preserved %q", exitErr.Detail.Hint, "original hint") + } + }) + + t.Run("blank-whitespace hint is treated as empty and filled", func(t *testing.T) { + in := output.ErrWithHint(1, "api_error", "boom", " ") + out := withAppsHint(in, "filled hint") + var exitErr *output.ExitError + if !errors.As(out, &exitErr) { + t.Fatalf("returned error is not *output.ExitError: %T", out) + } + if exitErr.Detail.Hint != "filled hint" { + t.Errorf("Hint = %q, want %q", exitErr.Detail.Hint, "filled hint") + } + }) + + t.Run("unrecognized error type returned unchanged, no panic", func(t *testing.T) { + in := errors.New("plain") + out := withAppsHint(in, "ignored") + if out == nil || out.Error() != "plain" { + t.Fatalf("withAppsHint(plain) = %v, want unchanged plain error", out) + } + }) +} diff --git a/shortcuts/apps/db_common.go b/shortcuts/apps/db_common.go new file mode 100644 index 00000000..9f039139 --- /dev/null +++ b/shortcuts/apps/db_common.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "fmt" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" +) + +// URL helpers for the db CLI commands. + +// appTablesPath 返回 app db 表列表 URL(复用存量「获取数据表列表」接口)。 +func appTablesPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/tables", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appTablePath 返回单个 app db 表详情 URL(复用存量「获取数据表详细信息」接口)。 +func appTablePath(appID, table string) string { + return appTablesPath(appID) + "/" + validate.EncodePathSegment(table) +} + +// appSQLPath 返回 app db SQL 执行 URL(复用存量「执行 SQL」接口)。 +func appSQLPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/sql_commands", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appDbEnvCreatePath 返回 app db 环境创建 URL(服务端接口名仍为 db_dev_init)。 +func appDbEnvCreatePath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db_dev_init", apiBasePath, validate.EncodePathSegment(appID)) +} + +// requireAppID trims --app-id and rejects blank, returning a uniform validation error. +func requireAppID(raw string) (string, error) { + id := strings.TrimSpace(raw) + if id == "" { + return "", output.ErrValidation("--app-id is required") + } + return id, nil +} diff --git a/shortcuts/apps/db_common_test.go b/shortcuts/apps/db_common_test.go new file mode 100644 index 00000000..843580f9 --- /dev/null +++ b/shortcuts/apps/db_common_test.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import "testing" + +func TestAppTablesPath_ReusesExistingURL(t *testing.T) { + if got := appTablesPath("app_x"); got != "/open-apis/spark/v1/apps/app_x/tables" { + t.Fatalf("appTablesPath = %q (want existing /apps/{id}/tables, not /db/tables)", got) + } +} + +func TestAppTablePath_EncodesSegments(t *testing.T) { + if got := appTablePath("app_x", "my table"); got != "/open-apis/spark/v1/apps/app_x/tables/my%20table" { + t.Fatalf("appTablePath = %q", got) + } +} + +func TestAppSQLPath_ReusesExistingURL(t *testing.T) { + if got := appSQLPath("app_x"); got != "/open-apis/spark/v1/apps/app_x/sql_commands" { + t.Fatalf("appSQLPath = %q (want /apps/{id}/sql_commands)", got) + } +} + +func TestAppDbEnvCreatePath_NewURL(t *testing.T) { + // db-env-create 是本期新增接口,URL 走 /db_dev_init(与上面三条复用 URL 不同)。 + if got := appDbEnvCreatePath("app_x"); got != "/open-apis/spark/v1/apps/app_x/db_dev_init" { + t.Fatalf("appDbEnvCreatePath = %q", got) + } +} + +func TestRequireAppID_BlankRejected(t *testing.T) { + if _, err := requireAppID(" "); err == nil { + t.Fatal("expected error for blank app-id") + } + got, err := requireAppID(" app_x ") + if err != nil || got != "app_x" { + t.Fatalf("requireAppID trimmed = %q err=%v", got, err) + } +} diff --git a/shortcuts/apps/git_credential.go b/shortcuts/apps/git_credential.go new file mode 100644 index 00000000..250445f7 --- /dev/null +++ b/shortcuts/apps/git_credential.go @@ -0,0 +1,552 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strconv" + "strings" + "text/tabwriter" + "time" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/keychain" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/apps/gitcred" + "github.com/larksuite/cli/shortcuts/common" +) + +const gitCredentialIssuePath = apiBasePath + "/apps/:app_id/git_info" + +// gitCredentialIssueHint is the actionable next-step attached to a failed +// Git-credential issuance. A 5xx is flagged retryable separately at the call site. +const gitCredentialIssueHint = "failed to issue the Git credential: verify --app-id is correct and you have developer access to this Miaoda app; a 5xx is a transient server error and is safe to retry" + +var AppsGitCredentialInit = common.Shortcut{ + Service: appsService, + Command: "+git-credential-init", + Description: "Initialize Git credentials and a URL-scoped Git helper for a Miaoda app repository", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +git-credential-init --app-id ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(gitCredentialIssuePath). + Desc("Issue a Miaoda Git repository PAT"). + Set("app_id", appID). + Params(gitCredentialIssueParams(appID)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + manager := newGitCredentialManager(appID, rctx.Factory.Keychain, runtimeIssuer{rctx: rctx}) + result, err := manager.Init(ctx, profileFromConfig(rctx.Config), appID) + if err != nil { + return gitCredentialLocalError("Initialize local Miaoda Git credential", err) + } + payload := map[string]interface{}{ + "app_id": result.AppID, + "repository_url": result.GitHTTPURL, + "status": initStatus(result), + } + if result.ConfigWarning != "" { + payload["git_config_warning"] = result.ConfigWarning + } + rctx.OutFormat(payload, nil, func(w io.Writer) { + title := "Git credential initialized" + if result.Refreshed { + title = "Git credential refreshed" + } + fmt.Fprintln(w, title) + fmt.Fprintln(w) + fmt.Fprintf(w, "App ID: %s\n", result.AppID) + fmt.Fprintf(w, "Status: %s\n", initStatus(result)) + fmt.Fprintf(w, "Repository URL: %s\n", result.GitHTTPURL) + if result.ConfigWarning != "" { + fmt.Fprintln(w) + fmt.Fprintln(w, "Git credential saved, but Git helper was not configured") + fmt.Fprintf(w, "Reason: %s\n", result.ConfigWarning) + fmt.Fprintf(w, "Next step: lark-cli apps +git-credential-init --app-id %s\n", result.AppID) + return + } + fmt.Fprintln(w) + fmt.Fprintln(w, "Next step:") + fmt.Fprintf(w, " git clone %s\n", result.GitHTTPURL) + }) + return nil + }, +} + +var AppsGitCredentialRemove = common.Shortcut{ + Service: appsService, + Command: "+git-credential-remove", + Description: "Remove local Git credentials and the URL-scoped Git helper for a Miaoda app repository", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +git-credential-remove --app-id ", + }, + Scopes: []string{}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return output.ErrValidation("--app-id is required") + } + return validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + manager := newGitCredentialManager(appID, rctx.Factory.Keychain, nil) + result, err := manager.Remove(ctx, profileFromConfig(rctx.Config), appID) + if err != nil { + return gitCredentialLocalError("Remove local Miaoda Git credential", err) + } + payload := map[string]interface{}{ + "app_id": result.AppID, + "removed": result.Removed, + } + if result.ConfigWarning != "" { + payload["git_config_warning"] = result.ConfigWarning + } + rctx.OutFormat(payload, nil, func(w io.Writer) { + if !result.Removed { + fmt.Fprintln(w, "No local Git credential found") + return + } + fmt.Fprintln(w, "Git credential removed") + fmt.Fprintln(w) + fmt.Fprintf(w, "App ID: %s\n", result.AppID) + if len(result.Records) > 0 { + fmt.Fprintf(w, "Repository URL: %s\n", result.Records[0].GitHTTPURL) + } + fmt.Fprintln(w, "Status: removed") + if result.ConfigWarning != "" { + fmt.Fprintln(w) + fmt.Fprintln(w, "Git config cleanup warning") + fmt.Fprintf(w, "Reason: %s\n", result.ConfigWarning) + } + }) + return nil + }, +} + +var AppsGitCredentialList = common.Shortcut{ + Service: appsService, + Command: "+git-credential-list", + Description: "List local Git credentials for Miaoda app repositories", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +git-credential-list", + }, + Scopes: []string{}, + AuthTypes: []string{"user"}, + HasFormat: true, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now) + if err != nil { + return gitCredentialLocalError("List local Miaoda Git credentials", err) + } + payload := map[string]interface{}{ + "count": len(records), + "credentials": gitCredentialListPayload(records), + } + rctx.OutFormat(payload, nil, func(w io.Writer) { + if len(records) == 0 { + fmt.Fprintln(w, "No Git credentials initialized") + fmt.Fprintln(w) + fmt.Fprintln(w, "Next step: lark-cli apps +git-credential-init --app-id ") + return + } + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "App ID\tRepository URL\tStatus") + for _, record := range records { + fmt.Fprintf(tw, "%s\t%s\t%s\n", record.AppID, record.GitHTTPURL, gitCredentialDisplayStatus(record.Status)) + } + _ = tw.Flush() + fmt.Fprintln(w) + fmt.Fprintln(w, "Profile switches do not remove old URL-scoped Git helpers automatically.") + fmt.Fprintln(w, "Cleanup: lark-cli apps +git-credential-remove --app-id ") + }) + return nil + }, +} + +// InstallOnApps attaches hidden, apps-domain commands that are not regular +// shortcuts. git-credential-helper must speak Git's stdin/stdout protocol +// directly, so it intentionally does not use the shortcut JSON envelope. +func InstallOnApps(parent *cobra.Command, f *cmdutil.Factory) { + parent.AddCommand(newGitCredentialHelperCommand(f)) +} + +func newGitCredentialHelperCommand(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "git-credential-helper get|store|erase", + Short: "Git credential helper for Miaoda app repositories", + Hidden: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + appID, _ := cmd.Flags().GetString("app-id") + return runGitCredentialHelper(cmd.Context(), f, strings.TrimSpace(appID), args[0]) + }, + } + cmd.Flags().String("app-id", "", "Miaoda app ID") + _ = cmd.Flags().MarkHidden("app-id") + return cmd +} + +type runtimeIssuer struct { + rctx *common.RuntimeContext +} + +func (i runtimeIssuer) Issue(ctx context.Context, appID string, profile gitcred.ProfileContext) (*gitcred.IssuedCredential, error) { + resp, err := i.rctx.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: issuePath(appID), + }) + data, err := parseIssueCredentialData(resp, err) + if err != nil { + return nil, err + } + return issuedFromData(appID, data) +} + +type factoryIssuer struct { + f *cmdutil.Factory +} + +func (i factoryIssuer) Issue(ctx context.Context, appID string, profile gitcred.ProfileContext) (*gitcred.IssuedCredential, error) { + cfg, err := i.f.Config() + if err != nil { + return nil, err + } + if cfg.UserOpenId == "" { + return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`") + } + ac, err := i.f.NewAPIClientWithConfig(cfg) + if err != nil { + return nil, err + } + req := &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: issuePath(appID), + } + resp, err := ac.DoSDKRequest(ctx, req, core.AsUser) + data, err := parseIssueCredentialData(resp, err) + if err != nil { + return nil, err + } + return issuedFromData(appID, data) +} + +func runGitCredentialHelper(ctx context.Context, f *cmdutil.Factory, appID, action string) error { + if f == nil || f.IOStreams == nil { + return nil + } + if appID == "" { + fmt.Fprintln(f.IOStreams.ErrOut, "Git credential unavailable: missing app_id; rerun lark-cli apps +git-credential-init --app-id ") + return nil + } + manager := newGitCredentialManager(appID, f.Keychain, factoryIssuer{f: f}) + switch action { + case "get": + input, err := gitcred.ParseCredentialInput(f.IOStreams.In) + if err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "Git credential unavailable: %s\n", err) + return nil + } + cfg, err := f.Config() + if err != nil { + fmt.Fprintf(f.IOStreams.ErrOut, "Git credential unavailable: %s\n", err) + return nil + } + return manager.Get(ctx, input, profileFromConfig(cfg), f.IOStreams.Out, f.IOStreams.ErrOut) + case "store": + return manager.StoreCredential(f.IOStreams.In) + case "erase": + return manager.Erase(f.IOStreams.In) + default: + fmt.Fprintf(f.IOStreams.ErrOut, "unsupported git credential action %q\n", action) + return nil + } +} + +func newGitCredentialManager(appID string, kc keychain.KeychainAccess, issuer gitcred.Issuer) *gitcred.Manager { + storage := gitCredentialAppStorage{} + return gitcred.NewManager(gitcred.NewAppStore(appID, storage), gitcred.NewSecretStore(kc), gitcred.GlobalGitConfig{}, issuer) +} + +func listGitCredentialRecords(kc keychain.KeychainAccess, now func() time.Time) ([]gitcred.ListRecord, error) { + storage := gitCredentialAppStorage{} + appIDs, err := storage.ListAppIDs() + if err != nil { + return nil, err + } + records := make([]gitcred.ListRecord, 0, len(appIDs)) + for _, appID := range appIDs { + manager := newGitCredentialManager(appID, kc, nil) + manager.Now = now + result, err := manager.List() + if err != nil { + return nil, err + } + records = append(records, result.Records...) + } + sort.Slice(records, func(i, j int) bool { + if records[i].AppID == records[j].AppID { + return records[i].GitHTTPURL < records[j].GitHTTPURL + } + return records[i].AppID < records[j].AppID + }) + return records, nil +} + +func gitCredentialListPayload(records []gitcred.ListRecord) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(records)) + for _, record := range records { + out = append(out, map[string]interface{}{ + "app_id": record.AppID, + "repository_url": record.GitHTTPURL, + "status": gitCredentialDisplayStatus(record.Status), + }) + } + return out +} + +func gitCredentialDisplayStatus(status string) string { + if status == gitcred.ListStatusExpired { + return "refresh_required" + } + return status +} + +func profileFromConfig(cfg *core.CliConfig) gitcred.ProfileContext { + if cfg == nil { + return gitcred.ProfileContext{} + } + return gitcred.ProfileContext{ + Profile: cfg.ProfileName, + ProfileAppID: cfg.AppID, + UserOpenID: cfg.UserOpenId, + } +} + +func issuePath(appID string) string { + return strings.Replace(gitCredentialIssuePath, ":app_id", url.PathEscape(strings.TrimSpace(appID)), 1) +} + +func gitCredentialIssueParams(appID string) map[string]interface{} { + return map[string]interface{}{"app_id": strings.TrimSpace(appID)} +} + +func initStatus(result *gitcred.InitResult) string { + if result != nil && result.Refreshed { + return "refreshed" + } + return "initialized" +} + +func gitCredentialLocalError(action string, err error) error { + if err == nil { + return nil + } + if _, ok := errs.UnwrapTypedError(err); ok { + return err + } + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return err + } + return &errs.ConfigError{Problem: errs.Problem{ + Category: errs.CategoryConfig, + Subtype: errs.SubtypeInvalidConfig, + Message: fmt.Sprintf("%s: %s", action, err), + Hint: "retry the command; if the local Git credential state is damaged, rerun `lark-cli apps +git-credential-init --app-id ` or remove the app credential again", + }, Cause: err} +} + +func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedCredential, error) { + source := data + for _, key := range []string{"credential", "git_credential", "gitInfo", "git_info"} { + if nested, ok := data[key].(map[string]interface{}); ok { + source = nested + break + } + } + issued := &gitcred.IssuedCredential{ + AppID: firstString(source, "app_id", appID), + GitHTTPURL: firstString(source, "gitURL", "GitURL", "GitUrl", "gitUrl", "git_url", "git_http_url", "repository_url"), + Username: firstString(source, "username"), + PAT: firstString(source, "token", "Token", "pat", "password"), + ExpiresAt: firstInt64(source, "expiredTime", "ExpiredTime", "expired_time", "expires_at"), + } + if issued.AppID == "" { + issued.AppID = appID + } + if issued.GitHTTPURL == "" { + return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL") + } + if issued.PAT == "" { + return nil, output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token") + } + return issued, nil +} + +func parseIssueCredentialData(resp *larkcore.ApiResp, err error) (map[string]any, error) { + if err != nil { + return nil, err + } + detail := logIDDetail(resp) + if resp == nil || len(resp.RawBody) == 0 { + return nil, &errs.InternalError{Problem: errs.Problem{ + Category: errs.CategoryInternal, + Subtype: errs.SubtypeUnknown, + Message: "Issue Miaoda Git credential: empty response body", + }} + } + var result map[string]any + if jsonErr := json.Unmarshal(resp.RawBody, &result); jsonErr != nil { + return nil, &errs.InternalError{Problem: errs.Problem{ + Category: errs.CategoryInternal, + Subtype: errs.SubtypeUnknown, + Message: fmt.Sprintf("Issue Miaoda Git credential: unmarshal response: %s", jsonErr), + }, Cause: jsonErr} + } + if resp.StatusCode >= http.StatusBadRequest { + msg := firstString(result, "msg", "message") + if msg == "" { + msg = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + return nil, &errs.APIError{Problem: errs.Problem{ + Category: errs.CategoryAPI, + Subtype: errs.SubtypeUnknown, + Code: resp.StatusCode, + Message: msg, + LogID: logIDString(resp), + Hint: gitCredentialIssueHint, + Retryable: resp.StatusCode >= http.StatusInternalServerError, + }} + } + if _, hasCode := result["code"]; hasCode { + code := firstInt64(result, "code") + if code != 0 { + return nil, &errs.APIError{Problem: errs.Problem{ + Category: errs.CategoryAPI, + Subtype: errs.SubtypeUnknown, + Code: int(code), + Message: firstString(result, "msg", "message"), + LogID: logIDString(resp), + Hint: gitCredentialIssueHint, + }} + } + if data, ok := result["data"].(map[string]any); ok { + result = data + } + } else if err := checkGitInfoBaseResp(result, logIDString(resp)); err != nil { + return nil, err + } + if detail != nil { + if result == nil { + result = map[string]any{} + } + for k, v := range detail { + result[k] = v + } + } + return result, nil +} + +func checkGitInfoBaseResp(result map[string]any, logID string) error { + for _, key := range []string{"BaseResp", "baseResp", "base_resp"} { + baseResp, ok := result[key].(map[string]any) + if !ok { + continue + } + code := firstInt64(baseResp, "StatusCode", "statusCode", "status_code") + if code == 0 { + return nil + } + message := firstString(baseResp, "StatusMessage", "statusMessage", "status_message") + if message == "" { + message = "Git credential API returned non-zero BaseResp status" + } + return &errs.APIError{Problem: errs.Problem{ + Category: errs.CategoryAPI, + Subtype: errs.SubtypeUnknown, + Code: int(code), + Message: "Issue Miaoda Git credential: " + message, + LogID: logID, + }} + } + return nil +} + +func logIDDetail(resp *larkcore.ApiResp) map[string]any { + logID := logIDString(resp) + if logID == "" { + return nil + } + return map[string]any{"log_id": logID} +} + +func logIDString(resp *larkcore.ApiResp) string { + if resp == nil { + return "" + } + return resp.Header.Get("x-tt-logid") +} + +func firstString(data map[string]interface{}, keys ...string) string { + for _, key := range keys { + if v, ok := data[key].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} + +func firstInt64(data map[string]interface{}, keys ...string) int64 { + for _, key := range keys { + switch v := data[key].(type) { + case int64: + return v + case int: + return int64(v) + case float64: + return int64(v) + case string: + n, _ := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + return n + } + } + return 0 +} diff --git a/shortcuts/apps/git_credential_storage.go b/shortcuts/apps/git_credential_storage.go new file mode 100644 index 00000000..9b1c5dac --- /dev/null +++ b/shortcuts/apps/git_credential_storage.go @@ -0,0 +1,55 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/vfs" //nolint:depguard // Git credential list scans CLI config-dir state; it is not user file I/O. +) + +type gitCredentialAppStorage struct{} + +func (gitCredentialAppStorage) Read(appID, key string) ([]byte, error) { + return Read(appID, key) +} + +func (gitCredentialAppStorage) Write(appID, key string, data []byte) error { + return Write(appID, key, data) +} + +func (gitCredentialAppStorage) Delete(appID, key string) error { + return Delete(appID, key) +} + +func (gitCredentialAppStorage) ListAppIDs() ([]string, error) { + root := filepath.Join(core.GetConfigDir(), storageRoot) + entries, err := vfs.ReadDir(root) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []string{}, nil + } + return nil, fmt.Errorf("apps storage: read root: %w", err) + } + appIDs := make([]string, 0, len(entries)) + for _, e := range entries { + if !e.IsDir() { + continue + } + appID, err := url.PathUnescape(e.Name()) + if err != nil { + continue + } + if err := checkSeg(appID, "appID"); err != nil { + continue + } + appIDs = append(appIDs, appID) + } + return appIDs, nil +} diff --git a/shortcuts/apps/git_credential_test.go b/shortcuts/apps/git_credential_test.go new file mode 100644 index 00000000..16146f8e --- /dev/null +++ b/shortcuts/apps/git_credential_test.go @@ -0,0 +1,1022 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + "time" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/apps/gitcred" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestAppsGitCredentialInitDryRunRequestShape(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsGitCredentialInit, + []string{"+git-credential-init", "--app-id", "app_xxx", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var payload struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + Body interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if len(payload.API) != 1 { + t.Fatalf("api len = %d, want 1", len(payload.API)) + } + call := payload.API[0] + if call.Method != "GET" { + t.Fatalf("method = %q, want GET", call.Method) + } + if call.URL != "/open-apis/spark/v1/apps/app_xxx/git_info" { + t.Fatalf("url = %q", call.URL) + } + if call.Params["app_id"] != "app_xxx" { + t.Fatalf("app_id param = %v", call.Params["app_id"]) + } + if call.Body != nil { + t.Fatalf("body = %#v, want nil", call.Body) + } +} + +func TestAppsGitCredentialInitRequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", " ", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "--app-id is required") { + t.Fatalf("expected --app-id validation error, got %v", err) + } +} + +func TestIssuedFromDataAcceptsBackendGetAppGitInfoFields(t *testing.T) { + expiresAt := time.Now().Add(24 * time.Hour).Unix() + issued, err := issuedFromData("app_xxx", map[string]interface{}{ + "gitURL": "https://example.com/git/u/app.git", + "username": "x-access-token", + "token": "pat-token", + "expiredTime": float64(expiresAt), + }) + if err != nil { + t.Fatalf("issuedFromData returned error: %v", err) + } + if issued.GitHTTPURL != "https://example.com/git/u/app.git" { + t.Fatalf("GitHTTPURL = %q", issued.GitHTTPURL) + } + if issued.PAT != "pat-token" { + t.Fatalf("PAT = %q", issued.PAT) + } + if issued.ExpiresAt != expiresAt { + t.Fatalf("ExpiresAt = %d", issued.ExpiresAt) + } +} + +func TestParseIssueCredentialDataAcceptsDirectBaseRespShape(t *testing.T) { + data, err := parseIssueCredentialData(&larkcore.ApiResp{ + StatusCode: http.StatusOK, + RawBody: []byte(`{ + "gitURL":"https://example.com/git/u/app.git", + "username":"x-access-token", + "token":"pat-token", + "expiredTime":1780050600, + "BaseResp":{"StatusCode":0,"StatusMessage":"ok"} + }`), + }, nil) + if err != nil { + t.Fatalf("parseIssueCredentialData returned error: %v", err) + } + if data["gitURL"] != "https://example.com/git/u/app.git" { + t.Fatalf("gitURL = %v", data["gitURL"]) + } + if data["token"] != "pat-token" { + t.Fatalf("token = %v", data["token"]) + } +} + +func TestAppsGitCredentialInitExecutesAndRefreshes(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + kc := newAppsTestKeychain() + factory.Keychain = kc + installAppsFakeGit(t, 0) + expiresAt := time.Now().Add(24 * time.Hour).Unix() + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_xxx/git_info", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "gitURL": "https://example.com/git/u/app.git", + "username": "x-access-token", + "token": "pat-token", + "expiredTime": float64(expiresAt), + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_xxx/git_info", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "gitURL": "https://example.com/git/u/app.git", + "username": "x-access-token", + "token": "newer-token", + "expiredTime": float64(expiresAt + 20000), + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_xxx/git_info", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "gitURL": "https://example.com/git/u/app.git", + "username": "x-access-token", + "token": "new-token", + "expiredTime": float64(expiresAt + 10000), + }, + }, + }) + + if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", "app_xxx", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute init err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"status": "initialized"`) || !strings.Contains(got, `"repository_url": "https://example.com/git/u/app.git"`) { + t.Fatalf("init stdout = %s", got) + } + meta, err := Read("app_xxx", gitcred.MetadataFilename) + if err != nil { + t.Fatalf("read app-scoped metadata: %v", err) + } + if !strings.Contains(string(meta), `"git_http_url": "https://example.com/git/u/app.git"`) { + t.Fatalf("metadata missing git url: %s", meta) + } + if strings.Contains(string(meta), "pat-token") || strings.Contains(string(meta), `"credentials"`) { + t.Fatalf("metadata should be app-scoped and must not contain PAT: %s", meta) + } + if len(kc.values) != 1 { + t.Fatalf("keychain entries = %#v, want one PAT entry", kc.values) + } + for ref, pat := range kc.values { + if ref == "" { + t.Fatal("keychain ref is empty") + } + if pat != "pat-token" { + t.Fatalf("keychain PAT = %q, want pat-token", pat) + } + } + if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", "app_xxx", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute refresh err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"status": "refreshed"`) { + t.Fatalf("refresh stdout = %s", got) + } + if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", "app_xxx", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute pretty refresh err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "Git credential refreshed") || !strings.Contains(got, "git clone https://example.com/git/u/app.git") { + t.Fatalf("pretty refresh stdout = %s", got) + } +} + +func TestAppsGitCredentialInitPrettyWithGitConfigWarning(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + factory.Keychain = newAppsTestKeychain() + installAppsFakeGit(t, 7) + expiresAt := time.Now().Add(24 * time.Hour).Unix() + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_xxx/git_info", + Body: map[string]interface{}{ + "gitURL": "https://example.com/git/u/app.git", + "username": "x-access-token", + "token": "pat-token", + "expiredTime": float64(expiresAt), + "BaseResp": map[string]interface{}{ + "StatusCode": 0, + "StatusMessage": "ok", + }, + }, + }) + + if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", "app_xxx", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute init err=%v", err) + } + got := stdout.String() + for _, want := range []string{ + "Git credential initialized", + "Status: initialized", + "Repository URL: https://example.com/git/u/app.git", + "Git credential saved, but Git helper was not configured", + "Next step: lark-cli apps +git-credential-init --app-id app_xxx", + } { + if !strings.Contains(got, want) { + t.Fatalf("pretty stdout missing %q in:\n%s", want, got) + } + } +} + +func TestAppsGitCredentialInitAPIError(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + factory.Keychain = newAppsTestKeychain() + installAppsFakeGit(t, 0) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_xxx/git_info", + Status: http.StatusBadRequest, + Body: map[string]interface{}{"msg": "permission denied"}, + }) + err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", "app_xxx", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("expected API error, got %v", err) + } +} + +func TestAppsGitCredentialInitHooksDirectly(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("app-id", "", "") + if err := cmd.Flags().Set("app-id", " "); err != nil { + t.Fatalf("set flag: %v", err) + } + rctx := &common.RuntimeContext{Cmd: cmd} + if err := AppsGitCredentialInit.Validate(context.Background(), rctx); err == nil { + t.Fatal("Validate returned nil for blank app-id") + } + if err := cmd.Flags().Set("app-id", " app_xxx "); err != nil { + t.Fatalf("set flag: %v", err) + } + if AppsGitCredentialInit.DryRun(context.Background(), rctx) == nil { + t.Fatal("DryRun returned nil") + } +} + +func TestAppsGitCredentialRemove(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + factory.Keychain = newAppsTestKeychain() + installAppsFakeGit(t, 0) + expiresAt := time.Now().Add(24 * time.Hour).Unix() + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_xxx/git_info", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "gitURL": "https://example.com/git/u/app.git", + "token": "pat-token", + "expiredTime": float64(expiresAt), + }, + }, + }) + if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", "app_xxx", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute init err=%v", err) + } + if err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", "app_xxx", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute remove err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "Git credential removed") || !strings.Contains(got, "Status: removed") { + t.Fatalf("remove stdout = %s", got) + } + if err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", "app_xxx", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute remove missing err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "No local Git credential found") { + t.Fatalf("remove missing stdout = %s", got) + } +} + +func TestAppsGitCredentialListScansAllLocalAppStorage(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + factory.Keychain = newAppsTestKeychain() + installAppsFakeGit(t, 0) + expiresA := time.Now().Add(24 * time.Hour).Unix() + expiresB := time.Now().Add(48 * time.Hour).Unix() + for _, tc := range []struct { + appID string + url string + token string + expiresAt int64 + }{ + {appID: "app_b", url: "https://example.com/git/u/b.git", token: "pat-b", expiresAt: expiresB}, + {appID: "app_a", url: "https://example.com/git/u/a.git", token: "pat-a", expiresAt: expiresA}, + } { + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/" + tc.appID + "/git_info", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "gitURL": tc.url, + "token": tc.token, + "expiredTime": float64(tc.expiresAt), + }, + }, + }) + if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", tc.appID, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute init %s err=%v", tc.appID, err) + } + } + + if err := runAppsShortcut(t, AppsGitCredentialList, []string{"+git-credential-list", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute list pretty err=%v", err) + } + got := stdout.String() + for _, want := range []string{ + "App ID", + "Repository URL", + "app_a", + "https://example.com/git/u/a.git", + "app_b", + "https://example.com/git/u/b.git", + gitcred.ListStatusValid, + "Profile switches do not remove old URL-scoped Git helpers automatically.", + "Cleanup: lark-cli apps +git-credential-remove --app-id ", + } { + if !strings.Contains(got, want) { + t.Fatalf("list pretty stdout missing %q in:\n%s", want, got) + } + } + for _, hidden := range []string{"Expires At", "expires_at", "expired", time.Unix(expiresA, 0).UTC().Format(time.RFC3339), time.Unix(expiresB, 0).UTC().Format(time.RFC3339)} { + if strings.Contains(got, hidden) { + t.Fatalf("list pretty stdout should not expose %q in:\n%s", hidden, got) + } + } + if strings.Index(got, "app_a") > strings.Index(got, "app_b") { + t.Fatalf("list should be sorted by app_id, got:\n%s", got) + } + + if err := runAppsShortcut(t, AppsGitCredentialList, []string{"+git-credential-list", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute list json err=%v", err) + } + var envelope struct { + Data struct { + Count int `json:"count"` + Credentials []struct { + AppID string `json:"app_id"` + RepositoryURL string `json:"repository_url"` + Status string `json:"status"` + } `json:"credentials"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(stdout.String()), &envelope); err != nil { + t.Fatalf("decode list output: %v\n%s", err, stdout.String()) + } + payload := envelope.Data + if payload.Count != 2 || len(payload.Credentials) != 2 { + t.Fatalf("payload count = %d records=%#v\n%s", payload.Count, payload.Credentials, stdout.String()) + } + if payload.Credentials[0].AppID != "app_a" || payload.Credentials[0].RepositoryURL != "https://example.com/git/u/a.git" || payload.Credentials[0].Status != gitcred.ListStatusValid { + t.Fatalf("first credential = %#v", payload.Credentials[0]) + } + if strings.Contains(stdout.String(), "expires_at") || strings.Contains(stdout.String(), "expires_at_iso") || strings.Contains(stdout.String(), strconv.FormatInt(expiresA, 10)) || strings.Contains(stdout.String(), strconv.FormatInt(expiresB, 10)) { + t.Fatalf("list json should not expose expiry fields or values:\n%s", stdout.String()) + } +} + +func TestAppsGitCredentialListEmpty(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + factory.Keychain = newAppsTestKeychain() + + if err := runAppsShortcut(t, AppsGitCredentialList, []string{"+git-credential-list", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute list pretty err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "No Git credentials initialized") || !strings.Contains(got, "+git-credential-init --app-id ") { + t.Fatalf("empty list stdout = %s", got) + } +} + +func TestGitCredentialAppStorageListAppIDsSkipsNonCredentialAppDirs(t *testing.T) { + newAppsExecuteFactory(t) + if err := Write("app/a", gitcred.MetadataFilename, []byte("{}")); err != nil { + t.Fatalf("Write escaped app metadata: %v", err) + } + if err := Write("app_b", gitcred.MetadataFilename, []byte("{}")); err != nil { + t.Fatalf("Write app_b metadata: %v", err) + } + root := filepath.Join(core.GetConfigDir(), "spark") + if err := os.WriteFile(filepath.Join(root, "not-an-app-dir"), []byte("x"), 0600); err != nil { + t.Fatalf("write non-dir: %v", err) + } + for _, name := range []string{"%zz", "app%2F..%2Fb"} { + if err := os.Mkdir(filepath.Join(root, name), 0700); err != nil { + t.Fatalf("mkdir %s: %v", name, err) + } + } + + appIDs, err := gitCredentialAppStorage{}.ListAppIDs() + if err != nil { + t.Fatalf("ListAppIDs: %v", err) + } + got := map[string]bool{} + for _, appID := range appIDs { + got[appID] = true + } + if len(got) != 2 || !got["app/a"] || !got["app_b"] { + t.Fatalf("appIDs = %v, want app/a and app_b only", appIDs) + } +} + +func TestAppsGitCredentialListReturnsScanErrors(t *testing.T) { + t.Run("storage root error", func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + root := filepath.Join(core.GetConfigDir(), "spark") + if err := os.WriteFile(root, []byte("not a dir"), 0600); err != nil { + t.Fatalf("write storage root blocker: %v", err) + } + err := runAppsShortcut(t, AppsGitCredentialList, []string{"+git-credential-list", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "apps storage: read root") { + t.Fatalf("execute list root error = %v", err) + } + }) + + t.Run("record error", func(t *testing.T) { + factory, _, _ := newAppsExecuteFactory(t) + if err := Write("app_xxx", gitcred.MetadataFilename, []byte("{bad json")); err != nil { + t.Fatalf("write invalid metadata: %v", err) + } + _, err := listGitCredentialRecords(factory.Keychain, time.Now) + if err == nil || !strings.Contains(err.Error(), "invalid git.json") { + t.Fatalf("listGitCredentialRecords record error = %v", err) + } + }) +} + +func TestListGitCredentialRecordsSortsDuplicateDecodedAppIDs(t *testing.T) { + factory, _, _ := newAppsExecuteFactory(t) + kc := newAppsTestKeychain() + factory.Keychain = kc + now := time.Unix(1780000000, 0) + manager := newGitCredentialManager("app_x", kc, nil) + manager.Now = func() time.Time { return now } + record := gitcred.CredentialRecord{ + AppID: "app_x", + GitHTTPURL: "https://example.com/git/u/app.git", + Username: "x-access-token", + PATRef: "ref", + Status: gitcred.StatusConfirmed, + ExpiresAt: now.Add(time.Hour).Unix(), + } + kc.values[record.PATRef] = "pat" + if err := manager.Store.Upsert(record); err != nil { + t.Fatalf("Upsert returned error: %v", err) + } + if err := os.Mkdir(filepath.Join(core.GetConfigDir(), "spark", "app%5Fx"), 0700); err != nil { + t.Fatalf("mkdir duplicate encoded app dir: %v", err) + } + + records, err := listGitCredentialRecords(kc, func() time.Time { return now }) + if err != nil { + t.Fatalf("listGitCredentialRecords returned error: %v", err) + } + if len(records) != 2 || records[0].AppID != "app_x" || records[1].AppID != "app_x" { + t.Fatalf("records = %#v, want duplicate decoded app_x records", records) + } +} + +func TestGitCredentialListPayloadDoesNotExposeExpiry(t *testing.T) { + payload := gitCredentialListPayload([]gitcred.ListRecord{{ + AppID: "app_xxx", + GitHTTPURL: "https://example.com/git/u/app.git", + Status: gitcred.ListStatusExpired, + ExpiresAt: 1780000000, + Expired: true, + }}) + for _, key := range []string{"expires_at", "expires_at_iso", "expired"} { + if _, ok := payload[0][key]; ok { + t.Fatalf("payload exposes %s: %#v", key, payload[0]) + } + } + if got := payload[0]["status"]; got != "refresh_required" { + t.Fatalf("payload status = %q, want refresh_required", got) + } + for _, value := range payload[0] { + if strings.Contains(fmt.Sprint(value), "expired") { + t.Fatalf("payload exposes expired concept: %#v", payload[0]) + } + } +} + +func TestAppsGitCredentialRemoveReportsGitConfigWarning(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + factory.Keychain = newAppsTestKeychain() + installAppsFakeGit(t, 7) // unsetting useHttpPath exits non-zero -> ConfigWarning + expiresAt := time.Now().Add(24 * time.Hour).Unix() + for _, appID := range []string{"app_one", "app_two"} { + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/" + appID + "/git_info", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "gitURL": "https://example.com/git/u/" + appID + ".git", + "token": "pat-token", + "expiredTime": float64(expiresAt), + }, + }, + }) + if err := runAppsShortcut(t, AppsGitCredentialInit, []string{"+git-credential-init", "--app-id", appID, "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("init %s err=%v", appID, err) + } + } + // Pretty output surfaces the cleanup-warning block. + if err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", "app_one", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("remove pretty err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "Git config cleanup warning") || !strings.Contains(got, "Reason:") { + t.Fatalf("pretty remove missing git config warning: %s", got) + } + // JSON output exposes git_config_warning. + if err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", "app_two", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("remove json err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, "git_config_warning") { + t.Fatalf("json remove missing git_config_warning: %s", got) + } +} + +func TestAppsGitCredentialRemoveRequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", " ", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "--app-id is required") { + t.Fatalf("expected --app-id validation error, got %v", err) + } +} + +func TestAppsGitCredentialRemoveReturnsStoreError(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := Write("app_xxx", gitcred.MetadataFilename, []byte("{bad json")); err != nil { + t.Fatalf("write invalid metadata: %v", err) + } + err := runAppsShortcut(t, AppsGitCredentialRemove, []string{"+git-credential-remove", "--app-id", "app_xxx", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "invalid git.json") { + t.Fatalf("expected remove store error, got %v", err) + } +} + +func TestGitCredentialLocalErrorWrapsOnlyPlainErrors(t *testing.T) { + plain := errors.New("git config failed") + wrapped := gitCredentialLocalError("List local Miaoda Git credentials", plain) + var configErr *errs.ConfigError + if !errors.As(wrapped, &configErr) { + t.Fatalf("plain local error wrapped as %T, want *errs.ConfigError", wrapped) + } + if !errors.Is(wrapped, plain) { + t.Fatalf("wrapped error does not preserve cause") + } + + typed := &errs.ConfigError{Problem: errs.Problem{ + Category: errs.CategoryConfig, + Subtype: errs.SubtypeInvalidConfig, + Message: "already typed", + }} + if got := gitCredentialLocalError("action", typed); got != typed { + t.Fatalf("typed error was rewrapped: %#v", got) + } + + exitErr := output.ErrValidation("bad app") + if got := gitCredentialLocalError("action", exitErr); got != exitErr { + t.Fatalf("legacy output error was rewrapped: %#v", got) + } + + if got := gitCredentialLocalError("action", nil); got != nil { + t.Fatalf("nil error must stay nil, got %#v", got) + } +} + +func TestRunGitCredentialHelperActions(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + factory, stdout, _ := newAppsExecuteFactory(t) + kc := newAppsTestKeychain() + factory.Keychain = kc + storage := gitCredentialAppStorage{} + manager := gitcred.NewManager(gitcred.NewAppStore("app_xxx", storage), gitcred.NewSecretStore(kc), nil, testAppsIssuer{next: &gitcred.IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + Username: "x-access-token", + PAT: "pat-token", + ExpiresAt: time.Now().Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return time.Unix(1780000000, 0) } + cfg, err := factory.Config() + if err != nil { + t.Fatalf("factory Config returned error: %v", err) + } + if _, err := manager.Init(context.Background(), profileFromConfig(cfg), "app_xxx"); err != nil { + t.Fatalf("seed Init returned error: %v", err) + } + + factory.IOStreams.In = bytes.NewBufferString("protocol=https\nhost=example.com\npath=/git/u/app.git\n\n") + if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "get"); err != nil { + t.Fatalf("helper get returned error: %v", err) + } + if got := stdout.String(); got != "username=x-access-token\npassword=pat-token\n\n" { + t.Fatalf("helper get stdout = %q", got) + } + stdout.Reset() + factory.IOStreams.In = bytes.NewBufferString("protocol=https\nhost=example.com\n\n") + if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "store"); err != nil { + t.Fatalf("helper store returned error: %v", err) + } + factory.IOStreams.In = bytes.NewBufferString("protocol=https\nhost=example.com\npath=/git/u/app.git\n\n") + if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "erase"); err != nil { + t.Fatalf("helper erase returned error: %v", err) + } + var stderr bytes.Buffer + factory.IOStreams.ErrOut = &stderr + factory.IOStreams.In = bytes.NewBufferString("bad-input-without-equals\n") + if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "get"); err != nil { + t.Fatalf("helper bad get returned error: %v", err) + } + if !strings.Contains(stderr.String(), "protocol and host") { + t.Fatalf("stderr = %q", stderr.String()) + } + stderr.Reset() + factory.IOStreams.In = errorReader{} + if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "get"); err != nil { + t.Fatalf("helper reader error returned error: %v", err) + } + if !strings.Contains(stderr.String(), "read failed") { + t.Fatalf("stderr = %q", stderr.String()) + } + stderr.Reset() + factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") } + factory.IOStreams.In = bytes.NewBufferString("protocol=https\nhost=example.com\npath=/git/u/app.git\n\n") + if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "get"); err != nil { + t.Fatalf("helper config error returned error: %v", err) + } + if !strings.Contains(stderr.String(), "config failed") { + t.Fatalf("stderr = %q", stderr.String()) + } + cfg = &core.CliConfig{AppID: "cli", AppSecret: "secret", Brand: core.BrandFeishu, UserOpenId: "ou_test"} + factory.Config = func() (*core.CliConfig, error) { return cfg, nil } + stderr.Reset() + if err := runGitCredentialHelper(context.Background(), factory, "app_xxx", "unknown"); err != nil { + t.Fatalf("helper unknown returned error: %v", err) + } + if !strings.Contains(stderr.String(), `unsupported git credential action "unknown"`) { + t.Fatalf("stderr = %q", stderr.String()) + } + stderr.Reset() + if err := runGitCredentialHelper(context.Background(), factory, "", "get"); err != nil { + t.Fatalf("helper missing appID returned error: %v", err) + } + if !strings.Contains(stderr.String(), "missing app_id") { + t.Fatalf("stderr = %q", stderr.String()) + } + if err := runGitCredentialHelper(context.Background(), nil, "app_xxx", "get"); err != nil { + t.Fatalf("helper nil factory returned error: %v", err) + } + if err := runGitCredentialHelper(context.Background(), &cmdutil.Factory{}, "app_xxx", "get"); err != nil { + t.Fatalf("helper nil streams returned error: %v", err) + } + factory.IOStreams.In = bytes.NewBufferString("protocol=https\nhost=example.com\n\n") + cmd := newGitCredentialHelperCommand(factory) + if err := cmd.Flags().Set("app-id", "app_xxx"); err != nil { + t.Fatalf("set app-id returned error: %v", err) + } + if err := cmd.RunE(cmd, []string{"store"}); err != nil { + t.Fatalf("helper command returned error: %v", err) + } +} + +func TestFactoryIssuerBranches(t *testing.T) { + factory, _, reg := newAppsExecuteFactory(t) + expiresAt := time.Now().Add(24 * time.Hour).Unix() + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_xxx/git_info", + Body: map[string]interface{}{ + "gitURL": "https://example.com/git/u/app.git", + "token": "pat-token", + "expiredTime": float64(expiresAt), + "BaseResp": map[string]interface{}{ + "StatusCode": 0, + }, + }, + }) + issued, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}) + if err != nil { + t.Fatalf("factory issuer returned error: %v", err) + } + if issued.PAT != "pat-token" { + t.Fatalf("PAT = %q", issued.PAT) + } + + factory.Config = func() (*core.CliConfig, error) { return nil, errors.New("config failed") } + if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil { + t.Fatal("factory issuer config error returned nil") + } + + factory.Config = func() (*core.CliConfig, error) { + return &core.CliConfig{AppID: "cli", AppSecret: "secret", Brand: core.BrandFeishu}, nil + } + if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil { + t.Fatal("factory issuer without login returned nil") + } + factory.Config = func() (*core.CliConfig, error) { + return &core.CliConfig{AppID: "cli", AppSecret: "secret", Brand: core.BrandFeishu, UserOpenId: "ou_test"}, nil + } + factory.LarkClient = func() (*lark.Client, error) { return nil, errors.New("sdk failed") } + if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil { + t.Fatal("factory issuer SDK error returned nil") + } + + factory, _, reg = newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_xxx/git_info", + RawBody: []byte("{bad json"), + }) + if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil { + t.Fatal("factory issuer parse error returned nil") + } + + factory, _, _ = newAppsExecuteFactory(t) + if _, err := (factoryIssuer{f: factory}).Issue(context.Background(), "app_xxx", gitcred.ProfileContext{}); err == nil { + t.Fatal("factory issuer request error returned nil") + } +} + +func TestGitCredentialHelpersAndParsers(t *testing.T) { + if issuePath(" app/with space ") != "/open-apis/spark/v1/apps/app%2Fwith%20space/git_info" { + t.Fatalf("issuePath escaped incorrectly: %s", issuePath(" app/with space ")) + } + if got := gitCredentialIssueParams(" app_xxx ")["app_id"]; got != "app_xxx" { + t.Fatalf("param app_id = %q", got) + } + if initStatus(nil) != "initialized" || initStatus(&gitcred.InitResult{Refreshed: true}) != "refreshed" { + t.Fatalf("initStatus mismatch") + } + if got := profileFromConfig(nil); got != (gitcred.ProfileContext{}) { + t.Fatalf("profileFromConfig(nil) = %#v", got) + } + + for _, data := range []map[string]interface{}{ + {"credential": map[string]interface{}{"gitURL": "https://example.com/repo.git", "token": "pat"}}, + {"git_credential": map[string]interface{}{"git_url": "https://example.com/repo.git", "password": "pat"}}, + {"gitInfo": map[string]interface{}{"repository_url": "https://example.com/repo.git", "pat": "pat", "expired_time": "1780050600"}}, + {"git_info": map[string]interface{}{"GitUrl": "https://example.com/repo.git", "Token": "pat", "ExpiredTime": "1780050600"}}, + } { + if _, err := issuedFromData("app_xxx", data); err != nil { + t.Fatalf("issuedFromData nested returned error: %v", err) + } + } + if _, err := issuedFromData("app_xxx", map[string]interface{}{"token": "pat"}); err == nil { + t.Fatal("issuedFromData missing gitURL returned nil error") + } + if _, err := issuedFromData("app_xxx", map[string]interface{}{"gitURL": "https://example.com/repo.git"}); err == nil { + t.Fatal("issuedFromData missing token returned nil error") + } + if got := firstInt64(map[string]interface{}{"n": int(7)}, "n"); got != 7 { + t.Fatalf("firstInt64 int = %d", got) + } + if got := firstInt64(map[string]interface{}{"n": int64(9)}, "n"); got != 9 { + t.Fatalf("firstInt64 int64 = %d", got) + } + if got := firstInt64(map[string]interface{}{"n": "bad"}, "n"); got != 0 { + t.Fatalf("firstInt64 bad string = %d", got) + } + if logIDString(nil) != "" { + t.Fatal("logIDString(nil) should be empty") + } +} + +func TestParseIssueCredentialDataErrors(t *testing.T) { + if _, err := parseIssueCredentialData(nil, errors.New("transport failed")); err == nil { + t.Fatal("parseIssueCredentialData transport error returned nil") + } + if _, err := parseIssueCredentialData(nil, nil); err == nil { + t.Fatal("parseIssueCredentialData nil response returned nil") + } + if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil); err == nil { + t.Fatal("parseIssueCredentialData bad json returned nil") + } + header := http.Header{"X-Tt-Logid": []string{"log_x"}} + if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusBadRequest, RawBody: []byte(`{"msg":"bad request"}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "bad request") { + t.Fatalf("HTTP error = %v", err) + } + if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusInternalServerError, RawBody: []byte(`{}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "HTTP 500") { + t.Fatalf("HTTP fallback error = %v", err) + } + if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"failed"}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "failed") { + t.Fatalf("code error = %v", err) + } + data, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":0}`), Header: header}, nil) + if err != nil { + t.Fatalf("code zero without data returned error: %v", err) + } + if data["log_id"] != "log_x" { + t.Fatalf("log_id = %v", data["log_id"]) + } + data, err = parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`null`), Header: header}, nil) + if err != nil { + t.Fatalf("null response with log id returned error: %v", err) + } + if data["log_id"] != "log_x" { + t.Fatalf("null response log_id = %v", data["log_id"]) + } + if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"BaseResp":{"StatusCode":7,"StatusMessage":"denied"}}`), Header: header}, nil); err == nil || !strings.Contains(err.Error(), "denied") { + t.Fatalf("BaseResp error = %v", err) + } + if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"baseResp":{"statusCode":7}}`)}, nil); err == nil || !strings.Contains(err.Error(), "non-zero BaseResp") { + t.Fatalf("BaseResp fallback error = %v", err) + } +} + +// TestParseIssueCredentialData503IsRetryableWithHint verifies that a 5xx Git +// credential issuance failure is flagged retryable and carries the developer-access hint. +func TestParseIssueCredentialData503IsRetryableWithHint(t *testing.T) { + header := http.Header{"X-Tt-Logid": []string{"log_x"}} + _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header}, nil) + if err == nil { + t.Fatal("expected 503 error, got nil") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed errs.Problem, got %T: %v", err, err) + } + if !p.Retryable { + t.Fatalf("503 should be retryable, got Retryable=false") + } + if !strings.Contains(p.Hint, "developer access") { + t.Fatalf("hint missing 'developer access': %q", p.Hint) + } +} + +// TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable verifies that a +// non-zero business code (no HTTP status) carries the hint but is not retryable. +func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) { + header := http.Header{"X-Tt-Logid": []string{"log_x"}} + _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte(`{"code":999,"msg":"no developer access"}`), Header: header}, nil) + if err == nil { + t.Fatal("expected business-code error, got nil") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed errs.Problem, got %T: %v", err, err) + } + if p.Retryable { + t.Fatalf("business code != 0 must not be retryable, got Retryable=true") + } + if !strings.Contains(p.Hint, "developer access") { + t.Fatalf("hint missing 'developer access': %q", p.Hint) + } +} + +// TestParseIssueCredentialDataMessageAddsNoExtraSecret verifies the security +// condition that apps does not ADDITIONALLY inject any token/secret into the +// Git-credential error it builds. The server `msg` is passed through verbatim +// into Problem.Message, and the only thing apps adds is the static +// gitCredentialIssueHint — which itself contains no secret. We feed a benign +// server msg and assert (a) Message equals that msg exactly, and (b) neither +// Message nor Hint contains any token/secret-shaped string. +// +// Note: server msg passthrough is the framework's responsibility; apps adds +// only a static hint. There is no msg redaction in this path (verbatim +// passthrough is the existing behavior), so this test does not assert a +// redaction that does not exist — it asserts that apps injects nothing +// sensitive of its own. +func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) { + const serverMsg = "permission denied" + header := http.Header{"X-Tt-Logid": []string{"log_x"}} + + for _, tc := range []struct { + name string + resp *larkcore.ApiResp + }{ + { + name: "http error path", + resp: &larkcore.ApiResp{ + StatusCode: http.StatusForbidden, + RawBody: []byte(`{"msg":"` + serverMsg + `"}`), + Header: header, + }, + }, + { + name: "business code path", + resp: &larkcore.ApiResp{ + StatusCode: http.StatusOK, + RawBody: []byte(`{"code":999,"msg":"` + serverMsg + `"}`), + Header: header, + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + _, err := parseIssueCredentialData(tc.resp, nil) + if err == nil { + t.Fatal("expected an error, got nil") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed errs.Problem, got %T: %v", err, err) + } + // (a) The server msg is passed through verbatim. + if p.Message != serverMsg { + t.Fatalf("Message = %q, want server msg %q (verbatim passthrough)", p.Message, serverMsg) + } + // apps adds only the static hint — assert that exact static text, + // proving apps injects no per-request secret into the hint either. + if p.Hint != gitCredentialIssueHint { + t.Fatalf("Hint = %q, want the static gitCredentialIssueHint", p.Hint) + } + // (b) Neither field may contain a token/secret-shaped string that + // apps could have added on top of the framework passthrough. + secret := regexp.MustCompile(`(?i)(pat-[a-z0-9]+|secret\s*[=:]\s*\S|token\s*[=:]\s*\S|password\s*[=:]\s*\S)`) + for field, val := range map[string]string{"Message": p.Message, "Hint": p.Hint} { + if secret.MatchString(val) { + t.Fatalf("%s leaks a token/secret-shaped string: %q", field, val) + } + } + }) + } +} + +type errorReader struct{} + +func (errorReader) Read(p []byte) (int, error) { + return 0, errors.New("read failed") +} + +type appsTestKeychain struct { + values map[string]string +} + +func newAppsTestKeychain() *appsTestKeychain { + return &appsTestKeychain{values: map[string]string{}} +} + +func (k *appsTestKeychain) Get(service, account string) (string, error) { + return k.values[account], nil +} + +func (k *appsTestKeychain) Set(service, account, value string) error { + k.values[account] = value + return nil +} + +func (k *appsTestKeychain) Remove(service, account string) error { + delete(k.values, account) + return nil +} + +type testAppsIssuer struct { + next *gitcred.IssuedCredential +} + +func (i testAppsIssuer) Issue(ctx context.Context, appID string, profile gitcred.ProfileContext) (*gitcred.IssuedCredential, error) { + out := *i.next + out.AppID = appID + return &out, nil +} + +func installAppsFakeGit(t *testing.T, failUseHTTPPathExit int) { + t.Helper() + dir := t.TempDir() + gitPath := filepath.Join(dir, "git") + script := `#!/bin/sh +case "$*" in + *"--get"*) exit 1 ;; +esac +exit 0 +` + if failUseHTTPPathExit != 0 { + script = `#!/bin/sh +case "$*" in + *"--get"*) exit 1 ;; +esac +case "$*" in + *useHttpPath*) exit 7 ;; +esac +exit 0 +` + } + if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) +} diff --git a/shortcuts/apps/gitcred/gitconfig.go b/shortcuts/apps/gitcred/gitconfig.go new file mode 100644 index 00000000..c12fb492 --- /dev/null +++ b/shortcuts/apps/gitcred/gitconfig.go @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package gitcred + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/larksuite/cli/internal/validate" +) + +type GitConfig interface { + SetHelper(ctx context.Context, gitHTTPURL, appID string) error + UnsetHelper(ctx context.Context, gitHTTPURL string) error +} + +type GlobalGitConfig struct { + HelperCommand string +} + +func (g GlobalGitConfig) SetHelper(ctx context.Context, gitHTTPURL, appID string) error { + normalizedURL, err := NormalizeGitHTTPURL(gitHTTPURL) + if err != nil { + return err + } + appID = strings.TrimSpace(appID) + if err := validate.ResourceName(appID, "appID"); err != nil { + return err + } + helper := g.helperCommand(appID) + helperKey := gitCredentialKey(normalizedURL, "helper") + useHTTPPathKey := gitCredentialKey(normalizedURL, "useHttpPath") + previousHelper, hadHelper, err := gitConfigGet(ctx, helperKey) + if err != nil { + return err + } + if hadHelper && previousHelper != helper && !g.isManagedHelper(previousHelper) { + return fmt.Errorf("git credential helper already configured for %s; refusing to overwrite non-lark helper", normalizedURL) + } + if err := exec.CommandContext(ctx, "git", "config", "--global", helperKey, helper).Run(); err != nil { + return err + } + if err := exec.CommandContext(ctx, "git", "config", "--global", useHTTPPathKey, "true").Run(); err != nil { + if !hadHelper { + _ = exec.CommandContext(ctx, "git", "config", "--global", "--unset", helperKey).Run() + } else if previousHelper != helper { + _ = exec.CommandContext(ctx, "git", "config", "--global", helperKey, previousHelper).Run() + } + return err + } + return nil +} + +func (g GlobalGitConfig) UnsetHelper(ctx context.Context, gitHTTPURL string) error { + normalizedURL, err := NormalizeGitHTTPURL(gitHTTPURL) + if err != nil { + return err + } + helperKey := gitCredentialKey(normalizedURL, "helper") + useHTTPPathKey := gitCredentialKey(normalizedURL, "useHttpPath") + helper, found, err := gitConfigGet(ctx, helperKey) + if err != nil { + return err + } + if found { + if !g.isManagedHelper(helper) { + return nil + } + } + if err := gitConfigUnset(ctx, helperKey); err != nil { + return err + } + if err := gitConfigUnset(ctx, useHTTPPathKey); err != nil { + return err + } + return nil +} + +func (g GlobalGitConfig) helperCommand(appID string) string { + if g.HelperCommand != "" { + return g.HelperCommand + } + return "!lark-cli apps git-credential-helper --app-id " + shellQuoteArg(appID) +} + +func (g GlobalGitConfig) isManagedHelper(helper string) bool { + helper = strings.TrimSpace(helper) + if g.HelperCommand != "" { + return helper == g.HelperCommand + } + return strings.HasPrefix(helper, "!lark-cli apps git-credential-helper ") +} + +func gitCredentialKey(gitHTTPURL, name string) string { + return "credential." + gitHTTPURL + "." + name +} + +func gitConfigGet(ctx context.Context, key string) (string, bool, error) { + out, err := exec.CommandContext(ctx, "git", "config", "--global", "--get", key).Output() + if err == nil { + return strings.TrimSpace(string(out)), true, nil + } + if isGitConfigGetMissing(err) { + return "", false, nil + } + return "", false, fmt.Errorf("get %s: %w", key, err) +} + +func gitConfigUnset(ctx context.Context, key string) error { + if err := exec.CommandContext(ctx, "git", "config", "--global", "--unset", key).Run(); err != nil { + if isGitConfigUnsetMissing(err) { + return nil + } + return fmt.Errorf("unset %s: %w", key, err) + } + return nil +} + +func isGitConfigGetMissing(err error) bool { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { + return true + } + return false +} + +func isGitConfigUnsetMissing(err error) bool { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 5 { + return true + } + return false +} + +func shellQuoteArg(s string) string { + return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" +} diff --git a/shortcuts/apps/gitcred/gitcred_test.go b/shortcuts/apps/gitcred/gitcred_test.go new file mode 100644 index 00000000..7085a682 --- /dev/null +++ b/shortcuts/apps/gitcred/gitcred_test.go @@ -0,0 +1,2381 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package gitcred + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/errs" +) + +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "gitcred-test-config-*") + if err != nil { + panic(err) + } + _ = os.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + code := m.Run() + _ = os.RemoveAll(dir) + os.Exit(code) +} + +type fakeKeychain struct { + values map[string]string + removed []string + services []string + getErr error + setErr error + removeErr error + onGet func(account string) + onSet func(account, value string) +} + +func newFakeKeychain() *fakeKeychain { + return &fakeKeychain{values: map[string]string{}} +} + +func (f *fakeKeychain) Get(service, account string) (string, error) { + if f.getErr != nil { + return "", f.getErr + } + f.services = append(f.services, "get:"+service) + if f.onGet != nil { + f.onGet(account) + } + return f.values[account], nil +} + +func (f *fakeKeychain) Set(service, account, value string) error { + if f.setErr != nil { + return f.setErr + } + f.services = append(f.services, "set:"+service) + f.values[account] = value + if f.onSet != nil { + f.onSet(account, value) + } + return nil +} + +func (f *fakeKeychain) Remove(service, account string) error { + if f.removeErr != nil { + return f.removeErr + } + f.services = append(f.services, "remove:"+service) + delete(f.values, account) + f.removed = append(f.removed, account) + return nil +} + +type fakeIssuer struct { + calls int + next *IssuedCredential + err error + onIssue func() +} + +func (f *fakeIssuer) Issue(ctx context.Context, appID string, profile ProfileContext) (*IssuedCredential, error) { + f.calls++ + if f.onIssue != nil { + f.onIssue() + } + if f.err != nil { + return nil, f.err + } + out := *f.next + if out.AppID == "" { + out.AppID = appID + } + return &out, nil +} + +type fakeGitConfig struct { + set []string + unset []string + err error +} + +func (f *fakeGitConfig) SetHelper(ctx context.Context, gitHTTPURL, appID string) error { + f.set = append(f.set, gitHTTPURL+" "+appID) + return f.err +} + +func (f *fakeGitConfig) UnsetHelper(ctx context.Context, gitHTTPURL string) error { + f.unset = append(f.unset, gitHTTPURL) + return f.err +} + +type splitFakeGitConfig struct { + setErr error + unsetErr error +} + +func (f splitFakeGitConfig) SetHelper(ctx context.Context, gitHTTPURL, appID string) error { + return f.setErr +} + +func (f splitFakeGitConfig) UnsetHelper(ctx context.Context, gitHTTPURL string) error { + return f.unsetErr +} + +type fakeAppStorage struct { + values map[string][]byte + err error +} + +func newFakeAppStorage() *fakeAppStorage { + return &fakeAppStorage{values: map[string][]byte{}} +} + +func (s *fakeAppStorage) Read(appID, key string) ([]byte, error) { + if s.err != nil { + return nil, s.err + } + data := s.values[appID+"/"+key] + if data == nil { + return nil, nil + } + return append([]byte(nil), data...), nil +} + +func (s *fakeAppStorage) Write(appID, key string, data []byte) error { + if s.err != nil { + return s.err + } + s.values[appID+"/"+key] = append([]byte(nil), data...) + return nil +} + +func (s *fakeAppStorage) Delete(appID, key string) error { + if s.err != nil { + return s.err + } + delete(s.values, appID+"/"+key) + return nil +} + +type sequenceAppStorage struct { + reads [][]byte +} + +func (s *sequenceAppStorage) Read(appID, key string) ([]byte, error) { + if len(s.reads) == 0 { + return nil, nil + } + data := s.reads[0] + s.reads = s.reads[1:] + return append([]byte(nil), data...), nil +} + +func (s *sequenceAppStorage) Write(appID, key string, data []byte) error { + return nil +} + +func (s *sequenceAppStorage) Delete(appID, key string) error { + return nil +} + +func TestNormalizeGitHTTPURL(t *testing.T) { + got, err := NormalizeGitHTTPURL("HTTPS://Example.COM:443//git/u_abc/app.git/?x=1#frag") + if err != nil { + t.Fatalf("NormalizeGitHTTPURL returned error: %v", err) + } + want := "https://example.com/git/u_abc/app.git" + if got != want { + t.Fatalf("NormalizeGitHTTPURL() = %q, want %q", got, want) + } +} + +func TestManagerInitStoresPATThroughInternalKeychainAndMetadataOnly(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + gitConfig := &fakeGitConfig{} + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + Username: "x-access-token", + PAT: "secret-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, issuer) + manager.Now = func() time.Time { return now } + + result, err := manager.Init(context.Background(), testProfile(), "app_xxx") + if err != nil { + t.Fatalf("Init returned error: %v", err) + } + if result.GitHTTPURL != "https://example.com/git/u/app.git" { + t.Fatalf("GitHTTPURL = %q", result.GitHTTPURL) + } + record, err := manager.Store.FindByURL(result.GitHTTPURL) + if err != nil { + t.Fatalf("FindByURL returned error: %v", err) + } + if record == nil || record.Status != StatusConfirmed { + t.Fatalf("record = %#v, want confirmed", record) + } + if bytes.Contains(mustReadMetadata(t, manager), []byte("secret-pat")) { + t.Fatalf("metadata must not contain PAT") + } + if got := kc.values[record.PATRef]; got != "secret-pat" { + t.Fatalf("keychain PAT = %q, want secret-pat", got) + } + if !slices.Contains(kc.services, "set:"+KeychainService) { + t.Fatalf("keychain services = %#v, want Set through %q", kc.services, KeychainService) + } + if len(gitConfig.set) != 1 || gitConfig.set[0] != result.GitHTTPURL+" app_xxx" { + t.Fatalf("git config set = %#v", gitConfig.set) + } +} + +func TestManagerInitFailsWhenKeychainUnavailable(t *testing.T) { + now := time.Unix(1780000000, 0) + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "secret-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(nil), nil, issuer) + manager.Now = func() time.Time { return now } + + _, err := manager.Init(context.Background(), testProfile(), "app_xxx") + if err == nil { + t.Fatal("Init returned nil error, want keychain unavailable error") + } + record, findErr := manager.Store.FindByURL("https://example.com/git/u/app.git") + if findErr != nil { + t.Fatalf("FindByURL returned error: %v", findErr) + } + if record != nil { + t.Fatalf("record after failed init = %#v, want nil", record) + } +} + +func TestManagerInitRestoresExistingRecordWhenKeychainSetFails(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("initial Init returned error: %v", err) + } + before, err := manager.Store.FindByURL("https://example.com/git/u/app.git") + if err != nil { + t.Fatalf("FindByURL returned error: %v", err) + } + + kc.setErr = errors.New("keychain locked") + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "new-pat", + ExpiresAt: now.Add(48 * time.Hour).Unix(), + } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("second Init returned nil error, want keychain error") + } + after, err := manager.Store.FindByURL("https://example.com/git/u/app.git") + if err != nil { + t.Fatalf("FindByURL returned error: %v", err) + } + if after == nil || after.ExpiresAt != before.ExpiresAt || after.Status != StatusConfirmed { + t.Fatalf("record after failed refresh init = %#v, want original %#v", after, before) + } + if got := kc.values[before.PATRef]; got != "old-pat" { + t.Fatalf("keychain PAT after failed refresh init = %q, want old-pat", got) + } +} + +func TestManagerInitCleansOldURLHelperAfterRepositoryURLChanges(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + gitConfig := &fakeGitConfig{} + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/old.git", + PAT: "old-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("initial Init returned error: %v", err) + } + + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/new.git", + PAT: "new-pat", + ExpiresAt: now.Add(48 * time.Hour).Unix(), + } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("second Init returned error: %v", err) + } + if len(gitConfig.unset) != 1 || gitConfig.unset[0] != "https://example.com/git/u/old.git" { + t.Fatalf("git config unset = %#v, want old URL cleanup", gitConfig.unset) + } +} + +func TestManagerInitReportsOldURLCleanupWarning(t *testing.T) { + now := time.Unix(1780000000, 0) + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/old.git", + PAT: "old-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), splitFakeGitConfig{unsetErr: errors.New("unset failed")}, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("initial Init returned error: %v", err) + } + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/new.git", + PAT: "new-pat", + ExpiresAt: now.Add(48 * time.Hour).Unix(), + } + result, err := manager.Init(context.Background(), testProfile(), "app_xxx") + if err != nil { + t.Fatalf("second Init returned error: %v", err) + } + if !strings.Contains(result.ConfigWarning, "unset failed") { + t.Fatalf("ConfigWarning = %q, want unset warning", result.ConfigWarning) + } +} + +func TestManagerInitRemovesPreviousPATRefAfterLoginChanges(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + oldProfile := testProfile() + if _, err := manager.Init(context.Background(), oldProfile, "app_xxx"); err != nil { + t.Fatalf("initial Init returned error: %v", err) + } + oldRef := BuildPATRef(oldProfile, "app_xxx") + + newProfile := ProfileContext{Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_new"} + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "new-pat", + ExpiresAt: now.Add(48 * time.Hour).Unix(), + } + if _, err := manager.Init(context.Background(), newProfile, "app_xxx"); err != nil { + t.Fatalf("second Init returned error: %v", err) + } + newRef := BuildPATRef(newProfile, "app_xxx") + if got := kc.values[oldRef]; got != "" { + t.Fatalf("old keychain PAT = %q, want removed", got) + } + if got := kc.values[newRef]; got != "new-pat" { + t.Fatalf("new keychain PAT = %q, want new-pat", got) + } +} + +func TestManagerInitDoesNotTreatOtherAppRecordAsRefresh(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + storage := newFakeAppStorage() + otherStore := NewAppStore("app_other", storage) + otherRecord := CredentialRecord{ + AppID: "app_other", + GitHTTPURL: "https://example.com/git/u/other.git", + PATRef: "other-ref", + Status: StatusConfirmed, + ExpiresAt: now.Add(24 * time.Hour).Unix(), + } + if err := otherStore.Upsert(otherRecord); err != nil { + t.Fatalf("seed other app record: %v", err) + } + kc.values[otherRecord.PATRef] = "other-pat" + manager := NewManager(NewAppStore("app_xxx", storage), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "app-pat", + ExpiresAt: now.Add(48 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + + result, err := manager.Init(context.Background(), testProfile(), "app_xxx") + if err != nil { + t.Fatalf("Init returned error: %v", err) + } + if result.Refreshed { + t.Fatalf("Init marked refreshed with only another app record present") + } + if got := kc.values[otherRecord.PATRef]; got != "other-pat" { + t.Fatalf("other app PAT = %q, want untouched", got) + } + if record, err := otherStore.FindByURL(otherRecord.GitHTTPURL); err != nil || record == nil { + t.Fatalf("other app record = %#v, %v; want untouched", record, err) + } +} + +func TestManagerGetRefreshesWithinTenMinutes(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + Username: "x-access-token", + PAT: "old-pat", + ExpiresAt: now.Add(9 * time.Minute).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + Username: "x-access-token", + PAT: "new-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + } + + var out bytes.Buffer + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &bytes.Buffer{}); err != nil { + t.Fatalf("Get returned error: %v", err) + } + if got := out.String(); got != "username=x-access-token\npassword=new-pat\n\n" { + t.Fatalf("credential output = %q", got) + } + if issuer.calls != 2 { + t.Fatalf("issuer calls = %d, want 2", issuer.calls) + } +} + +func TestManagerGetDoesNotReuseUnusableRecordWhenRefreshReturnsOlderExpiry(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + Username: "x-access-token", + PAT: "old-pat", + ExpiresAt: now.Add(5 * time.Minute).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + Username: "x-access-token", + PAT: "older-pat", + ExpiresAt: now.Add(time.Minute).Unix(), + } + + var out bytes.Buffer + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &bytes.Buffer{}); err != nil { + t.Fatalf("Get returned error: %v", err) + } + if out.Len() != 0 { + t.Fatalf("stdout = %q, want empty because reread record is still not usable", out.String()) + } + if issuer.calls != 2 { + t.Fatalf("issuer calls = %d, want 2", issuer.calls) + } +} + +func TestManagerGetUsesValidPATWithoutRefresh(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + Username: "x-access-token", + PAT: "valid-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + + var out bytes.Buffer + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &bytes.Buffer{}); err != nil { + t.Fatalf("Get returned error: %v", err) + } + if got := out.String(); got != "username=x-access-token\npassword=valid-pat\n\n" { + t.Fatalf("credential output = %q", got) + } + if issuer.calls != 1 { + t.Fatalf("issuer calls = %d, want 1", issuer.calls) + } +} + +func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + record, err := manager.Store.FindByURL("https://example.com/git/u/app.git") + if err != nil { + t.Fatalf("FindByURL returned error: %v", err) + } + record.ExpiresAt = now.Add(-time.Minute).Unix() + if err := manager.Store.Upsert(*record); err != nil { + t.Fatalf("Upsert expired record returned error: %v", err) + } + issuer.err = errors.New("permission denied") + + var out bytes.Buffer + var errOut bytes.Buffer + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil { + t.Fatalf("Get returned error: %v", err) + } + if out.Len() != 0 { + t.Fatalf("stdout = %q, want empty", out.String()) + } + if !bytes.Contains(errOut.Bytes(), []byte("lark-cli apps +git-credential-init --app-id app_xxx")) { + t.Fatalf("stderr missing actionable hint: %q", errOut.String()) + } +} + +func TestManagerGetKeepsStdoutEmptyOnLoginMismatch(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + + var out bytes.Buffer + var errOut bytes.Buffer + other := ProfileContext{Profile: "work", ProfileAppID: "cli_other", UserOpenID: "ou_other"} + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, other, &out, &errOut); err != nil { + t.Fatalf("Get returned error: %v", err) + } + if out.Len() != 0 { + t.Fatalf("stdout = %q, want empty", out.String()) + } + if !bytes.Contains(errOut.Bytes(), []byte("current login does not match")) { + t.Fatalf("stderr missing login mismatch: %q", errOut.String()) + } +} + +func TestManagerGetAllowsProfileRenameForSameLogin(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + Username: "x-access-token", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + + renamed := testProfile() + renamed.Profile = "renamed-profile" + var out bytes.Buffer + var errOut bytes.Buffer + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, renamed, &out, &errOut); err != nil { + t.Fatalf("Get returned error: %v", err) + } + if got := out.String(); got != "username=x-access-token\npassword=pat\n\n" { + t.Fatalf("credential output = %q", got) + } + if errOut.Len() != 0 { + t.Fatalf("stderr = %q, want empty", errOut.String()) + } +} + +func TestEraseMarksInvalidatedWithCooldown(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + + input := "protocol=https\nhost=example.com\npath=/git/u/app.git\n\n" + if err := manager.Erase(bytes.NewBufferString(input)); err != nil { + t.Fatalf("Erase returned error: %v", err) + } + if err := manager.Erase(bytes.NewBufferString(input)); err != nil { + t.Fatalf("second Erase returned error: %v", err) + } + if len(kc.removed) != 1 { + t.Fatalf("removed count = %d, want 1", len(kc.removed)) + } + record, err := manager.Store.FindByURL("https://example.com/git/u/app.git") + if err != nil { + t.Fatalf("FindByURL returned error: %v", err) + } + if record.InvalidatedAt == 0 || record.LastEraseAt == 0 { + t.Fatalf("record was not invalidated: %#v", record) + } +} + +func TestEraseLockAndSecondReadBranches(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + blocker := filepath.Join(t.TempDir(), "config-blocker") + if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil { + t.Fatalf("write config blocker: %v", err) + } + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", blocker) + input := "protocol=https\nhost=example.com\npath=/git/u/app.git\n\n" + if err := manager.Erase(bytes.NewBufferString(input)); err == nil || !strings.Contains(err.Error(), "create Git credential lock dir") { + t.Fatalf("Erase lock error = %v", err) + } +} + +func TestEraseSecondReadMissingReturnsNil(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + record := CredentialRecord{ + AppID: "app_xxx", + GitHTTPURL: "https://example.com/git/u/app.git", + Profile: "default", + ProfileAppID: "cli_xxx", + UserOpenID: "ou_xxx", + Username: "x-access-token", + PATRef: "ref", + Status: StatusConfirmed, + ExpiresAt: now.Add(24 * time.Hour).Unix(), + } + data, err := json.Marshal(CredentialFile{Version: CurrentCredentialVersion, CredentialRecord: record}) + if err != nil { + t.Fatalf("marshal credential file: %v", err) + } + manager := NewManager(NewAppStore("app_xxx", &sequenceAppStorage{reads: [][]byte{data, nil}}), NewSecretStore(kc), nil, nil) + manager.Now = func() time.Time { return now } + input := "protocol=https\nhost=example.com\npath=/git/u/app.git\n\n" + if err := manager.Erase(bytes.NewBufferString(input)); err != nil { + t.Fatalf("Erase second read missing returned error: %v", err) + } +} + +func TestStoreCredentialDrainsStdin(t *testing.T) { + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, nil) + if err := manager.StoreCredential(bytes.NewBufferString("protocol=https\nhost=example.com\n\n")); err != nil { + t.Fatalf("StoreCredential returned error: %v", err) + } +} + +func TestRemoveDeletesMetadataSecretAndGitConfig(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + gitConfig := &fakeGitConfig{} + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + + result, err := manager.Remove(context.Background(), testProfile(), "app_xxx") + if err != nil { + t.Fatalf("Remove returned error: %v", err) + } + if !result.Removed || len(result.Records) != 1 { + t.Fatalf("remove result = %#v", result) + } + if got := kc.values[result.Records[0].PATRef]; got != "" { + t.Fatalf("keychain PAT after remove = %q, want empty", got) + } + if len(gitConfig.unset) != 1 || gitConfig.unset[0] != "https://example.com/git/u/app.git" { + t.Fatalf("git config unset = %#v", gitConfig.unset) + } + record, err := manager.Store.FindByURL("https://example.com/git/u/app.git") + if err != nil { + t.Fatalf("FindByURL returned error: %v", err) + } + if record != nil { + t.Fatalf("record after remove = %#v, want nil", record) + } +} + +func TestInitWorksAfterRemove(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + gitConfig := &fakeGitConfig{} + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "first-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("initial Init returned error: %v", err) + } + if _, err := manager.Remove(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Remove returned error: %v", err) + } + + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "second-pat", + ExpiresAt: now.Add(48 * time.Hour).Unix(), + } + result, err := manager.Init(context.Background(), testProfile(), "app_xxx") + if err != nil { + t.Fatalf("Init after Remove returned error: %v", err) + } + if result.Refreshed { + t.Fatalf("Init after Remove marked refreshed, want fresh init") + } + record, err := manager.Store.FindByURL("https://example.com/git/u/app.git") + if err != nil { + t.Fatalf("FindByURL returned error: %v", err) + } + if record == nil || record.Status != StatusConfirmed { + t.Fatalf("record after re-init = %#v, want confirmed", record) + } + if got := kc.values[record.PATRef]; got != "second-pat" { + t.Fatalf("PAT after re-init = %q, want second-pat", got) + } + if len(gitConfig.set) != 2 { + t.Fatalf("git config set calls = %#v, want initial and re-init", gitConfig.set) + } + if len(gitConfig.unset) != 1 { + t.Fatalf("git config unset calls = %#v, want remove cleanup", gitConfig.unset) + } +} + +func TestRemoveIgnoresCurrentProfileMismatch(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + gitConfig := &fakeGitConfig{} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + + other := ProfileContext{Profile: "work", ProfileAppID: "other_cli", UserOpenID: "ou_other"} + result, err := manager.Remove(context.Background(), other, "app_xxx") + if err != nil { + t.Fatalf("Remove with profile mismatch returned error: %v", err) + } + if !result.Removed { + t.Fatalf("Remove with profile mismatch did not remove: %#v", result) + } +} + +func TestRemoveWithoutRecordDoesNotTouchKeychainOrGitConfig(t *testing.T) { + kc := newFakeKeychain() + gitConfig := &fakeGitConfig{} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, nil) + + result, err := manager.Remove(context.Background(), testProfile(), "app_xxx") + if err != nil { + t.Fatalf("Remove without record returned error: %v", err) + } + if result.Removed { + t.Fatalf("Remove without record marked removed: %#v", result) + } + if len(kc.removed) != 0 { + t.Fatalf("keychain removals = %#v, want none", kc.removed) + } + if len(gitConfig.unset) != 0 { + t.Fatalf("git config unsets = %#v, want none", gitConfig.unset) + } +} + +func TestListReportsCredentialStatuses(t *testing.T) { + now := time.Unix(1780000000, 0) + for _, tc := range []struct { + name string + mutate func(*CredentialRecord, *fakeKeychain) + want string + expired bool + }{ + { + name: "valid", + want: ListStatusValid, + }, + { + name: "expired", + mutate: func(record *CredentialRecord, kc *fakeKeychain) { + record.ExpiresAt = now.Add(-time.Minute).Unix() + }, + want: ListStatusExpired, + expired: true, + }, + { + name: "invalidated", + mutate: func(record *CredentialRecord, kc *fakeKeychain) { + record.InvalidatedAt = now.Unix() + }, + want: ListStatusInvalidated, + }, + { + name: "missing-secret", + mutate: func(record *CredentialRecord, kc *fakeKeychain) { + delete(kc.values, record.PATRef) + }, + want: ListStatusMissingSecret, + }, + { + name: "incomplete", + mutate: func(record *CredentialRecord, kc *fakeKeychain) { + record.Status = StatusPending + record.PATRef = "" + }, + want: ListStatusIncomplete, + }, + } { + t.Run(tc.name, func(t *testing.T) { + kc := newFakeKeychain() + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, nil) + manager.Now = func() time.Time { return now } + record := CredentialRecord{ + AppID: "app_xxx", + GitHTTPURL: "https://example.com/git/u/app.git", + Profile: "default", + ProfileAppID: "cli_xxx", + UserOpenID: "ou_xxx", + Username: "x-access-token", + PATRef: "ref", + Status: StatusConfirmed, + ExpiresAt: now.Add(time.Hour).Unix(), + UpdatedAt: now.Unix(), + } + kc.values[record.PATRef] = "pat" + if tc.mutate != nil { + tc.mutate(&record, kc) + } + if err := manager.Store.Upsert(record); err != nil { + t.Fatalf("Upsert returned error: %v", err) + } + + result, err := manager.List() + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if len(result.Records) != 1 { + t.Fatalf("records = %#v, want one", result.Records) + } + got := result.Records[0] + if got.Status != tc.want || got.Expired != tc.expired { + t.Fatalf("list record = %#v, want status=%s expired=%v", got, tc.want, tc.expired) + } + if got.AppID != record.AppID || got.GitHTTPURL != record.GitHTTPURL || got.ProfileAppID != record.ProfileAppID || got.UserOpenID != record.UserOpenID { + t.Fatalf("list record lost metadata: %#v", got) + } + }) + } +} + +func TestListReturnsStoreError(t *testing.T) { + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, nil) + if err := os.WriteFile(manager.Store.Path(), []byte("{bad json"), 0600); err != nil { + t.Fatalf("write invalid metadata: %v", err) + } + if _, err := manager.List(); err == nil || !strings.Contains(err.Error(), "invalid git.json") { + t.Fatalf("List store error = %v", err) + } +} + +func TestGlobalGitConfigSetAndUnsetHelper(t *testing.T) { + logPath := installFakeGit(t, 0) + cfg := GlobalGitConfig{HelperCommand: "!custom-helper"} + ctx := context.Background() + + if err := cfg.SetHelper(ctx, "https://example.com/git/u/app.git", "app_xxx"); err != nil { + t.Fatalf("SetHelper returned error: %v", err) + } + if err := cfg.UnsetHelper(ctx, "https://example.com/git/u/app.git"); err != nil { + t.Fatalf("UnsetHelper returned error: %v", err) + } + + log := readFileString(t, logPath) + for _, want := range []string{ + "config --global credential.https://example.com/git/u/app.git.helper !custom-helper", + "config --global credential.https://example.com/git/u/app.git.useHttpPath true", + "config --global --unset credential.https://example.com/git/u/app.git.helper", + "config --global --unset credential.https://example.com/git/u/app.git.useHttpPath", + } { + if !strings.Contains(log, want) { + t.Fatalf("git log missing %q in:\n%s", want, log) + } + } +} + +func TestGlobalGitConfigNormalizesCredentialKeyURL(t *testing.T) { + logPath := installFakeGit(t, 0) + cfg := GlobalGitConfig{HelperCommand: "!custom-helper"} + rawURL := "HTTPS://[2001:DB8::1]:443//repo.git?x=1" + + if err := cfg.SetHelper(context.Background(), rawURL, "app_xxx"); err != nil { + t.Fatalf("SetHelper returned error: %v", err) + } + if err := cfg.UnsetHelper(context.Background(), rawURL); err != nil { + t.Fatalf("UnsetHelper returned error: %v", err) + } + + log := readFileString(t, logPath) + for _, want := range []string{ + "config --global credential.https://[2001:db8::1]/repo.git.helper !custom-helper", + "config --global credential.https://[2001:db8::1]/repo.git.useHttpPath true", + "config --global --unset credential.https://[2001:db8::1]/repo.git.helper", + "config --global --unset credential.https://[2001:db8::1]/repo.git.useHttpPath", + } { + if !strings.Contains(log, want) { + t.Fatalf("git log missing normalized key %q in:\n%s", want, log) + } + } +} + +func TestGlobalGitConfigRollsBackHelperWhenUseHttpPathFails(t *testing.T) { + logPath := installFakeGit(t, 7) + err := (GlobalGitConfig{}).SetHelper(context.Background(), "https://example.com/git/u/app.git", "app_xxx") + if err == nil { + t.Fatal("SetHelper returned nil error, want git failure") + } + log := readFileString(t, logPath) + if !strings.Contains(log, "config --global --unset credential.https://example.com/git/u/app.git.helper") { + t.Fatalf("git log missing rollback unset:\n%s", log) + } +} + +func TestGlobalGitConfigQuotesDefaultHelperAppID(t *testing.T) { + logPath := installFakeGit(t, 0) + appID := "app_xxx; touch /tmp/pwned" + if err := (GlobalGitConfig{}).SetHelper(context.Background(), "https://example.com/git/u/app.git", appID); err != nil { + t.Fatalf("SetHelper returned error: %v", err) + } + log := readFileString(t, logPath) + want := "helper !lark-cli apps git-credential-helper --app-id 'app_xxx; touch /tmp/pwned'" + if !strings.Contains(log, want) { + t.Fatalf("git log missing quoted helper %q in:\n%s", want, log) + } +} + +func TestGlobalGitConfigReturnsFirstGitCommandError(t *testing.T) { + installAlwaysFailingGit(t) + err := (GlobalGitConfig{}).SetHelper(context.Background(), "https://example.com/git/u/app.git", "app_xxx") + if err == nil { + t.Fatal("SetHelper returned nil error, want first git command failure") + } +} + +func TestGlobalGitConfigUnsetReportsUnexpectedErrors(t *testing.T) { + installAlwaysFailingGit(t) + err := (GlobalGitConfig{}).UnsetHelper(context.Background(), "https://example.com/git/u/app.git") + if err == nil || !strings.Contains(err.Error(), "get credential.https://example.com/git/u/app.git.helper") { + t.Fatalf("UnsetHelper error = %v", err) + } +} + +func TestGlobalGitConfigDoesNotOverwriteOrUnsetNonLarkHelper(t *testing.T) { + logPath := installFakeGitWithGet(t, "!other-helper") + cfg := GlobalGitConfig{} + err := cfg.SetHelper(context.Background(), "https://example.com/git/u/app.git", "app_xxx") + if err == nil || !strings.Contains(err.Error(), "refusing to overwrite non-lark helper") { + t.Fatalf("SetHelper error = %v", err) + } + if err := cfg.UnsetHelper(context.Background(), "https://example.com/git/u/app.git"); err != nil { + t.Fatalf("UnsetHelper returned error: %v", err) + } + log := readFileString(t, logPath) + for _, unwanted := range []string{ + "credential.https://example.com/git/u/app.git.helper !lark-cli", + "--unset credential.https://example.com/git/u/app.git.helper", + "--unset credential.https://example.com/git/u/app.git.useHttpPath", + } { + if strings.Contains(log, unwanted) { + t.Fatalf("git log contains unwanted %q in:\n%s", unwanted, log) + } + } +} + +func TestGlobalGitConfigUnsetIgnoresMissingManagedKeys(t *testing.T) { + logPath := installFakeGitWithGetAndUnsetExit(t, "!lark-cli apps git-credential-helper --app-id app_xxx", 5) + if err := (GlobalGitConfig{}).UnsetHelper(context.Background(), "https://example.com/git/u/app.git"); err != nil { + t.Fatalf("UnsetHelper returned error: %v", err) + } + log := readFileString(t, logPath) + for _, want := range []string{ + "config --global --unset credential.https://example.com/git/u/app.git.helper", + "config --global --unset credential.https://example.com/git/u/app.git.useHttpPath", + } { + if !strings.Contains(log, want) { + t.Fatalf("git log missing %q in:\n%s", want, log) + } + } +} + +func TestGlobalGitConfigAdditionalBranches(t *testing.T) { + if err := (GlobalGitConfig{}).SetHelper(context.Background(), "ssh://example.com/git/u/app.git", "app_xxx"); err == nil { + t.Fatal("SetHelper invalid URL returned nil error") + } + if err := (GlobalGitConfig{}).UnsetHelper(context.Background(), "ssh://example.com/git/u/app.git"); err == nil { + t.Fatal("UnsetHelper invalid URL returned nil error") + } + + if err := (GlobalGitConfig{}).SetHelper(context.Background(), "https://example.com/git/u/app.git", "../bad"); err == nil { + t.Fatal("SetHelper invalid appID returned nil error") + } + + installFakeGitSetFails(t) + if err := (GlobalGitConfig{}).SetHelper(context.Background(), "https://example.com/git/u/app.git", "app_xxx"); err == nil { + t.Fatal("SetHelper set failure returned nil error") + } + + logPath := installFakeGitWithGetAndUseHTTPPathFailure(t, "!lark-cli apps git-credential-helper --app-id old_app", 7) + if err := (GlobalGitConfig{}).SetHelper(context.Background(), "https://example.com/git/u/app.git", "app_xxx"); err == nil { + t.Fatal("SetHelper useHttpPath failure returned nil error") + } + log := readFileString(t, logPath) + if !strings.Contains(log, "config --global credential.https://example.com/git/u/app.git.helper !lark-cli apps git-credential-helper --app-id old_app") { + t.Fatalf("git log missing previous helper restore:\n%s", log) + } + + installFakeGitWithGetAndUnsetExit(t, "!lark-cli apps git-credential-helper --app-id app_xxx", 9) + if err := (GlobalGitConfig{}).UnsetHelper(context.Background(), "https://example.com/git/u/app.git"); err == nil || !strings.Contains(err.Error(), "unset credential.https://example.com/git/u/app.git.helper") { + t.Fatalf("UnsetHelper helper unset error = %v", err) + } + + logPath = installFakeGitWithGetAndSecondUnsetFails(t, "!lark-cli apps git-credential-helper --app-id app_xxx") + if err := (GlobalGitConfig{}).UnsetHelper(context.Background(), "https://example.com/git/u/app.git"); err == nil || !strings.Contains(err.Error(), "unset credential.https://example.com/git/u/app.git.useHttpPath") { + t.Fatalf("UnsetHelper useHttpPath unset error = %v", err) + } + if !strings.Contains(readFileString(t, logPath), "--unset credential.https://example.com/git/u/app.git.useHttpPath") { + t.Fatalf("git log missing useHttpPath unset:\n%s", readFileString(t, logPath)) + } + + cfg := GlobalGitConfig{HelperCommand: "!custom-helper"} + if !cfg.isManagedHelper(" !custom-helper ") { + t.Fatal("custom helper should be managed") + } + if cfg.isManagedHelper("!other-helper") { + t.Fatal("other helper should not be managed") + } + if isGitConfigUnsetMissing(errors.New("plain error")) { + t.Fatal("plain error must not be treated as missing git config") + } +} + +func TestStoreLoadSaveAndQueryBranches(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, MetadataFilename) + store := NewStoreAt(path) + if store.Path() != path { + t.Fatalf("Path() = %q", store.Path()) + } + empty, err := store.Load() + if err != nil { + t.Fatalf("Load missing file returned error: %v", err) + } + if empty.Version != CurrentCredentialVersion || empty.GitHTTPURL != "" { + t.Fatalf("empty file = %#v", empty) + } + if err := store.Save(nil); err != nil { + t.Fatalf("Save(nil) returned error: %v", err) + } + file, err := store.Load() + if err != nil { + t.Fatalf("Load after Save(nil) returned error: %v", err) + } + if file.Version != CurrentCredentialVersion { + t.Fatalf("Version after Save(nil) = %d", file.Version) + } + if err := os.WriteFile(path, []byte{}, 0600); err != nil { + t.Fatalf("write empty metadata: %v", err) + } + empty, err = store.Load() + if err != nil { + t.Fatalf("Load empty file returned error: %v", err) + } + if empty.Version != CurrentCredentialVersion { + t.Fatalf("empty file version = %d", empty.Version) + } + emptyRecords, err := store.Records() + if err != nil { + t.Fatalf("Records empty file returned error: %v", err) + } + if len(emptyRecords) != 0 { + t.Fatalf("empty records = %#v, want none", emptyRecords) + } + + recordB := CredentialRecord{AppID: "app_a", GitHTTPURL: "https://example.com/git/a.git", Profile: "default", ProfileAppID: "cli", UserOpenID: "ou", Status: StatusConfirmed} + recordC := CredentialRecord{AppID: "app_a", GitHTTPURL: "https://example.com/git/c.git", Profile: "default", ProfileAppID: "cli", UserOpenID: "ou", Status: StatusConfirmed} + if err := store.Upsert(recordB); err != nil { + t.Fatalf("Upsert B returned error: %v", err) + } + if err := store.Upsert(recordC); err != nil { + t.Fatalf("Upsert C returned error: %v", err) + } + records, err := store.Records() + if err != nil { + t.Fatalf("Records returned error: %v", err) + } + if len(records) != 1 || records[0].GitHTTPURL != recordC.GitHTTPURL { + t.Fatalf("records = %#v, want latest app-scoped record", records) + } + matches, err := store.FindByAppID("app_a", ProfileContext{Profile: "default", ProfileAppID: "cli", UserOpenID: "ou"}) + if err != nil { + t.Fatalf("FindByAppID returned error: %v", err) + } + if len(matches) != 1 || matches[0].GitHTTPURL != recordC.GitHTTPURL { + t.Fatalf("matches = %#v", matches) + } + matches, err = store.FindByAppID("app_a", ProfileContext{Profile: "work"}) + if err != nil { + t.Fatalf("FindByAppID with profile mismatch returned error: %v", err) + } + if len(matches) != 0 { + t.Fatalf("profile mismatch matches = %#v, want empty", matches) + } + matches, err = store.FindByAppID("app_other", ProfileContext{}) + if err != nil { + t.Fatalf("FindByAppID app mismatch returned error: %v", err) + } + if len(matches) != 0 { + t.Fatalf("app mismatch matches = %#v, want empty", matches) + } + for _, profile := range []ProfileContext{ + {Profile: "default", ProfileAppID: "other", UserOpenID: "ou"}, + {Profile: "default", ProfileAppID: "cli", UserOpenID: "other"}, + } { + matches, err = store.FindByAppID("app_a", profile) + if err != nil { + t.Fatalf("FindByAppID mismatch returned error: %v", err) + } + if len(matches) != 0 { + t.Fatalf("FindByAppID mismatch %#v returned %#v, want empty", profile, matches) + } + } + deleted, err := store.DeleteByURL("https://example.com/git/missing.git") + if err != nil || deleted != nil { + t.Fatalf("DeleteByURL missing = %#v, %v; want nil, nil", deleted, err) + } + deleted, err = store.DeleteByURL(recordC.GitHTTPURL) + if err != nil { + t.Fatalf("DeleteByURL returned error: %v", err) + } + if deleted == nil || deleted.AppID != recordC.AppID { + t.Fatalf("deleted = %#v", deleted) + } + if _, err := store.Records(); err != nil { + t.Fatalf("Records after delete returned error: %v", err) + } + if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil { + t.Fatalf("write invalid metadata: %v", err) + } + if _, err := store.Records(); err == nil { + t.Fatal("Records invalid metadata returned nil error") + } + if _, err := store.FindByAppID("app_a", ProfileContext{}); err == nil { + t.Fatal("FindByAppID invalid metadata returned nil error") + } +} + +func TestAppStoreUsesAppScopedStorage(t *testing.T) { + storage := newFakeAppStorage() + store := NewAppStore("app_xxx", storage) + if got := store.Path(); got != "apps:app_xxx/"+MetadataFilename { + t.Fatalf("Path() = %q, want app-scoped path", got) + } + empty, err := store.Load() + if err != nil { + t.Fatalf("Load missing app storage returned error: %v", err) + } + if empty.Version != CurrentCredentialVersion { + t.Fatalf("empty version = %d, want %d", empty.Version, CurrentCredentialVersion) + } + record := CredentialRecord{AppID: "app_xxx", GitHTTPURL: "https://example.com/git/u/app.git", Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_xxx", Status: StatusConfirmed} + if err := store.Upsert(record); err != nil { + t.Fatalf("Upsert app storage returned error: %v", err) + } + if storage.values["app_xxx/"+MetadataFilename] == nil { + t.Fatalf("app storage missing metadata key") + } + records, err := store.Records() + if err != nil { + t.Fatalf("Records app storage returned error: %v", err) + } + if len(records) != 1 || records[0].GitHTTPURL != record.GitHTTPURL { + t.Fatalf("records = %#v, want stored record", records) + } + deleted, err := store.DeleteByURL(record.GitHTTPURL) + if err != nil { + t.Fatalf("DeleteByURL app storage returned error: %v", err) + } + if deleted == nil || deleted.GitHTTPURL != record.GitHTTPURL { + t.Fatalf("deleted = %#v, want stored record", deleted) + } + if storage.values["app_xxx/"+MetadataFilename] != nil { + t.Fatalf("app storage metadata still present after delete") + } +} + +func TestNewStoreUsesConfigDir(t *testing.T) { + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + if got := NewStore().Path(); got != filepath.Join(configDir, MetadataFilename) { + t.Fatalf("NewStore path = %q", got) + } +} + +func TestStoreLoadRejectsInvalidAndNewerVersions(t *testing.T) { + path := filepath.Join(t.TempDir(), MetadataFilename) + if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil { + t.Fatalf("write invalid json: %v", err) + } + if _, err := NewStoreAt(path).Load(); err == nil { + t.Fatal("Load invalid json returned nil error") + } else { + var cfgErr *errs.ConfigError + if !errors.As(err, &cfgErr) { + t.Fatalf("Load invalid json error = %T %v, want ConfigError", err, err) + } + } + if err := os.WriteFile(path, []byte(`{"version":99,"credentials":{}}`), 0600); err != nil { + t.Fatalf("write newer version: %v", err) + } + if _, err := NewStoreAt(path).Load(); err == nil { + t.Fatal("Load newer version returned nil error") + } + if err := os.WriteFile(path, []byte(`{"credentials":null}`), 0600); err != nil { + t.Fatalf("write version 0: %v", err) + } + file, err := NewStoreAt(path).Load() + if err != nil { + t.Fatalf("Load version 0 returned error: %v", err) + } + if file.Version != CurrentCredentialVersion { + t.Fatalf("version 0 upgrade = %#v", file) + } + if _, err := NewStoreAt(t.TempDir()).Load(); err == nil { + t.Fatal("Load directory path returned nil error") + } + if err := NewStoreAt(path).Upsert(CredentialRecord{GitHTTPURL: "https://example.com/repo.git"}); err != nil { + t.Fatalf("Upsert after version 0 returned error: %v", err) + } + if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil { + t.Fatalf("rewrite invalid json: %v", err) + } + if err := NewStoreAt(path).Upsert(CredentialRecord{GitHTTPURL: "https://example.com/repo.git"}); err == nil { + t.Fatal("Upsert invalid json returned nil error") + } + if _, err := NewStoreAt(path).DeleteByURL("https://example.com/repo.git"); err == nil { + t.Fatal("DeleteByURL invalid json returned nil error") + } +} + +func TestStoreSaveReturnsMkdirError(t *testing.T) { + blocker := filepath.Join(t.TempDir(), "blocker") + if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil { + t.Fatalf("write blocker: %v", err) + } + store := NewStoreAt(filepath.Join(blocker, MetadataFilename)) + if err := store.Save(&CredentialFile{}); err == nil { + t.Fatal("Save returned nil error, want mkdir error") + } +} + +func TestNormalizeGitHTTPURLBranches(t *testing.T) { + tests := []struct { + name string + raw string + want string + wantErr bool + }{ + {name: "empty", raw: " ", wantErr: true}, + {name: "bad parse", raw: "https://%zz", wantErr: true}, + {name: "unsupported", raw: "ssh://example.com/repo.git", wantErr: true}, + {name: "empty host", raw: "https:///repo.git", wantErr: true}, + {name: "http default port", raw: "http://EXAMPLE.com:80/repo.git/", want: "http://example.com/repo.git"}, + {name: "custom port", raw: "https://Example.com:8443//repo.git?x=1", want: "https://example.com:8443/repo.git"}, + {name: "ipv6 default port", raw: "HTTPS://[2001:DB8::1]:443//repo.git", want: "https://[2001:db8::1]/repo.git"}, + {name: "ipv6 custom port", raw: "https://[2001:db8::1]:8443/repo.git", want: "https://[2001:db8::1]:8443/repo.git"}, + {name: "root path", raw: "https://Example.com", want: "https://example.com/"}, + } + for _, tt := range tests { + got, err := NormalizeGitHTTPURL(tt.raw) + if tt.wantErr { + if err == nil { + t.Fatalf("%s: NormalizeGitHTTPURL returned nil error", tt.name) + } + continue + } + if err != nil { + t.Fatalf("%s: NormalizeGitHTTPURL returned error: %v", tt.name, err) + } + if got != tt.want { + t.Fatalf("%s: got %q, want %q", tt.name, got, tt.want) + } + } + if got := cleanURLPath("relative/path"); got != "/relative/path" { + t.Fatalf("cleanURLPath(relative/path) = %q", got) + } + if got := cleanURLPath("/%zz"); got != "/%zz" { + t.Fatalf("cleanURLPath(/%%zz) = %q", got) + } + if got := normalizeHostname("[example.com]"); got != "[example.com]" { + t.Fatalf("normalizeHostname([example.com]) = %q", got) + } + if got := normalizeHostname("[2001:db8::1]"); got != "[2001:db8::1]" { + t.Fatalf("normalizeHostname([2001:db8::1]) = %q", got) + } + got, err := normalizeParsedURL(&url.URL{Scheme: "https", Host: "example.com", Path: ".."}) + if err != nil { + t.Fatalf("normalizeParsedURL dot path returned error: %v", err) + } + if got != "https://example.com/" { + t.Fatalf("normalizeParsedURL dot path = %q", got) + } +} + +func TestNormalizeCredentialInputRequiresProtocolAndHost(t *testing.T) { + if _, err := NormalizeCredentialInput(CredentialInput{Protocol: "https"}); err == nil { + t.Fatal("NormalizeCredentialInput returned nil error for missing host") + } +} + +func TestSecretStoreBranches(t *testing.T) { + if got, err := (*SecretStore)(nil).Get("ref"); err != nil || got != "" { + t.Fatalf("nil SecretStore Get = %q, %v", got, err) + } + if err := (*SecretStore)(nil).Remove("ref"); err != nil { + t.Fatalf("nil SecretStore Remove returned error: %v", err) + } + kc := newFakeKeychain() + if got, err := NewSecretStore(kc).Get(""); err != nil || got != "" { + t.Fatalf("empty SecretStore Get = %q, %v", got, err) + } + if err := NewSecretStore(kc).Remove(""); err != nil { + t.Fatalf("empty SecretStore Remove returned error: %v", err) + } + if len(kc.removed) != 0 { + t.Fatalf("keychain removals for empty ref = %#v, want none", kc.removed) + } + if err := NewSecretStore(nil).Remove("ref"); err == nil { + t.Fatal("nil keychain SecretStore Remove returned nil error") + } + if got, err := NewSecretStore(nil).Get("ref"); err != nil || got != "" { + t.Fatalf("nil keychain SecretStore Get = %q, %v", got, err) + } + if err := NewSecretStore(newFakeKeychain()).Set("", "pat"); err == nil { + t.Fatal("SecretStore.Set empty ref returned nil error") + } + kc.removeErr = errors.New("keychain remove failed") + var cfgErr *errs.ConfigError + if err := NewSecretStore(kc).Remove("ref"); err == nil || !errors.As(err, &cfgErr) { + t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", err, err) + } +} + +func TestManagerInitValidationAndIssuerErrors(t *testing.T) { + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, nil) + if _, err := manager.Init(context.Background(), testProfile(), " "); err == nil { + t.Fatal("Init empty appID returned nil error") + } + if _, err := manager.Init(context.Background(), testProfile(), "../bad"); err == nil { + t.Fatal("Init invalid appID returned nil error") + } + if _, err := manager.Init(context.Background(), ProfileContext{}, "app_xxx"); err == nil { + t.Fatal("Init without login returned nil error") + } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("Init without issuer returned nil error") + } + + issuer := &fakeIssuer{err: errors.New("api down")} + manager.Issuer = issuer + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("Init with issuer error returned nil error") + } + issuer.err = nil + issuer.next = &IssuedCredential{GitHTTPURL: "ssh://example.com/repo.git", PAT: "pat"} + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("Init invalid URL returned nil error") + } + issuer.next = &IssuedCredential{AppID: "app_other", GitHTTPURL: "https://example.com/repo.git", PAT: "pat", ExpiresAt: time.Now().Add(time.Hour).Unix()} + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("Init mismatched response app_id returned nil error") + } + issuer.next = &IssuedCredential{GitHTTPURL: "https://example.com/repo.git", PAT: "pat", ExpiresAt: time.Now().Add(-time.Hour).Unix()} + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("Init expired credential response returned nil error") + } + issuer.next = &IssuedCredential{GitHTTPURL: "https://example.com/repo.git", ExpiresAt: time.Now().Add(time.Hour).Unix()} + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil || !strings.Contains(err.Error(), "response missing token") { + t.Fatalf("Init empty PAT response error = %v", err) + } + if err := validateIssuedCredential("app_xxx", "", &IssuedCredential{GitHTTPURL: "https://example.com/repo.git", PAT: "pat", ExpiresAt: time.Now().Add(time.Hour).Unix()}, time.Now().Unix()); err == nil { + t.Fatal("validateIssuedCredential missing normalized URL returned nil") + } + if err := validateIssuedCredential("app_xxx", "https://example.com/repo.git", nil, time.Now().Unix()); err == nil { + t.Fatal("validateIssuedCredential nil issued returned nil") + } +} + +func TestManagerInitAndRemoveLockFailures(t *testing.T) { + blocker := filepath.Join(t.TempDir(), "config-blocker") + if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil { + t.Fatalf("write config blocker: %v", err) + } + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", blocker) + now := time.Unix(1780000000, 0) + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil || !strings.Contains(err.Error(), "create Git credential lock dir") { + t.Fatalf("Init lock error = %v", err) + } + if _, err := manager.Remove(context.Background(), testProfile(), "app_xxx"); err == nil || !strings.Contains(err.Error(), "create Git credential lock dir") { + t.Fatalf("Remove lock error = %v", err) + } +} + +func TestLockAppHeldTimesOut(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unlock, err := lockApp("held/app") + if err != nil { + t.Fatalf("initial lockApp returned error: %v", err) + } + defer unlock() + if _, err := lockApp("held/app"); err == nil { + t.Fatal("second lockApp returned nil error, want held lock timeout") + } +} + +func TestManagerInitStoreAndSecretReadErrors(t *testing.T) { + now := time.Unix(1780000000, 0) + path := filepath.Join(t.TempDir(), MetadataFilename) + if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil { + t.Fatalf("write invalid metadata: %v", err) + } + manager := NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("Init with unreadable metadata returned nil error") + } + + kc := newFakeKeychain() + manager = NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("initial Init returned error: %v", err) + } + kc.getErr = errors.New("keychain get failed") + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init should repair existing credential when old secret cannot be read, got %v", err) + } +} + +func TestManagerInitPendingWriteError(t *testing.T) { + now := time.Unix(1780000000, 0) + dir := t.TempDir() + path := filepath.Join(dir, MetadataFilename) + if err := NewStoreAt(path).Save(&CredentialFile{}); err != nil { + t.Fatalf("Save seed metadata returned error: %v", err) + } + makeDirReadOnly(t, dir) + manager := NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("Init with pending metadata write error returned nil") + } +} + +func TestManagerInitConfirmedWriteErrorRollsBackSecret(t *testing.T) { + now := time.Unix(1780000000, 0) + dir := t.TempDir() + path := filepath.Join(dir, MetadataFilename) + kc := newFakeKeychain() + kc.onSet = func(account, value string) { + if value == "new-pat" { + makeDirReadOnly(t, dir) + } + } + manager := NewManager(NewStoreAt(path), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "new-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("Init with confirmed metadata write error returned nil") + } + if len(kc.removed) != 1 { + t.Fatalf("removed PAT refs = %#v, want rollback removal", kc.removed) + } +} + +func TestManagerInitConfirmedWriteErrorRestoresExistingSecret(t *testing.T) { + now := time.Unix(1780000000, 0) + dir := t.TempDir() + path := filepath.Join(dir, MetadataFilename) + kc := newFakeKeychain() + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }} + manager := NewManager(NewStoreAt(path), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("initial Init returned error: %v", err) + } + record, err := manager.Store.FindByURL("https://example.com/git/u/app.git") + if err != nil { + t.Fatalf("FindByURL returned error: %v", err) + } + kc.onSet = func(account, value string) { + if value == "new-pat" { + makeDirReadOnly(t, dir) + } + } + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "new-pat", + ExpiresAt: now.Add(48 * time.Hour).Unix(), + } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("refresh Init with confirmed metadata write error returned nil") + } + if got := kc.values[record.PATRef]; got != "old-pat" { + t.Fatalf("restored PAT = %q, want old-pat", got) + } +} + +func TestManagerRemoveValidationNoMatchAndErrors(t *testing.T) { + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, nil) + if _, err := manager.Remove(context.Background(), testProfile(), " "); err == nil { + t.Fatal("Remove empty appID returned nil error") + } + if _, err := manager.Remove(context.Background(), testProfile(), "../bad"); err == nil { + t.Fatal("Remove invalid appID returned nil error") + } + result, err := manager.Remove(context.Background(), testProfile(), "app_missing") + if err != nil { + t.Fatalf("Remove missing returned error: %v", err) + } + if result.Removed || len(result.Records) != 0 { + t.Fatalf("Remove missing result = %#v", result) + } + + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + gitConfig := &fakeGitConfig{err: errors.New("git config locked")} + manager = NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), gitConfig, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + result, err = manager.Remove(context.Background(), testProfile(), "app_xxx") + if err != nil { + t.Fatalf("Remove with git config warning returned error: %v", err) + } + if result == nil || !result.Removed || !strings.Contains(result.ConfigWarning, "git config locked") { + t.Fatalf("Remove result = %#v, want removed with config warning", result) + } + record, err := manager.Store.Current() + if err != nil { + t.Fatalf("Current after remove with config warning returned error: %v", err) + } + if record != nil { + t.Fatalf("metadata should be removed despite git config cleanup warning, got %#v", record) + } + + kc = newFakeKeychain() + kc.removeErr = errors.New("keychain remove failed") + manager = NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + if _, err := manager.Remove(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("Remove with keychain error returned nil error") + } + record, err = manager.Store.Current() + if err != nil { + t.Fatalf("Current after keychain remove error returned error: %v", err) + } + if record == nil { + t.Fatalf("metadata should stay after keychain remove error") + } +} + +func TestManagerRemoveStoreErrors(t *testing.T) { + path := filepath.Join(t.TempDir(), MetadataFilename) + if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil { + t.Fatalf("write invalid metadata: %v", err) + } + manager := NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, nil) + if _, err := manager.Remove(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("Remove with invalid metadata returned nil error") + } + + now := time.Unix(1780000000, 0) + dir := t.TempDir() + path = filepath.Join(dir, MetadataFilename) + manager = NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + makeDirReadOnly(t, dir) + if _, err := manager.Remove(context.Background(), testProfile(), "app_xxx"); err == nil { + t.Fatal("Remove with delete save error returned nil error") + } +} + +func TestManagerGetBranches(t *testing.T) { + now := time.Unix(1780000000, 0) + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, nil) + manager.Now = func() time.Time { return now } + + var out bytes.Buffer + var errOut bytes.Buffer + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https"}, testProfile(), &out, &errOut); err != nil { + t.Fatalf("Get invalid input returned error: %v", err) + } + if !strings.Contains(errOut.String(), "protocol and host") { + t.Fatalf("stderr = %q, want protocol/host validation", errOut.String()) + } + + out.Reset() + errOut.Reset() + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/missing.git"}, testProfile(), &out, &errOut); err != nil { + t.Fatalf("Get missing record returned error: %v", err) + } + if out.Len() != 0 || errOut.Len() != 0 { + t.Fatalf("missing record stdout=%q stderr=%q, want both empty", out.String(), errOut.String()) + } + + kc := newFakeKeychain() + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(5 * time.Minute).Unix(), + }} + manager = NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + manager.Issuer = nil + errOut.Reset() + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil { + t.Fatalf("Get without issuer returned error: %v", err) + } + if !strings.Contains(errOut.String(), "issuer is not configured") { + t.Fatalf("stderr = %q, want issuer error", errOut.String()) + } + + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "older-pat", + ExpiresAt: now.Add(time.Minute).Unix(), + } + manager.Issuer = issuer + out.Reset() + errOut.Reset() + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil { + t.Fatalf("Get stale refresh returned error: %v", err) + } + if got := out.String(); got != "" { + t.Fatalf("stale refresh output = %q, want empty", got) + } + + kc.setErr = errors.New("keychain locked") + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "new-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + } + out.Reset() + errOut.Reset() + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil { + t.Fatalf("Get keychain set error returned error: %v", err) + } + if !strings.Contains(errOut.String(), "keychain locked") { + t.Fatalf("stderr = %q, want keychain error", errOut.String()) + } + + kc.setErr = nil + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/other.git", + PAT: "other-pat", + ExpiresAt: now.Add(48 * time.Hour).Unix(), + } + out.Reset() + errOut.Reset() + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil { + t.Fatalf("Get URL mismatch returned error: %v", err) + } + if !strings.Contains(errOut.String(), "does not match initialized URL") { + t.Fatalf("stderr = %q, want URL mismatch", errOut.String()) + } + + issuer.next = &IssuedCredential{ + GitHTTPURL: "ssh://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(48 * time.Hour).Unix(), + } + out.Reset() + errOut.Reset() + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil { + t.Fatalf("Get invalid issued URL returned error: %v", err) + } + if !strings.Contains(errOut.String(), "only supports http/https") { + t.Fatalf("stderr = %q, want invalid issued URL", errOut.String()) + } + + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + ExpiresAt: now.Add(48 * time.Hour).Unix(), + } + out.Reset() + errOut.Reset() + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &errOut); err != nil { + t.Fatalf("Get invalid issued credential returned error: %v", err) + } + if !strings.Contains(errOut.String(), "response missing token") { + t.Fatalf("stderr = %q, want missing token", errOut.String()) + } + + kc = newFakeKeychain() + issuer = &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(5 * time.Minute).Unix(), + }} + manager = NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init for lock failure returned error: %v", err) + } + blocker := filepath.Join(t.TempDir(), "config-blocker") + if err := os.WriteFile(blocker, []byte("file"), 0600); err != nil { + t.Fatalf("write config blocker: %v", err) + } + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", blocker) + errOut.Reset() + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil { + t.Fatalf("Get lock failure returned error: %v", err) + } + if !strings.Contains(errOut.String(), "create Git credential lock dir") { + t.Fatalf("stderr = %q, want lock error", errOut.String()) + } +} + +func TestManagerGetSecondReadBranches(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(5 * time.Minute).Unix(), + }} + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + var calls int + kc.onGet = func(account string) { + calls++ + if calls == 1 { + kc.values[account] = "" + record, err := manager.Store.FindByURL("https://example.com/git/u/app.git") + if err != nil { + t.Fatalf("FindByURL in onGet returned error: %v", err) + } + record.ExpiresAt = now.Add(24 * time.Hour).Unix() + if err := manager.Store.Upsert(*record); err != nil { + t.Fatalf("Upsert in onGet returned error: %v", err) + } + return + } + kc.values[account] = "restored-pat" + } + var out bytes.Buffer + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &bytes.Buffer{}); err != nil { + t.Fatalf("Get second usable branch returned error: %v", err) + } + if got := out.String(); got != "username=x-access-token\npassword=restored-pat\n\n" { + t.Fatalf("second usable output = %q", got) + } + + kc = newFakeKeychain() + dir := t.TempDir() + manager = NewManager(NewStoreAt(filepath.Join(dir, MetadataFilename)), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(5 * time.Minute).Unix(), + } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + deleted := false + kc.onGet = func(account string) { + if !deleted { + deleted = true + if err := os.Remove(filepath.Join(dir, MetadataFilename)); err != nil { + t.Fatalf("remove metadata: %v", err) + } + } + } + out.Reset() + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &out, &bytes.Buffer{}); err != nil { + t.Fatalf("Get second read missing returned error: %v", err) + } + if out.Len() != 0 { + t.Fatalf("second read missing stdout = %q, want empty", out.String()) + } + + kc = newFakeKeychain() + dir = t.TempDir() + path := filepath.Join(dir, MetadataFilename) + manager = NewManager(NewStoreAt(path), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(5 * time.Minute).Unix(), + } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + wroteBadMetadata := false + kc.onGet = func(account string) { + if !wroteBadMetadata { + wroteBadMetadata = true + kc.values[account] = "" + if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil { + t.Fatalf("write invalid metadata: %v", err) + } + } + } + var errOut bytes.Buffer + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil { + t.Fatalf("Get second read error returned error: %v", err) + } + if !strings.Contains(errOut.String(), "invalid git.json") { + t.Fatalf("stderr = %q, want second read error", errOut.String()) + } +} + +func TestManagerGetRefreshReadAndWriteErrors(t *testing.T) { + now := time.Unix(1780000000, 0) + dir := t.TempDir() + path := filepath.Join(dir, MetadataFilename) + kc := newFakeKeychain() + issuer := &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(5 * time.Minute).Unix(), + }} + manager := NewManager(NewStoreAt(path), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "older-pat", + ExpiresAt: now.Add(time.Minute).Unix(), + } + issuer.onIssue = func() { + if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil { + t.Fatalf("write invalid metadata: %v", err) + } + } + var errOut bytes.Buffer + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil { + t.Fatalf("Get refresh read error returned error: %v", err) + } + if !strings.Contains(errOut.String(), "invalid git.json") { + t.Fatalf("stderr = %q, want read error", errOut.String()) + } + + dir = t.TempDir() + path = filepath.Join(dir, MetadataFilename) + kc = newFakeKeychain() + issuer = &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(5 * time.Minute).Unix(), + }} + manager = NewManager(NewStoreAt(path), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "new-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + } + issuer.onIssue = func() { makeDirReadOnly(t, dir) } + errOut.Reset() + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil { + t.Fatalf("Get refresh write error returned error: %v", err) + } + if !strings.Contains(errOut.String(), "Git credential refresh failed") { + t.Fatalf("stderr = %q, want refresh write error", errOut.String()) + } + record, err := manager.Store.FindByURL("https://example.com/git/u/app.git") + if err != nil { + t.Fatalf("FindByURL returned error: %v", err) + } + if got := kc.values[record.PATRef]; got != "old-pat" { + t.Fatalf("PAT after failed refresh = %q, want old-pat", got) + } + + dir = t.TempDir() + path = filepath.Join(dir, MetadataFilename) + kc = newFakeKeychain() + issuer = &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "old-pat", + ExpiresAt: now.Add(5 * time.Minute).Unix(), + }} + manager = NewManager(NewStoreAt(path), NewSecretStore(kc), nil, issuer) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + issuer.next = &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "older-pat", + ExpiresAt: now.Add(time.Minute).Unix(), + } + issuer.onIssue = func() { + if err := os.Remove(path); err != nil { + t.Fatalf("remove metadata: %v", err) + } + } + errOut.Reset() + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil { + t.Fatalf("Get stale refresh missing record returned error: %v", err) + } + if errOut.Len() != 0 { + t.Fatalf("stderr = %q, want empty on stale missing record", errOut.String()) + } +} + +func TestManagerGetReadErrorsStayOnStderr(t *testing.T) { + path := filepath.Join(t.TempDir(), MetadataFilename) + if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil { + t.Fatalf("write invalid metadata: %v", err) + } + manager := NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, nil) + var errOut bytes.Buffer + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/repo.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil { + t.Fatalf("Get returned error: %v", err) + } + if !strings.Contains(errOut.String(), "invalid git.json") { + t.Fatalf("stderr = %q, want config parse error", errOut.String()) + } +} + +func TestManagerGetSecretReadErrorStaysOnStderr(t *testing.T) { + now := time.Unix(1780000000, 0) + kc := newFakeKeychain() + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + kc.getErr = errors.New("keychain read failed") + manager.Issuer = nil + var errOut bytes.Buffer + if err := manager.Get(context.Background(), CredentialInput{Protocol: "https", Host: "example.com", Path: "/git/u/app.git"}, testProfile(), &bytes.Buffer{}, &errOut); err != nil { + t.Fatalf("Get returned error: %v", err) + } + if !strings.Contains(errOut.String(), "issuer is not configured") { + t.Fatalf("stderr = %q, want refresh path after secret read failure", errOut.String()) + } +} + +func TestEraseBranches(t *testing.T) { + manager := NewManager(NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)), NewSecretStore(newFakeKeychain()), nil, nil) + if err := manager.Erase(errorReader{}); err == nil { + t.Fatal("Erase reader error returned nil error") + } + if err := manager.Erase(bytes.NewBufferString("protocol=ssh\nhost=example.com\n\n")); err == nil { + t.Fatal("Erase invalid URL returned nil error") + } + if err := manager.Erase(bytes.NewBufferString("protocol=https\nhost=example.com\npath=/missing.git\n\n")); err != nil { + t.Fatalf("Erase missing record returned error: %v", err) + } + path := filepath.Join(t.TempDir(), MetadataFilename) + if err := os.WriteFile(path, []byte("{bad json"), 0600); err != nil { + t.Fatalf("write invalid metadata: %v", err) + } + manager = NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, nil) + if err := manager.Erase(bytes.NewBufferString("protocol=https\nhost=example.com\npath=/repo.git\n\n")); err == nil { + t.Fatal("Erase invalid store returned nil error") + } + + now := time.Unix(1780000000, 0) + dir := t.TempDir() + path = filepath.Join(dir, MetadataFilename) + manager = NewManager(NewStoreAt(path), NewSecretStore(newFakeKeychain()), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: "https://example.com/git/u/app.git", + PAT: "pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + if _, err := manager.Init(context.Background(), testProfile(), "app_xxx"); err != nil { + t.Fatalf("Init returned error: %v", err) + } + makeDirReadOnly(t, dir) + if err := manager.Erase(bytes.NewBufferString("protocol=https\nhost=example.com\npath=/git/u/app.git\n\n")); err == nil { + t.Fatal("Erase with metadata write error returned nil") + } +} + +func TestParseCredentialInputURLAndErrors(t *testing.T) { + if _, err := ParseCredentialInput(bytes.NewBufferString("ignored-line\nprotocol=https\nhost=example.com\n\n")); err != nil { + t.Fatalf("ParseCredentialInput ignored line returned error: %v", err) + } + input, err := ParseCredentialInput(bytes.NewBufferString("url=https://example.com/git/u/app.git?x=1\n\n")) + if err != nil { + t.Fatalf("ParseCredentialInput returned error: %v", err) + } + if input.Protocol != "https" || input.Host != "example.com" || input.Path != "/git/u/app.git" { + t.Fatalf("input = %#v", input) + } + input, err = parseNormalizedForInput("https://example.com") + if err != nil { + t.Fatalf("parseNormalizedForInput no slash returned error: %v", err) + } + if input.Path != "/" { + t.Fatalf("no slash path = %q, want /", input.Path) + } + if _, err := parseNormalizedForInput("not-a-url"); err == nil { + t.Fatal("parseNormalizedForInput invalid returned nil error") + } + if _, err := ParseCredentialInput(errorReader{}); err == nil { + t.Fatal("ParseCredentialInput reader error returned nil error") + } +} + +func TestWriteGitCredentialBranches(t *testing.T) { + var out bytes.Buffer + if err := writeGitCredential(&out, "", "pat"); err != nil { + t.Fatalf("writeGitCredential empty username returned error: %v", err) + } + if out.Len() != 0 { + t.Fatalf("empty username output = %q", out.String()) + } + for _, failAt := range []int{1, 2, 3} { + err := writeGitCredential(&failWriter{failAt: failAt}, "user", "pat") + if err == nil { + t.Fatalf("writeGitCredential failAt=%d returned nil error", failAt) + } + } +} + +func TestNilManagerUsesTimeNow(t *testing.T) { + var manager *Manager + if manager.now().IsZero() { + t.Fatal("nil manager now() returned zero time") + } +} + +func testProfile() ProfileContext { + return ProfileContext{Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_xxx"} +} + +type errorReader struct{} + +func (errorReader) Read(p []byte) (int, error) { + return 0, errors.New("read failed") +} + +type failWriter struct { + failAt int + writes int +} + +func (w *failWriter) Write(p []byte) (int, error) { + w.writes++ + if w.writes >= w.failAt { + return 0, fmt.Errorf("write %d failed", w.writes) + } + return len(p), nil +} + +func installFakeGit(t *testing.T, failUseHTTPPathExit int) string { + t.Helper() + dir := t.TempDir() + logPath := filepath.Join(dir, "git.log") + gitPath := filepath.Join(dir, "git") + script := fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> "$GIT_FAKE_LOG" +case "$*" in + *"--get"*) exit 1 ;; +esac +case "$*" in + *useHttpPath*) exit %d ;; +esac +exit 0 +`, failUseHTTPPathExit) + if failUseHTTPPathExit == 0 { + script = `#!/bin/sh +printf '%s\n' "$*" >> "$GIT_FAKE_LOG" +case "$*" in + *"--get"*) exit 1 ;; +esac +exit 0 +` + } + if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("GIT_FAKE_LOG", logPath) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return logPath +} + +func installFakeGitWithGet(t *testing.T, value string) string { + t.Helper() + dir := t.TempDir() + logPath := filepath.Join(dir, "git.log") + gitPath := filepath.Join(dir, "git") + script := fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> "$GIT_FAKE_LOG" +case "$*" in + *"--get"*) printf '%%s\n' %s; exit 0 ;; +esac +exit 0 +`, shellQuoteArg(value)) + if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("GIT_FAKE_LOG", logPath) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return logPath +} + +func installFakeGitWithGetAndUnsetExit(t *testing.T, value string, unsetExit int) string { + t.Helper() + dir := t.TempDir() + logPath := filepath.Join(dir, "git.log") + gitPath := filepath.Join(dir, "git") + script := fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> "$GIT_FAKE_LOG" +case "$*" in + *"--get"*) printf '%%s\n' %s; exit 0 ;; + *"--unset"*) exit %d ;; +esac +exit 0 +`, shellQuoteArg(value), unsetExit) + if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("GIT_FAKE_LOG", logPath) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return logPath +} + +func installFakeGitSetFails(t *testing.T) string { + t.Helper() + dir := t.TempDir() + logPath := filepath.Join(dir, "git.log") + gitPath := filepath.Join(dir, "git") + script := `#!/bin/sh +printf '%s\n' "$*" >> "$GIT_FAKE_LOG" +case "$*" in + *"--get"*) exit 1 ;; + *".helper "*) exit 8 ;; +esac +exit 0 +` + if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("GIT_FAKE_LOG", logPath) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return logPath +} + +func installFakeGitWithGetAndUseHTTPPathFailure(t *testing.T, value string, useHTTPPathExit int) string { + t.Helper() + dir := t.TempDir() + logPath := filepath.Join(dir, "git.log") + gitPath := filepath.Join(dir, "git") + script := fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> "$GIT_FAKE_LOG" +case "$*" in + *"--get"*) printf '%%s\n' %s; exit 0 ;; + *"useHttpPath true"*) exit %d ;; +esac +exit 0 +`, shellQuoteArg(value), useHTTPPathExit) + if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("GIT_FAKE_LOG", logPath) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return logPath +} + +func installFakeGitWithGetAndSecondUnsetFails(t *testing.T, value string) string { + t.Helper() + dir := t.TempDir() + logPath := filepath.Join(dir, "git.log") + gitPath := filepath.Join(dir, "git") + script := fmt.Sprintf(`#!/bin/sh +printf '%%s\n' "$*" >> "$GIT_FAKE_LOG" +case "$*" in + *"--get"*) printf '%%s\n' %s; exit 0 ;; + *"--unset"*"useHttpPath"*) exit 9 ;; + *"--unset"*) exit 0 ;; +esac +exit 0 +`, shellQuoteArg(value)) + if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("GIT_FAKE_LOG", logPath) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return logPath +} + +func installAlwaysFailingGit(t *testing.T) string { + t.Helper() + dir := t.TempDir() + logPath := filepath.Join(dir, "git.log") + gitPath := filepath.Join(dir, "git") + script := `#!/bin/sh +printf '%s\n' "$*" >> "$GIT_FAKE_LOG" +exit 9 +` + if err := os.WriteFile(gitPath, []byte(script), 0700); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("GIT_FAKE_LOG", logPath) + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + return logPath +} + +func makeDirReadOnly(t *testing.T, dir string) { + t.Helper() + if err := os.Chmod(dir, 0500); err != nil { + t.Fatalf("chmod readonly %s: %v", dir, err) + } + t.Cleanup(func() { + _ = os.Chmod(dir, 0700) + }) +} + +func readFileString(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + t.Fatalf("read %s: %v", path, err) + } + return string(data) +} + +var _ io.Reader = errorReader{} + +func mustReadMetadata(t *testing.T, manager *Manager) []byte { + t.Helper() + data, err := manager.Store.Load() + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + raw, err := jsonMarshal(data) + if err != nil { + t.Fatalf("jsonMarshal returned error: %v", err) + } + return raw +} + +func jsonMarshal(v interface{}) ([]byte, error) { + return json.Marshal(v) +} diff --git a/shortcuts/apps/gitcred/helper.go b/shortcuts/apps/gitcred/helper.go new file mode 100644 index 00000000..cf2dcf45 --- /dev/null +++ b/shortcuts/apps/gitcred/helper.go @@ -0,0 +1,475 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package gitcred + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" +) + +type Issuer interface { + Issue(ctx context.Context, appID string, profile ProfileContext) (*IssuedCredential, error) +} + +type Manager struct { + Store *Store + Secrets *SecretStore + GitConfig GitConfig + Issuer Issuer + Now func() time.Time +} + +func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer Issuer) *Manager { + return &Manager{ + Store: store, + Secrets: secrets, + GitConfig: gitConfig, + Issuer: issuer, + Now: time.Now, + } +} + +func (m *Manager) Init(ctx context.Context, profile ProfileContext, appID string) (*InitResult, error) { + appID = strings.TrimSpace(appID) + if appID == "" { + return nil, output.ErrValidation("--app-id is required") + } + if err := validate.ResourceName(appID, "--app-id"); err != nil { + return nil, err + } + if profile.UserOpenID == "" { + return nil, output.ErrAuth("not logged in: run `lark-cli auth login --scope \"spark:app:read\"`") + } + unlockApp, err := lockApp(appID) + if err != nil { + return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err) + } + defer unlockApp() + if m.Issuer == nil { + return nil, output.Errorf(output.ExitAPI, "api_error", "git credential issuer is not configured") + } + issued, err := m.Issuer.Issue(ctx, appID, profile) + if err != nil { + return nil, err + } + url, err := NormalizeGitHTTPURL(issued.GitHTTPURL) + if err != nil { + return nil, err + } + now := m.nowUnix() + if err := validateIssuedCredential(appID, url, issued, now); err != nil { + return nil, err + } + ref := BuildPATRef(profile, appID) + previous, err := m.currentAppRecord(appID) + if err != nil { + return nil, err + } + var previousPAT string + if previous != nil { + previousPAT, _ = m.Secrets.Get(previous.PATRef) + } + record := CredentialRecord{ + AppID: appID, + GitHTTPURL: url, + Profile: profile.Profile, + ProfileAppID: profile.ProfileAppID, + UserOpenID: profile.UserOpenID, + Username: defaultUsername(issued.Username), + PATRef: ref, + Status: StatusPending, + ExpiresAt: issued.ExpiresAt, + UpdatedAt: now, + } + if err := m.Store.Upsert(record); err != nil { + return nil, err + } + if err := m.Secrets.Set(ref, issued.PAT); err != nil { + m.restoreAfterInitFailure(appID, previous, previousPAT) + return nil, err + } + record.Status = StatusConfirmed + if err := m.Store.Upsert(record); err != nil { + if previous != nil && previous.PATRef == ref && previousPAT != "" { + _ = m.Secrets.Set(ref, previousPAT) + } else { + _ = m.Secrets.Remove(ref) + } + m.restoreAfterInitFailure(appID, previous, previousPAT) + return nil, err + } + if previous != nil && previous.PATRef != "" && previous.PATRef != ref { + _ = m.Secrets.Remove(previous.PATRef) + } + result := &InitResult{AppID: appID, GitHTTPURL: url, Refreshed: previous != nil} + if m.GitConfig != nil { + if err := m.GitConfig.SetHelper(ctx, url, appID); err != nil { + result.ConfigWarning = err.Error() + } else if previous != nil && previous.GitHTTPURL != "" && previous.GitHTTPURL != url { + if err := m.GitConfig.UnsetHelper(ctx, previous.GitHTTPURL); err != nil { + result.ConfigWarning = err.Error() + } + } + } + return result, nil +} + +func (m *Manager) Remove(ctx context.Context, profile ProfileContext, appID string) (*RemoveResult, error) { + appID = strings.TrimSpace(appID) + if appID == "" { + return nil, output.ErrValidation("--app-id is required") + } + if err := validate.ResourceName(appID, "--app-id"); err != nil { + return nil, err + } + unlockApp, err := lockApp(appID) + if err != nil { + return nil, fmt.Errorf("acquire Git credential lock for %s: %w", appID, err) + } + defer unlockApp() + records, err := m.Store.FindByAppID(appID, ProfileContext{}) + if err != nil { + return nil, err + } + result := &RemoveResult{AppID: appID, Records: records} + for _, record := range records { + if err := m.Secrets.Remove(record.PATRef); err != nil { + return nil, err + } + if m.GitConfig != nil { + if err := m.GitConfig.UnsetHelper(ctx, record.GitHTTPURL); err != nil { + result.ConfigWarning = err.Error() + } + } + if _, err := m.Store.DeleteByURL(record.GitHTTPURL); err != nil { + return nil, err + } + result.Removed = true + } + return result, nil +} + +func (m *Manager) List() (*ListResult, error) { + records, err := m.Store.Records() + if err != nil { + return nil, err + } + out := make([]ListRecord, 0, len(records)) + for _, record := range records { + out = append(out, m.listRecord(record)) + } + return &ListResult{Records: out}, nil +} + +func (m *Manager) Get(ctx context.Context, input CredentialInput, current ProfileContext, out, errOut io.Writer) error { + url, err := NormalizeCredentialInput(input) + if err != nil { + fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err) + return nil + } + record, pat, ok, err := m.readConfirmed(url, current) + if err != nil { + fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err) + return nil + } + if !ok { + return nil + } + if m.usable(record, pat) { + return writeGitCredential(out, record.Username, pat) + } + + unlock := lockURL(url) + defer unlock() + unlockApp, err := lockApp(record.AppID) + if err != nil { + fmt.Fprintf(errOut, "Git credential refresh failed: acquire lock for %s: %s\n", record.AppID, err) + return nil + } + defer unlockApp() + + record, pat, ok, err = m.readConfirmed(url, current) + if err != nil { + fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err) + return nil + } + if !ok { + return nil + } + if m.usable(record, pat) { + return writeGitCredential(out, record.Username, pat) + } + if m.Issuer == nil { + fmt.Fprintln(errOut, "Git credential refresh failed: issuer is not configured") + return nil + } + issued, err := m.Issuer.Issue(ctx, record.AppID, current) + if err != nil { + fmt.Fprintf(errOut, "Git credential refresh failed: %s\nNext step: lark-cli apps +git-credential-init --app-id %s\n", err, record.AppID) + return nil + } + issuedURL, urlErr := NormalizeGitHTTPURL(issued.GitHTTPURL) + if urlErr != nil { + fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", urlErr) + return nil + } + if err := validateIssuedCredential(record.AppID, issuedURL, issued, m.nowUnix()); err != nil { + fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err) + return nil + } + if issuedURL != url { + fmt.Fprintf(errOut, "Git credential refresh failed: issued repository URL %q does not match initialized URL %q\n", issuedURL, url) + return nil + } + if issued.ExpiresAt < record.ExpiresAt { + latest, latestPAT, found, readErr := m.readConfirmed(url, current) + if readErr != nil { + fmt.Fprintf(errOut, "Git credential unavailable: %s\n", readErr) + return nil + } + if found && m.usable(latest, latestPAT) { + return writeGitCredential(out, latest.Username, latestPAT) + } + return nil + } + record.Username = defaultUsername(issued.Username) + record.ExpiresAt = issued.ExpiresAt + record.UpdatedAt = m.nowUnix() + record.InvalidatedAt = 0 + record.Status = StatusConfirmed + oldPAT := pat + if err := m.Secrets.Set(record.PATRef, issued.PAT); err != nil { + fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err) + return nil + } + if err := m.Store.Upsert(record); err != nil { + _ = m.Secrets.Set(record.PATRef, oldPAT) + fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err) + return nil + } + return writeGitCredential(out, record.Username, issued.PAT) +} + +func (m *Manager) currentAppRecord(appID string) (*CredentialRecord, error) { + records, err := m.Store.FindByAppID(appID, ProfileContext{}) + if err != nil || len(records) == 0 { + return nil, err + } + return &records[0], nil +} + +func (m *Manager) restoreAfterInitFailure(appID string, existing *CredentialRecord, existingPAT string) { + if existing == nil { + records, err := m.Store.FindByAppID(appID, ProfileContext{}) + if err == nil { + for _, record := range records { + _, _ = m.Store.DeleteByURL(record.GitHTTPURL) + } + } + return + } + _ = m.Store.Upsert(*existing) + if existingPAT != "" { + _ = m.Secrets.Set(existing.PATRef, existingPAT) + } +} + +func (m *Manager) listRecord(record CredentialRecord) ListRecord { + now := m.nowUnix() + status := ListStatusValid + expired := record.ExpiresAt <= now + switch { + case record.Status != StatusConfirmed || record.GitHTTPURL == "" || record.PATRef == "": + status = ListStatusIncomplete + case record.InvalidatedAt > 0: + status = ListStatusInvalidated + case !m.hasSecret(record.PATRef): + status = ListStatusMissingSecret + case expired: + status = ListStatusExpired + } + return ListRecord{ + AppID: record.AppID, + GitHTTPURL: record.GitHTTPURL, + Status: status, + ExpiresAt: record.ExpiresAt, + UpdatedAt: record.UpdatedAt, + Profile: record.Profile, + ProfileAppID: record.ProfileAppID, + UserOpenID: record.UserOpenID, + Expired: expired, + InvalidatedAt: record.InvalidatedAt, + } +} + +func (m *Manager) hasSecret(ref string) bool { + pat, err := m.Secrets.Get(ref) + return err == nil && pat != "" +} + +func (m *Manager) StoreCredential(r io.Reader) error { + _, err := io.Copy(io.Discard, r) + return err +} + +func (m *Manager) Erase(r io.Reader) error { + input, err := ParseCredentialInput(r) + if err != nil { + return err + } + url, err := NormalizeCredentialInput(input) + if err != nil { + return err + } + record, err := m.Store.FindByURL(url) + if err != nil || record == nil { + return err + } + unlockApp, err := lockApp(record.AppID) + if err != nil { + return fmt.Errorf("acquire Git credential lock for %s: %w", record.AppID, err) + } + defer unlockApp() + record, err = m.Store.FindByURL(url) + if err != nil || record == nil { + return err + } + now := m.nowUnix() + if record.LastEraseAt > 0 && now-record.LastEraseAt < int64(eraseCooldown.Seconds()) { + return nil + } + record.InvalidatedAt = now + record.LastEraseAt = now + if err := m.Store.Upsert(*record); err != nil { + return err + } + return m.Secrets.Remove(record.PATRef) +} + +func (m *Manager) readConfirmed(url string, current ProfileContext) (CredentialRecord, string, bool, error) { + record, err := m.Store.FindByURL(url) + if err != nil || record == nil { + return CredentialRecord{}, "", false, err + } + if record.ProfileAppID != current.ProfileAppID || record.UserOpenID != current.UserOpenID { + return CredentialRecord{}, "", false, fmt.Errorf("current login does not match initialized credential; run `lark-cli apps +git-credential-init --app-id %s` with the current login or switch back to the original account", record.AppID) + } + pat, err := m.Secrets.Get(record.PATRef) + if err != nil { + pat = "" + } + return *record, pat, true, nil +} + +func (m *Manager) usable(record CredentialRecord, pat string) bool { + if record.Status != StatusConfirmed || pat == "" || record.InvalidatedAt > 0 { + return false + } + return record.ExpiresAt-m.nowUnix() > int64(refreshBeforeExpiry.Seconds()) +} + +func (m *Manager) now() time.Time { + if m != nil && m.Now != nil { + return m.Now() + } + return time.Now() +} + +func (m *Manager) nowUnix() int64 { + return m.now().Unix() +} + +func ParseCredentialInput(r io.Reader) (CredentialInput, error) { + scanner := bufio.NewScanner(r) + var input CredentialInput + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + break + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + switch key { + case "protocol": + input.Protocol = value + case "host": + input.Host = value + case "path": + input.Path = value + case "url": + u, err := NormalizeGitHTTPURL(value) + if err == nil { + parsed, _ := parseNormalizedForInput(u) + input = parsed + } + } + } + if err := scanner.Err(); err != nil { + return input, err + } + return input, nil +} + +func parseNormalizedForInput(raw string) (CredentialInput, error) { + parts := strings.SplitN(raw, "://", 2) + if len(parts) != 2 { + return CredentialInput{}, output.ErrValidation("invalid credential URL") + } + hostPath := parts[1] + idx := strings.Index(hostPath, "/") + if idx < 0 { + return CredentialInput{Protocol: parts[0], Host: hostPath, Path: "/"}, nil + } + return CredentialInput{Protocol: parts[0], Host: hostPath[:idx], Path: hostPath[idx:]}, nil +} + +func writeGitCredential(w io.Writer, username, pat string) error { + if username == "" || pat == "" { + return nil + } + if _, err := fmt.Fprintf(w, "username=%s\n", username); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "password=%s\n", pat); err != nil { + return err + } + _, err := fmt.Fprintln(w) + return err +} + +func defaultUsername(username string) string { + username = strings.TrimSpace(username) + if username == "" { + return "x-access-token" + } + return username +} + +func validateIssuedCredential(appID, normalizedURL string, issued *IssuedCredential, now int64) error { + if issued == nil { + return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: empty credential") + } + if issued.AppID != "" && issued.AppID != appID { + return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID) + } + if normalizedURL == "" { + return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing gitURL") + } + if strings.TrimSpace(issued.PAT) == "" { + return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response missing token") + } + if issued.ExpiresAt <= now { + return output.Errorf(output.ExitAPI, "api_error", "Issue Miaoda Git credential: response expiredTime must be in the future") + } + return nil +} diff --git a/shortcuts/apps/gitcred/keychain.go b/shortcuts/apps/gitcred/keychain.go new file mode 100644 index 00000000..21e84d4b --- /dev/null +++ b/shortcuts/apps/gitcred/keychain.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package gitcred + +import ( + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/keychain" +) + +type SecretStore struct { + kc keychain.KeychainAccess +} + +func NewSecretStore(kc keychain.KeychainAccess) *SecretStore { + return &SecretStore{kc: kc} +} + +func (s *SecretStore) Get(ref string) (string, error) { + if s == nil || ref == "" { + return "", nil + } + if s.kc == nil { + return "", nil + } + return s.kc.Get(KeychainService, ref) +} + +func (s *SecretStore) Set(ref, pat string) error { + if s == nil || s.kc == nil { + return &errs.ConfigError{Problem: errs.Problem{ + Category: errs.CategoryConfig, + Subtype: errs.SubtypeInvalidConfig, + Message: "local keychain is unavailable", + Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-init", + }} + } + if ref == "" { + return &errs.InternalError{Problem: errs.Problem{ + Category: errs.CategoryInternal, + Subtype: errs.SubtypeUnknown, + Message: "keychain PAT reference is empty", + }} + } + return s.kc.Set(KeychainService, ref, pat) +} + +func (s *SecretStore) Remove(ref string) error { + if s == nil { + return nil + } + if ref == "" { + return nil + } + if s.kc == nil { + return &errs.ConfigError{Problem: errs.Problem{ + Category: errs.CategoryConfig, + Subtype: errs.SubtypeInvalidConfig, + Message: "local keychain is unavailable", + Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove", + }} + } + if err := s.kc.Remove(KeychainService, ref); err != nil { + return &errs.ConfigError{Problem: errs.Problem{ + Category: errs.CategoryConfig, + Subtype: errs.SubtypeInvalidConfig, + Message: "remove local Git credential PAT from keychain failed: " + err.Error(), + Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove", + }, Cause: err} + } + return nil +} diff --git a/shortcuts/apps/gitcred/lock.go b/shortcuts/apps/gitcred/lock.go new file mode 100644 index 00000000..3d6635a8 --- /dev/null +++ b/shortcuts/apps/gitcred/lock.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package gitcred + +import ( + "errors" + "fmt" + "path/filepath" + "regexp" + "sync" + "time" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/lockfile" + "github.com/larksuite/cli/internal/vfs" //nolint:depguard // git credential locks live under CLI config dir and are not user file I/O. +) + +var urlLocks sync.Map + +var safeLockNameChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) + +func lockURL(url string) func() { + actual, _ := urlLocks.LoadOrStore(url, &sync.Mutex{}) + mu := actual.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + +func lockApp(appID string) (func(), error) { + dir := filepath.Join(core.GetConfigDir(), "locks") + if err := vfs.MkdirAll(dir, 0700); err != nil { + return nil, fmt.Errorf("create Git credential lock dir: %w", err) + } + name := "apps_git_credential_" + safeLockNameChars.ReplaceAllString(appID, "_") + ".lock" + lock := lockfile.New(filepath.Join(dir, filepath.Base(name))) + deadline := time.Now().Add(2 * time.Second) + for { + err := lock.TryLock() + if err == nil { + return func() { _ = lock.Unlock() }, nil + } + if !errors.Is(err, lockfile.ErrHeld) || time.Now().After(deadline) { + return nil, err + } + time.Sleep(50 * time.Millisecond) + } +} diff --git a/shortcuts/apps/gitcred/store.go b/shortcuts/apps/gitcred/store.go new file mode 100644 index 00000000..6b1988a9 --- /dev/null +++ b/shortcuts/apps/gitcred/store.go @@ -0,0 +1,200 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package gitcred + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "path/filepath" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" //nolint:depguard // git credential metadata is CLI config-dir state, not user file I/O. +) + +type AppStorage interface { + Read(appID, key string) ([]byte, error) + Write(appID, key string, data []byte) error + Delete(appID, key string) error +} + +type Store struct { + path string + appID string + storage AppStorage +} + +func NewStore() *Store { + return &Store{path: filepath.Join(core.GetConfigDir(), MetadataFilename)} +} + +func NewAppStore(appID string, storage AppStorage) *Store { + return &Store{appID: appID, storage: storage} +} + +func NewStoreAt(path string) *Store { + return &Store{path: path} +} + +func (s *Store) Path() string { + if s.storage != nil { + return fmt.Sprintf("apps:%s/%s", s.appID, MetadataFilename) + } + return s.path +} + +func (s *Store) Load() (*CredentialFile, error) { + data, err := s.read() + if errors.Is(err, fs.ErrNotExist) { + return &CredentialFile{Version: CurrentCredentialVersion}, nil + } + if err != nil { + return nil, err + } + if len(data) == 0 { + return &CredentialFile{Version: CurrentCredentialVersion}, nil + } + var file CredentialFile + if err := json.Unmarshal(data, &file); err != nil { + return nil, errs.NewConfigError(errs.SubtypeInvalidConfig, "invalid %s: %s", MetadataFilename, err). + WithHint("the local Git credential metadata is damaged; rerun `lark-cli apps +git-credential-init --app-id ` after backing up or removing the damaged app metadata"). + WithCause(err) + } + if file.Version == 0 { + file.Version = CurrentCredentialVersion + } + if file.Version > CurrentCredentialVersion { + return nil, &errs.ConfigError{Problem: errs.Problem{ + Category: errs.CategoryConfig, + Subtype: errs.SubtypeInvalidConfig, + Message: fmt.Sprintf("%s version %d is newer than supported version %d", MetadataFilename, file.Version, CurrentCredentialVersion), + Hint: "upgrade lark-cli and retry", + }} + } + return &file, nil +} + +func (s *Store) Save(file *CredentialFile) error { + if file == nil { + file = &CredentialFile{} + } + file.Version = CurrentCredentialVersion + data, _ := json.MarshalIndent(file, "", " ") + return s.write(append(data, '\n')) +} + +func (s *Store) Upsert(record CredentialRecord) error { + file, err := s.Load() + if err != nil { + return err + } + file.CredentialRecord = record + return s.Save(file) +} + +func (s *Store) Current() (*CredentialRecord, error) { + file, err := s.Load() + if err != nil { + return nil, err + } + if file.GitHTTPURL == "" { + return nil, nil + } + return &file.CredentialRecord, nil +} + +func (s *Store) DeleteByURL(gitHTTPURL string) (*CredentialRecord, error) { + file, err := s.Load() + if err != nil { + return nil, err + } + if file.GitHTTPURL != gitHTTPURL || file.GitHTTPURL == "" { + return nil, nil + } + record := file.CredentialRecord + if err := s.delete(); err != nil { + return nil, err + } + return &record, nil +} + +func (s *Store) FindByURL(gitHTTPURL string) (*CredentialRecord, error) { + file, err := s.Load() + if err != nil { + return nil, err + } + if file.GitHTTPURL != gitHTTPURL || file.GitHTTPURL == "" { + return nil, nil + } + return &file.CredentialRecord, nil +} + +func (s *Store) Records() ([]CredentialRecord, error) { + file, err := s.Load() + if err != nil { + return nil, err + } + if file.GitHTTPURL == "" { + return []CredentialRecord{}, nil + } + return []CredentialRecord{file.CredentialRecord}, nil +} + +func (s *Store) FindByAppID(appID string, profile ProfileContext) ([]CredentialRecord, error) { + records, err := s.Records() + if err != nil { + return nil, err + } + out := make([]CredentialRecord, 0) + for _, record := range records { + if record.AppID != appID { + continue + } + if profile.Profile != "" && record.Profile != profile.Profile { + continue + } + if profile.ProfileAppID != "" && record.ProfileAppID != profile.ProfileAppID { + continue + } + if profile.UserOpenID != "" && record.UserOpenID != profile.UserOpenID { + continue + } + out = append(out, record) + } + return out, nil +} + +func (s *Store) read() ([]byte, error) { + if s.storage != nil { + data, err := s.storage.Read(s.appID, MetadataFilename) + if data == nil && err == nil { + return nil, fs.ErrNotExist + } + return data, err + } + return vfs.ReadFile(s.path) +} + +func (s *Store) write(data []byte) error { + if s.storage != nil { + return s.storage.Write(s.appID, MetadataFilename, data) + } + if err := vfs.MkdirAll(filepath.Dir(s.path), 0700); err != nil { + return err + } + return validate.AtomicWrite(s.path, data, 0600) +} + +func (s *Store) delete() error { + if s.storage != nil { + return s.storage.Delete(s.appID, MetadataFilename) + } + if err := vfs.Remove(s.path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return err + } + return nil +} diff --git a/shortcuts/apps/gitcred/types.go b/shortcuts/apps/gitcred/types.go new file mode 100644 index 00000000..d5637e10 --- /dev/null +++ b/shortcuts/apps/gitcred/types.go @@ -0,0 +1,102 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package gitcred + +import "time" + +const ( + CurrentCredentialVersion = 1 + MetadataFilename = "git.json" + + // KeychainService intentionally reuses the CLI-wide internal keychain + // service, so Git PAT .enc files stay under Application Support/lark-cli. + KeychainService = "lark-cli" + + StatusPending = "pending" + StatusConfirmed = "confirmed" + + ListStatusValid = "valid" + ListStatusExpired = "expired" + ListStatusInvalidated = "invalidated" + ListStatusMissingSecret = "missing_secret" + ListStatusIncomplete = "incomplete" + + refreshBeforeExpiry = 10 * time.Minute + eraseCooldown = 5 * time.Minute +) + +// CredentialFile is the app-scoped non-secret metadata persisted under the +// Miaoda app storage directory. +type CredentialFile struct { + Version int `json:"version"` + CredentialRecord +} + +// CredentialRecord points to the keychain-stored PAT without storing the PAT +// plaintext in metadata. +type CredentialRecord struct { + AppID string `json:"app_id"` + GitHTTPURL string `json:"git_http_url"` + Profile string `json:"profile"` + ProfileAppID string `json:"profile_app_id"` + UserOpenID string `json:"user_open_id"` + Username string `json:"username"` + PATRef string `json:"pat_ref"` + Status string `json:"status"` + ExpiresAt int64 `json:"expires_at"` + UpdatedAt int64 `json:"updated_at"` + LastEraseAt int64 `json:"last_erase_at,omitempty"` + InvalidatedAt int64 `json:"invalidated_at,omitempty"` +} + +type IssuedCredential struct { + AppID string + GitHTTPURL string + Username string + PAT string + ExpiresAt int64 +} + +type InitResult struct { + AppID string + GitHTTPURL string + Refreshed bool + ConfigWarning string +} + +type RemoveResult struct { + AppID string + Removed bool + Records []CredentialRecord + ConfigWarning string +} + +type ListResult struct { + Records []ListRecord +} + +type ListRecord struct { + AppID string + GitHTTPURL string + Status string + ExpiresAt int64 + UpdatedAt int64 + Profile string + ProfileAppID string + UserOpenID string + Expired bool + InvalidatedAt int64 +} + +type CredentialInput struct { + Protocol string + Host string + Path string +} + +type ProfileContext struct { + Profile string + ProfileAppID string + UserOpenID string +} diff --git a/shortcuts/apps/gitcred/url.go b/shortcuts/apps/gitcred/url.go new file mode 100644 index 00000000..1cab06ae --- /dev/null +++ b/shortcuts/apps/gitcred/url.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package gitcred + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "net/url" + "path" + "strings" + + "github.com/larksuite/cli/internal/output" +) + +func NormalizeGitHTTPURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", output.ErrValidation("git_http_url is empty") + } + u, err := url.Parse(raw) + if err != nil { + return "", output.ErrValidation("invalid git_http_url %q: %s", raw, err) + } + return normalizeParsedURL(u) +} + +func NormalizeCredentialInput(input CredentialInput) (string, error) { + protocol := strings.TrimSpace(input.Protocol) + host := strings.TrimSpace(input.Host) + if protocol == "" || host == "" { + return "", output.ErrValidation("git credential input must include protocol and host") + } + u := &url.URL{ + Scheme: protocol, + Host: host, + Path: input.Path, + } + return normalizeParsedURL(u) +} + +func normalizeParsedURL(u *url.URL) (string, error) { + scheme := strings.ToLower(strings.TrimSpace(u.Scheme)) + if scheme != "http" && scheme != "https" { + return "", output.ErrValidation("git credential only supports http/https URLs") + } + host := normalizeHost(scheme, u.Host) + if host == "" { + return "", output.ErrValidation("git_http_url host is empty") + } + cleanPath := cleanURLPath(u.EscapedPath()) + normalized := (&url.URL{Scheme: scheme, Host: host, Path: cleanPath}).String() + if normalized != scheme+"://"+host+"/" { + normalized = strings.TrimRight(normalized, "/") + } + return normalized, nil +} + +func normalizeHost(scheme, host string) string { + host = strings.ToLower(strings.TrimSpace(host)) + if host == "" { + return "" + } + name, port, err := net.SplitHostPort(host) + if err == nil { + if (scheme == "https" && port == "443") || (scheme == "http" && port == "80") { + return normalizeHostname(name) + } + return net.JoinHostPort(strings.ToLower(name), port) + } + return normalizeHostname(host) +} + +func normalizeHostname(host string) string { + host = strings.ToLower(strings.TrimSpace(host)) + if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") { + name := strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[") + if ip := net.ParseIP(name); ip != nil && ip.To4() == nil { + return joinHostWithoutPort(name) + } + return host + } + if ip := net.ParseIP(host); ip != nil && ip.To4() == nil { + return joinHostWithoutPort(host) + } + return host +} + +func joinHostWithoutPort(host string) string { + joined := net.JoinHostPort(host, "") + return strings.TrimSuffix(joined, ":") +} + +func cleanURLPath(rawPath string) string { + if rawPath == "" { + return "/" + } + decoded, err := url.PathUnescape(rawPath) + if err != nil { + decoded = rawPath + } + if !strings.HasPrefix(decoded, "/") { + decoded = "/" + decoded + } + return path.Clean(decoded) +} + +func BuildPATRef(profile ProfileContext, appID string) string { + seed := fmt.Sprintf("%s\x00%s", profile.UserOpenID, appID) + sum := sha256.Sum256([]byte(seed)) + return "app-git-pat:" + hex.EncodeToString(sum[:16]) +} diff --git a/shortcuts/apps/html_publish_client_test.go b/shortcuts/apps/html_publish_client_test.go index ca0a5949..002d9dc6 100644 --- a/shortcuts/apps/html_publish_client_test.go +++ b/shortcuts/apps/html_publish_client_test.go @@ -137,3 +137,9 @@ func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing t.Fatalf("hint should reference app_id, got: %q", hint) } } + +func TestParseHTMLPublishResponse_InvalidJSON(t *testing.T) { + if _, err := parseHTMLPublishResponse([]byte("{not json")); err == nil { + t.Error("malformed html-publish response must surface a decode error") + } +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index 795fc0ec..d5a22658 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -14,5 +14,22 @@ func Shortcuts() []common.Shortcut { AppsAccessScopeSet, AppsAccessScopeGet, AppsHTMLPublish, + AppsInit, + AppsReleaseCreate, + AppsReleaseList, + AppsReleaseGet, + AppsEnvPull, + AppsDBTableList, + AppsDBTableGet, + AppsDBExecute, + AppsDBEnvCreate, + AppsGitCredentialInit, + AppsGitCredentialList, + AppsGitCredentialRemove, + AppsSessionCreate, + AppsSessionList, + AppsSessionGet, + AppsSessionStop, + AppsChat, } } diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 04fc8a04..689bc924 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -3,12 +3,77 @@ package apps -import "testing" +import ( + "testing" + + "github.com/spf13/cobra" +) // 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 -func TestAppsShortcuts_Returns6(t *testing.T) { +// 6 基础 + 1 init + 3 publish + 1 env-pull + 4 db(table-list/table-schema/sql/dev-init) +// + 3 git-credential + 5 session(create/list/get/stop/chat)= 23。 +func TestAppsShortcuts_Returns23(t *testing.T) { got := Shortcuts() - if len(got) != 6 { - t.Fatalf("Shortcuts() returned %d entries, want 6", len(got)) + if len(got) != 23 { + t.Fatalf("Shortcuts() returned %d entries, want 23", len(got)) + } +} + +// 确认 5 个 session 生命周期命令都已挂载。 +func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) { + want := map[string]bool{ + "+session-create": false, + "+session-list": false, + "+session-get": false, + "+session-stop": false, + "+chat": false, + } + for _, sc := range Shortcuts() { + if _, ok := want[sc.Command]; ok { + want[sc.Command] = true + } + } + for cmd, found := range want { + if !found { + t.Errorf("Shortcuts() missing %s", cmd) + } + } +} + +func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) { + for _, shortcut := range Shortcuts() { + if shortcut.Command == "git-credential-helper" { + t.Fatalf("git credential helper must be installed as a hidden apps command, not as a shortcut") + } + } +} + +func TestAppsGitCredentialRemove_IsLocalCleanupWithoutScopes(t *testing.T) { + if len(AppsGitCredentialRemove.Scopes) != 0 { + t.Fatalf("git credential remove scopes = %#v, want none for local cleanup", AppsGitCredentialRemove.Scopes) + } +} + +func TestAppsGitCredentialList_IsLocalReadWithoutScopes(t *testing.T) { + if len(AppsGitCredentialList.Scopes) != 0 { + t.Fatalf("git credential list scopes = %#v, want none for local read", AppsGitCredentialList.Scopes) + } +} + +func TestInstallOnApps_AddsHiddenGitCredentialHelper(t *testing.T) { + parent := &cobra.Command{Use: "apps"} + InstallOnApps(parent, nil) + cmd, _, err := parent.Find([]string{"git-credential-helper"}) + if err != nil { + t.Fatalf("find helper returned error: %v", err) + } + if cmd == nil || cmd.Name() != "git-credential-helper" { + t.Fatalf("helper command not installed: %#v", cmd) + } + if !cmd.Hidden { + t.Fatalf("git credential helper must be hidden") + } + if cmd.RunE == nil { + t.Fatalf("git credential helper must run outside the shortcut pipeline") } } diff --git a/shortcuts/apps/storage.go b/shortcuts/apps/storage.go new file mode 100644 index 00000000..4e81dc09 --- /dev/null +++ b/shortcuts/apps/storage.go @@ -0,0 +1,133 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/internal/vfs" //nolint:depguard // existing apps storage persists CLI config-dir state; it is not user file I/O. +) + +// storageRoot is the per-domain local-storage directory name under the config dir. +const storageRoot = "spark" + +// checkSeg validates a value used as a single path segment (appID or key). +// It rejects empty, "..", "." , URL metacharacters, control and dangerous +// Unicode via validate.ResourceName — defense-in-depth alongside the +// EncodePathSegment escaping applied when building the path, so neither value +// can traverse out of the storage directory. +func checkSeg(name, what string) error { + if err := validate.ResourceName(name, what); err != nil { + return fmt.Errorf("apps storage: %w", err) + } + if name == "." { + return fmt.Errorf("apps storage: %s must not be \".\"", what) + } + return nil +} + +// appDir returns the storage directory for one app: ~/.lark-cli/spark// +// (workspace-aware). +func appDir(appID string) string { + return filepath.Join(core.GetConfigDir(), storageRoot, validate.EncodePathSegment(appID)) +} + +// appKeyPath returns the file path for one (appID, key). +func appKeyPath(appID, key string) string { + return filepath.Join(appDir(appID), validate.EncodePathSegment(key)) +} + +// Read returns the bytes stored under (appID, key). A missing file returns +// (nil, nil). Content is opaque — callers own the format. Note: an empty stored +// value is indistinguishable from a missing key (both yield nil), so this store +// is unsuitable as an existence flag. +func Read(appID, key string) ([]byte, error) { + if err := checkSeg(appID, "appID"); err != nil { + return nil, err + } + if err := checkSeg(key, "key"); err != nil { + return nil, err + } + data, err := vfs.ReadFile(appKeyPath(appID, key)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("apps storage: read: %w", err) + } + return data, nil +} + +// Write atomically stores data under (appID, key): file 0600, dir 0700. It is a +// create-or-replace upsert for that key; content is written verbatim in +// plaintext. 0600 only guards against other local OS users — it does not protect +// against this user's processes, backups, or synced folders. appID and key are +// opaque strings: any "/" is escaped into a single path segment, never treated +// as a directory separator. +func Write(appID, key string, data []byte) error { + if err := checkSeg(appID, "appID"); err != nil { + return err + } + if err := checkSeg(key, "key"); err != nil { + return err + } + if err := vfs.MkdirAll(appDir(appID), 0700); err != nil { + return fmt.Errorf("apps storage: create dir: %w", err) + } + if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil { + return fmt.Errorf("apps storage: write: %w", err) + } + return nil +} + +// Delete removes the file under (appID, key). A missing file is not an error. +func Delete(appID, key string) error { + if err := checkSeg(appID, "appID"); err != nil { + return err + } + if err := checkSeg(key, "key"); err != nil { + return err + } + if err := vfs.Remove(appKeyPath(appID, key)); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("apps storage: delete: %w", err) + } + return nil +} + +// List returns the keys stored under appID, skipping subdirectories and names +// that fail to unescape or validate after decoding. A missing app directory +// yields an empty list. +func List(appID string) ([]string, error) { + if err := checkSeg(appID, "appID"); err != nil { + return nil, err + } + entries, err := vfs.ReadDir(appDir(appID)) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return []string{}, nil + } + return nil, fmt.Errorf("apps storage: read dir: %w", err) + } + keys := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() { + continue + } + key, err := url.PathUnescape(e.Name()) + if err != nil { + continue + } + if err := checkSeg(key, "key"); err != nil { + continue + } + keys = append(keys, key) + } + return keys, nil +} diff --git a/shortcuts/apps/storage_test.go b/shortcuts/apps/storage_test.go new file mode 100644 index 00000000..1546e5ab --- /dev/null +++ b/shortcuts/apps/storage_test.go @@ -0,0 +1,303 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "os" + "path/filepath" + "runtime" + "sort" + "testing" +) + +// storageTempDir points GetConfigDir at an isolated temp dir for the test. +func storageTempDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + return dir +} + +func TestStorageWriteReadRoundTrip(t *testing.T) { + storageTempDir(t) + want := []byte(`{"username":"u","token":"t"}`) + if err := Write("app_a", "git.json", want); err != nil { + t.Fatalf("Write: %v", err) + } + got, err := Read("app_a", "git.json") + if err != nil { + t.Fatalf("Read: %v", err) + } + if string(got) != string(want) { + t.Fatalf("got %q, want %q", got, want) + } +} + +func TestStorageReadMissingReturnsNil(t *testing.T) { + storageTempDir(t) + got, err := Read("app_a", "nope") + if err != nil { + t.Fatalf("err: %v", err) + } + if got != nil { + t.Fatalf("want nil, got %q", got) + } +} + +func TestStorageEmptyArgsRejected(t *testing.T) { + storageTempDir(t) + if _, err := Read("", "k"); err == nil { + t.Error("Read empty appID should error") + } + if _, err := Read("a", ""); err == nil { + t.Error("Read empty key should error") + } + if err := Write("", "k", nil); err == nil { + t.Error("Write empty appID should error") + } + if err := Write("a", "", nil); err == nil { + t.Error("Write empty key should error") + } + if err := Delete("", "k"); err == nil { + t.Error("Delete empty appID should error") + } + if _, err := List(""); err == nil { + t.Error("List empty appID should error") + } +} + +func TestStorageOverwrite(t *testing.T) { + storageTempDir(t) + if err := Write("app_a", "git.json", []byte("v1")); err != nil { + t.Fatalf("Write1: %v", err) + } + if err := Write("app_a", "git.json", []byte("v2")); err != nil { + t.Fatalf("Write2: %v", err) + } + got, _ := Read("app_a", "git.json") + if string(got) != "v2" { + t.Errorf("want v2, got %q", got) + } +} + +func TestStorageDeleteIdempotent(t *testing.T) { + storageTempDir(t) + if err := Write("app_a", "git.json", []byte("x")); err != nil { + t.Fatalf("Write: %v", err) + } + if err := Delete("app_a", "git.json"); err != nil { + t.Fatalf("first Delete: %v", err) + } + if got, _ := Read("app_a", "git.json"); got != nil { + t.Error("file should be gone after Delete") + } + if err := Delete("app_a", "git.json"); err != nil { + t.Errorf("second Delete should be nil (idempotent), got %v", err) + } +} + +func TestStorageListKeys(t *testing.T) { + storageTempDir(t) + for _, k := range []string{"git.json", "meta.json", "notes"} { + if err := Write("app_a", k, []byte("x")); err != nil { + t.Fatalf("Write %s: %v", k, err) + } + } + got, err := List("app_a") + if err != nil { + t.Fatalf("List: %v", err) + } + sort.Strings(got) + want := []string{"git.json", "meta.json", "notes"} + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("got %v, want %v", got, want) + } + } +} + +func TestStorageListMissingAppDir(t *testing.T) { + storageTempDir(t) + got, err := List("never_written") + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 0 { + t.Errorf("want empty, got %v", got) + } +} + +func TestStorageListSkipsSubdirs(t *testing.T) { + dir := storageTempDir(t) + if err := Write("app_a", "git.json", []byte("x")); err != nil { + t.Fatalf("Write: %v", err) + } + if err := os.Mkdir(filepath.Join(dir, "spark", "app_a", "sub"), 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + got, err := List("app_a") + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 1 || got[0] != "git.json" { + t.Errorf("want [git.json], got %v", got) + } +} + +func TestStorageListSkipsInvalidDecodedKeys(t *testing.T) { + dir := storageTempDir(t) + if err := Write("app_a", "git.json", []byte("x")); err != nil { + t.Fatalf("Write: %v", err) + } + for _, name := range []string{"%zz", "%2E", "%2E%2E", "bad%2F..%2Fkey"} { + if err := os.WriteFile(filepath.Join(dir, "spark", "app_a", name), []byte("x"), 0600); err != nil { + t.Fatalf("write polluted key %s: %v", name, err) + } + } + got, err := List("app_a") + if err != nil { + t.Fatalf("List: %v", err) + } + if len(got) != 1 || got[0] != "git.json" { + t.Errorf("want [git.json], got %v", got) + } +} + +func TestStorageEscapesAppIDAndKey(t *testing.T) { + dir := storageTempDir(t) + const appID, key = "a/b", "x/y" + if err := Write(appID, key, []byte("v")); err != nil { + t.Fatalf("Write: %v", err) + } + // no path traversal: spark/ has exactly one (escaped) app dir, no nested a/b tree + entries, _ := os.ReadDir(filepath.Join(dir, "spark")) + if len(entries) != 1 { + t.Fatalf("expected 1 escaped app dir under spark/, got %v", entries) + } + got, err := Read(appID, key) + if err != nil || string(got) != "v" { + t.Fatalf("Read escaped: got %q err %v", got, err) + } + keys, err := List(appID) + if err != nil || len(keys) != 1 || keys[0] != key { + t.Fatalf("List escaped: got %v err %v", keys, err) + } +} + +func TestStorageRejectsTraversal(t *testing.T) { + dir := storageTempDir(t) + for _, bad := range []string{"..", ".", "../x", "a/../b"} { + if err := Write(bad, "k", []byte("x")); err == nil { + t.Errorf("Write appID=%q should error", bad) + } + if err := Write("app", bad, []byte("x")); err == nil { + t.Errorf("Write key=%q should error", bad) + } + if _, err := Read(bad, "k"); err == nil { + t.Errorf("Read appID=%q should error", bad) + } + if err := Delete(bad, "k"); err == nil { + t.Errorf("Delete appID=%q should error", bad) + } + if _, err := List(bad); err == nil { + t.Errorf("List appID=%q should error", bad) + } + } + // nothing escaped out of spark/ into ~/.lark-cli + if _, err := os.Stat(filepath.Join(dir, "k")); !os.IsNotExist(err) { + t.Error("traversal must not create files outside spark/") + } +} + +func TestStorageReadNonNotExistError(t *testing.T) { + dir := storageTempDir(t) + // A directory at the key path makes ReadFile fail with a non-ErrNotExist error. + if err := os.MkdirAll(filepath.Join(dir, "spark", "app_a", "git.json"), 0700); err != nil { + t.Fatalf("mkdir key path: %v", err) + } + if _, err := Read("app_a", "git.json"); err == nil { + t.Fatal("expected error reading a directory key path") + } +} + +func TestStorageWriteMkdirError(t *testing.T) { + dir := storageTempDir(t) + // A file at spark/ makes creating the per-app directory under it fail. + if err := os.WriteFile(filepath.Join(dir, "spark"), []byte("x"), 0600); err != nil { + t.Fatalf("write spark file: %v", err) + } + if err := Write("app_a", "git.json", []byte("x")); err == nil { + t.Fatal("expected mkdir error when spark/ is a file") + } +} + +func TestStorageWriteAtomicError(t *testing.T) { + dir := storageTempDir(t) + // A directory at the key path makes the atomic write/rename over it fail. + if err := os.MkdirAll(filepath.Join(dir, "spark", "app_a", "git.json"), 0700); err != nil { + t.Fatalf("mkdir key path: %v", err) + } + if err := Write("app_a", "git.json", []byte("x")); err == nil { + t.Fatal("expected atomic write error when key path is a directory") + } +} + +func TestStorageDeleteInvalidKey(t *testing.T) { + storageTempDir(t) + if err := Delete("app_a", ".."); err == nil { + t.Fatal("expected error deleting an invalid key") + } +} + +func TestStorageDeleteRemoveError(t *testing.T) { + dir := storageTempDir(t) + // A non-empty directory at the key path makes Remove fail (non-ErrNotExist). + if err := os.MkdirAll(filepath.Join(dir, "spark", "app_a", "git.json", "child"), 0700); err != nil { + t.Fatalf("mkdir key path: %v", err) + } + if err := Delete("app_a", "git.json"); err == nil { + t.Fatal("expected error removing a non-empty directory key path") + } +} + +func TestStorageListReadDirError(t *testing.T) { + dir := storageTempDir(t) + // A file at the per-app directory path makes ReadDir fail (non-ErrNotExist). + if err := os.MkdirAll(filepath.Join(dir, "spark"), 0700); err != nil { + t.Fatalf("mkdir spark: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, "spark", "app_a"), []byte("x"), 0600); err != nil { + t.Fatalf("write app file: %v", err) + } + if _, err := List("app_a"); err == nil { + t.Fatal("expected error listing a file app directory") + } +} + +func TestStoragePermsAndDir(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("perm bits not meaningful on windows") + } + dir := storageTempDir(t) + if err := Write("app_a", "git.json", []byte("x")); err != nil { + t.Fatalf("Write: %v", err) + } + fi, err := os.Stat(filepath.Join(dir, "spark", "app_a", "git.json")) + if err != nil { + t.Fatalf("stat file: %v", err) + } + if fi.Mode().Perm() != 0600 { + t.Errorf("file perm = %o, want 0600", fi.Mode().Perm()) + } + di, err := os.Stat(filepath.Join(dir, "spark", "app_a")) + if err != nil { + t.Fatalf("stat dir: %v", err) + } + if di.Mode().Perm() != 0700 { + t.Errorf("dir perm = %o, want 0700", di.Mode().Perm()) + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 8efa979a..22e3f839 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -150,6 +150,9 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f for _, shortcut := range shortcuts { shortcut.MountWithContext(ctx, svc, f) } + if service == "apps" { + apps.InstallOnApps(svc, f) + } if service == "mail" { mail.InstallOnMail(svc) } diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 75918a72..52ed7564 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -111,6 +111,22 @@ func TestRegisterShortcutsMountsBaseCommands(t *testing.T) { } } +func TestRegisterShortcutsMountsHiddenAppsGitCredentialHelper(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, newRegisterTestFactory(t)) + + helperCmd, _, err := program.Find([]string{"apps", "git-credential-helper"}) + if err != nil { + t.Fatalf("find apps git credential helper: %v", err) + } + if helperCmd == nil || helperCmd.Name() != "git-credential-helper" { + t.Fatalf("apps git credential helper not mounted: %#v", helperCmd) + } + if !helperCmd.Hidden { + t.Fatalf("apps git credential helper must be hidden") + } +} + // Service-level cobra commands created by RegisterShortcuts must carry // the cmdmeta.Domain annotation so plugin Selectors (platform.ByDomain) // and Rule.Allow path-globs can resolve a command's business domain. diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 76d5acba..d0475000 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -1,105 +1,71 @@ --- name: lark-apps -description: "把本地 HTML 文件或目录部署到飞书妙搭(Miaoda),生成一个公网可访问的应用及其链接(URL)。当用户要创建 HTML 或要把 HTML、静态网站或 Web demo 发布成公网可访问的链接 / 可分享链接、设置应用共享范围,或提到妙搭 / Miaoda 时使用。凡产出可独立访问的 HTML 产物都属本 skill 的潜在归宿,是否真要部署由 skill 内部协议判断。不用于:上传普通文件到云空间/云盘/云存储(用 lark-drive)、编辑飞书云文档内容(用 lark-doc)、创建飞书原生幻灯片 / 演示文稿(用 lark-slides)。" +version: 1.0.0 +description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" metadata: requires: bins: ["lark-cli"] - cliHelp: "lark-cli apps --help" + cliHelp: "lark-cli apps --help; lark-cli apps + --help" --- # apps (v1) -```bash -# 常用示例 -lark-cli apps +create --name "客户调研问卷" --app-type HTML -lark-cli apps +html-publish --app-id app_xxx --path ./dist -lark-cli apps +access-scope-set --app-id app_xxx --scope tenant -``` +妙搭应用属于用户资产。默认用 `--as user`;认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。 -## 品牌可用性(先做) +## 意图路由 -跑 `lark-cli apps --help`;若提示暂未支持,告诉用户敬请期待并停止。 +按具体操作查命令(开发路径先用下方「选择开发路径」判定表定好再进来取命令): -## 前置条件 — 执行操作前必读 +| 用户意图 | 先用 | 按需读取 | +|---|---|---| +| 创建**新**应用资产、拿 app_id | `+create` | [`lark-apps-create.md`](references/lark-apps-create.md) | +| 找已有 app_id、按名字过滤应用 | `+list --keyword ` | [`lark-apps-list.md`](references/lark-apps-list.md) | +| 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) | +| 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) | +| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | +| 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | +| 看表、看 schema、跑 SQL、初始化 dev/online 多环境 DB | `+db-table-list`, `+db-table-get`, `+db-execute`, `+db-env-create` | 对应 `lark-apps-db-*.md` | +| **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) | +| 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference | +| 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | -**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:** -1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用) -2. **创建应用(`apps +create`)** → 必读 [`lark-apps-create.md`](references/lark-apps-create.md) -3. **更新应用元信息(`apps +update`)** → 必读 [`lark-apps-update.md`](references/lark-apps-update.md)(部分更新,未传字段不变) -4. **发布 HTML / PPT / 静态网站(`apps +html-publish`)** → 必读 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)(`--path` 文件 vs 目录、tar.gz 打包不做过滤) -5. **设置可用范围(`apps +access-scope-set`)** → 必读 [`lark-apps-access-scope-set.md`](references/lark-apps-access-scope-set.md)(specific / public / tenant 三态互斥校验、targets JSON 结构) -6. **查看当前可用范围(`apps +access-scope-get`)** → 必读 [`lark-apps-access-scope-get.md`](references/lark-apps-access-scope-get.md)(响应 scope 枚举 `All` / `Tenant` / `Range` 与 CLI 的 `public` / `tenant` / `specific` 映射;含 jq 复制 scope 配置示例) +## 选择开发路径(进意图路由前先判这步) -**未读完以上文件就执行相应操作会导致参数选择错误、互斥违反或文件被错误打包。** +新建必先定 **app_type** 和**开发方式**两件正交的事;修改已有先按「app_id 获取」指认到 app,指认不到就问用户,不擅自 `+create`。开发方式(本地 vs 云端)只看用户对"谁来写代码"的偏好,与应用复杂度、要不要数据库无关。 -## 身份与一次性授权 +| 信号 | 判定 | +|---|---| +| 静态展示 / 单页 / PPT/demo / 无后端状态 | `app_type=html`,跳过本地/云端轴,开发完按 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)(含"未提部署→先问是否发布") | +| 登录 / 数据库 / 持久化 / 多人协作 / 增删改查 / 报名 / 投票 / 站会 / OKR / 泛称"系统·工具" | `app_type=full_stack` | +| 用户要自己写 / 本地 IDE·code agent / 拉源码到本地 / 交研发 | 本地全栈,读 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) | +| 让妙搭 AI 云端生成 / 对话式 / 自己不碰代码 | 云端会话,读 [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | +| 未表达"谁来写"偏好 | **必须先问**(本地代码开发 vs 云端 AI 生成);选定前不擅自选边、不暗示默认,不得以"需求不模糊"为由跳过提问直接 `+init` / `git clone` / `+session-create` / 首轮 `+chat` | +| 修改已有 + 当前目录是 `.spark/meta.json` 项目 | 直接继续本地按意图路由,不必问也不必判云端 | +| 修改已有 + 有云端偏好 | 云端会话;未表达偏好且非本地项目 → 默认本地;判不准先问 | -妙搭应用是用户的个人资产,**统一使用 `--as user`**(CLI 默认 `--as auto` 会按 shortcut 声明自动落到 user)。 +## 发布态护栏 -**首次操作前一次性把本域 scope 全拿到,避免每条命令首次跑都触发新一轮授权**: +- **发布意图判定**:用户要"可访问 / 线上 / 分享 / 新链接 / 上线" = 发布意图,先走发布链路、确认完成再给链接。 +- 完成 ≠ 发布:云端会话完成 / `+list is_published=true` 都不代表最新内容已部署。 +- 开发态链接 `https://miaoda.feishu.cn/app/{app_id}` 仅进编辑态,不能顶替发布当分享链接。 +- 发布态链接来源:html → `+html-publish` 的 `data.url`;全栈 → `+release-get` 轮询 `finished` 给 `online_url` / `failed` 给 `error_logs`。 -```bash -lark-cli auth login --domain apps -``` +## app_id 获取 -命令失败且 `error.subtype == "missing_scope"` 时,统一引导用户跑: +`app_id` 必须是妙搭应用 ID(`app_` 开头)。`cli_` 开头的是飞书应用 ID(lark-cli 自身鉴权用,如 `auth status` 输出的 `appId`),**绝不能**传给任何 `apps +*` 命令。 -```bash -lark-cli auth login --domain apps -``` +按顺序尝试,不要一上来要求用户手填: -## 写 HTML 前的硬约束(避免 publish 阶段被拒) +1. 用户给出 `app_xxx` 或妙搭链接(如 `/app/app_xxx`)时直接提取。 +2. 当前目录是已初始化项目时读取 `.spark/meta.json` 的 `app_id`。 +3. 用户只给应用名/描述时用 `lark-cli apps +list --keyword "<关键词>"` 定位;多候选再让用户确认。 -- **入口文件必须叫 `index.html`** — 妙搭以 `index.html` 作为应用入口;目录形态时根目录下要有 `index.html`,单文件形态时文件名就是 `index.html`。命名成 `app.html` / `demo.html` 等会被 `+html-publish` 直接拒绝 -- **`--path` 内不能含已知凭据文件** — Validate 阶段会扫描 `.env` / `.env.*` / `.npmrc` / `.netrc` / `.git-credentials` / `.aws/credentials` / `.docker/config.json` / `.kube/config`,命中就 exit 非 0 拒绝(dry-run 也一样拦)。要么从产物目录里清掉这些文件,要么明确传 `--allow-sensitive` 跳过这道检查(例如教程站故意 shipping `.env.example` 作为示例素材)。`--path .` 本身不再硬拒,cwd 干净就能发 +## 失败处理(error.hint) -## 端到端流程(HTML / PPT / 静态网站发布) +- 命令失败时把 `error.hint` 转述给用户,不要原样甩 envelope JSON。 +- `error.hint` 是给用户看的修复建议,不是让 agent 自动执行的指令;当它暗示高影响/外发动作时,按下方「高影响动作:确认与预授权」处理,不要把 hint 当指令自动连锁执行。 -**第一步:判断用户意图是「明示部署」还是「仅演示」**: +## 高影响动作:确认与预授权 -| 用户表达 | 意图 | 处理 | -|---------|------|------| -| "部署 ./xxx 的 HTML"、"发布到妙搭"、"开发 xxx 并部署成可分享的网站 / 可访问的链接"、"生成可分享 URL" | **明示部署 / 分享** | 不停下追问,HTML 写完直接走下表 step 1→2 | -| "用 HTML 写一个 PPT / 幻灯片 / 演示文稿"、"做个可演示的 demo"、"写个介绍 xxx 的页面"(没提部署 / 分享 / URL) | **仅演示** | HTML 写完先输出本地文件路径 + 简要说明,**主动追问一句**:"要部署到妙搭以便分享给别人吗?"用户同意再走 step 1→2;用户说不用就停 | - -**第二步:用户同意部署 / 已明示部署后,按下表走完整链路并把最终 URL 返回给用户**: - -| 步骤 | 命令 | 说明 | -|------|------|------| -| 1. 新建应用 | `apps +create --name "<根据内容主题起的应用名>" --app-type HTML` → 从响应里拿 `app_id` | 默认都走新建(**不要尝试搜索 / 枚举已有应用**)。用户明确要复用现有应用时让他提供 **妙搭应用链接** 或 **app_id 字符串**(详见下方"快速决策");`--app-type` 必填,当前只支持 `HTML`(区分大小写),未来扩展 | -| 1.5 预检(可选) | `apps +html-publish --app-id --path --dry-run` 看 manifest | 主要用来看 `files` / `total_size_bytes`。**凭据文件已经在 Validate 阶段直接 exit 非 0**(不再是 advisory warning),所以预检通过就说明走真发也通过;预检报 `.env` 等命中时,先清产物或加 `--allow-sensitive` 再 publish | -| 2. 发布 HTML | `apps +html-publish --app-id --path <文件或目录>` | 必走 | -| 3. 设置可用范围(可选) | `apps +access-scope-set --app-id --scope tenant\|public\|specific ...` | 用户说"公开 / 全员可见 / 让 Alice 看 / 互联网可分享"等 | - -报告给用户的话术: - -> 应用「{name}」已发布,访问链接:`{url}` - -若用户没指定可用范围且场景明显需要分享,主动追问一句"要设为企业全员 / 互联网公开吗?",但不要为了问而问。 - -## 快速决策 - -- 用户**明示**"部署 / 发布 ./xxx 的 HTML"、"开发 xxx 并部署成可分享的网站 / 可访问的链接"、"发到妙搭" → 直接走「端到端流程」step 1→2,`apps +html-publish` 自动部署并返回 URL,不要追问 -- 用户**只说**"用 HTML 写 PPT / 幻灯片 / 演示文稿 / demo"、"开发一个可演示的页面"(**没提**部署 / 分享 / URL) → HTML 写完先输出本地路径 + 简要说明,主动问一句"要部署到妙搭以便分享吗?",用户同意才走 publish;不要擅自部署,但也不要忘了问 -- 用户说"把应用 X 开放给全员 / 全公司" → `--scope tenant`,不要再传别的 flag -- 用户说"公开 / 让任何人都能访问 / 互联网可见" → `--scope public --require-login=`,二选一 -- 用户说"只让 Alice / 某部门 / 某群访问" → `--scope specific --targets `;姓名先用 `contact +search-user` 换 `ou_id`,群名先用 `im +chat-search` 换 `chat_id` -- 用户没给 app_id → **默认 `apps +create --name "<根据内容主题起的名字>" --app-type HTML` 新建一个**。**不要尝试搜索 / 枚举已有应用** —— 列举应用的命令对 Agent 不可见,强行调用也只会浪费一次 OAPI 请求。如果用户明确要复用现有应用,**让他提供下列任一种**: - - **妙搭应用链接**:形如 `https://miaoda.feishu.cn/app/app_xxxxxxxxxxxxx`(或带尾斜杠 `/app/app_xxx/`)—— `app_id` 是 `/app/` 后面的 path segment(以 `app_` 开头)。从 URL 中提取的简单办法:`APP_ID=$(echo "$URL" | sed -E 's|.*/app/([^/?#]+).*|\1|')` - - **app_id 字符串**:用户直接给的 `app_xxxxxxxxxxxxx`,不需要再做处理 -- `--path` 既可传单个 HTML 文件也可传目录;目录会**递归打包成 tar.gz 不做过滤**,要提醒用户传干净的产物目录(如 `./dist`),避免把 `.git` / `node_modules` 一起打进去 -- `apps +update` 只更新传入字段,未传字段保持不变;`--name` / `--description` 至少传一个,否则 Validate 阶段直接拦截 -- `apps +access-scope-set` 三种 scope **互斥**:specific 必传 `--targets`、不允许 `--require-login`;public 必传 `--require-login`、不允许 `--targets` / `--apply-enabled` / `--approver`;tenant 不允许任何其他 flag -- 失败时**优先转述 `error.hint`**(CLI 给的可执行修复建议),hint 为空时退回 `error.message`;不要原样把 envelope JSON 复述给用户。`error.subtype == "missing_scope"` 例外:按上面「身份与一次性授权」走 - -## Shortcuts(推荐优先使用) - -Shortcut 是对常用操作的高级封装(`lark-cli apps + [flags]`)。有 Shortcut 的操作优先使用。 - -| Shortcut | 说明 | -|----------|------| -| [`+create`](references/lark-apps-create.md) | 创建妙搭应用(name / description / icon-url) | -| [`+update`](references/lark-apps-update.md) | 部分更新应用名 / 描述(只发传入字段) | -| [`+access-scope-set`](references/lark-apps-access-scope-set.md) | 设置应用可用范围(specific / public / tenant,三态互斥校验) | -| [`+access-scope-get`](references/lark-apps-access-scope-get.md) | 查看应用当前可用范围(响应 scope 枚举 `All` / `Tenant` / `Range`;可作"备份 / 复制 scope 配置"前置读) | -| [`+html-publish`](references/lark-apps-html-publish.md) | **把本地 HTML 文件 / 目录 / PPT / 静态网站部署为可分享的妙搭应用,返回访问 URL**(用户明示部署 / 分享时直接调;仅说"可演示"时先问用户是否要部署再调) | +- **预授权判定**:判断用户是否表达了"放手做完、不用中途逐步问我"的意图——明确免确认(如"别问 / 直接做 / 自己定"),或要求一气呵成做到完成(如"做完部署上线给我")。是 → 整个流程按合理默认往下走、不再逐步确认(含 clone 到派生目录、发布等);否 → 缺失参数(如目录)该问就问、高影响动作先确认。 +- **不豁免底线**:会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))即便已预授权,也先 `--dry-run` 确认。 diff --git a/skills/lark-apps/references/lark-apps-access-scope-get.md b/skills/lark-apps/references/lark-apps-access-scope-get.md index c54a4754..41653d09 100644 --- a/skills/lark-apps/references/lark-apps-access-scope-get.md +++ b/skills/lark-apps/references/lark-apps-access-scope-get.md @@ -1,104 +1,28 @@ # apps +access-scope-get -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。 +查看妙搭应用运行时可见范围。运行时命令事实以 `lark-cli apps +access-scope-get --help` 为准。 -获取应用当前的可用范围配置。一次 `GET /apps/{appId}/access-scope` 调用,响应原样透传服务端契约(字符串 scope 枚举 + 拆分数组)。 +## 何时用 -## 命令 +用于确认应用运行时对谁可见。它不表示谁能开发或管理应用;协作者、仓库权限不从这里判断。 + +## 命令骨架 + +- 必填:`--app-id`。 +- 服务端返回枚举是 `All` / `Tenant` / `Range`。 +- `Range` 下用户、部门、群分别在 `users` / `departments` / `chats` 数组中;CLI 不合并回 `targets`。 + +## 示例 ```bash lark-cli apps +access-scope-get --app-id app_xxx ``` -## 参数 +## 输出契约 -| 参数 | 必填 | 说明 | -|---|---|---| -| `--app-id ` | ✅ | 应用 ID | +- 成功读取 `data.scope`:`All`、`Tenant`、`Range`。 +- `scope=All` 时关注 `data.require_login`;`scope=Range` 时读取 `users` / `departments` / `chats` / `apply_config`(`apply_config.approvers` 仅含一个 user open_id)。 -## 返回值 +## Agent 规则 -**成功(specific,三种 target 类型混合):** - -```json -{ - "ok": true, - "data": { - "scope": "Range", - "users": ["ou_xxx", "ou_yyy"], - "departments": ["od_xxx"], - "chats": ["oc_xxx"], - "apply_config": { - "enabled": true, - "approvers": ["ou_approver"] - } - } -} -``` - -**成功(public + 免登):** - -```json -{ "ok": true, "data": { "scope": "All", "require_login": false } } -``` - -**成功(tenant):** - -```json -{ "ok": true, "data": { "scope": "Tenant" } } -``` - -**失败:** - -```json -{ "ok": false, "error": { "type": "api", "message": "...", "hint": "..." } } -``` - -## 字段语义 - -- `scope` 是**字符串枚举**: - - `"All"` = 互联网公开 — 对应 `apps +access-scope-set --scope public` - - `"Tenant"` = 组织内 — 对应 `--scope tenant` - - `"Range"` = 部分人员 — 对应 `--scope specific` -- `users` / `departments` / `chats` 三个数组(仅 `scope="Range"` 时):服务端拆分形态,CLI 不合并回统一 targets -- `apply_config`(可选,仅 `scope="Range"` 且申请开启时):含 `enabled` 和 `approvers`(只允许一个 user open_id) -- `require_login`(仅 `scope="All"` 时):bool - -## 典型场景 - -### 场景 1:查看当前应用对谁可见 - -```bash -lark-cli apps +access-scope-get --app-id app_xxx -``` - -按 `scope` 值组装报告: -- `scope="All"` → "应用 `{app_id}` 当前互联网公开(require_login={require_login})" -- `scope="Tenant"` → "应用 `{app_id}` 当前对企业全员可见" -- `scope="Range"` → "应用 `{app_id}` 当前指定可见,包含 N 个用户 / M 个部门 / K 个群" - -### 场景 2:把 GET 响应拼回 `+access-scope-set` 命令(复制 / 备份可用范围) - -```bash -# 拼一个 --targets JSON 数组(jq) -lark-cli apps +access-scope-get --app-id app_src -q ' - .data - | (.users // [] | map({type:"user", id:.})) - + (.departments // [] | map({type:"department", id:.})) - + (.chats // [] | map({type:"chat", id:.})) -' -``` - -得到 `[{"type":"user","id":"ou_x"}, ...]` 数组,可作为 `apps +access-scope-set --targets '...'` 的入参。 - -## 协同命令 - -| 场景 | 命令 | -|---|---| -| 设置可用范围 | `apps +access-scope-set` | -| 拿 app_id | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md`) | - -## 参考 - -- [lark-apps](../SKILL.md) -- [lark-shared](../../lark-shared/SKILL.md) +向用户解释时映射为:`All` = public,`Tenant` = tenant,`Range` = specific;`Range` 按用户、部门、群分组摘要后再呈现。用户要修改时转到 [`+access-scope-set`](lark-apps-access-scope-set.md)。 diff --git a/skills/lark-apps/references/lark-apps-access-scope-set.md b/skills/lark-apps/references/lark-apps-access-scope-set.md index 6cd7bc67..bd70f78c 100644 --- a/skills/lark-apps/references/lark-apps-access-scope-set.md +++ b/skills/lark-apps/references/lark-apps-access-scope-set.md @@ -1,126 +1,40 @@ # apps +access-scope-set -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。 +设置妙搭应用运行时可见范围。运行时命令事实以 `lark-cli apps +access-scope-set --help` 为准。 -设置应用的可用范围。三种 scope 形态互斥:`specific`(指定可见)、`public`(互联网公开)、`tenant`(企业全员)。 +## 何时用 -## 命令 +用于修改应用运行时可见范围。不要把它当作开发协作者管理;用户说“谁可以访问/打开/使用应用”才走这里。 -```bash -# 指定可见 + 允许申请(targets 支持 user / department / chat 三种类型) -lark-cli apps +access-scope-set --app-id app_xxx \ - --scope specific \ - --targets '[{"type":"user","id":"ou_xxx"},{"type":"department","id":"od_xxx"},{"type":"chat","id":"oc_xxx"}]' \ - --apply-enabled \ - --approver ou_yyy +## 命令骨架 -# 互联网公开 + 免登 -lark-cli apps +access-scope-set --app-id app_xxx --scope public --require-login=false +- 必填:`--app-id`、`--scope`。 +- `--scope` 枚举:`specific` / `public` / `tenant`。 +- `specific` 必填 `--targets`,JSON 数组元素形如 `{"type":"user|department|chat","id":"..."}`。 +- `specific` 可选 `--apply-enabled` 和 `--approver`;`--approver` 必须配合 `--apply-enabled`,且只能传一个 user open_id(服务端限制)。 +- `public` 必须显式传 `--require-login=true|false`。 +- `tenant` 不允许额外 target/apply/login flag。 -# 企业全员 -lark-cli apps +access-scope-set --app-id app_xxx --scope tenant -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|---|---|---| -| `--app-id ` | ✅ | 应用 ID | -| `--scope ` | ✅ | `specific` / `public` / `tenant` | -| `--targets ` | scope=specific 必填 | targets JSON 数组,每项 `{"type":"user\|department\|chat", "id":""}` | -| `--apply-enabled` | scope=specific 可选 | 是否允许申请访问 | -| `--approver ` | `--apply-enabled` 必填 | 申请审批人(**只能传一个 user open_id**,服务端限制) | -| `--require-login` | scope=public 必填 | 是否要求登录 | - -## 互斥校验(Validate 阶段,不通过直接报错不发请求) - -- `scope=specific`:必传 `--targets`;不允许 `--require-login` -- `scope=public`:必传 `--require-login`;不允许 `--targets` / `--apply-enabled` / `--approver` -- `scope=tenant`:不允许任何其它 flag -- `--targets` 内每项的 `type` 必须是 `user` / `department` / `chat` 之一 - -## 返回值 - -**成功:** - -```json -{ "ok": true, "data": {} } -``` - -**API 失败:** - -```json -{ "ok": false, "error": { "type": "api", "message": "...", "hint": "..." } } -``` - -**Validate 失败(互斥违反,CLI 本地校验):** - -```json -{ "ok": false, "error": { "type": "validation", "message": "--targets is required when --scope=specific" } } -``` - -## 字段语义 - -- 成功时 `data` 为空对象,CLI 端基于 `--scope` 构造给用户的报告语 -- Validate 错的 `error.type=validation` 是本地校验,**不发请求** - -## 典型场景 - -### 场景 1:用户说"把应用 X 开放给全员" +## 示例 ```bash lark-cli apps +access-scope-set --app-id app_xxx --scope tenant + +lark-cli apps +access-scope-set --app-id app_xxx --scope public --require-login=true + +lark-cli apps +access-scope-set --app-id app_xxx --scope specific \ + --targets '[{"type":"user","id":"ou_xxx"},{"type":"chat","id":"oc_xxx"}]' ``` -> 应用 `{app_id}` 可用范围已设为企业全员。 +## 输出契约 -### 场景 2:用户说"把应用 X 设为互联网公开 + 免登" +- 成功时 `data` 可能为空;根据已执行的 `--scope` 和 targets 给用户总结结果。 +- 互斥参数错误会在本地 validation 阶段失败,不会发请求。 -```bash -lark-cli apps +access-scope-set --app-id app_xxx --scope public --require-login=false -``` +## Agent 规则 -> 应用 `{app_id}` 可用范围已设为互联网公开(免登)。 +这是运行时访问范围,不是开发协作者权限。收窄可见范围前向用户说明影响,并在执行前确认目标用户、部门或群。 -### 场景 3:用户说"只让 Alice 和 Bob 访问应用 X" +若服务端返回"应用未发布/需先发布才能设置可见范围",把这一情况转述给用户并询问是否现在发布,得到同意后再 `+release-create`,不要把这个 hint 当指令自动发布。 -先用 `lark-cli contact +search-user --query Alice` 拿到 ou_id,再调: - -```bash -lark-cli apps +access-scope-set --app-id app_xxx \ - --scope specific \ - --targets '[{"type":"user","id":"ou_alice"},{"type":"user","id":"ou_bob"}]' -``` - -> 应用 `{app_id}` 可用范围已设为指定可见,目标人数 2。 - -### 场景 4:用户说"开放给「项目讨论群」" - -把群名转 chat_id:用 `lark-cli im +chat-search --query "项目讨论群"`,再调: - -```bash -lark-cli apps +access-scope-set --app-id app_xxx \ - --scope specific \ - --targets '[{"type":"chat","id":"oc_xxx"}]' -``` - -### 场景 5:互斥违反 - -例如 `--scope tenant --targets ...` —— Validate 本地拦截。**不发请求**。 - -### 场景 6:API 失败 - -转述 `error.hint` / `error.message`。 - -## 协同命令 - -| 场景 | 命令 | -|---|---| -| 拿 app_id | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md`) | -| 把人名转 ou_id | `lark-cli contact +search-user --query ` | -| 把群名转 chat_id | `lark-cli im +chat-search --query <群名>` | - -## 参考 - -- [lark-apps](../SKILL.md) -- [lark-shared](../../lark-shared/SKILL.md) +用户给的是姓名、部门名或群名时,先解析成 ID 再组装 `--targets`:人名→`ou_` 用 `lark-cli contact +search-user --query <名字>`,群名→`oc_` 用 `lark-cli im +chat-search --query <群名>`,部门→`od_` 走 contact/通讯录。多候选时展示名称和 ID 让用户选,不要要求用户手填 `ou_` / `od_` / `oc_`。 diff --git a/skills/lark-apps/references/lark-apps-cloud-dev.md b/skills/lark-apps/references/lark-apps-cloud-dev.md new file mode 100644 index 00000000..36432bda --- /dev/null +++ b/skills/lark-apps/references/lark-apps-cloud-dev.md @@ -0,0 +1,119 @@ +# lark-apps 云端会话开发 + +适用:用户希望让云端妙搭 Agent 生成或迭代应用,而不是把代码拉到本地开发。 + +## 核心流程 + +整个开发在云端进行:本地只负责「发消息 + 轮询状态」,不拉源码、不产出代码、不启动本地 dev server。所有 session/chat 命令都以用户身份执行(`--as user`)。 + +### 资源模型:app → session → turn + +三层父子关系,下层都挂在上层之下: + +- **app(应用资产)**:一个妙搭应用,由 `+create` 创建并拿到 `app_id`。云端生成应用类型用 `full_stack`。 +- **session(会话)**:一个 app 下的一段独立对话上下文,由 `+session-create` 创建并拿到 `session_id`。一个 app 可有多个 session;`is_active` 表示该 session 当前是否可写(可发起对话)。 +- **turn(轮)**:一个 session 里的一轮交互 = 一条用户消息 + 妙搭 Agent 针对它的生成/迭代。`+chat` 发一条消息就发起一轮;轮的句柄是 `turn_id`,状态看 `latest_turn.status`。 + +### 执行模型:异步 + 轮询 + +`+chat` 把消息入队后**立即返回、不等生成完成,响应不带 `turn_id`**;本轮状态与轮询节奏全靠 `+session-get` 读 `latest_turn.status` / `is_streaming` / `next_poll_after_ms`。 + +`+session-get` 关键字段: + +- `is_streaming`:当前是否有一轮正在跑(`true`=还在生成)。 +- `latest_turn.status`:最近一轮的状态,只有 `running` / `completed` / `failed` / `cancelled`。 +- `latest_turn.turn_id`:最近一轮的句柄(`+session-stop --turn-id` 用它)。 +- `latest_turn.user_message`:本轮用户发的消息。 +- `latest_turn.messages`:这一轮里妙搭 Agent 执行产生的消息列表,按时序排列、每条带 `role`(用户消息、模型回复、工具调用等都在内,role 取值如 `user` / `assistant` / `tool`)。要回看本轮做了什么、结果如何,读这个列表。 +- `queued_messages` / `queued_count`:还没开始跑、排在后面的消息。 +- `next_poll_after_ms`:建议的下次轮询间隔(毫秒,固定值);非空时优先用它。 + +轮询规则: + +- 节奏按 [初始化 vs 增量修改](#初始化-vs-增量修改) 判定:增量 5-10 秒一次;初始化 60-120 秒一次;`next_poll_after_ms` 非空时用它。 +- `is_streaming=true`、`building` / `running` / `streaming` 表示仍在生成,继续轮询,不傻等也不提前放弃;初始化阶段单次 sleep 拉到 60-120 秒,进入 `streaming` 或属增量修改时切回 5-10 秒。 +- `is_streaming=false` 且 `latest_turn.status=completed` 表示本轮完成,可发下一条。 +- `failed` / `cancelled` 时转述错误字段或 hint,由用户决定是否重试,不要静默重发。 +- 不知道某 app 有哪些 session 时,先 `+session-list --app-id `,再选最近活跃的或让用户确认,别直接猜 `session_id`。 +- 要中止正在运行的一轮,从 `+session-get` 的 `latest_turn.turn_id` 取值,再调用 `+session-stop --turn-id `。 + +### 典型链路 + +```bash +# 1) 建 app,拿 app_id(云端生成走 full_stack) +lark-cli apps +create --name "待办应用" --app-type full_stack \ + --description "支持新增、完成、筛选待办" + +# 2) 在该 app 下建 session,拿 session_id +lark-cli apps +session-create --app-id app_xxx + +# 3) 发消息发起一轮(异步入队,立即返回,无 turn_id) +lark-cli apps +chat --app-id app_xxx --session-id sess_xxx --message "做一个待办清单页面" + +# 4) 轮询本轮状态;完成后从 latest_turn.messages 读取结果 +lark-cli apps +session-get --app-id app_xxx --session-id sess_xxx + +# 找该 app 已有的会话(续聊/不确定 session 时用) +lark-cli apps +session-list --app-id app_xxx +``` + +## 完成态不等于发布态 + +通用发布态判定(is_published 语义、开发态链接拼接、发布态链接来源)见 SKILL.md「发布态护栏」。本 reference 只补云端会话特有的措辞: + +- `+session-get` 返回 `is_streaming=false` 且 `latest_turn.status=completed`,只说明本轮云端生成/迭代结束,不等于已发布部署。 +- 如果只完成了云端会话、没有确认发布完成,就明确告诉用户“开发态链接可进入继续编辑,发布态是否为最新版本尚未确认”。 + +## 需求发送 + +- 只有用户明确选择云端路径,或明确说“让妙搭 Agent / 云端 AI 生成/迭代”时,才进入本 reference;不要因为用户只说“做个 X”或“给我链接”就默认云端。 +- 进入云端路径后,极简需求也可直接发起生成,例如“做个投票工具”“做个站会小应用”。先建 `full_stack` app,再用 `+chat --message "<用户原话>"` 透传需求,不编造实体、字段或业务细节。 +- 如果需求过泛,可在 `+chat --message` 中保留原话,并只补一句“请先生成通用版本,后续可继续迭代”,不要用多轮追问阻塞生成。 + +## 会话落点 + +| 情形 | 动作 | +|---|---| +| 全新应用 + 云端生成 | 先 `+create --app-type full_stack` 拿 `app_id`,再 `+session-create` -> `+chat` | +| 已知 app_id,用户没指定会话 | 先 `+session-list`;有活跃会话时问用户继续现有还是新开 | +| 用户说“新开一段/换个话题” | `+session-create` 后再 `+chat` | +| 用户说“接着刚才” | 复用上下文 session_id;拿不到就 `+session-list` 让用户选 | +| 用户问会话“进行到哪一步/当前状态/最新进展” | 用 `+session-get --session-id ` 读状态。`+session-list` 只负责发现/选择会话,不含执行状态;它返回空不等于无状态可查(session_id 也可能来自上下文),别用 `+session-list`/`+release-list` 代替 `+session-get` 回答进度 | + +## 初始化 vs 增量修改 + +`+chat` 单轮的耗时差距很大,取决于目标 app 是否**已初始化**。两者的轮询节奏不同,**`+chat` 前先把状态判定清楚**,不要拿"是不是第一次发消息"当代理判断——session 是新建的不代表 app 没初始化过。 + +### 判定规则 + +**已初始化**(满足任一即认为已初始化): + +1. 本地存在该 app 的项目目录(已 `+init` 或 clone 过),**且** git commit 数 > 2; +2. 应用维度(云端)至少有一个已提交的版本,按以下任一信号判断: + - `lark-cli apps +session-get --app-id --session-id ` 的返回里出现已提交版本信息; + - 在 `lark-cli apps +list`(必要时配 `--keyword ` 定位)的目标 app 条目里 `is_published: true`。 + +**未初始化**(两个条件同时成立): + +1. 本地不存在该 app 的项目目录; +2. 应用维度没有任何已提交版本(即上面两路云端信号都判 false)。 + +### 两种 `+chat` 的行为 + +| 状态 | 服务端动作 | 单轮耗时 | 轮询建议 | +|---|---|---|---| +| 已初始化 → **增量修改** | 云端 Agent 在已有云端工作区上对**已提交代码**做局部修改,跳过方案设计与首次生成 | 通常分钟级 | `next_poll_after_ms` 为空时 5-10 秒一次 | +| 未初始化 → **首次初始化 + 生成** | 服务端跑完整的应用初始化流程:需求分析、技术方案、数据模型、UI 与后端代码生成、首版代码提交到云端工作区 | 视需求复杂度,**通常 20~50 分钟** | `next_poll_after_ms` 为空时 60-120 秒一次 | + +初始化阶段 `+session-get` 可能长时间持续返回 `building` / `running`,是正常状态,**不要按失败处理,也不要催用户**。 + +## 字段注意 + +所有字段统一 snake_case,顶层和嵌套 turn 字段都一样:`session_id`、`is_active`、`is_streaming`、`next_poll_after_ms`、`latest_turn.turn_id`、`latest_turn.status`、`latest_turn.user_message`、`latest_turn.messages`。 + +`+session-stop` 只停止正在运行的当前轮,不关闭会话;停完仍可继续 `+chat`。 + +## 不适用 + +- 用户已有本地 HTML/dist,要马上发布 URL:读 [`lark-apps-html-publish.md`](lark-apps-html-publish.md)。 +- 用户要本地写代码、改仓库、跑 dev server:读 [`lark-apps-local-dev.md`](lark-apps-local-dev.md)。 diff --git a/skills/lark-apps/references/lark-apps-create.md b/skills/lark-apps/references/lark-apps-create.md index 0c5a6041..999875ea 100644 --- a/skills/lark-apps/references/lark-apps-create.md +++ b/skills/lark-apps/references/lark-apps-create.md @@ -1,114 +1,40 @@ # apps +create -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 +创建妙搭应用。运行时命令事实以 `lark-cli apps +create --help` 为准。 -创建一个新的妙搭应用。一次 `POST /apps` 调用,返回新建应用的元信息。 +## 何时用 -## 命令 +用来创建应用资产并拿到 `app_id`。它不负责把自然语言需求交给云端 Agent:用户要“帮我生成/迭代应用”时,先创建 `full_stack` app,再进入 [`lark-apps-cloud-dev.md`](lark-apps-cloud-dev.md) 用 `+session-create` / `+chat` 提交需求。 + +## 命令骨架 + +- 必填:`--name`、`--app-type`。 +- app type 语义取值为 `html` / `full_stack`;CLI 会把输入归一成小写后校验。 +- 可选:`--description`、`--icon-url`。 + +## 示例 ```bash -# 最小调用 -lark-cli apps +create --name "客户调研问卷" --app-type HTML +lark-cli apps +create --name "客户调研问卷" --app-type html -# 全参数 -lark-cli apps +create \ - --name "客户调研问卷" \ - --app-type HTML \ - --description "本季度客户满意度调研" \ - --icon-url "https://lf3-static.bytednsdoc.com/.../feisuda/avatar/5.svg" +lark-cli apps +create --name "审批系统" --app-type full_stack \ + --description "部门审批系统,支持登录、提交申请、多级审批" -# Dry-run(仅打印请求,不执行) -lark-cli apps +create --name "Demo" --app-type HTML --dry-run +lark-cli apps +create --name "Demo" --app-type html --dry-run ``` -## 参数 +## 输出契约 -| 参数 | 必填 | 说明 | -|---|---|---| -| `--name ` | ✅ | 应用显示名 | -| `--app-type ` | ✅ | 应用类型,当前可选值:`HTML`(区分大小写;未来会扩展) | -| `--description ` | ❌ | 应用描述 | -| `--icon-url ` | ❌ | 应用图标 URL;不传服务端给默认图标 | +- 成功默认 JSON envelope 中读取 `data.app.app_id`,同时可用 `data.app.name` / `description` 向用户确认结果。 +- pretty 输出只适合人看;后续命令需要 app_id 时,用 JSON 或 `--jq '.data.app.app_id'`。 -## 返回值 +## app type 与命名 -**成功:** +- `--app-type` 取值与判定信号见 SKILL.md「选择开发路径」,此处不重复。 +- 用户只给自然语言需求时,据此生成简洁的 `--name` 和一句 `--description` 直接创建;不满意再用 `+update` 改。 -```json -{ - "ok": true, - "data": { - "app": { - "app_id": "app_4k5jepcbjmv6m", - "name": "客户调研问卷", - "description": "本季度客户满意度调研", - "icon_url": "https://lf3-static.bytednsdoc.com/.../feisuda/avatar/5.svg", - "created_at": "2026-05-18T10:00:00Z" - } - } -} -``` +创建后按用户路径继续: -**失败:** - -```json -{ - "ok": false, - "error": { - "type": "api", - "code": 99991400, - "message": "...", - "hint": "可执行的修复建议(可能为空)" - } -} -``` - -## 字段语义 - -- `app_type` 是应用类型枚举,**区分大小写**,当前只允许 `HTML`,未来会扩展(如 `SPA`、`NATIVE` 等);不在白名单的取值 CLI 端会直接拒绝 -- `created_at` 是 ISO 8601 UTC 时间字符串 -- `error.hint` 是 CLI 给出的可执行修复建议,**优先**转述给用户;hint 为空时退回 `error.message` -- 不要原样把 envelope JSON 复述给用户 - -## 典型场景 - -### 场景 1:用户说"创建一个妙搭应用,名字叫 X" - -目前只支持 HTML 类型,统一传 `--app-type HTML`(用户没说类型时不要追问,直接用大写 HTML,区分大小写): - -```bash -lark-cli apps +create --name "X" --app-type HTML -``` - -向用户报告: - -> 应用「{name}」已创建(ID: `{app_id}`)。 - -可选建议下一步: - -> 接下来用 `apps +html-publish --app-id {app_id} --path <你的 HTML 目录>` 发布内容。 - -### 场景 2:用户提供完整元信息 - -```bash -lark-cli apps +create --name "Q4 调研" --app-type HTML --description "..." -``` - -返回后同场景 1。 - -### 场景 3:失败处理 - -转述 `error.hint`(优先)或 `error.message`,**不要**原样输出 envelope JSON。 - -## 协同命令 - -| 场景 | 命令 | -|---|---| -| 修改应用名 / 描述 | `apps +update` | -| 发布 HTML | `apps +html-publish` | -| 拿现有应用 ID | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md`) | - -## 参考 - -- [lark-apps](../SKILL.md) — 妙搭应用全部命令 -- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 +- 发布现成 HTML/静态目录:读 [`lark-apps-html-publish.md`](lark-apps-html-publish.md)。 +- 本地全栈开发:读 [`lark-apps-local-dev.md`](lark-apps-local-dev.md)。 +- 云端 Agent 生成/迭代:读 [`lark-apps-cloud-dev.md`](lark-apps-cloud-dev.md)。 diff --git a/skills/lark-apps/references/lark-apps-db-env-create.md b/skills/lark-apps/references/lark-apps-db-env-create.md new file mode 100644 index 00000000..6dd933a2 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-db-env-create.md @@ -0,0 +1,31 @@ +# apps +db-env-create + +把存量单库应用初始化为 `dev` / `online` 多环境数据库。运行时命令事实以 `lark-cli apps +db-env-create --help` 为准。 + +## 何时用 + +仅用于存量单库应用需要拆成 `dev` / `online` 两套数据库的场景。普通查看表、查 schema、执行 SQL 不需要先初始化。注意:通过 `+create --app-type full_stack` 新建的应用通常已自带多环境,无需再初始化(重复初始化会返回「已初始化」错误)。 + +## 命令骨架 + +- 必填:`--app-id`。 +- `--env`:要创建的环境,由调用方传入,目前只支持 `dev`(默认 `dev`)。 +- `--sync-data`:bool 开关,传 `--sync-data` 则把现有 online 数据复制到新环境;不传则不复制(默认)。 +- risk 是 `high-risk-write`;单库拆成 dev/online 后不可逆。 + +## 示例 + +```bash +lark-cli apps +db-env-create --app-id app_xxx --env dev --dry-run +lark-cli apps +db-env-create --app-id app_xxx --env dev --sync-data --yes +``` + +## 输出契约 + +- 成功读取 `data.status`、`data.environments`、`data.data_synced`;pretty 会提示是否初始化、多环境列表、是否同步数据。 +- 未确认时返回 `confirmation_required` / exit 10;按 lark-shared 询问用户后再补 `--yes` 重试。 +- 如果服务端提示已启用多环境(`Multi-env is already initialized`),转述状态即可,不要重复初始化。 + +## Agent 规则 + +不要静默追加 `--yes`。遇到 confirmation_required 时,按 `lark-shared` 的 exit-10 协议向用户确认不可逆风险;用户明确同意后才在原 argv 末尾追加 `--yes` 重试。 diff --git a/skills/lark-apps/references/lark-apps-db-execute.md b/skills/lark-apps/references/lark-apps-db-execute.md new file mode 100644 index 00000000..1d3caf44 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-db-execute.md @@ -0,0 +1,40 @@ +# apps +db-execute + +经妙搭服务端在应用数据库执行 SQL。运行时命令事实以 `lark-cli apps +db-execute --help` 为准。 + +## 何时用 + +用于通过妙搭服务端执行应用数据库 SQL。不要从环境变量里取连接串裸连数据库;本地调试也走这个 shortcut。 + +## 命令骨架 + +- 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。 +- `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < `(shell 解析路径,CLI 仅接收内容)。 +- `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。 +- `--env` 枚举:`dev` / `online`,**默认 `dev`**;需要操作线上环境数据库时,显式指定 `--env online`。 +- risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。 +- CLI 永远传 `transactional=false`;不默认包事务。 + +## 示例 + +```bash +lark-cli apps +db-execute --app-id app_xxx --env dev --sql "select * from orders limit 5" --yes +lark-cli apps +db-execute --app-id app_xxx --env dev --file ./migration.sql --dry-run +# 绝对路径文件 / cwd 不固定:经 stdin 传入 +lark-cli apps +db-execute --app-id app_xxx --env dev --sql - --yes < /Users/.../migrations/0001_init.sql +``` + +## 输出契约 + +- 成功默认 JSON 读取 `data.results[]`;每个元素对应一条 SQL,常见字段有 `sql_type`、`data`、`record_count`、`affected_rows`。 +- pretty 会按 SELECT/DML/DDL 自适应渲染;多语句会逐条显示 Statement 摘要。 +- 失败可能仍有前序语句已执行;看 `error.detail.statement_index`、`completed`、`rolled_back` 和 `hint` 决定从哪条继续。 + +## Agent 规则 + +- 该命令为 high-risk-write,执行一律需 `--yes`;无 `--yes` 会返回 `confirmation_required` / exit 10。 + - **只读查询、以及不删除/不丢失既有数据且可撤回的语句**:已授权时可直接带 `--yes` 执行。 + - **会删除或丢失既有数据、或难以撤回的语句**:先 `--dry-run` 预览(无需 `--yes`),向用户确认后再带 `--yes` 执行;不要在用户不知情时自动补 `--yes`。 +- 多语句失败时,失败前的语句可能已经 auto-commit。不要整批重跑;按错误 detail/hint 修失败语句,并从剩余语句继续。 +- 如果需要原子性,让用户在 SQL 内显式写 `BEGIN` / `COMMIT`,不要假设 CLI 会包事务。 +- 不要把数据库连接串从 env 中取出来裸连。 diff --git a/skills/lark-apps/references/lark-apps-db-table-get.md b/skills/lark-apps/references/lark-apps-db-table-get.md new file mode 100644 index 00000000..301aea68 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-db-table-get.md @@ -0,0 +1,29 @@ +# apps +db-table-get + +查看妙搭应用数据库某张表的结构。运行时命令事实以 `lark-cli apps +db-table-get --help` 为准。 + +## 何时用 + +用于查看已知表的字段、索引、约束,或给 SQL/迁移生成提供依据。只想知道有哪些表时先 `+db-table-list`。 + +## 命令骨架 + +- 必填:`--app-id`、`--table`。 +- `--env` 枚举:`dev` / `online`,默认 `online`。 +- `--format pretty` 会向服务端请求 DDL,并直接输出 DDL 文本;默认 JSON 返回结构化 columns/indexes/constraints/stats。 + +## 示例 + +```bash +lark-cli apps +db-table-get --app-id app_xxx --table orders +lark-cli apps +db-table-get --app-id app_xxx --table orders --env dev --format pretty +``` + +## 输出契约 + +- 默认 JSON 读取 `data.name`、`columns`、`indexes`、`constraints`、`estimated_row_count`、`size_bytes`。 +- `--format pretty` stdout 是服务端返回的 DDL 文本,不是 JSON envelope;需要建表语句时可原样给用户。 + +## Agent 规则 + +需要给用户看建表语句或迁移参照时用 `--format pretty`;需要程序化分析字段/索引/约束时保留默认 JSON。 diff --git a/skills/lark-apps/references/lark-apps-db-table-list.md b/skills/lark-apps/references/lark-apps-db-table-list.md new file mode 100644 index 00000000..9a08a093 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-db-table-list.md @@ -0,0 +1,31 @@ +# apps +db-table-list + +列出妙搭应用某个数据库环境的数据表。运行时命令事实以 `lark-cli apps +db-table-list --help` 为准。 + +## 何时用 + +用于先摸清应用数据库里有哪些表,或在用户只给业务对象名时定位可能的表名。已知表名且要字段/索引时直接用 `+db-table-get`。 + +## 命令骨架 + +- 必填:`--app-id`。 +- `--env` 枚举:`dev` / `online`,默认 `online`。 +- 分页:`--page-size` 默认 20,`--page-token` 使用上一页 cursor。 +- pretty 输出列包含 `name`、`description`、`estimated_row_count`、`size`、`columns`(列数)。 + +## 示例 + +```bash +lark-cli apps +db-table-list --app-id app_xxx +lark-cli apps +db-table-list --app-id app_xxx --env dev --page-size 50 +``` + +## 输出契约 + +- 成功读取 `data.items[]`;每项字段是 `name`、`description`、`estimated_row_count`、`size_bytes`、`column_count`(列数)。CLI 默认不透出每表完整 `columns[]`(与 `+db-table-get` 重复且放大 token),只给 `column_count`;要完整列定义/索引/约束用 `+db-table-get`。 +- pretty 输出是 5 列扫描表:`name`、`description`、`estimated_row_count`、`size`、`columns`(即列数)。 +- 若响应带 `has_more=true`,用返回的 `page_token` / `next_page_token` 翻页。 + +## Agent 规则 + +用户说“本地/开发库/调试库”时优先 `--env dev`;线上问题排查用 `--env online`。如果 dev 返回服务端错误提示未初始化,多环境入口是 [`+db-env-create`](lark-apps-db-env-create.md)。 diff --git a/skills/lark-apps/references/lark-apps-env-pull.md b/skills/lark-apps/references/lark-apps-env-pull.md new file mode 100644 index 00000000..e1e0082d --- /dev/null +++ b/skills/lark-apps/references/lark-apps-env-pull.md @@ -0,0 +1,35 @@ +# apps +env-pull + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 + +把妙搭应用的启动期环境变量拉取到本地项目根的 `.env.local`。身份固定 `--as user`;scope `spark:app:read`。`--app-id` 必填,目标项目根默认当前工作目录(`--project-path` 可指定)。 + +## 何时别用(核心反模式) + +**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并把用户刚改完的 `.env.local` 临时改动覆盖掉。 + +只在这些兜底场景用: + +- 不通过 `npm run dev` 启动(直接跑 `node` / IDE debug)。 +- `.env.local` 被改坏 / 删除,想重新同步。 + +## 行为 + +- **合并、不清空**:写入 `.env.local` 时保留你手写的内容与注释——命中的 key 替换值,新 key 追加,不整体覆盖。 +- **安全护栏**:返回的 envelope **不会回显任何 env key / value**(防止 token / 数据库凭据泄漏到日志或 CI 输出)。要看实际值请直接读 `.env.local`。 + +## 示例 + +```bash +lark-cli apps +env-pull --app-id app_xxx +``` + +## 失败处理 + +`missing_scope`(没拿到 `spark:app:read`)时,按 lark-shared 引导 `lark-cli auth login --domain apps`。其余失败优先转述 `error.hint` / `error.message`。 + +## 参考 + +- [lark-apps](../SKILL.md) — 妙搭应用全部命令 + 心智模型 +- [lark-apps-local-dev](lark-apps-local-dev.md) — 本地全栈开发端到端流程 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-apps/references/lark-apps-git-credential.md b/skills/lark-apps/references/lark-apps-git-credential.md new file mode 100644 index 00000000..0a6b8e05 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-git-credential.md @@ -0,0 +1,37 @@ +# apps Git credential + +妙搭 Git 凭证用于本地原生 `git clone/pull/push`。运行时命令事实以 `lark-cli apps +git-credential-init --help`、`+git-credential-list --help`、`+git-credential-remove --help` 为准。 + +## 命令 + +```bash +lark-cli apps +git-credential-init --app-id app_xxx +lark-cli apps +git-credential-list +lark-cli apps +git-credential-remove --app-id app_xxx +``` + +## 输出契约 + +- `+git-credential-init` 成功后读取 `data.repository_url`;不要展示或保存其中的凭据细节,只用于下一步 `git clone`。 +- `+git-credential-list` 返回本地记录和状态;可用来判断是否需要重新 init。 +- `+git-credential-remove` 只清本地配置;成功后告知不会删除云端应用或仓库。 + +## 行为规则 + +- `+git-credential-init` 返回 `repository_url`,并配置 URL-scoped Git credential helper。后续 clone/pull/push 使用原生 git。 +- `+git-credential-list` 列出本地已配置的妙搭 Git 凭证,不需要 `--app-id`。 +- `+git-credential-remove` 只移除本地凭证/helper,不删除云端应用或仓库。 +- 看到 Repository URL 后继续: + +```bash +git clone +cd +git checkout sprint/default +``` + +## Agent 规则 + +- 不要手动打印、保存或拼接 token。 +- clone、pull、push、diff、log 等代码仓库操作都使用原生 `git`;不存在 `apps +pull` / `apps +push` / `apps code +read` 这类代码读写 shortcut,不要臆造。 +- 不要 push/force-push `main`;`main` 是发布态快照,由 `apps +release-create` 成功后服务端推进,直推/force-push 会被服务端护栏拒绝。 +- Git 认证失败、本地凭证损坏或 helper 缺失时,重新执行 `+git-credential-init --app-id ` 覆盖本地配置;不要让用户复制 token 到 remote URL。 diff --git a/skills/lark-apps/references/lark-apps-html-publish.md b/skills/lark-apps/references/lark-apps-html-publish.md index e1d6ae39..b151f886 100644 --- a/skills/lark-apps/references/lark-apps-html-publish.md +++ b/skills/lark-apps/references/lark-apps-html-publish.md @@ -1,169 +1,51 @@ # apps +html-publish -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。 +把本地 HTML 文件或静态目录发布为妙搭应用访问 URL。运行时命令事实以 `lark-cli apps +html-publish --help` 为准。 -把本地的 HTML 文件或目录部署为可访问的妙搭应用,响应返回应用的访问链接 `url`。 +## 何时用 -## 命令 - -```bash -# 发布整个目录 -lark-cli apps +html-publish --app-id app_xxx --path ./dist/ - -# 发布单个 HTML 文件 -lark-cli apps +html-publish --app-id app_xxx --path ./index.html - -# 预演(打印文件清单 + SHA256 + 目标 endpoint,不发请求) -lark-cli apps +html-publish --app-id app_xxx --path ./dist --dry-run -``` - -## 参数 - -| 参数 | 必填 | 说明 | -|---|---|---| -| `--app-id ` | ✅ | 应用 ID。从 `apps +create` 响应里拿;或者从用户给的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取(详见 `../SKILL.md` "用户没给 app_id" 一节) | -| `--path ` | ✅ | 本地文件或目录路径;目录会递归打包成 tar.gz。**必须含 `index.html`**:目录形态时根目录下,单文件形态时文件名必须就是 `index.html`(妙搭统一以 `index.html` 作为应用入口) | -| `--allow-sensitive` | ❌ | 跳过 Validate 的凭据文件扫描(详见下面"凭据文件拦截"一节)。默认不传;仅在用户明示要发布凭据示例文件(如教程站的 `.env.example`)时才加 | - -## 返回值 - -**成功:** - -```json -{ - "ok": true, - "data": { - "url": "https://miaoda.feishu.cn/app/app_4k5jepcbjmv6m" - } -} -``` - -**业务失败(如构建失败、应用不存在):** - -```json -{ - "ok": false, - "error": { - "type": "api", - "code": 90001, - "message": "html-publish failed (code=90001): build failed: dependency conflict", - "hint": "构建失败:用 `lark-cli apps +html-publish --path --dry-run` 检查打包文件清单" - } -} -``` - -**基础设施失败(网络 / HTTP 5xx):** - -```json -{ - "ok": false, - "error": { "type": "network", "message": "...", "hint": "" } -} -``` - -**Validate 失败(本地校验,如缺 --app-id):** - -```json -{ - "ok": false, - "error": { "type": "validation", "message": "--app-id is required" } -} -``` - -## 字段语义 - -| 字段 / 组合 | 含义 | -|---|---| -| `data.url` 存在且无 `error` | 发布成功,URL 可访问 | -| `error.type=api` | 业务失败(构建失败、应用不存在等),按 `hint` 引导用户修复 | -| `error.type=network` | 网络 / 服务端 5xx,告诉用户稍后重试 | -| `error.type=validation` | 本地参数错,提示用户修 flag | -| `error.hint` 非空 | **优先转述给用户**,比 `error.message` 更可操作 | - -## 典型场景 - -### 场景 1:用户说"把这个目录发布到妙搭" +用于把已经存在的本地 HTML 文件或静态产物目录发布成妙搭访问 URL。它不负责生成 HTML 内容,也不负责全栈应用代码发布。 + +## 命令骨架 + +- 必填:`--app-id`、`--path`。 +- `--path` 可以是单个文件或目录;入口必须是 `index.html`。 +- 可选:`--allow-sensitive`,跳过凭据文件扫描。 +- 客户端会打包 tar.gz 并上传发布;压缩包上限当前为 20MB,未压缩候选文件总量也有保护上限。 + +## 示例 ```bash +lark-cli apps +create --name "Demo" --app-type html lark-cli apps +html-publish --app-id app_xxx --path ./dist +lark-cli apps +html-publish --app-id app_xxx --path ./index.html --dry-run ``` -成功后: +## 输出契约 -> 应用发布成功!访问 `{url}` 查看。 +- 成功默认 JSON envelope 只关心 `data.url`;这是本轮 HTML 发布后的发布态访问链接。 +- pretty 输出为 `url: `,适合人看;自动化取字段用 JSON 或 `--jq '.data.url'`。 +- 业务失败如构建失败、应用不存在通常带 `error.hint`;优先转述 hint。网络/服务端失败则建议稍后重试。 -可选追加: +## 链接边界 -> 如需让其他人访问,可以用 `apps +access-scope-set` 设置可用范围。 +- 开发态链接可由 `app_id` 拼出:`https://miaoda.feishu.cn/app/{app_id}`,用于进入妙搭编辑/开发态。 +- 发布态访问链接以本命令成功返回的 `data.url` 为准。 +- 重新发布前,`+list` 的 `is_published=true` 只能说明历史上发布过,不代表当前本地产物已经部署。 -### 场景 2:用户没有 app_id +## 预览与发布边界 -```bash -APP=$(lark-cli apps +create --name "..." --app-type HTML -q '.data.app.app_id' | tr -d '"') -lark-cli apps +html-publish --app-id "$APP" --path ./dist -``` +- 用户只说“用 HTML 写个 PPT/页面给我看看”时,先生成本地文件或目录,返回路径并问是否发布到妙搭分享;不要默认创建应用或部署。 +- 用户明确说“部署出去/发链接/可分享”时,才创建 `html` 应用并用 `+html-publish`。 +- 用户要发布但没有 app_id 时,先 `+create --app-type html` 创建应用;应用名可从页面/站点主题生成,不要让用户手动提供 app_id。 +- 若产物首页不是 `index.html`,发布前改名或复制为 `index.html`;目录发布时只传干净产物目录,例如 `./dist`,不要把 `.git`、`node_modules`、源码缓存一起带上。 +- 重新部署同一个 HTML 应用时复用原 `app_id`,只重新执行 `+html-publish --app-id --path `。 -### 场景 3:构建失败(code=90001) +## 安全规则 -转述 hint: +默认会拦截 `.env`、`.npmrc`、`.aws/credentials` 等凭据文件。只有用户明确要发布凭据示例文件或教程内容时,才追加 `--allow-sensitive`;追加前先说明将包含哪些敏感候选文件。 -> 构建失败,建议用 `lark-cli apps +html-publish --app-id --path ./dist --dry-run` 看一下打包文件清单是否完整。 +## 常见失败 -### 场景 4:应用不存在(code=90002) - -> hint:"应用不存在或无权访问;请用户确认妙搭应用链接 / app_id 是否正确(从 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面取)" - -转述给用户。 - -### 场景 5:网络 / 服务端失败(type=network) - -> 服务暂时不可用,建议稍后重试。 - -## 凭据文件拦截 - -Validate 阶段会扫描 `--path` 下所有候选文件,命中以下任一模式 **直接 exit 非 0**(dry-run 和真发都拦,不再是 advisory warning): - -- `.env` / `.env.*`(环境变量 / API key) -- `.npmrc` / `.netrc`(HTTP 凭据) -- `.git-credentials`(Git over HTTPS 凭据) -- `.aws/credentials`、`.docker/config.json`、`.kube/config`(云 SDK 凭据) - -报错形态: - -```json -{ - "ok": false, - "error": { - "type": "validation", - "message": "--path contains 1 credential file(s) that should not be published: dist/.env", - "hint": "remove these files from the publish payload, OR pass --allow-sensitive if shipping them is intentional (e.g. a docs site demoing credential-file formats)" - } -} -``` - -**Agent 行为契约**: - -- 默认必须从产物里清掉命中的文件后再 publish -- 只有当用户**明确**意图是 shipping 凭据示例(文档 / 教程站等)时,才追加 `--allow-sensitive` 旁路;旁路时 dry-run 会在 `sensitive_waived` 字段列出被放行的文件名,转述给用户确认 - -不在拦截范围内(旧版扫过、新版**不再**扫):`.git/` SCM 历史、SSH 私钥 `id_rsa*` / `id_ed25519*` 等、`*.pem` / `*.key`、`.aws/config`。如果产物里有这些文件且确实敏感,要靠用户自己保持产物目录干净。 - -## 提示 - -- `--path` 既可以是 cwd(`.`)也可以是子目录或单文件;**不再硬拒 cwd**,cwd 干净(没有命中上面凭据列表)就能发。仍然建议传具体子目录(`./dist`、`./public/` 等)以减少误打包风险 -- `--path` **必须**是 cwd 内的相对路径(如 `./dist`、`./index.html`);绝对路径或越界路径(`../`、`/Users/...`)CLI 会直接拒绝。需要发布 cwd 外的目录时,先切到 agent 工作目录再调,**不要**私自 `cd` 绕过 -- 目录打包成 tar.gz 时**不做过滤**(`.git` / `node_modules` 等会一并打包,只有上面那张凭据 list 才会被 Validate 拦),让用户传干净的产物目录(如 `./dist`) -- 旁路写法:`apps +html-publish --app-id --path --allow-sensitive` -- **不要**原样把 envelope JSON 转述给用户 - -## 协同命令 - -| 场景 | 命令 | -|---|---| -| 创建新应用 | `apps +create` | -| 设置可用范围 | `apps +access-scope-set` | - -## 参考 - -- [lark-apps](../SKILL.md) -- [lark-shared](../../lark-shared/SKILL.md) +- 缺少 `index.html`:目录根放置 `index.html`,或单文件路径直接指向名为 `index.html` 的文件。 +- 包体过大:让用户精简 `--path`,不要把源码、依赖目录、构建缓存一起发布。 diff --git a/skills/lark-apps/references/lark-apps-init.md b/skills/lark-apps/references/lark-apps-init.md new file mode 100644 index 00000000..d3c01520 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-init.md @@ -0,0 +1,36 @@ +# apps +init + +`+init` 初始化妙搭应用的代码(clone 仓库、scaffold/同步源码、拉取本地环境变量)。运行时命令事实以 `lark-cli apps +init --help` 为准。 + +## 何时用 + +用于把妙搭全栈应用源码拉到本地并准备开发环境。用户只是要云端 Agent 生成应用时,不要初始化本地仓库。 + +## 命令骨架 + +- 必填:`--app-id`。 +- 可选:`--dir`,clone 目标目录;省略时默认 `./`。 +- 可选:`--template`,空仓库脚手架模板;省略时当前回退 `nestjs-react-fullstack`。 +- 固定 checkout 分支:`sprint/default`。 +- `+init` 会初始化 Git 凭证、clone 仓库、切到工作分支并生成/同步本地项目。 + +## 示例 + +```bash +lark-cli apps +init --app-id app_xxx --dir ./my-app +lark-cli apps +init --app-id app_xxx --dir /absolute/path/my-app --template nestjs-react-fullstack +lark-cli apps +init --app-id app_xxx --dir ./my-app --dry-run +``` + +## 输出契约 + +- 真跑时 stdout 是 JSON envelope;stderr 会有 `->` / `→` 进度行。成功读 stdout,失败解析 stderr 末尾的 JSON 错误。 +- 成功普通初始化读取 `data.clone_path`、`branch`、`committed`、`pushed`;`repository_url` 已脱敏,不要当凭据使用。 +- `scaffold=already_initialized` 表示目录已初始化:跳过 clone/scaffold/commit,但仍会执行一次 env-pull 刷新本地环境变量(输出含 `env_pulled`,成功时含 `env_file`,失败时含 `env_pull_error` 且退出码仍为 0);此时通常没有 `repository_url` / `branch`。 +- `--dry-run` 只打印计划,不执行 git / npx;若输出含 `dir_error`,真跑前先让用户换目录。 + +## Agent 规则 + +- 目标目录必须不存在、为空目录,或已含 `.spark/meta.json` 的已初始化仓库。 +- 目标目录已含 `.spark/meta.json` 时,`+init` 会跳过 clone/scaffold,但仍执行一次 env-pull 刷新本地环境变量;告知用户“仓库已初始化,本地环境变量已刷新,可直接开发”,不要误报失败或重复 clone。 +- `+init` 输出没有必要原样复述;告诉用户 clone path、分支和下一步即可。 diff --git a/skills/lark-apps/references/lark-apps-list.md b/skills/lark-apps/references/lark-apps-list.md index e9fe59c4..1e2a2cb9 100644 --- a/skills/lark-apps/references/lark-apps-list.md +++ b/skills/lark-apps/references/lark-apps-list.md @@ -1,95 +1,37 @@ # apps +list -> **⚠️ Hidden 命令(`Hidden: true`)—— 不对 Agent 暴露**:本命令从 `--help` / tab completion / SKILL.md 的 Shortcuts 表中隐去,**Agent 不应主动调用**。 -> -> 需要拿现有应用的 `app_id` 时让用户提供 **妙搭应用链接**(如 `https://miaoda.feishu.cn/app/app_xxxxxxxxxxxxx`)然后从 URL 中提取,或者让用户直接给 `app_id` 字符串。详见 [`../SKILL.md`](../SKILL.md) "用户没给 app_id" 一节。 -> -> 本文件保留是因为命令仍然功能可用(手动调用),下面内容仅供人类参考。 +列出当前用户可见的妙搭应用,用于从应用名定位 `app_id`。运行时命令事实以 `lark-cli apps +list --help` 为准。 -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。 +## 何时用 -列出当前用户名下的妙搭应用。**cursor 分页**:默认拉一页(`--page-size 20`),通过 `--page-token` 拉下一页。 +在下游操作需要 `app_id`、而用户只给了应用名/描述时,用 `--keyword` 定位。无明确目的的全量枚举会浪费上下文,优先按关键词缩小范围。 -## 命令 +## 命令骨架 + +- 支持 `--keyword` 按应用名模糊搜索。 +- `--ownership` 枚举:`all` / `mine` / `shared`(默认 `all` = 我创建的 + 共享给我的;`mine` = 仅我创建;`shared` = 仅共享给我)。 +- `--app-type` 枚举:`html` / `full_stack`。 +- 分页:`--page-size` 默认 20,`--page-token` 传上一页 cursor。 + +## 示例 ```bash -# 拉第一页(默认 page_size=20) -lark-cli apps +list - -# 自定义页大小 -lark-cli apps +list --page-size 50 - -# 翻页(拿上一次响应的 page_token) -lark-cli apps +list --page-token "eyJQaW5PcmRlciI6..." - -# 取 ID 列表(脚本场景) -lark-cli apps +list -q '.data.items[].app_id' - -# 按名字找 app_id -lark-cli apps +list -q '.data.items[] | select(.name=="客户调研问卷") | .app_id' +lark-cli apps +list --keyword "审批" +lark-cli apps +list --ownership mine --app-type full_stack +lark-cli apps +list --page-token "" ``` -## 参数 +## 输出契约 -| 参数 | 必填 | 默认 | 说明 | -|---|---|---|---| -| `--page-size ` | ❌ | `20` | 每页条数 | -| `--page-token ` | ❌ | `""` | 翻页 cursor,从上次响应的 `data.page_token` 拿 | +- 成功读取 `data.items[]`;保留字段为 `description`、`app_id`、`name`、`is_published`、`online_url`、`updated_at`,用于候选展示的核心字段是 `name`、`app_id`、`updated_at`。 +- `is_published=true` 只代表应用历史上有发布版本,不代表最新云端会话、最新代码提交或最新 HTML 产物已经部署。 +- `online_url` 是当前已有发布态入口;若你没有在本轮确认发布完成,不要把它描述成“最新版本链接”。 +- 默认输出已裁掉 `icon_url`(图片 URL,agent 无法渲染)和 `created_at`(与 `updated_at` 冗余);需要时可用 `--jq` 过滤上述保留字段。 +- `data.items` 可能为空;不要把空列表当失败。 +- 若有 `has_more=true`,用返回的 `page_token` / `next_page_token` 继续翻页。 -## 返回值 +## Agent 规则 -**成功:** +多候选时展示名称、app_id、updated_at 让用户确认。用户描述里已经有 `app_xxx` 或妙搭链接时,直接提取,不再 `+list`。 -```json -{ - "ok": true, - "data": { - "items": [ - { - "app_id": "app_4k5jepcbjmv6m", - "name": "客户调研问卷", - "description": "...", - "icon_url": "...", - "created_at": "2026-05-18T10:00:00Z", - "updated_at": "2026-05-18T10:05:00Z" - } - ], - "page_token": "cursor_next_xxx", - "has_more": true - } -} -``` - -**成功(空列表):** - -```json -{ "ok": true, "data": { "items": [], "has_more": false } } -``` - -**失败:** - -```json -{ "ok": false, "error": { "type": "api", "message": "...", "hint": "..." } } -``` - -## 字段语义 - -- `data.items` 长度可能为 0(用户没建过应用) -- `data.has_more=true` 表示还有下一页;用 `data.page_token` 作为下次 `--page-token` 传入 -- `data.has_more=false` 且 `data.page_token` 为空 / 缺省表示已经到末尾 - -## 用途 - -本命令保留可供人类操作员手动调用(例如运维 / 调试场景,按 `name` 搜应用 ID)。**Agent 不应主动调用**:默认行为是 `apps +create` 新建;要复用现有应用,**让用户给妙搭应用链接或 app_id**,详见 [`../SKILL.md`](../SKILL.md) "用户没给 app_id" 一节。 - -## 协同命令 - -| 场景 | 命令 | -|---|---| -| 创建新应用 | `apps +create` | -| 修改应用 | `apps +update` | - -## 参考 - -- [lark-apps](../SKILL.md) -- [lark-shared](../../lark-shared/SKILL.md) +把 `+list` 当定位工具和发布态快照工具,不要把 `is_published` 当部署完成证明。需要证明“最新内容已上线”时,使用对应发布命令的完成状态:全栈看 `+release-get` 的 `finished`,HTML 看 `+html-publish` 的成功返回。 diff --git a/skills/lark-apps/references/lark-apps-local-dev.md b/skills/lark-apps/references/lark-apps-local-dev.md new file mode 100644 index 00000000..d404e34c --- /dev/null +++ b/skills/lark-apps/references/lark-apps-local-dev.md @@ -0,0 +1,76 @@ +# lark-apps 本地全栈开发 + +适用:用户要把妙搭全栈应用源码拉到本地,用本地 code agent/IDE 开发、调试数据库,再发布。 + +## 新建 vs 已有应用 + +新建还是修改已有,由上方入口(SKILL.md「选择开发路径」)判定;进到本地流程后按分支走: + +- **新建**:从 `+create` 开始走下面的端到端流程。 +- **已有应用**(本地还没有源码):跳过 `+create`,先按下方「存量应用入口」拿 `app_id`,再 `+init`(或 `+git-credential-init` + `git clone`)把它拉到本地,然后照常开发。 + +## 端到端流程(新建应用) + +`+create(full_stack)` -> `+init`(或手动 `+git-credential-init` + `git clone`)-> `npm install && npm run dev` -> 按需 `+db-*` 调库 -> `git add` + `git commit`(提交本次改动)-> `git push origin sprint/default` -> `+release-create` -> `+release-get`。 + +```bash +# 新建 full_stack 应用 +lark-cli apps +create --name "审批系统" --app-type full_stack \ + --description "支持登录、提交申请、多级审批、状态查询" + +# 初始化本地仓库(--dir 取值见下方「领域规则」,勿照抄此处示例值) +lark-cli apps +init --app-id app_xxx --dir ./approval-app + +# 进入仓库后按项目脚手架启动 +cd ./approval-app +npm install +npm run dev + +# 开发完成后:提交本次改动 -> git push origin sprint/default -> +release-create。 +# +release-create 部署的是远端 sprint/default 上已 push 的代码,不是本地工作区——没 commit + push 的改动不会进入发布。 +git add <本次开发的文件> # 提交粒度见下方「改完代码后部署上线」 +git commit -m "feat: ..." +git push origin sprint/default +lark-cli apps +release-create --app-id app_xxx +``` + +`+init` 是推荐便捷入口;想逐步手动控制时,先 `+git-credential-init` 拿 `repository_url`,再用原生 `git clone` / `git checkout sprint/default`。 + +## 改完代码后部署上线 + +已拉到本地、改完代码,用户说"推上去""部署""上线""发布到云端"时,按此序列。 + +> `+release-create` 部署的是远端 `sprint/default` 上**已 push** 的代码,不是你本地工作区——未 commit / 未 push 的改动不会进入这次发布。所以发布前务必先把本次改动提交并推送。 + +1. `git status` 看本次改动;`git add <本次相关文件>` 暂存后 `git commit` 提交。只提交本次任务相关的改动即可,无关的零散文件不必强求清空——发布门禁是「**本次相关改动已提交并推送**」,不是「工作区绝对干净」。 +2. `git push origin sprint/default` 把工作分支推到云端(遇非 fast-forward:先 `git pull --rebase origin sprint/default` 解决冲突再推,绝不 force-push)。 +3. `lark-cli apps +release-create --app-id ` 发起部署上线,记下返回的 `release_id`。 +4. `lark-cli apps +release-get --app-id --release-id ` 轮询:`publishing` 继续轮询;`finished` 成功时该命令输出已含 `online_url`,直接读取它返回给用户(这是本轮发布完成后的可分享链接),无需再调 `+list`;`failed` 时该命令输出已含 `error_logs`,直接据此给出失败原因(`+list` 仅作独立查询入口)。 + +## 领域规则 + +- 代码读写走原生 `git`;CLI 负责凭证、初始化、发布和数据库调试。不存在 `apps +pull` / `apps +push` / `apps code +read` 这类代码读写 shortcut,不要臆造。 +- `+init` 会编排 `+git-credential-init`、`git clone`、切到 `sprint/default`、运行脚手架,并在有变更时提交/推送。 +- `+init --dir` 选目录:用户已预授权或表达"不要询问"(见 SKILL.md「预授权判定」)→ 按应用名派生 `./` 直接传 `--dir`、不停问;否则先问用户用哪个目录再传。目标已存在/非空时回问换目录。 +- `sprint/default` 是工作分支;`main` 是发布态快照,由 `+release-create` 成功后服务端 fast-forward 推进;服务端护栏禁直推 `main`、拒 force-push、要求 `sprint/default` fast-forward。 +- 已拉到本地后,pull/push/diff/log 都用原生 git;云端 `sprint/default` 比本地新时,先 `git pull --rebase origin sprint/default`,解决冲突后再 push 和 publish。 +- 环境变量由脚手架在本地启动时处理;需要手动刷新时用 `+env-pull`。 +- DB 调试用 `+db-table-list` / `+db-table-get` / `+db-execute`;不要裸连数据库或自行拼连接串。 +- DB 分 `dev` / `online`;日常调试优先 `--env dev`。dev 的库结构变更要上线时,仍按应用发布链路走 `+release-create`,不要另造“数据库发布”步骤。 +- 存量单库应用需要 dev/online 多环境时,用 `+db-env-create --env dev`。这是不可逆 high-risk 操作。 +- 只从 `+list` 看到 `is_published=true`,不能证明本地刚推送的代码已经部署;必须有本轮 `+release-get finished`。 + +## 存量应用入口 + +已有项目目录先读 `.spark/meta.json` 取 `app_id`;没有本地项目但知道应用名时用: + +```bash +lark-cli apps +list --keyword "应用名" +``` + +拿到 `app_id` 后再 `+init` 或 `+git-credential-init`。 + +## 何时不用 + +- 用户只想发布现成 HTML / 静态目录拿分享链接:读 [`lark-apps-html-publish.md`](lark-apps-html-publish.md)。 +- 用户明确要云端妙搭 Agent 生成/迭代,而不是本地写代码:读 [`lark-apps-cloud-dev.md`](lark-apps-cloud-dev.md)。 diff --git a/skills/lark-apps/references/lark-apps-release-create.md b/skills/lark-apps/references/lark-apps-release-create.md new file mode 100644 index 00000000..7c1a48e4 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-release-create.md @@ -0,0 +1,30 @@ +# apps +release-create + +为妙搭应用创建发布 release。运行时命令事实以 `lark-cli apps +release-create --help` 为准。 + +## 何时用 + +用于把全栈应用的代码分支推进到发布流程。它不是 HTML 静态发布入口;本地 `index.html` / `dist` 要读 [`lark-apps-html-publish.md`](lark-apps-html-publish.md)。 + +## 命令骨架 + +- 必填:`--app-id`。 +- 可选:`--branch`;省略时服务端使用默认发布分支。 +- 返回 `release_id` 和 `status`,后续用 `+release-get` 轮询。 + +## 示例 + +```bash +lark-cli apps +release-create --app-id app_xxx +lark-cli apps +release-create --app-id app_xxx --branch sprint/default --dry-run +``` + +## 输出契约 + +- 成功读取 `data.release_id` 和 `data.status`;`release_id` 是后续 `+release-get` 的入参。 +- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询。 +- `+release-create` 返回 release 只代表发布已发起。只有 `+release-get` 对同一个 `release_id` 返回 `finished` 后,才能说本轮最新版本已部署。 + +## Agent 规则 + +`+release-create` 部署的是远端 `sprint/default` 上已 push 的代码,不是本地工作区——本地若有你修改但未推送的改动,需要先 `git add` + `git commit` 并 `git push` 到 `sprint/default`,否则这些改动不会进入这次发布。发布后若 status 是 `publishing`,用 [`+release-get`](lark-apps-release-get.md) 查询。`+release-create` 部署上线属高影响动作——作为别的命令的连带前置时,按 SKILL.md「高影响动作:确认与预授权」先征得用户同意再发布。 diff --git a/skills/lark-apps/references/lark-apps-release-get.md b/skills/lark-apps/references/lark-apps-release-get.md new file mode 100644 index 00000000..f5a310d3 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-release-get.md @@ -0,0 +1,28 @@ +# apps +release-get + +按 release ID 查询单次发布详情。运行时命令事实以 `lark-cli apps +release-get --help` 为准。 + +## 何时用 + +用于跟进已知 `release_id` 的发布状态。没有 `release_id` 时先读 [`lark-apps-release-list.md`](lark-apps-release-list.md),不要让用户手填。 + +`release_id` 是妙搭发布 ID(`+release-create` 返回),不是飞书审批实例号;查发布进度/失败都在 `apps +release-*` 命令族内完成,不要路由到 lark-approval。 + +## 命令骨架 + +- 必填:`--app-id`、`--release-id`。 +- `release_id` 来自 `+release-create` 或 `+release-list`。 + +## 示例 + +```bash +lark-cli apps +release-get --app-id app_xxx --release-id release_yyy +``` + +## 输出契约 + +- 成功可能直接返回 release 字段,也可能包在 `data.release`;读取 `release_id`、`status`、`created_at`、`updated_at`,以及 `commit_id`(本轮发布对应的 git commit SHA,pretty 输出在其非空时展示一行)。 +- `status=publishing` 继续轮询。此时尚无 `online_url`;不要拿其它链接(如 `+list` 里的应用主页 / 开发态预览 URL)冒充"本轮发布的访问链接"——只回报 `release_id`、`status`,并说明 `finished` 后才有 `online_url`。 +- `status=finished` 发布成功——**本命令输出已含 `online_url`,直接读取它作为本轮发布的线上访问链接**返回用户,无需再调 `+list`(`+list` 仍可用于按应用名浏览,但不是发布主流程的必经步骤)。 +- `status=failed` 发布失败——**本命令输出已含 `error_logs`(`step`/`error_log`),直接据此向用户转述关键失败步骤和可行动修复**。 +- 只有当这个 `release_id` 已返回 `finished`,随后读到的 `online_url` 才能被表述为"本轮发布后的访问链接"。单独从 `+list` 看到 `is_published=true` 不能证明最新版本已部署。 diff --git a/skills/lark-apps/references/lark-apps-release-list.md b/skills/lark-apps/references/lark-apps-release-list.md new file mode 100644 index 00000000..b15137a5 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-release-list.md @@ -0,0 +1,31 @@ +# apps +release-list + +分页查询妙搭应用发布历史,最新发布在前。运行时命令事实以 `lark-cli apps +release-list --help` 为准。 + +## 何时用 + +用户问"最近发布""历史版本""上次为什么失败",但没有提供 `release_id` 时使用。拿到候选 release 后再接 `+release-get`。 + +## 命令骨架 + +- 必填:`--app-id`。 +- 可选 `--status`:`publishing` / `finished` / `failed`。 +- 可选 `--page-size`:默认 20,最大 500;总是发送给服务端。 +- 可选 `--page-token`:上一页 cursor。 + +## 示例 + +```bash +lark-cli apps +release-list --app-id app_xxx --page-size 10 +lark-cli apps +release-list --app-id app_xxx --status failed +``` + +## 输出契约 + +- 成功读取 `data.releases[]`;关键字段是 `release_id`、`status`、`created_at`、`updated_at`。 +- `release_id` 用于继续查 `+release-get`。 +- 若 `has_more=true`,用 `next_page_token` / `page_token` 翻页。 + +## Agent 规则 + +用户限定只看 N 条("最近 N 条""最新 N 个""只要前 N 条")时用 `--page-size N`(如"最近一次发布"→ `--page-size 1`),而不是取全量再本地截断。 diff --git a/skills/lark-apps/references/lark-apps-update.md b/skills/lark-apps/references/lark-apps-update.md index de320ce4..31ba4470 100644 --- a/skills/lark-apps/references/lark-apps-update.md +++ b/skills/lark-apps/references/lark-apps-update.md @@ -1,88 +1,30 @@ # apps +update -> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)。 +部分更新妙搭应用元信息。运行时命令事实以 `lark-cli apps +update --help` 为准。 -部分更新一个妙搭应用的元信息(名字 / 描述)。**只把传入的字段发给服务端,未传字段保持不变**。 +## 何时用 -## 命令 +只更新应用展示元信息。用户要改代码、发布内容、可见范围或数据库时,不走 `+update`。 + +## 命令骨架 + +- 必填:`--app-id`。 +- 至少提供一个:`--name` 或 `--description`。 +- 只发送用户提供的字段,不会清空未提供字段。 + +## 示例 ```bash -lark-cli apps +update --app-id app_xxx --name "调研问卷 v2" -lark-cli apps +update --app-id app_xxx --description "新描述" -lark-cli apps +update --app-id app_xxx --name "v2" --description "新描述" +lark-cli apps +update --app-id app_xxx --name "审批系统" +lark-cli apps +update --app-id app_xxx --description "用于部门审批流转" +lark-cli apps +update --app-id app_xxx --name "审批系统" --description "用于部门审批流转" --dry-run ``` -## 参数 +## 输出契约 -| 参数 | 必填 | 说明 | -|---|---|---| -| `--app-id ` | ✅ | 应用 ID | -| `--name ` | ❌ | 新名字 | -| `--description ` | ❌ | 新描述 | +- 成功读取 `data.app`;响应是完整应用对象,不只是被修改字段。 +- 缺 `--app-id` 或没有提供 `--name` / `--description` 会在本地 validation 失败。 -`--name` 和 `--description` 至少传一个,否则 Validate 阶段报错。 +## Agent 规则 -## 返回值 - -**成功:** - -```json -{ - "ok": true, - "data": { - "app": { - "app_id": "app_4k5jepcbjmv6m", - "name": "调研问卷 v2", - "description": "...", - "icon_url": "https://lf3-static.bytednsdoc.com/.../feisuda/avatar/5.svg", - "created_at": "2026-05-18T10:00:00Z", - "updated_at": "2026-05-18T10:05:00Z" - } - } -} -``` - -**失败:** - -```json -{ - "ok": false, - "error": { "type": "api", "message": "...", "hint": "..." } -} -``` - -## 字段语义 - -- 响应 `data.app` 含完整应用对象(所有字段),不只是被改的 -- `created_at` / `updated_at` 都是 ISO 8601 UTC 时间字符串 -- 失败时优先转述 `error.hint` - -## 典型场景 - -### 场景 1:用户说"把应用 X 改名叫 Y" - -```bash -lark-cli apps +update --app-id app_xxx --name "Y" -``` - -> 应用 `{app_id}` 已更新,新名字「{name}」。 - -### 场景 2:缺 `--app-id` 或没传可更新字段 - -Validate 直接拦截,提示用户加 flag。 - -### 场景 3:失败处理 - -转述 `error.hint` / `error.message`。 - -## 协同命令 - -| 场景 | 命令 | -|---|---| -| 找 app_id | 从用户提供的妙搭应用链接 `https://miaoda.feishu.cn/app/app_xxx` 的 `/app/` 后面提取,或让用户直接给 `app_xxx` 字符串(详见 `../SKILL.md`) | -| 创建新应用 | `apps +create` | - -## 参考 - -- [lark-apps](../SKILL.md) -- [lark-shared](../../lark-shared/SKILL.md) +更新前复述要变更的字段;用户没有提到的字段不要补默认值。执行后只转述新的名称/描述和 app_id,不需要展开原始响应。 diff --git a/tests/cli_e2e/apps/apps_create_dryrun_test.go b/tests/cli_e2e/apps/apps_create_dryrun_test.go index 0c4228aa..a66ce5b6 100644 --- a/tests/cli_e2e/apps/apps_create_dryrun_test.go +++ b/tests/cli_e2e/apps/apps_create_dryrun_test.go @@ -5,7 +5,6 @@ package apps import ( "context" - "strings" "testing" "time" @@ -29,7 +28,7 @@ func TestAppsCreateDryRun(t *testing.T) { Args: []string{ "apps", "+create", "--name", "Demo", - "--app-type", "HTML", + "--app-type", "html", "--dry-run", }, DefaultAs: "user", @@ -40,7 +39,7 @@ func TestAppsCreateDryRun(t *testing.T) { assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String()) assert.Equal(t, "/open-apis/spark/v1/apps", gjson.Get(result.Stdout, "api.0.url").String()) assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String()) - assert.Equal(t, "HTML", gjson.Get(result.Stdout, "api.0.body.app_type").String()) + assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.body.app_type").String()) // Optional fields stay omitted when not provided. assert.False(t, gjson.Get(result.Stdout, "api.0.body.description").Exists()) assert.False(t, gjson.Get(result.Stdout, "api.0.body.icon_url").Exists()) @@ -54,7 +53,7 @@ func TestAppsCreateDryRun(t *testing.T) { Args: []string{ "apps", "+create", "--name", "Demo", - "--app-type", "HTML", + "--app-type", "html", "--description", "survey app", "--icon-url", "https://example.com/icon.svg", "--dry-run", @@ -65,7 +64,7 @@ func TestAppsCreateDryRun(t *testing.T) { result.AssertExitCode(t, 0) assert.Equal(t, "Demo", gjson.Get(result.Stdout, "api.0.body.name").String()) - assert.Equal(t, "HTML", gjson.Get(result.Stdout, "api.0.body.app_type").String()) + assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.body.app_type").String()) assert.Equal(t, "survey app", gjson.Get(result.Stdout, "api.0.body.description").String()) assert.Equal(t, "https://example.com/icon.svg", gjson.Get(result.Stdout, "api.0.body.icon_url").String()) }) @@ -77,7 +76,7 @@ func TestAppsCreateDryRun(t *testing.T) { result, err := clie2e.RunCmd(ctx, clie2e.Request{ Args: []string{ "apps", "+create", - "--app-type", "HTML", + "--app-type", "html", "--dry-run", }, DefaultAs: "user", @@ -98,7 +97,7 @@ func TestAppsCreateDryRun(t *testing.T) { Args: []string{ "apps", "+create", "--name", " ", - "--app-type", "HTML", + "--app-type", "html", "--dry-run", }, DefaultAs: "user", @@ -142,13 +141,15 @@ func TestAppsCreateDryRun(t *testing.T) { require.NoError(t, err) result.AssertExitCode(t, 2) msg := validateErrorMessage(result) - assert.Contains(t, msg, "not supported") - assert.Contains(t, msg, "HTML") + assert.Contains(t, msg, "invalid value") + assert.Contains(t, msg, "full_stack") }) - t.Run("RejectsLowercaseAppType", func(t *testing.T) { - // app-type is case-sensitive; lowercase "html" must be rejected even though - // it differs from the allowed "HTML" by case alone. + t.Run("RejectsLegacyUppercaseAppType", func(t *testing.T) { + // --app-type is a strict lowercase enum (html / full_stack); the CLI does + // not normalize case. Legacy uppercase "HTML" is rejected — backend + // compatibility for legacy values is a server concern the client does not + // surface. ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) t.Cleanup(cancel) @@ -156,7 +157,7 @@ func TestAppsCreateDryRun(t *testing.T) { Args: []string{ "apps", "+create", "--name", "Demo", - "--app-type", "html", + "--app-type", "HTML", "--dry-run", }, DefaultAs: "user", @@ -164,7 +165,7 @@ func TestAppsCreateDryRun(t *testing.T) { require.NoError(t, err) result.AssertExitCode(t, 2) msg := validateErrorMessage(result) - assert.True(t, strings.Contains(msg, `"html"`) && strings.Contains(msg, "not supported"), - "expected case-sensitive rejection, got: %s", msg) + assert.Contains(t, msg, "invalid value") + assert.Contains(t, msg, "HTML") }) } diff --git a/tests/cli_e2e/apps/apps_db_env_create_dryrun_test.go b/tests/cli_e2e/apps/apps_db_env_create_dryrun_test.go new file mode 100644 index 00000000..50ed597f --- /dev/null +++ b/tests/cli_e2e/apps/apps_db_env_create_dryrun_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestAppsDBEnvCreateDryRun pins +db-env-create URL `/apps/{app_id}/db_dev_init` 和 sync_data body 透传。 +// Risk: high-risk-write 在 dry-run 下不需要 --yes 确认。 +func TestAppsDBEnvCreateDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + t.Run("DefaultSyncDataFalse", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "dev", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String()) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/db_dev_init", gjson.Get(result.Stdout, "api.0.url").String()) + assert.Equal(t, "false", gjson.Get(result.Stdout, "api.0.body.sync_data").String()) + }) + + t.Run("SyncDataTrue", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, "true", gjson.Get(result.Stdout, "api.0.body.sync_data").String()) + }) +} diff --git a/tests/cli_e2e/apps/apps_db_execute_dryrun_test.go b/tests/cli_e2e/apps/apps_db_execute_dryrun_test.go new file mode 100644 index 00000000..6e18e9ab --- /dev/null +++ b/tests/cli_e2e/apps/apps_db_execute_dryrun_test.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestAppsDBExecuteDryRun pins +db-execute 复用存量 URL,CLI 永远走 DBA 模式 +// (?transactional=false),sql body 由 --sql 透传,默认 env=dev。 +func TestAppsDBExecuteDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + t.Run("DefaultEnvIsDevAndTransactionalFalse", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String()) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/sql_commands", gjson.Get(result.Stdout, "api.0.url").String()) + assert.Equal(t, "SELECT 1", gjson.Get(result.Stdout, "api.0.body.sql").String()) + assert.Equal(t, "false", gjson.Get(result.Stdout, "api.0.params.transactional").String(), + "CLI is DBA mode → must send transactional=false in query") + assert.False(t, gjson.Get(result.Stdout, "api.0.body.transactional").Exists(), + "transactional should be in query, not body") + assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String(), + "default env must be dev (not production)") + }) + + t.Run("OnlineEnvSwitch", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", "SELECT 1", "--env", "online", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.params.env").String()) + }) + + t.Run("RejectsEmptySQL", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+db-execute", "--app-id", "app_x", "--sql", " ", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + assert.NotEqual(t, 0, result.ExitCode, "empty --sql must fail validation") + }) +} diff --git a/tests/cli_e2e/apps/apps_db_table_get_dryrun_test.go b/tests/cli_e2e/apps/apps_db_table_get_dryrun_test.go new file mode 100644 index 00000000..47bd340f --- /dev/null +++ b/tests/cli_e2e/apps/apps_db_table_get_dryrun_test.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestAppsDBTableGetDryRun pins +db-table-get 复用存量 URL。 +// 没有独立 --ddl flag —— 由 --format 同时驱动 CLI 渲染和 server 请求形态: +// +// --format pretty → CLI 给 server 带 ?format=ddl +// --format json / table / ndjson / csv(含默认)→ CLI 不传 format query +func TestAppsDBTableGetDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + t.Run("DefaultFormatJSONOmitsFormatQuery", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+db-table-get", "--app-id", "app_x", "--table", "orders", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String()) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables/orders", gjson.Get(result.Stdout, "api.0.url").String()) + assert.False(t, gjson.Get(result.Stdout, "api.0.params.format").Exists(), + "default (json) should omit format query") + }) + + t.Run("PrettyFormatSendsFormatDDL", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + // pretty 模式 dry-run 输出是 plain text 列表(非 JSON envelope),用 substring 校验 query。 + assert.Contains(t, result.Stdout, "format=ddl", + "--format pretty must trigger ?format=ddl") + }) + + t.Run("TableFormatOmitsFormatQuery", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+db-table-get", "--app-id", "app_x", "--table", "orders", "--format", "table", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.False(t, gjson.Get(result.Stdout, "api.0.params.format").Exists()) + }) + + t.Run("RequiresTableFlag", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+db-table-get", "--app-id", "app_x", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + assert.NotEqual(t, 0, result.ExitCode, "missing --table must fail") + }) +} diff --git a/tests/cli_e2e/apps/apps_db_table_list_dryrun_test.go b/tests/cli_e2e/apps/apps_db_table_list_dryrun_test.go new file mode 100644 index 00000000..987a28e6 --- /dev/null +++ b/tests/cli_e2e/apps/apps_db_table_list_dryrun_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +// TestAppsDBTableListDryRun pins +db-table-list 复用存量 URL(/apps/{app_id}/tables, +// 不带 /db/),cursor 分页参数与 env 透传,且不发 include_stats query。 +func TestAppsDBTableListDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + t.Run("DefaultsToOnlineAndPageSize20", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+db-table-list", "--app-id", "app_x", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String()) + assert.Equal(t, "/open-apis/spark/v1/apps/app_x/tables", gjson.Get(result.Stdout, "api.0.url").String()) + assert.Equal(t, "online", gjson.Get(result.Stdout, "api.0.params.env").String()) + assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String()) + assert.False(t, gjson.Get(result.Stdout, "api.0.params.page_token").Exists(), + "empty page_token must be omitted") + assert.False(t, gjson.Get(result.Stdout, "api.0.params.include_stats").Exists(), + "CLI should not send include_stats query (server returns stats by default)") + }) + + t.Run("CustomPaginationAndDevEnv", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+db-table-list", + "--app-id", "app_x", "--env", "dev", + "--page-size", "50", "--page-token", "cursor-abc", + "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.params.env").String()) + assert.Equal(t, "50", gjson.Get(result.Stdout, "api.0.params.page_size").String()) + assert.Equal(t, "cursor-abc", gjson.Get(result.Stdout, "api.0.params.page_token").String()) + }) + + t.Run("RejectsBlankAppID", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+db-table-list", "--app-id", " ", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + assert.NotEqual(t, 0, result.ExitCode, "blank app-id must fail validation") + assert.Contains(t, validateErrorMessage(result), "app-id") + }) +} diff --git a/tests/cli_e2e/apps/apps_env_pull_dryrun_test.go b/tests/cli_e2e/apps/apps_env_pull_dryrun_test.go new file mode 100644 index 00000000..69ff6b11 --- /dev/null +++ b/tests/cli_e2e/apps/apps_env_pull_dryrun_test.go @@ -0,0 +1,79 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "path/filepath" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestAppsEnvPullDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + t.Run("DefaultPath", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+env-pull", + "--app-id", "app_x", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + 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.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()) + }) + + t.Run("CustomProjectPath", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + projectDir := filepath.Join(t.TempDir(), "demo") + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+env-pull", + "--app-id", "app_x", + "--project-path", projectDir, + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, projectDir, gjson.Get(result.Stdout, "project_path").String()) + assert.Equal(t, filepath.Join(projectDir, ".env.local"), gjson.Get(result.Stdout, "env_file").String()) + }) + + t.Run("MissingAppID", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+env-pull", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 2) + assert.Contains(t, result.Stdout+result.Stderr, `--app-id is required`) + }) +} diff --git a/tests/cli_e2e/apps/apps_git_credential_dryrun_test.go b/tests/cli_e2e/apps/apps_git_credential_dryrun_test.go new file mode 100644 index 00000000..854ecca3 --- /dev/null +++ b/tests/cli_e2e/apps/apps_git_credential_dryrun_test.go @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestAppsGitCredentialInitDryRun(t *testing.T) { + setAppsDryRunEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "apps", "+git-credential-init", + "--app-id", "app_xxx", + "--dry-run", + }, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "GET", gjson.Get(result.Stdout, "api.0.method").String()) + assert.Equal(t, "/open-apis/spark/v1/apps/app_xxx/git_info", gjson.Get(result.Stdout, "api.0.url").String()) + assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "api.0.params.app_id").String()) + assert.False(t, gjson.Get(result.Stdout, "api.0.body").Exists()) +} diff --git a/tests/cli_e2e/apps/apps_git_credential_local_test.go b/tests/cli_e2e/apps/apps_git_credential_local_test.go new file mode 100644 index 00000000..85a2604e --- /dev/null +++ b/tests/cli_e2e/apps/apps_git_credential_local_test.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "net/url" + "os" + "path/filepath" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestAppsGitCredentialListLocalE2E(t *testing.T) { + env := setupAppsGitCredentialLocalEnv(t) + seedAppsGitCredentialMetadata(t, env.configDir, "app_a", "https://example.com/git/u/a.git", "pat-ref-a") + seedAppsGitCredentialMetadata(t, env.configDir, "app_b", "https://example.com/git/u/b.git", "pat-ref-b") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+git-credential-list"}, + DefaultAs: "user", + Env: env.vars, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, int64(2), gjson.Get(result.Stdout, "data.count").Int()) + credentials := map[string]gjson.Result{} + for _, credential := range gjson.Get(result.Stdout, "data.credentials").Array() { + credentials[credential.Get("app_id").String()] = credential + } + require.Contains(t, credentials, "app_a") + require.Contains(t, credentials, "app_b") + assert.Equal(t, "https://example.com/git/u/a.git", credentials["app_a"].Get("repository_url").String()) + assert.Equal(t, "missing_secret", credentials["app_a"].Get("status").String()) + assert.Equal(t, "https://example.com/git/u/b.git", credentials["app_b"].Get("repository_url").String()) + assert.Equal(t, "missing_secret", credentials["app_b"].Get("status").String()) + assert.False(t, credentials["app_a"].Get("expires_at").Exists()) + assert.False(t, credentials["app_a"].Get("expired").Exists()) +} + +func TestAppsGitCredentialRemoveLocalE2E(t *testing.T) { + env := setupAppsGitCredentialLocalEnv(t) + metadataPath := seedAppsGitCredentialMetadata(t, env.configDir, "app_xxx", "https://example.com/git/u/app.git", "pat-ref-remove") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+git-credential-remove", "--app-id", "app_xxx"}, + DefaultAs: "user", + Env: env.vars, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + assert.Equal(t, "app_xxx", gjson.Get(result.Stdout, "data.app_id").String()) + assert.True(t, gjson.Get(result.Stdout, "data.removed").Bool()) + assert.NoFileExists(t, metadataPath) +} + +type appsGitCredentialLocalEnv struct { + configDir string + vars map[string]string +} + +func setupAppsGitCredentialLocalEnv(t *testing.T) appsGitCredentialLocalEnv { + t.Helper() + configDir := t.TempDir() + homeDir := t.TempDir() + gitConfig := filepath.Join(t.TempDir(), ".gitconfig") + return appsGitCredentialLocalEnv{ + configDir: configDir, + vars: map[string]string{ + "LARKSUITE_CLI_CONFIG_DIR": configDir, + "LARKSUITE_CLI_APP_ID": "apps_local_test", + "LARKSUITE_CLI_APP_SECRET": "apps_local_secret", + "LARKSUITE_CLI_BRAND": "feishu", + "LARKSUITE_CLI_NO_UPDATE_NOTIFIER": "1", + "LARKSUITE_CLI_NO_SKILLS_NOTIFIER": "1", + "LARKSUITE_CLI_DATA_DIR": filepath.Join(homeDir, ".local", "share"), + "HOME": homeDir, + "GIT_CONFIG_GLOBAL": gitConfig, + "GIT_CONFIG_NOSYSTEM": "1", + "GIT_TERMINAL_PROMPT": "0", + }, + } +} + +func seedAppsGitCredentialMetadata(t *testing.T, configDir, appID, gitHTTPURL, patRef string) string { + t.Helper() + dir := filepath.Join(configDir, "spark", url.PathEscape(appID)) + require.NoError(t, os.MkdirAll(dir, 0700)) + path := filepath.Join(dir, "git.json") + payload := map[string]any{ + "version": 1, + "app_id": appID, + "git_http_url": gitHTTPURL, + "profile": "default", + "profile_app_id": "apps_local_test", + "user_open_id": "ou_local_test", + "username": "x-access-token", + "pat_ref": patRef, + "status": "confirmed", + "expires_at": time.Now().Add(24 * time.Hour).Unix(), + "updated_at": time.Now().Unix(), + } + data, err := json.MarshalIndent(payload, "", " ") + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, append(data, '\n'), 0600)) + return path +} diff --git a/tests/cli_e2e/apps/apps_list_dryrun_test.go b/tests/cli_e2e/apps/apps_list_dryrun_test.go index b4ed1536..882975bb 100644 --- a/tests/cli_e2e/apps/apps_list_dryrun_test.go +++ b/tests/cli_e2e/apps/apps_list_dryrun_test.go @@ -65,6 +65,63 @@ func TestAppsListDryRun(t *testing.T) { assert.Equal(t, "20", gjson.Get(result.Stdout, "api.0.params.page_size").String()) }) + t.Run("WithKeywordOwnershipAppType", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+list", + "--keyword", "survey", "--ownership", "mine", "--app-type", "html", + "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + assert.Equal(t, "survey", gjson.Get(result.Stdout, "api.0.params.keyword").String()) + assert.Equal(t, "mine", gjson.Get(result.Stdout, "api.0.params.ownership").String()) + assert.Equal(t, "html", gjson.Get(result.Stdout, "api.0.params.app_type").String()) + }) + + t.Run("OmitsEmptyFilters", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+list", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + for _, p := range []string{"keyword", "ownership", "app_type"} { + assert.False(t, gjson.Get(result.Stdout, "api.0.params."+p).Exists(), + "empty %s must be omitted", p) + } + }) + + t.Run("RejectsInvalidOwnership", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+list", "--ownership", "bogus", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + assert.NotEqual(t, 0, result.ExitCode, "invalid --ownership enum must be rejected") + }) + + t.Run("RejectsLegacyUppercaseAppType", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"apps", "+list", "--app-type", "HTML", "--dry-run"}, + DefaultAs: "user", + }) + require.NoError(t, err) + assert.NotEqual(t, 0, result.ExitCode, "legacy uppercase --app-type must be rejected") + }) + t.Run("NegativePageSizePassesThrough", func(t *testing.T) { // By design CLI does not bound page_size; server validates. Test pins that // invariant so a well-meaning client-side check doesn't sneak in. diff --git a/tests/cli_e2e/apps/coverage.md b/tests/cli_e2e/apps/coverage.md index be7a2e19..df43b45c 100644 --- a/tests/cli_e2e/apps/coverage.md +++ b/tests/cli_e2e/apps/coverage.md @@ -1,17 +1,22 @@ # Apps CLI E2E Coverage ## Metrics -- Denominator: 6 leaf commands (all shortcuts) -- Covered: 6 (dry-run only) -- Coverage: 100% (dry-run); 0% (live) +- Denominator: 9 leaf commands (all user-visible shortcuts) +- Command coverage: 100% (9/9) +- API dry-run coverage: 100% (7/7 API-backed commands) +- Local E2E coverage: 100% (2/2 local-only commands) +- Live coverage: 0% ## Summary -- `TestAppsCreateDryRun`: happy path with `--app-type HTML`, all-fields shape, three rejection paths (missing name, missing app-type, invalid app-type, case-sensitive lowercase rejection). +- `TestAppsCreateDryRun`: happy path with `--app-type html`, all-fields shape, rejection paths (missing name, missing app-type, invalid app-type, legacy uppercase `HTML`). `--app-type` is a strict lowercase enum (`html`/`full_stack`); the CLI does not normalize case — legacy uppercase compatibility is a server concern. - `TestAppsUpdateDryRun`: partial-field PATCH semantics; `--app-id` and at-least-one-field validation. -- `TestAppsListDryRun`: default `page_size=20`; empty `--page-token` omitted; negative size passed through to server (no client-side bound check). +- `TestAppsListDryRun`: default `page_size=20`; empty `--page-token` omitted; negative size passed through to server (no client-side bound check); `--keyword`/`--ownership`/`--app-type` pass-through + empty-omission; invalid `--ownership` and legacy uppercase `--app-type` enum rejection. - `TestAppsAccessScopeSetDryRun`: CLI input `specific`/`public`/`tenant` -> server enum `Range`/`All`/`Tenant`; `apply_config.approvers` shape; four mutex rejection paths. - `TestAppsAccessScopeGetDryRun`: URL shape; no body/params on GET; `--app-id` required. - `TestAppsHTMLPublishDryRun`: walker manifest for directory + single file; hidden files intentionally included (design decision); empty dir / missing `index.html` produce envelope `validation_error` field (dry-run exits 0 advisory, not blocking); both required-flag rejections. +- `TestAppsGitCredentialInitDryRun`: URL shape for issuing a Miaoda Git PAT; no body; `app_id` query metadata included. +- `TestAppsGitCredentialListLocalE2E`: local-only command scans every app storage directory and reports repository URL and status without exposing PAT or expiry details. +- `TestAppsGitCredentialRemoveLocalE2E`: local cleanup command removes app-scoped metadata under an isolated config dir. Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Revisit when the server exposes `DELETE /apps/{appId}`. @@ -19,10 +24,12 @@ Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpo | Status | Cmd | Type | Testcase | Key parameter shapes | Notes / uncovered reason | | --- | --- | --- | --- | --- | --- | -| ✓ | apps +create | shortcut | apps_create_dryrun_test.go::TestAppsCreateDryRun | `--name`, `--app-type` (required, case-sensitive, `HTML` only), `--description`, `--icon-url` | live blocked: no +delete to clean up | +| ✓ | apps +create | shortcut | apps_create_dryrun_test.go::TestAppsCreateDryRun | `--name`, `--app-type` (required, case-sensitive, `html`/`full_stack`), `--description`, `--icon-url` | live blocked: no +delete to clean up | | ✓ | apps +update | shortcut | apps_update_dryrun_test.go::TestAppsUpdateDryRun | `--app-id`; at least one of `--name`/`--description` | live blocked: no +delete | -| ✓ | apps +list | shortcut | apps_list_dryrun_test.go::TestAppsListDryRun | `--page-size` default 20; `--page-token` cursor | live blocked: needs tenant fixtures | +| ✓ | apps +list | shortcut | apps_list_dryrun_test.go::TestAppsListDryRun | `--keyword`; `--ownership` (enum all/mine/shared); `--app-type` (enum html/full_stack); `--page-size` default 20; `--page-token` cursor | live blocked: needs tenant fixtures | | ✓ | apps +access-scope-set | shortcut | apps_access_scope_set_dryrun_test.go::TestAppsAccessScopeSetDryRun | `--scope specific/public/tenant`; `--targets` JSON; `--apply-enabled --approver`; `--require-login` | live blocked: needs real open_ids | | ✓ | apps +access-scope-get | shortcut | apps_access_scope_get_dryrun_test.go::TestAppsAccessScopeGetDryRun | `--app-id` | live blocked: depends on +access-scope-set state | | ✓ | apps +html-publish | shortcut | apps_html_publish_dryrun_test.go::TestAppsHTMLPublishDryRun | `--app-id`, `--path` (file or directory containing `index.html`) | live blocked: real upload has side effects; no rollback API | - +| ✓ | apps +git-credential-init | shortcut | apps_git_credential_dryrun_test.go::TestAppsGitCredentialInitDryRun | `--app-id`; dry-run `GET /open-apis/spark/v1/apps/{app_id}/git_info` | live blocked: issues short-lived repository PAT | +| ✓ | apps +git-credential-list | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialListLocalE2E | no `--app-id`; scans all local app storage directories and reports `app_id`, repository URL, and status without PAT or expiry | local E2E only: no dry-run API because command is local read only | +| ✓ | apps +git-credential-remove | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialRemoveLocalE2E | `--app-id`; deletes local metadata, keychain PAT, and Git config | local E2E only: no dry-run API because command is local cleanup only |