diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..0df71cc07 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,30 @@ +/internal/ @liangshuo-1 + +# Last match wins: existing domains below are exempt, only new skills/ entries need review. +/skills/ @liangshuo-1 +/skills/lark-approval/ +/skills/lark-apps/ +/skills/lark-attendance/ +/skills/lark-base/ +/skills/lark-calendar/ +/skills/lark-contact/ +/skills/lark-doc/ +/skills/lark-drive/ +/skills/lark-event/ +/skills/lark-im/ +/skills/lark-mail/ +/skills/lark-markdown/ +/skills/lark-minutes/ +/skills/lark-okr/ +/skills/lark-openapi-explorer/ +/skills/lark-shared/ +/skills/lark-sheets/ +/skills/lark-skill-maker/ +/skills/lark-slides/ +/skills/lark-task/ +/skills/lark-vc/ +/skills/lark-vc-agent/ +/skills/lark-whiteboard/ +/skills/lark-wiki/ +/skills/lark-workflow-meeting-summary/ +/skills/lark-workflow-standup-report/ diff --git a/.gitignore b/.gitignore index cf7fd1bec..b02ac7e4e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,8 @@ tests/mail/reports/ # Generated / test artifacts .hammer/ .lark-slides/ +/notes/ +/minutes/ internal/registry/meta_data.json cmd/api/download.bin app.log diff --git a/.golangci.yml b/.golangci.yml index bddedb889..260e4b065 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -73,20 +73,20 @@ linters: - forbidigo # errs-typed-only enforced on paths already migrated to errs.NewXxxError. # Add a path when its migration is complete. - - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) + - path-except: (internal/auth/|internal/errcompat/|internal/errclass/|internal/client/|internal/cmdutil/factory\.go|cmd/auth/|cmd/config/|cmd/service/|shortcuts/common/mcp_client\.go|shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|internal/event/consume/|cmd/event/|events/|shortcuts/event/) text: errs-typed-only linters: - forbidigo # errs-no-bare-wrap enforced on paths fully migrated to typed final # errors. Scoped separately from errs-typed-only because cmd/auth/, # cmd/config/ still have residual fmt.Errorf and must not be caught. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) + - path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|shortcuts/common/mcp_client\.go|cmd/event/|events/|shortcuts/event/) text: errs-no-bare-wrap linters: - forbidigo # errs-no-legacy-helper enforced on domains whose shared validation/save # helpers have migrated to typed final errors. - - path-except: (shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/minutes/|shortcuts/okr/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|cmd/event/|events/|shortcuts/event/) + - path-except: (shortcuts/apps/|shortcuts/base/|shortcuts/calendar/|shortcuts/contact/|shortcuts/doc/|shortcuts/drive/|shortcuts/im/|shortcuts/mail/|shortcuts/markdown/|shortcuts/minutes/|shortcuts/okr/|shortcuts/sheets/|shortcuts/slides/|shortcuts/task/|shortcuts/vc/|shortcuts/whiteboard/|shortcuts/wiki/|cmd/event/|events/|shortcuts/event/) text: errs-no-legacy-helper linters: - forbidigo diff --git a/.goreleaser.yml b/.goreleaser.yml index 4040cc17c..9fc7f62b0 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -17,6 +17,7 @@ builds: goarch: - amd64 - arm64 + - riscv64 archives: - name_template: "lark-cli-{{ .Version }}-{{ .Os }}-{{ .Arch }}" diff --git a/AGENTS.md b/AGENTS.md index 69dc44c1b..6e2b38d45 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ ```bash make build # Build (runs fetch_meta first) -make unit-test # Required before PR (runs with -race) +make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64) make test # Full: vet + unit + integration ``` diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a8c5b195..4faa5c272 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,89 @@ All notable changes to this project will be documented in this file. +## [v1.0.53] - 2026-06-12 + +### Features + +- **auth**: Revoke user tokens server-side on `auth logout` (#1434) +- **auth**: Add `--json` flag support to auth subcommands (#1431) +- **token**: Mint TAT via unified OAuth v3 Token Endpoint (#1408) +- **note**: Split note into a dedicated domain with `+detail` and `+transcript` flows (#1345, #1417, #1435) +- **im**: Unify sort flags into `--sort` field and `--order` direction (#1302) + +### Bug Fixes + +- **apps**: Read release error_logs from `data.error_logs` in `+release-get` (#1436) + +### Documentation + +- **skills**: Optimize whiteboard skill (#1371) +- **skills**: Optimize okr skill (#1368) + +## [v1.0.52] - 2026-06-11 + +### Features + +- **events**: Per-resource subscription identity + Match hook (#1185) +- **apps**: Emit typed error envelopes across the apps domain (#1288) +- **wiki**: Emit typed error envelopes across the wiki domain (#1350) +- **im**: Add `--chat-modes` filter to chat search (#1317) +- **apps**: Exclude `.git` directory from `+html-publish` package (#1396) +- **build**: Support riscv64 prebuilt binaries in release and install pipeline + +### Bug Fixes + +- **apps**: Support git credential dry-run (#1390) +- **whiteboard**: Fix parsing empty whiteboard content (#1391) +- **build**: Make `-race` flag arch-conditional to support riscv64 + +### Documentation + +- **im**: Document `chat.user_setting` batch_query/batch_update (#1339) +- **im**: Document `chat.managers` and `chat.moderation` API resources (#1294) +- **skills**: Optimize lark-drive skill routing (#1284) +- **skills**: Expand cite user guidance and fix typos (#1394) + +## [v1.0.51] - 2026-06-10 + +### Features + +- **apps**: Support multi dev modes (#1175) +- **im**: Complete audio/post rendering and add opt-in `--download-resources` (#1245) +- **base**: Configure initial base table schema (#1377) +- **vc**: Add recording event support (#1369) +- **minutes**: Replace words for transcript (#1372) +- **markdown**: Emit typed error envelopes across the markdown domain (#1347) +- **sheets**: Emit typed error envelopes across the sheets domain (#1348) +- **slides**: Emit typed error envelopes across the slides domain (#1349) + +### Documentation + +- **skills**: Warn about `@file` absolute path restriction in lark-doc skills (#1375) +- **skills**: Remove unsupported ⚠️ from callout emoji list (#1374) + +## [v1.0.50] - 2026-06-09 + +### Features + +- **doc**: Emit typed error envelopes across the doc domain (#1346) +- **event**: Emit typed error envelopes across the event domain (#1289) +- **contact**: Emit typed error envelopes across the contact domain (#1287) +- **sheets**: Guard `+csv-put --csv` against a path passed without `@` (#1337) +- **cli**: Adjust agent timeout hint output conditions (#1328) + +### Bug Fixes + +- **drive**: Add `@file`/stdin support to `+add-comment --content` (#1343) +- **slides**: Build create URL locally instead of drive metas call (#1329) +- **cli**: Clarify `--block-id` supports comma-separated batch delete in help text (#1336) + +### Documentation + +- **doc**: Replace append with `block_insert_after` in skeleton workflow guidance (#1340) +- **doc**: Document `` resource block (#1168) +- **drive**: Add drive comment location guidance (#1258) + ## [v1.0.49] - 2026-06-08 ### Features @@ -1066,6 +1149,10 @@ Bundled AI agent skills for intelligent assistance: - Bilingual documentation (English & Chinese). - CI/CD pipelines: linting, testing, coverage reporting, and automated releases. +[v1.0.53]: https://github.com/larksuite/cli/releases/tag/v1.0.53 +[v1.0.52]: https://github.com/larksuite/cli/releases/tag/v1.0.52 +[v1.0.51]: https://github.com/larksuite/cli/releases/tag/v1.0.51 +[v1.0.50]: https://github.com/larksuite/cli/releases/tag/v1.0.50 [v1.0.49]: https://github.com/larksuite/cli/releases/tag/v1.0.49 [v1.0.48]: https://github.com/larksuite/cli/releases/tag/v1.0.48 [v1.0.47]: https://github.com/larksuite/cli/releases/tag/v1.0.47 diff --git a/Makefile b/Makefile index 14480aeec..07cb3d740 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,13 @@ DATE := $(shell date +%Y-%m-%d) LDFLAGS := -s -w -X $(MODULE)/internal/build.Version=$(VERSION) -X $(MODULE)/internal/build.Date=$(DATE) PREFIX ?= /usr/local +# The repository's Go 1.23 CI toolchain does not support -race on riscv64. +# Prefer GOARCH passed to make (for example, `make GOARCH=riscv64 unit-test`) +# over `go env GOARCH`, because command-line make variables are not visible to +# $(shell ...). +TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH)) +RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race) + .PHONY: all build vet fmt-check test unit-test integration-test examples-build install uninstall clean fetch_meta gitleaks all: test @@ -34,7 +41,7 @@ fmt-check: # ./extension/... keeps the public plugin SDK in the default test matrix. unit-test: fetch_meta - go test -race -gcflags="all=-N -l" -count=1 \ + go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \ ./cmd/... ./internal/... ./shortcuts/... ./extension/... # examples-build keeps the shipped plugin-SDK examples compilable. If this diff --git a/README.md b/README.md index f61910841..5ea47838a 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 d0df8d4e6..f597ca68f 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/cmd/api/api_test.go b/cmd/api/api_test.go index c3530c323..393e2542c 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -66,6 +66,24 @@ func TestApiCmd_DryRun(t *testing.T) { } } +// Regression: --params null parses to a nil map; writing page_size onto it must +// not panic. Symmetric to the typed-flag overlay path in cmd/service — both +// write into the map ParseJSONMap returns. +func TestApiCmd_NullParamsWithPageSize(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdApi(f, nil) + cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("--params null with --page-size should not error, got: %v", err) + } + if out := stdout.String(); !strings.Contains(out, "page_size") { + t.Errorf("expected page_size applied over null --params, got:\n%s", out) + } +} + func TestApiCmd_BotMode(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, diff --git a/cmd/auth/auth_test.go b/cmd/auth/auth_test.go index 996c71c68..f633a6143 100644 --- a/cmd/auth/auth_test.go +++ b/cmd/auth/auth_test.go @@ -91,6 +91,29 @@ func TestAuthCheckCmd_FlagParsing(t *testing.T) { } } +func TestAuthCheckCmd_AcceptsJSONFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *CheckOptions + cmd := NewCmdAuthCheck(f, func(opts *CheckOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--scope", "calendar:calendar:read", "--json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Fatal("expected opts to be set") + } + if !gotOpts.JSON { + t.Error("expected JSON=true") + } +} + func TestAuthLogoutCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) @@ -109,6 +132,27 @@ func TestAuthLogoutCmd_FlagParsing(t *testing.T) { } } +func TestAuthLogoutCmd_AcceptsJSONFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *LogoutOptions + cmd := NewCmdAuthLogout(f, func(opts *LogoutOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Fatal("expected opts to be set") + } + if !gotOpts.JSON { + t.Error("expected JSON=true") + } +} + func TestAuthListCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, nil) @@ -126,6 +170,27 @@ func TestAuthListCmd_FlagParsing(t *testing.T) { } } +func TestAuthListCmd_AcceptsJSONFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *ListOptions + cmd := NewCmdAuthList(f, func(opts *ListOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Error("expected opts to be set") + } + if !gotOpts.JSON { + t.Error("expected JSON=true") + } +} + func TestAuthStatusCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -145,6 +210,29 @@ func TestAuthStatusCmd_FlagParsing(t *testing.T) { } } +func TestAuthStatusCmd_AcceptsJSONFlag(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *StatusOptions + cmd := NewCmdAuthStatus(f, func(opts *StatusOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Error("expected opts to be set") + } + if !gotOpts.JSON { + t.Error("expected JSON=true") + } +} + func TestAuthStatusCmd_VerifyFlag(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -267,6 +355,32 @@ func TestAuthScopesCmd_FlagParsing(t *testing.T) { } } +func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + var gotOpts *ScopesOptions + cmd := NewCmdAuthScopes(f, func(opts *ScopesOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--format", "pretty", "--json"}) + err := cmd.Execute() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotOpts == nil { + t.Fatal("expected opts to be set") + } + if !gotOpts.JSON { + t.Error("expected JSON=true") + } + if gotOpts.Format != "json" { + t.Errorf("expected format json, got %s", gotOpts.Format) + } +} + func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu, diff --git a/cmd/auth/check.go b/cmd/auth/check.go index 50c47d103..5af634934 100644 --- a/cmd/auth/check.go +++ b/cmd/auth/check.go @@ -19,6 +19,7 @@ import ( type CheckOptions struct { Factory *cmdutil.Factory Scope string + JSON bool } // NewCmdAuthCheck creates the auth check subcommand. @@ -37,6 +38,7 @@ func NewCmdAuthCheck(f *cmdutil.Factory, runF func(*CheckOptions) error) *cobra. } cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to check (space-separated)") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmd.MarkFlagRequired("scope") cmdutil.SetRisk(cmd, "read") diff --git a/cmd/auth/list.go b/cmd/auth/list.go index ff682f824..d92a028cb 100644 --- a/cmd/auth/list.go +++ b/cmd/auth/list.go @@ -18,6 +18,7 @@ import ( // ListOptions holds all inputs for auth list. type ListOptions struct { Factory *cmdutil.Factory + JSON bool } // NewCmdAuthList creates the auth list subcommand. @@ -34,6 +35,7 @@ func NewCmdAuthList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Co return authListRun(opts) }, } + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmdutil.SetRisk(cmd, "read") return cmd @@ -44,6 +46,14 @@ func authListRun(opts *ListOptions) error { multi, _ := core.LoadMultiAppConfig() if multi == nil || len(multi.Apps) == 0 { + if opts.JSON { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "ok": true, + "users": []map[string]interface{}{}, + "reason": "not_configured", + }) + return nil + } // auth list is a read-only probe; the "configured but no users" // branch below already returns exit 0 with a stderr hint, so we // keep the same contract here. We still want the hint to be @@ -61,6 +71,14 @@ func authListRun(opts *ListOptions) error { app := multi.CurrentAppConfig(f.Invocation.Profile) if app == nil || len(app.Users) == 0 { + if opts.JSON { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "ok": true, + "users": []map[string]interface{}{}, + "reason": "not_logged_in", + }) + return nil + } fmt.Fprintln(f.IOStreams.ErrOut, "No logged-in users. Run `lark-cli auth login` to log in.") return nil } diff --git a/cmd/auth/list_test.go b/cmd/auth/list_test.go index e26266d64..070e4fae1 100644 --- a/cmd/auth/list_test.go +++ b/cmd/auth/list_test.go @@ -4,6 +4,7 @@ package auth import ( + "encoding/json" "strings" "testing" @@ -34,6 +35,33 @@ func TestAuthListRun_NotConfigured_ReturnsExitZero(t *testing.T) { } } +func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil { + t.Fatalf("auth list should succeed when not configured (exit 0); got: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String()) + } + if payload["ok"] != true { + t.Errorf("stdout.ok = %v, want true", payload["ok"]) + } + users, ok := payload["users"].([]any) + if !ok || len(users) != 0 { + t.Errorf("stdout.users = %v, want empty array", payload["users"]) + } + if payload["reason"] != "not_configured" { + t.Errorf("stdout.reason = %v, want not_configured", payload["reason"]) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String()) + } +} + // TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the // reason this hint exists workspace-aware in the first place: an AI agent // in OpenClaw / Hermes that probes auth list before binding gets routed to @@ -57,3 +85,48 @@ func TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp(t *testing.T) t.Errorf("agent hint must not mention config init: %s", out) } } + +func TestAuthListRun_JSONMode_NoLoggedInUsers_WritesStdoutOnly(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + writeLogoutConfig(t, nil) + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authListRun(&ListOptions{Factory: f, JSON: true}); err != nil { + t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String()) + } + if payload["ok"] != true { + t.Errorf("stdout.ok = %v, want true", payload["ok"]) + } + users, ok := payload["users"].([]any) + if !ok || len(users) != 0 { + t.Errorf("stdout.users = %v, want empty array", payload["users"]) + } + if payload["reason"] != "not_logged_in" { + t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"]) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String()) + } +} + +func TestAuthListRun_DefaultMode_NoLoggedInUsers_KeepsTextOutput(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + writeLogoutConfig(t, nil) + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authListRun(&ListOptions{Factory: f}); err != nil { + t.Fatalf("auth list should succeed when no users exist (exit 0); got: %v", err) + } + + if stdout.Len() != 0 { + t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String()) + } + if !strings.Contains(stderr.String(), "No logged-in users") { + t.Errorf("stderr = %q, want no-users hint", stderr.String()) + } +} diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index a68efc142..70e01065c 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -92,16 +92,11 @@ func buildDomainMeta(name, lang string) domainMeta { Description: desc, } } - // Fallback: read from from_meta spec (legacy) - meta := registry.LoadFromMeta(name) + // Fallback: read from the typed service spec (legacy) dm := domainMeta{Name: name} - if meta != nil { - if t, ok := meta["title"].(string); ok { - dm.Title = t - } - if d, ok := meta["description"].(string); ok { - dm.Description = d - } + if svc, ok := registry.ServiceTyped(name); ok { + dm.Title = svc.Title + dm.Description = svc.Description } return dm } diff --git a/cmd/auth/login_messages.go b/cmd/auth/login_messages.go index 2d8ff1eb0..defaae01f 100644 --- a/cmd/auth/login_messages.go +++ b/cmd/auth/login_messages.go @@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg { // (not backed by from_meta service specs). Descriptions are now centralized in // service_descriptions.json. func getShortcutOnlyDomainNames() []string { - return []string{"base", "contact", "docs", "markdown", "apps"} + return []string{"base", "contact", "docs", "markdown", "apps", "note"} } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 3409f297b..d063c3350 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -9,6 +9,7 @@ import ( "errors" "io" "net/http" + "slices" "sort" "strings" "testing" @@ -214,6 +215,12 @@ func TestGetShortcutOnlyDomainNames_HaveDescriptions(t *testing.T) { } } +func TestGetShortcutOnlyDomainNames_IncludesNote(t *testing.T) { + if !slices.Contains(getShortcutOnlyDomainNames(), "note") { + t.Fatal("shortcut-only domains must include note so auth login can select vc:note:read") + } +} + func TestCollectScopesForDomains(t *testing.T) { projects := registry.ListFromMetaProjects() if len(projects) == 0 { diff --git a/cmd/auth/logout.go b/cmd/auth/logout.go index 1e864fd7d..7e82127ed 100644 --- a/cmd/auth/logout.go +++ b/cmd/auth/logout.go @@ -18,6 +18,7 @@ import ( // LogoutOptions holds all inputs for auth logout. type LogoutOptions struct { Factory *cmdutil.Factory + JSON bool } // NewCmdAuthLogout creates the auth logout subcommand. @@ -34,6 +35,7 @@ func NewCmdAuthLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobr return authLogoutRun(opts) }, } + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmdutil.SetRisk(cmd, "write") return cmd @@ -44,25 +46,65 @@ func authLogoutRun(opts *LogoutOptions) error { multi, _ := core.LoadMultiAppConfig() if multi == nil || len(multi.Apps) == 0 { + if opts.JSON { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "ok": true, + "loggedOut": false, + "reason": "not_configured", + }) + return nil + } fmt.Fprintln(f.IOStreams.ErrOut, "No configuration found.") return nil } app := multi.CurrentAppConfig(f.Invocation.Profile) if app == nil || len(app.Users) == 0 { + if opts.JSON { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "ok": true, + "loggedOut": false, + "reason": "not_logged_in", + }) + return nil + } fmt.Fprintln(f.IOStreams.ErrOut, "Not logged in.") return nil } + httpClient, httpErr := f.HttpClient() + appSecret, secretErr := core.ResolveSecretInput(app.AppSecret, f.Keychain) + for _, user := range app.Users { + if httpErr == nil && secretErr == nil { + if token := larkauth.GetStoredToken(app.AppId, user.UserOpenId); token != nil { + revokeToken := token.RefreshToken + tokenTypeHint := "refresh_token" + if revokeToken == "" { + revokeToken = token.AccessToken + tokenTypeHint = "access_token" + } + if revokeToken != "" { + _ = larkauth.RevokeToken(httpClient, app.AppId, appSecret, app.Brand, revokeToken, tokenTypeHint) + } + } + } if err := larkauth.RemoveStoredToken(app.AppId, user.UserOpenId); err != nil { fmt.Fprintf(f.IOStreams.ErrOut, "Warning: failed to remove token for %s: %v\n", user.UserOpenId, err) } } + app.Users = []core.AppUser{} if err := core.SaveMultiAppConfig(multi); err != nil { return errs.NewInternalError(errs.SubtypeStorage, "failed to save config: %v", err).WithCause(err) } + if opts.JSON { + output.PrintJson(f.IOStreams.Out, map[string]interface{}{ + "ok": true, + "loggedOut": true, + }) + return nil + } output.PrintSuccess(f.IOStreams.ErrOut, "Logged out") return nil } diff --git a/cmd/auth/logout_test.go b/cmd/auth/logout_test.go new file mode 100644 index 000000000..613e47054 --- /dev/null +++ b/cmd/auth/logout_test.go @@ -0,0 +1,356 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "encoding/json" + "net/url" + "strings" + "testing" + + larkauth "github.com/larksuite/cli/internal/auth" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/zalando/go-keyring" +) + +func writeLogoutConfig(t *testing.T, users []core.AppUser) { + t.Helper() + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + CurrentApp: "test-app", + Apps: []core.AppConfig{ + { + AppId: "test-app", + AppSecret: core.PlainSecret("test-secret"), + Brand: core.BrandFeishu, + Users: users, + }, + }, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } +} + +func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil { + t.Fatalf("authLogoutRun() error = %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String()) + } + if payload["ok"] != true { + t.Errorf("stdout.ok = %v, want true", payload["ok"]) + } + if payload["loggedOut"] != false { + t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"]) + } + if payload["reason"] != "not_configured" { + t.Errorf("stdout.reason = %v, want not_configured", payload["reason"]) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String()) + } +} + +func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + writeLogoutConfig(t, nil) + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil { + t.Fatalf("authLogoutRun() error = %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String()) + } + if payload["ok"] != true { + t.Errorf("stdout.ok = %v, want true", payload["ok"]) + } + if payload["loggedOut"] != false { + t.Errorf("stdout.loggedOut = %v, want false", payload["loggedOut"]) + } + if payload["reason"] != "not_logged_in" { + t.Errorf("stdout.reason = %v, want not_logged_in", payload["reason"]) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String()) + } +} + +func TestAuthLogoutRun_JSONMode_Success_WritesStdoutOnly(t *testing.T) { + keyring.MockInit() + t.Setenv("HOME", t.TempDir()) + t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}) + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "test-app", + UserOpenId: "ou_user", + }); err != nil { + t.Fatalf("SetStoredToken() error = %v", err) + } + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true}); err != nil { + t.Fatalf("authLogoutRun() error = %v", err) + } + + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("stdout must be valid JSON: %v\nstdout=%s", err, stdout.String()) + } + if payload["ok"] != true { + t.Errorf("stdout.ok = %v, want true", payload["ok"]) + } + if payload["loggedOut"] != true { + t.Errorf("stdout.loggedOut = %v, want true", payload["loggedOut"]) + } + if _, hasReason := payload["reason"]; hasReason { + t.Errorf("stdout.reason must be absent on success, got %v", payload["reason"]) + } + if stderr.Len() != 0 { + t.Errorf("stderr must stay empty in JSON mode, got:\n%s", stderr.String()) + } +} + +func TestAuthLogoutRun_DefaultMode_KeepsTextOutput(t *testing.T) { + keyring.MockInit() + t.Setenv("HOME", t.TempDir()) + t.Setenv("LARKSUITE_CLI_DATA_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + writeLogoutConfig(t, []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}) + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "test-app", + UserOpenId: "ou_user", + }); err != nil { + t.Fatalf("SetStoredToken() error = %v", err) + } + + f, stdout, stderr, _ := cmdutil.TestFactory(t, nil) + if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil { + t.Fatalf("authLogoutRun() error = %v", err) + } + + if stdout.Len() != 0 { + t.Errorf("stdout must stay empty in default mode, got:\n%s", stdout.String()) + } + if !strings.Contains(stderr.String(), "Logged out") { + t.Errorf("stderr = %q, want success text", stderr.String()) + } +} + +func TestAuthLogoutRun_RevokesTokenAndClearsLocalState(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + t.Setenv("HOME", t.TempDir()) + + multi := &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + { + Name: "default", + AppId: "cli_test", + AppSecret: core.PlainSecret("secret"), + Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}, + }, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_test", + UserOpenId: "ou_user", + AccessToken: "user-access-token", + RefreshToken: "user-refresh-token", + }); err != nil { + t.Fatalf("SetStoredToken() error = %v", err) + } + + f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + AppSecret: "secret", + Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: larkauth.PathOAuthRevoke, + Body: map[string]interface{}{"code": 0}, + BodyFilter: func(body []byte) bool { + values, err := url.ParseQuery(string(body)) + if err != nil { + return false + } + return values.Get("client_id") == "cli_test" && + values.Get("client_secret") == "secret" && + values.Get("token") == "user-refresh-token" && + values.Get("token_type_hint") == "refresh_token" + }, + }) + + if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil { + t.Fatalf("authLogoutRun() error = %v", err) + } + + if got := stderr.String(); !strings.Contains(got, "Logged out") { + t.Fatalf("stderr = %q, want Logged out", got) + } + if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil { + t.Fatalf("expected stored token removed, got %#v", got) + } + saved, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig() error = %v", err) + } + if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 { + t.Fatalf("expected users cleared, got %#v", saved.Apps) + } +} + +func TestAuthLogoutRun_FallsBackToAccessTokenWhenRefreshTokenMissing(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + t.Setenv("HOME", t.TempDir()) + + multi := &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + { + Name: "default", + AppId: "cli_test", + AppSecret: core.PlainSecret("secret"), + Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}, + }, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_test", + UserOpenId: "ou_user", + AccessToken: "user-access-token", + }); err != nil { + t.Fatalf("SetStoredToken() error = %v", err) + } + + f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + AppSecret: "secret", + Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: larkauth.PathOAuthRevoke, + Body: map[string]interface{}{"code": 0}, + BodyFilter: func(body []byte) bool { + values, err := url.ParseQuery(string(body)) + if err != nil { + return false + } + return values.Get("client_id") == "cli_test" && + values.Get("client_secret") == "secret" && + values.Get("token") == "user-access-token" && + values.Get("token_type_hint") == "access_token" + }, + }) + + if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil { + t.Fatalf("authLogoutRun() error = %v", err) + } + + if got := stderr.String(); !strings.Contains(got, "Logged out") { + t.Fatalf("stderr = %q, want Logged out", got) + } + if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil { + t.Fatalf("expected stored token removed, got %#v", got) + } + saved, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig() error = %v", err) + } + if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 { + t.Fatalf("expected users cleared, got %#v", saved.Apps) + } +} + +func TestAuthLogoutRun_RevokeFailureStillClearsLocalState(t *testing.T) { + keyring.MockInit() + setupLoginConfigDir(t) + t.Setenv("HOME", t.TempDir()) + + multi := &core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{ + { + Name: "default", + AppId: "cli_test", + AppSecret: core.PlainSecret("secret"), + Brand: core.BrandFeishu, + Users: []core.AppUser{{UserOpenId: "ou_user", UserName: "tester"}}, + }, + }, + } + if err := core.SaveMultiAppConfig(multi); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + if err := larkauth.SetStoredToken(&larkauth.StoredUAToken{ + AppId: "cli_test", + UserOpenId: "ou_user", + AccessToken: "user-access-token", + RefreshToken: "user-refresh-token", + }); err != nil { + t.Fatalf("SetStoredToken() error = %v", err) + } + + f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "default", + AppID: "cli_test", + AppSecret: "secret", + Brand: core.BrandFeishu, + }) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: larkauth.PathOAuthRevoke, + Status: 500, + Body: map[string]interface{}{"error": "server_error"}, + }) + + if err := authLogoutRun(&LogoutOptions{Factory: f}); err != nil { + t.Fatalf("authLogoutRun() error = %v", err) + } + + gotErr := stderr.String() + if strings.Contains(gotErr, "failed to revoke token for ou_user") { + t.Fatalf("stderr = %q, want no revoke warning", gotErr) + } + if !strings.Contains(gotErr, "Logged out") { + t.Fatalf("stderr = %q, want Logged out", gotErr) + } + if got := larkauth.GetStoredToken("cli_test", "ou_user"); got != nil { + t.Fatalf("expected stored token removed, got %#v", got) + } + saved, err := core.LoadMultiAppConfig() + if err != nil { + t.Fatalf("LoadMultiAppConfig() error = %v", err) + } + if len(saved.Apps) != 1 || len(saved.Apps[0].Users) != 0 { + t.Fatalf("expected users cleared, got %#v", saved.Apps) + } +} diff --git a/cmd/auth/scopes.go b/cmd/auth/scopes.go index 4de475824..91f290f98 100644 --- a/cmd/auth/scopes.go +++ b/cmd/auth/scopes.go @@ -19,6 +19,7 @@ type ScopesOptions struct { Factory *cmdutil.Factory Ctx context.Context Format string + JSON bool } // NewCmdAuthScopes creates the auth scopes subcommand. @@ -30,6 +31,9 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr Short: "Query scopes enabled for the app", RunE: func(cmd *cobra.Command, args []string) error { opts.Ctx = cmd.Context() + if opts.JSON { + opts.Format = "json" + } if runF != nil { return runF(opts) } @@ -38,6 +42,7 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr } cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmdutil.SetRisk(cmd, "read") return cmd diff --git a/cmd/auth/status.go b/cmd/auth/status.go index f0cf85e4d..15de7dda9 100644 --- a/cmd/auth/status.go +++ b/cmd/auth/status.go @@ -17,6 +17,7 @@ import ( type StatusOptions struct { Factory *cmdutil.Factory Verify bool + JSON bool } // NewCmdAuthStatus creates the auth status subcommand. @@ -35,6 +36,7 @@ func NewCmdAuthStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobr } cmd.Flags().BoolVar(&opts.Verify, "verify", false, "verify token against server (requires network)") + cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output") cmdutil.SetRisk(cmd, "read") return cmd diff --git a/cmd/command_catalog_path_test.go b/cmd/command_catalog_path_test.go new file mode 100644 index 000000000..c13538a84 --- /dev/null +++ b/cmd/command_catalog_path_test.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +// TestCommandCatalogPath pins that the auth-hint path reconstruction inverts the +// service command tree for any depth — flat dotted resources AND genuinely +// nested resources — so it round-trips through apicatalog.Resolve instead of +// assuming a fixed root->service->resource->method shape. +func TestCommandCatalogPath(t *testing.T) { + chain := func(names ...string) *cobra.Command { + var parent, leaf *cobra.Command + for _, n := range names { + c := &cobra.Command{Use: n} + if parent != nil { + parent.AddCommand(c) + } + parent = c + leaf = c + } + return leaf + } + + tests := []struct { + name string + leaf *cobra.Command + want []string + }{ + {"flat dotted resource", chain("lark-cli", "im", "chat.members", "create"), []string{"im", "chat.members", "create"}}, + {"nested resources", chain("lark-cli", "im", "spaces", "items", "get"), []string{"im", "spaces", "items", "get"}}, + {"service level", chain("lark-cli", "im"), []string{"im"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := commandCatalogPath(tt.leaf); !reflect.DeepEqual(got, tt.want) { + t.Errorf("commandCatalogPath = %v, want %v", got, tt.want) + } + }) + } + + // The root command (no parent) has no catalog path. + if got := commandCatalogPath(&cobra.Command{Use: "lark-cli"}); len(got) != 0 { + t.Errorf("root path = %v, want empty", got) + } +} diff --git a/cmd/config/init_probe.go b/cmd/config/init_probe.go index e56aa1445..b6873ac21 100644 --- a/cmd/config/init_probe.go +++ b/cmd/config/init_probe.go @@ -33,15 +33,16 @@ const probeTimeout = 3 * time.Second // // 1. A TAT request using the just-saved credentials. credential.FetchTAT // returns a typed errs.* error (via the shared classifyTATResponseCode) -// only when the server deterministically rejected the credentials — a -// non-zero TAT body code, classified as CategoryConfig / SubtypeInvalidClient -// (10003 / 10014) or whatever codemeta maps. That typed error is propagated -// so the root dispatcher renders the canonical envelope and `config init` -// exits non-zero — identical to how every other token-resolving command -// reports the same bad credentials. Ambiguous failures (transport errors, -// HTTP non-200, JSON parse errors, timeouts) come back as raw untyped -// errors and are swallowed (return nil), so valid configurations are never -// disturbed by upstream noise. errs.IsTyped is the discriminator. +// only when the unified Token Endpoint deterministically rejected the +// credentials — an OAuth2 invalid_client / unauthorized_client classified as +// CategoryConfig / SubtypeInvalidClient, or whatever codemeta maps. That +// typed error is propagated so the root dispatcher renders the canonical +// envelope and `config init` exits non-zero — identical to how every other +// token-resolving command reports the same bad credentials. Ambiguous +// failures (transport errors, transient 5xx/server_error, JSON parse errors, +// timeouts) come back as raw untyped errors and are swallowed (return nil), +// so valid configurations are never disturbed by upstream noise. +// errs.IsTyped is the discriminator. // // 2. If TAT succeeded, a POST to the probe endpoint is fired. The outcome of // that call (success, server error, timeout, parse failure) is always diff --git a/cmd/config/init_probe_test.go b/cmd/config/init_probe_test.go index 097817d73..f4156a73b 100644 --- a/cmd/config/init_probe_test.go +++ b/cmd/config/init_probe_test.go @@ -31,10 +31,10 @@ type fakeRT struct { func (f *fakeRT) RoundTrip(req *http.Request) (*http.Response, error) { switch { - case strings.HasSuffix(req.URL.Path, "/auth/v3/tenant_access_token/internal"): + case strings.HasSuffix(req.URL.Path, "/oauth/v3/token"): f.tatCalls++ if f.tatHandler == nil { - return jsonResp(200, `{"code":0,"tenant_access_token":"t-ok"}`), nil + return jsonResp(200, `{"code":0,"access_token":"t-ok","token_type":"Bearer"}`), nil } return f.tatHandler(req) case strings.HasSuffix(req.URL.Path, "/application/v6/larksuite_cli_app/probe"): @@ -84,14 +84,15 @@ func fakeFactory(t *testing.T, rt http.RoundTripper) (*cmdutil.Factory, *bytes.B } // assertConfigRejection asserts runProbe propagated a deterministic credential -// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient) with -// the expected upstream code. This is the same typed error every other -// token-resolving command returns for the same bad credentials, and nothing is -// written to stderr (the root dispatcher renders the envelope). -func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCode int) { +// rejection: a *errs.ConfigError (CategoryConfig / SubtypeInvalidClient). This +// is the same typed error every other token-resolving command returns for the +// same bad credentials, and nothing is written to stderr (the root dispatcher +// renders the envelope). The numeric code is not asserted: the unified v3 Token +// Endpoint reports invalid_client via the OAuth2 error string, not a Lark code. +func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer) { t.Helper() if err == nil { - t.Fatalf("expected *errs.ConfigError (code %d), got nil", wantCode) + t.Fatal("expected *errs.ConfigError, got nil") } var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { @@ -103,9 +104,6 @@ func assertConfigRejection(t *testing.T, err error, errBuf *bytes.Buffer, wantCo if cfgErr.Subtype != errs.SubtypeInvalidClient { t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) } - if cfgErr.Code != wantCode { - t.Errorf("Code = %d, want %d", cfgErr.Code, wantCode) - } if errBuf.Len() != 0 { t.Errorf("runProbe must not write to stderr, got: %q", errBuf.String()) } @@ -123,11 +121,13 @@ func assertSilent(t *testing.T, err error, errBuf *bytes.Buffer) { } } -// 10003 (bad / non-existent app_id) → ConfigError/InvalidClient, propagated. -func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) { +// invalid_client (bad / non-existent app_id or wrong secret) → the v3 Token +// Endpoint returns HTTP 400 with the OAuth2 error → ConfigError/InvalidClient, +// propagated. The probe endpoint must not be called when TAT fails. +func TestRunProbe_TATInvalidClient_ReturnsConfigError(t *testing.T) { rt := &fakeRT{ tatHandler: func(req *http.Request) (*http.Response, error) { - return jsonResp(200, `{"code":10003,"msg":"invalid param"}`), nil + return jsonResp(400, `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`), nil }, } f, errBuf := fakeFactory(t, rt) @@ -137,28 +137,27 @@ func TestRunProbe_TATCode10003_ReturnsConfigError(t *testing.T) { if rt.probeCalls != 0 { t.Error("probe endpoint must not be called when TAT fails") } - assertConfigRejection(t, err, errBuf, 10003) + assertConfigRejection(t, err, errBuf) } -// 10014 (real app_id + wrong secret) → ConfigError/InvalidClient via codemeta — -// the most common real-world rejection, propagated. -func TestRunProbe_TATCode10014_ReturnsConfigError(t *testing.T) { +// unauthorized_client is treated as the same credential rejection, propagated. +func TestRunProbe_TATUnauthorizedClient_ReturnsConfigError(t *testing.T) { rt := &fakeRT{ tatHandler: func(req *http.Request) (*http.Response, error) { - return jsonResp(200, `{"code":10014,"msg":"app secret invalid"}`), nil + return jsonResp(401, `{"error":"unauthorized_client","error_description":"client not authorized"}`), nil }, } f, errBuf := fakeFactory(t, rt) - assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf, 10014) + assertConfigRejection(t, runProbe(context.Background(), f, "cli_x", "secret_y", core.BrandFeishu), errBuf) } -// Any non-zero body code is a deterministic rejection and propagates (typed). -// An unrecognized code falls back to *errs.APIError via BuildAPIError — still -// typed, so the probe still surfaces it rather than swallowing. -func TestRunProbe_TATUnknownBodyCode_Propagates(t *testing.T) { +// Any other deterministic client-side OAuth error (e.g. invalid_scope) falls +// back to *errs.APIError via BuildAPIError — still typed, so the probe surfaces +// it rather than swallowing — but is not a credential (ConfigError) rejection. +func TestRunProbe_TATOtherClientError_Propagates(t *testing.T) { rt := &fakeRT{ tatHandler: func(req *http.Request) (*http.Response, error) { - return jsonResp(200, `{"code":99999,"msg":"future-unknown"}`), nil + return jsonResp(400, `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`), nil }, } f, errBuf := fakeFactory(t, rt) diff --git a/cmd/error_auth_hint.go b/cmd/error_auth_hint.go index 2851a1b4e..1c3f37e68 100644 --- a/cmd/error_auth_hint.go +++ b/cmd/error_auth_hint.go @@ -11,6 +11,7 @@ import ( "github.com/spf13/cobra" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/apicatalog" internalauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -118,38 +119,37 @@ func resolveDeclaredShortcutScopes(cmd *cobra.Command, identity string) []string } // resolveDeclaredServiceMethodScopes returns the scopes declared by a -// service/resource/method command from the embedded from_meta registry. +// service/resource/method command. It reconstructs the catalog path from the +// command ancestry and resolves it through the same navigation Module the +// command tree is built from (apicatalog), so it stays correct for nested +// resources instead of hard-coding a root->service->resource->method depth. +// Non-method commands (services, resources, shortcuts) resolve to a non-method +// target and yield no scopes. func resolveDeclaredServiceMethodScopes(cmd *cobra.Command, identity string) []string { - // Service-method scope lookup only applies to commands mounted as - // root -> service -> resource -> method. Non-resource/method commands - // intentionally return no scopes here so auth-hint enrichment does not - // change runtime semantics for other command shapes. - if cmd == nil || cmd.Parent() == nil || cmd.Parent().Parent() == nil || cmd.Parent().Parent().Parent() == nil { + if cmd == nil || strings.HasPrefix(cmd.Name(), "+") { return nil } - if strings.HasPrefix(cmd.Name(), "+") { + path := commandCatalogPath(cmd) + if len(path) == 0 { return nil } + target, err := registry.RuntimeCatalog().Resolve(path) + if err != nil || target.Kind != apicatalog.TargetMethod { + return nil + } + return registry.DeclaredScopesForMethod(target.Method.Method, identity) +} - service := cmd.Parent().Parent().Name() - resource := cmd.Parent().Name() - method := cmd.Name() - - spec := registry.LoadFromMeta(service) - if spec == nil { - return nil +// commandCatalogPath reconstructs the catalog path [service, resource..., method] +// from a command's ancestry, excluding the root command. It is the inverse of +// the service command tree's construction, so any depth (flat or nested) +// round-trips through apicatalog.Resolve. +func commandCatalogPath(cmd *cobra.Command) []string { + var path []string + for c := cmd; c != nil && c.Parent() != nil; c = c.Parent() { + path = append([]string{c.Name()}, path...) } - resources, _ := spec["resources"].(map[string]interface{}) - resMap, _ := resources[resource].(map[string]interface{}) - if resMap == nil { - return nil - } - methods, _ := resMap["methods"].(map[string]interface{}) - methodMap, _ := methods[method].(map[string]interface{}) - if methodMap == nil { - return nil - } - return registry.DeclaredScopesForMethod(methodMap, identity) + return path } // shortcutSupportsIdentity reports whether a shortcut supports the requested diff --git a/cmd/event/format_helpers_test.go b/cmd/event/format_helpers_test.go index 939b85d25..a9aaf694e 100644 --- a/cmd/event/format_helpers_test.go +++ b/cmd/event/format_helpers_test.go @@ -143,6 +143,79 @@ func TestWriteStatusText_CoversAllStates(t *testing.T) { } } +func TestWriteStatusText_ShowsSubColumn(t *testing.T) { + var buf bytes.Buffer + writeStatusText(&buf, []appStatus{ + { + AppID: "cli_RUNNINGXXXXXXXXX", + State: stateRunning, + PID: 1234, + UptimeSec: 60, + Active: 2, + Consumers: []protocol.ConsumerInfo{ + {PID: 1001, EventKey: "mail.x", SubscriptionID: "mail.x:alice", Received: 5, Dropped: 0}, + {PID: 1002, EventKey: "mail.x", SubscriptionID: "mail.x:bob", Received: 3, Dropped: 0}, + }, + }, + }) + out := buf.String() + if !strings.Contains(out, "SUB") { + t.Errorf("missing SUB column header: %s", out) + } + if !strings.Contains(out, "alice") { + t.Errorf("missing alice suffix in SUB column: %s", out) + } + if !strings.Contains(out, "bob") { + t.Errorf("missing bob suffix in SUB column: %s", out) + } +} + +func TestWriteStatusText_LegacySubscriptionID_RendersDash(t *testing.T) { + var buf bytes.Buffer + writeStatusText(&buf, []appStatus{ + { + AppID: "cli_RUNNINGXXXXXXXXX", + State: stateRunning, + PID: 1234, + UptimeSec: 60, + Active: 1, + Consumers: []protocol.ConsumerInfo{ + {PID: 1001, EventKey: "im.x", SubscriptionID: "", Received: 5}, + }, + }, + }) + out := buf.String() + if !strings.Contains(out, "SUB") { + t.Errorf("missing SUB header: %s", out) + } + if !strings.Contains(out, "-") { + t.Errorf("missing dash placeholder for empty SubscriptionID: %s", out) + } +} + +func TestWriteStatusText_EventKeyEqualSubscriptionID_RendersDash(t *testing.T) { + var buf bytes.Buffer + writeStatusText(&buf, []appStatus{ + { + AppID: "cli_RUNNINGXXXXXXXXX", + State: stateRunning, + PID: 1234, + UptimeSec: 60, + Active: 1, + Consumers: []protocol.ConsumerInfo{ + {PID: 1001, EventKey: "im.x", SubscriptionID: "im.x", Received: 5}, + }, + }, + }) + out := buf.String() + if !strings.Contains(out, "SUB") { + t.Errorf("missing SUB header: %s", out) + } + if !strings.Contains(out, "-") { + t.Errorf("missing dash placeholder when SubscriptionID==EventKey: %s", out) + } +} + func TestWriteStatusJSON_OrphanHint(t *testing.T) { var buf bytes.Buffer if err := writeStatusJSON(&buf, []appStatus{ diff --git a/cmd/event/schema.go b/cmd/event/schema.go index 74b56edfb..c078767f7 100644 --- a/cmd/event/schema.go +++ b/cmd/event/schema.go @@ -134,12 +134,16 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error { if len(def.Params) > 0 { fmt.Fprintf(out, "\nParameters:\n") w := tabwriter.NewWriter(out, 0, 4, 2, ' ', 0) - fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tDEFAULT\tDESCRIPTION\n") + fmt.Fprintf(w, " NAME\tTYPE\tREQUIRED\tSUB-KEY\tDEFAULT\tDESCRIPTION\n") for _, p := range def.Params { required := "no" if p.Required { required = "yes" } + subKey := "no" + if p.SubscriptionKey { + subKey = "yes" + } defaultVal := p.Default if defaultVal == "" { defaultVal = "-" @@ -148,7 +152,7 @@ func runSchema(f *cmdutil.Factory, key string, asJSON bool) error { if desc == "" { desc = "-" } - fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, defaultVal, desc) + fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Type, required, subKey, defaultVal, desc) } w.Flush() diff --git a/cmd/event/schema_test.go b/cmd/event/schema_test.go index 2fb67f0b7..e02f6758d 100644 --- a/cmd/event/schema_test.go +++ b/cmd/event/schema_test.go @@ -96,6 +96,79 @@ func TestRunSchema_JSONOutput(t *testing.T) { } } +func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) { + const syntheticKey = "test.evt_sub" + t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) }) + + eventlib.RegisterKey(eventlib.KeyDefinition{ + Key: syntheticKey, + EventType: syntheticKey, + Params: []eventlib.ParamDef{ + {Name: "mailbox", SubscriptionKey: true, Description: "subscription id source"}, + {Name: "folders", Description: "filter only"}, + }, + Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}}, + }) + + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + if err := runSchema(f, syntheticKey, false); err != nil { + t.Fatalf("runSchema: %v", err) + } + + out := stdout.String() + if !strings.Contains(out, "SUB-KEY") { + t.Errorf("missing SUB-KEY column header in:\n%s", out) + } + + // Find the mailbox row and verify "yes" is present + var mailboxRow string + for _, ln := range strings.Split(out, "\n") { + if strings.Contains(ln, "mailbox") && !strings.Contains(ln, "NAME") { + mailboxRow = ln + break + } + } + if !strings.Contains(mailboxRow, "yes") { + t.Errorf("mailbox row missing yes SUB-KEY marker: %q", mailboxRow) + } + + // Find the folders row and verify "no" is present + var foldersRow string + for _, ln := range strings.Split(out, "\n") { + if strings.Contains(ln, "folders") && !strings.Contains(ln, "NAME") { + foldersRow = ln + break + } + } + if !strings.Contains(foldersRow, "no") { + t.Errorf("folders row missing no SUB-KEY marker: %q", foldersRow) + } +} + +func TestSchema_JSON_IncludesSubscriptionKey(t *testing.T) { + const syntheticKey = "test.evt_json" + t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) }) + + eventlib.RegisterKey(eventlib.KeyDefinition{ + Key: syntheticKey, + EventType: syntheticKey, + Params: []eventlib.ParamDef{{Name: "mailbox", SubscriptionKey: true}}, + Schema: eventlib.SchemaDef{Native: &eventlib.SchemaSpec{Type: reflect.TypeOf(struct{ X string }{})}}, + }) + + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + if err := runSchema(f, syntheticKey, true); err != nil { + t.Fatalf("runSchema json: %v", err) + } + + if !strings.Contains(stdout.String(), `"subscription_key"`) { + t.Errorf("JSON output missing subscription_key field: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `true`) { + t.Errorf("JSON output missing subscription_key: true value: %s", stdout.String()) + } +} + func TestResolveSchemaJSON_CustomWithOverlay(t *testing.T) { const syntheticKey = "t.custom.overlay" t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) }) diff --git a/cmd/event/status.go b/cmd/event/status.go index 92c8be25d..ece33958e 100644 --- a/cmd/event/status.go +++ b/cmd/event/status.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "sort" + "strings" "sync" "time" @@ -242,12 +243,17 @@ func writeStatusText(out io.Writer, statuses []appStatus) { s.PID, (time.Duration(s.UptimeSec) * time.Second).String()) fmt.Fprintf(out, " Active consumers: %d\n", s.Active) if len(s.Consumers) > 0 { - headers := []string{"CONSUMER", "EVENT KEY", "RECEIVED", "DROPPED"} + headers := []string{"CONSUMER", "EVENT KEY", "SUB", "RECEIVED", "DROPPED"} rows := make([][]string, 0, len(s.Consumers)) for _, c := range s.Consumers { + subDisplay := "-" + if c.SubscriptionID != "" && c.SubscriptionID != c.EventKey { + subDisplay = strings.TrimPrefix(c.SubscriptionID, c.EventKey+":") + } rows = append(rows, []string{ fmt.Sprintf("pid=%d", c.PID), c.EventKey, + subDisplay, fmt.Sprintf("%d", c.Received), fmt.Sprintf("%d", c.Dropped), }) diff --git a/cmd/root.go b/cmd/root.go index 4cbb072bd..1f5b2e491 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -36,7 +36,7 @@ const rootLong = `lark-cli — Lark/Feishu CLI tool. USAGE: lark-cli [subcommand] [method] [options] lark-cli api [--params ] [--data ] - lark-cli schema [--format pretty] + lark-cli schema EXAMPLES: # View upcoming events diff --git a/cmd/schema/schema.go b/cmd/schema/schema.go index 5276052e0..64d5dc9a3 100644 --- a/cmd/schema/schema.go +++ b/cmd/schema/schema.go @@ -5,17 +5,17 @@ package schema import ( "context" - "fmt" + "errors" "io" - "sort" "strings" + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/schema" - "github.com/larksuite/cli/internal/util" "github.com/spf13/cobra" ) @@ -24,336 +24,10 @@ type SchemaOptions struct { Factory *cmdutil.Factory Ctx context.Context - // Positional args - Path string // first positional, when only one is given - ExtraArgs []string // 2nd+ positional args (space-separated form) - - // Flags - Format string -} - -func printServices(w io.Writer) { - services := registry.ListFromMetaProjects() - fmt.Fprintf(w, "%sAvailable services:%s\n\n", output.Bold, output.Reset) - for _, s := range services { - spec := registry.LoadFromMeta(s) - title := registry.GetStrFromMap(spec, "title") - if title == "" { - title = registry.GetStrFromMap(spec, "description") - } - fmt.Fprintf(w, " %s%s%s %s%s%s\n", output.Cyan, s, output.Reset, output.Dim, title, output.Reset) - } - fmt.Fprintf(w, "\n%sUsage: lark-cli schema ..%s\n", output.Dim, output.Reset) -} - -func printResourceList(w io.Writer, spec map[string]interface{}, mode core.StrictMode) { - name := registry.GetStrFromMap(spec, "name") - version := registry.GetStrFromMap(spec, "version") - title := registry.GetStrFromMap(spec, "title") - if title == "" { - title = registry.GetStrFromMap(spec, "description") - } - servicePath := registry.GetStrFromMap(spec, "servicePath") - - fmt.Fprintf(w, "%s%s%s (%s) — %s\n\n", output.Bold, name, output.Reset, version, title) - fmt.Fprintf(w, "%sBase path: %s%s\n\n", output.Dim, servicePath, output.Reset) - - resources, _ := spec["resources"].(map[string]interface{}) - for _, resName := range sortedKeys(resources) { - resMap, _ := resources[resName].(map[string]interface{}) - methods, _ := resMap["methods"].(map[string]interface{}) - methods = filterMethodsByStrictMode(methods, mode) - if len(methods) == 0 { - continue - } - fmt.Fprintf(w, " %s%s%s\n", output.Cyan, resName, output.Reset) - for _, methodName := range sortedKeys(methods) { - m, _ := methods[methodName].(map[string]interface{}) - httpMethod := registry.GetStrFromMap(m, "httpMethod") - desc := registry.GetStrFromMap(m, "description") - danger := "" - if d, _ := m["danger"].(bool); d { - danger = fmt.Sprintf(" %s[danger]%s", output.Red, output.Reset) - } - fmt.Fprintf(w, " %-7s %s%s%s %s%s%s%s\n", httpMethod, output.Bold, methodName, output.Reset, output.Dim, desc, output.Reset, danger) - } - fmt.Fprintln(w) - } - fmt.Fprintf(w, "%sUsage: lark-cli schema %s..%s\n", output.Dim, name, output.Reset) -} - -// hasFileFields returns true if any requestBody field has type "file". -func hasFileFields(method map[string]interface{}) (bool, []string) { - names := cmdutil.DetectFileFields(method) - return len(names) > 0, names -} - -func printMethodDetail(w io.Writer, spec map[string]interface{}, resName, methodName string, method map[string]interface{}) { - servicePath := registry.GetStrFromMap(spec, "servicePath") - specName := registry.GetStrFromMap(spec, "name") - methodPath := registry.GetStrFromMap(method, "path") - fullPath := servicePath + "/" + methodPath - httpMethod := registry.GetStrFromMap(method, "httpMethod") - desc := registry.GetStrFromMap(method, "description") - isFileUpload, fileFieldNames := hasFileFields(method) - - fmt.Fprintf(w, "%s%s.%s.%s%s\n\n", output.Bold, specName, resName, methodName, output.Reset) - - httpColor := output.Yellow - if httpMethod == "GET" { - httpColor = output.Green - } else if httpMethod == "DELETE" { - httpColor = output.Red - } - fmt.Fprintf(w, " %s%s%s %s\n", httpColor, httpMethod, output.Reset, fullPath) - if desc != "" { - fmt.Fprintf(w, " %s\n", desc) - } - fmt.Fprintln(w) - - // Parameters - params, _ := method["parameters"].(map[string]interface{}) - if len(params) > 0 { - fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset) - fmt.Fprintf(w, " %s--params%s %soptional%s\n", output.Cyan, output.Reset, output.Dim, output.Reset) - for _, paramName := range sortedParamKeys(params) { - p, _ := params[paramName].(map[string]interface{}) - pType := registry.GetStrFromMap(p, "type") - if pType == "" { - pType = "string" - } - location := registry.GetStrFromMap(p, "location") - required, _ := p["required"].(bool) - reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset) - if required { - reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset) - } - locColor := output.Dim - if location == "path" { - locColor = output.Yellow - } - // Options (enum values) - optStr := formatOptions(p) - fmt.Fprintf(w, " - %s%s%s (%s, %s%s%s, %s)%s\n", output.Cyan, paramName, output.Reset, pType, locColor, location, output.Reset, reqStr, optStr) - if pdesc := registry.GetStrFromMap(p, "description"); pdesc != "" { - pdesc = util.TruncateStrWithEllipsis(pdesc, 100) - fmt.Fprintf(w, " %s%s%s\n", output.Dim, pdesc, output.Reset) - } - if ex := registry.GetStrFromMap(p, "example"); ex != "" { - fmt.Fprintf(w, " %se.g. %s%s\n", output.Dim, ex, output.Reset) - } - if rangeStr := formatRange(p); rangeStr != "" { - fmt.Fprintf(w, " %srange: %s%s\n", output.Dim, rangeStr, output.Reset) - } - } - fmt.Fprintln(w) - } - - // --data for write methods - if httpMethod == "POST" || httpMethod == "PUT" || httpMethod == "PATCH" || httpMethod == "DELETE" { - if len(params) == 0 { - fmt.Fprintf(w, "%sParameters:%s\n\n", output.Bold, output.Reset) - } - fileUploadTag := "" - if isFileUpload { - fileUploadTag = fmt.Sprintf(" %s[file upload]%s", output.Yellow, output.Reset) - } - fmt.Fprintf(w, " %s--data%s %soptional%s%s\n", output.Cyan, output.Reset, output.Dim, output.Reset, fileUploadTag) - requestBody, _ := method["requestBody"].(map[string]interface{}) - if len(requestBody) > 0 { - printNestedFields(w, requestBody, " ", "") - } - - if isFileUpload { - if len(fileFieldNames) == 1 { - fmt.Fprintf(w, "\n %s--file%s <[field=]path> %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset) - fmt.Fprintf(w, " Upload file as multipart/form-data. Default field: %q\n", fileFieldNames[0]) - } else { - fmt.Fprintf(w, "\n %s--file%s %sfile upload%s\n", output.Cyan, output.Reset, output.Dim, output.Reset) - fmt.Fprintf(w, " Upload file as multipart/form-data. Fields: %s\n", strings.Join(fileFieldNames, ", ")) - } - } - fmt.Fprintln(w) - } - - // Response - responseBody, _ := method["responseBody"].(map[string]interface{}) - if len(responseBody) > 0 { - fmt.Fprintf(w, "%sResponse:%s\n\n", output.Bold, output.Reset) - printNestedFields(w, responseBody, " ", "") - fmt.Fprintln(w) - } - - // Identity - if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 { - var identities []string - for _, t := range tokens { - if s, ok := t.(string); ok { - switch s { - case "user": - identities = append(identities, "user") - case "tenant": - identities = append(identities, "bot") - } - } - } - if len(identities) > 0 { - fmt.Fprintf(w, "%sIdentity:%s %s\n", output.Bold, output.Reset, strings.Join(identities, ", ")) - } - } - - // Scopes (all) - if scopes, ok := method["scopes"].([]interface{}); ok && len(scopes) > 0 { - var scopeStrs []string - for _, s := range scopes { - if str, ok := s.(string); ok { - scopeStrs = append(scopeStrs, str) - } - } - fmt.Fprintf(w, "%sScopes:%s %s\n", output.Bold, output.Reset, strings.Join(scopeStrs, ", ")) - } - - // CLI example - if isFileUpload && len(fileFieldNames) == 1 { - fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file \n", output.Bold, output.Reset, specName, resName, methodName) - } else if isFileUpload { - fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s --file \n", output.Bold, output.Reset, specName, resName, methodName) - } else { - fmt.Fprintf(w, "%sCLI:%s lark-cli %s %s %s\n", output.Bold, output.Reset, specName, resName, methodName) - } - - // Docs - if docUrl := registry.GetStrFromMap(method, "docUrl"); docUrl != "" { - fmt.Fprintf(w, "%sDocs:%s %s\n", output.Bold, output.Reset, docUrl) - } -} - -func printNestedFields(w io.Writer, fields map[string]interface{}, indent, prefix string) { - for _, fieldName := range sortedFieldKeys(fields) { - f, _ := fields[fieldName].(map[string]interface{}) - fullName := fieldName - if prefix != "" { - fullName = prefix + "." + fieldName - } - fType := registry.GetStrFromMap(f, "type") - required, _ := f["required"].(bool) - reqStr := fmt.Sprintf("%soptional%s", output.Dim, output.Reset) - if required { - reqStr = fmt.Sprintf("%srequired%s", output.Red, output.Reset) - } - optStr := formatOptions(f) - fmt.Fprintf(w, "%s- %s%s%s (%s, %s)%s\n", indent, output.Cyan, fullName, output.Reset, fType, reqStr, optStr) - desc := registry.GetStrFromMap(f, "description") - if desc != "" { - desc = util.TruncateStrWithEllipsis(desc, 100) - fmt.Fprintf(w, "%s %s%s%s\n", indent, output.Dim, desc, output.Reset) - } - if ex := registry.GetStrFromMap(f, "example"); ex != "" { - fmt.Fprintf(w, "%s %se.g. %s%s\n", indent, output.Dim, ex, output.Reset) - } - if rangeStr := formatRange(f); rangeStr != "" { - fmt.Fprintf(w, "%s %srange: %s%s\n", indent, output.Dim, rangeStr, output.Reset) - } - if props, ok := f["properties"].(map[string]interface{}); ok && len(props) > 0 { - printNestedFields(w, props, indent+" ", fullName) - } - } -} - -// formatOptions returns " — val1 | val2 | ..." if field has options, else "". -func formatOptions(f map[string]interface{}) string { - opts, ok := f["options"].([]interface{}) - if !ok || len(opts) == 0 { - return "" - } - var vals []string - for _, o := range opts { - if om, ok := o.(map[string]interface{}); ok { - if v := registry.GetStrFromMap(om, "value"); v != "" { - vals = append(vals, v) - } - } - } - if len(vals) == 0 { - return "" - } - return fmt.Sprintf(" %s— %s%s", output.Dim, strings.Join(vals, " | "), output.Reset) -} - -// formatRange returns "min..max" if field has min/max, else "". -func formatRange(f map[string]interface{}) string { - minVal := registry.GetStrFromMap(f, "min") - maxVal := registry.GetStrFromMap(f, "max") - if minVal == "" && maxVal == "" { - return "" - } - if minVal != "" && maxVal != "" { - return minVal + ".." + maxVal - } - if minVal != "" { - return ">=" + minVal - } - return "<=" + maxVal -} - -// sortedKeys returns map keys in alphabetical order. -func sortedKeys(m map[string]interface{}) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} - -// sortedParamKeys returns parameter keys sorted: required first, then alphabetical. -func sortedParamKeys(params map[string]interface{}) []string { - keys := make([]string, 0, len(params)) - for k := range params { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { - pi, _ := params[keys[i]].(map[string]interface{}) - pj, _ := params[keys[j]].(map[string]interface{}) - ri, _ := pi["required"].(bool) - rj, _ := pj["required"].(bool) - if ri != rj { - return ri - } - return keys[i] < keys[j] - }) - return keys -} - -// sortedFieldKeys returns field keys sorted: required first, then alphabetical. -func sortedFieldKeys(fields map[string]interface{}) []string { - keys := make([]string, 0, len(fields)) - for k := range fields { - keys = append(keys, k) - } - sort.Slice(keys, func(i, j int) bool { - fi, _ := fields[keys[i]].(map[string]interface{}) - fj, _ := fields[keys[j]].(map[string]interface{}) - ri, _ := fi["required"].(bool) - rj, _ := fj["required"].(bool) - if ri != rj { - return ri - } - return keys[i] < keys[j] - }) - return keys -} - -func findResourceByPath(resources map[string]interface{}, parts []string) (map[string]interface{}, string, []string) { - for i := len(parts); i >= 1; i-- { - candidateName := strings.Join(parts[:i], ".") - if res, ok := resources[candidateName]; ok { - if resMap, ok := res.(map[string]interface{}); ok { - return resMap, candidateName, parts[i:] - } - } - } - return nil, "", nil + // Args are the positional path segments, in either the dotted single-arg + // form ("im.messages.reply") or the space-separated form ("im messages + // reply"); apicatalog.ParsePath normalizes both. + Args []string } // NewCmdSchema creates the schema command. If runF is non-nil it is called instead of schemaRun (test hook). @@ -365,12 +39,7 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co Short: "View API method parameters, types, and scopes", Args: cobra.MaximumNArgs(8), RunE: func(cmd *cobra.Command, args []string) error { - if len(args) > 0 { - opts.Path = args[0] - } - if len(args) > 1 { - opts.ExtraArgs = args[1:] - } + opts.Args = append([]string(nil), args...) opts.Ctx = cmd.Context() if runF != nil { return runF(opts) @@ -380,433 +49,89 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co } cmdutil.DisableAuthCheck(cmd) + // Tolerated for agent compatibility; ignored — schema only emits the JSON + // envelope, and its output is identity-independent (strict-mode filtering + // comes from ResolveStrictMode, never from --as). + cmd.Flags().String("format", "json", "") + cmd.Flags().Bool("json", true, "") + cmd.Flags().String("as", "", "") + _ = cmd.Flags().MarkHidden("format") + _ = cmd.Flags().MarkHidden("json") + _ = cmd.Flags().MarkHidden("as") + cmd.ValidArgsFunction = completeSchemaPath(f) - cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json (default) | pretty") - cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { - return []string{"json", "pretty"}, cobra.ShellCompDirectiveNoFileComp - }) cmdutil.SetRisk(cmd, cmdutil.RiskRead) return cmd } -// completeSchemaPath provides tab-completion for the schema path argument. -// It handles both legacy dotted resource names (e.g. app.table.fields) and the -// newer space-separated form (e.g. `schema im messages reply`). +// completeSchemaPath is a thin adapter over the embedded catalog's Complete. +// It uses the embedded source so completion candidates match what `schema` +// execution can resolve (both overlay-free). func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { mode := f.ResolveStrictMode(cmd.Context()) - - // Case 1: legacy "single dotted arg" path — no previous args yet - if len(args) == 0 { - parts := strings.Split(toComplete, ".") - if len(parts) <= 1 { - var completions []string - for _, s := range registry.ListFromMetaProjects() { - if strings.HasPrefix(s, toComplete) { - completions = append(completions, s+".") - } - } - return completions, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveNoSpace - } - serviceName := parts[0] - spec := registry.LoadFromMeta(serviceName) - if spec == nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - spec = filterSpecByStrictMode(spec, mode) - resources, _ := spec["resources"].(map[string]interface{}) - if resources == nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - afterService := strings.Join(parts[1:], ".") - completions := completeSchemaPathForSpec(serviceName, resources, afterService) - allTrailingDot := len(completions) > 0 - for _, c := range completions { - if !strings.HasSuffix(c, ".") { - allTrailingDot = false - break - } - } - directive := cobra.ShellCompDirectiveNoFileComp - if allTrailingDot { - directive |= cobra.ShellCompDirectiveNoSpace - } - return completions, directive + completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode)) + directive := cobra.ShellCompDirectiveNoFileComp + if noSpace { + directive |= cobra.ShellCompDirectiveNoSpace } - - // Case 2: space-form, args already has segments - // Walk down service -> resource(s) -> method based on existing args - serviceName := args[0] - spec := registry.LoadFromMeta(serviceName) - if spec == nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - spec = filterSpecByStrictMode(spec, mode) - resources, _ := spec["resources"].(map[string]interface{}) - if resources == nil { - return nil, cobra.ShellCompDirectiveNoFileComp - } - - // args[1:] are resource path segments (possibly partial); current - // toComplete is the next segment under cursor. - consumed := args[1:] - resource, _, remaining := findResourceByPath(resources, consumed) - if resource == nil { - // Suggest top-level resource names that match toComplete - var completions []string - for resName := range resources { - if strings.HasPrefix(resName, toComplete) { - completions = append(completions, resName) - } - } - sort.Strings(completions) - return completions, cobra.ShellCompDirectiveNoFileComp - } - if len(remaining) > 0 { - // Already typed past the resource — suggest methods - methods, _ := resource["methods"].(map[string]interface{}) - methods = filterMethodsByStrictMode(methods, mode) - var completions []string - for mName := range methods { - if strings.HasPrefix(mName, toComplete) { - completions = append(completions, mName) - } - } - sort.Strings(completions) - return completions, cobra.ShellCompDirectiveNoFileComp - } - // Resource matched exactly, suggest methods - methods, _ := resource["methods"].(map[string]interface{}) - methods = filterMethodsByStrictMode(methods, mode) - var completions []string - for mName := range methods { - if strings.HasPrefix(mName, toComplete) { - completions = append(completions, mName) - } - } - sort.Strings(completions) - return completions, cobra.ShellCompDirectiveNoFileComp + return completions, directive } } -func completeSchemaPathForSpec(serviceName string, resources map[string]interface{}, afterService string) []string { - var completions []string - - for resName, resVal := range resources { - if strings.HasPrefix(resName, afterService) { - completions = append(completions, serviceName+"."+resName+".") - continue - } - if !strings.HasPrefix(afterService, resName+".") { - continue - } - methodPrefix := afterService[len(resName)+1:] - resMap, _ := resVal.(map[string]interface{}) - if resMap == nil { - continue - } - methods, _ := resMap["methods"].(map[string]interface{}) - for methodName := range methods { - if strings.HasPrefix(methodName, methodPrefix) { - completions = append(completions, serviceName+"."+resName+"."+methodName) - } - } - } - - sort.Strings(completions) - return completions -} - func schemaRun(opts *SchemaOptions) error { out := opts.Factory.IOStreams.Out mode := opts.Factory.ResolveStrictMode(opts.Ctx) - - // args may have arrived as a single string (legacy single-arg path) or - // split into multiple — normalize to a single args slice. - var rawArgs []string - if opts.Path != "" { - rawArgs = []string{opts.Path} - } - if len(opts.ExtraArgs) > 0 { - if opts.Path != "" { - rawArgs = append([]string{opts.Path}, opts.ExtraArgs...) - } else { - rawArgs = append([]string(nil), opts.ExtraArgs...) - } - } - parts := schema.ParsePath(rawArgs) - - if opts.Format == "pretty" { - return runPrettyMode(out, parts, mode) - } - return runJSONMode(out, parts, mode) + return runSchema(out, apicatalog.ParsePath(opts.Args), mode) } -// runJSONMode dispatches list/single envelope output based on parts. -// JSON mode uses embedded data only (bypasses remote overlay) so envelope -// output is deterministic across machines. -func runJSONMode(out io.Writer, parts []string, mode core.StrictMode) error { - filter := strictModeFilter(mode) - - switch len(parts) { - case 0: - envs := schema.AssembleAll(filter) - output.PrintJson(out, envs) - return nil - case 1: - spec := registry.EmbeddedSpec(parts[0]) - if spec == nil { - return errUnknownEmbeddedService(parts[0]) +// runSchema resolves the path through the embedded catalog and renders the +// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and +// schema owns rendering (Envelope/Envelopes); this adapter only chooses the +// output shape — a single resolved method renders as one envelope object, +// anything broader as an array — and maps resolve failures to hints. +func runSchema(out io.Writer, parts []string, mode core.StrictMode) error { + catalog := registry.EmbeddedCatalog() + target, err := catalog.Resolve(parts) + if err != nil { + return resolveError(err) + } + refs := catalog.MethodRefs(target, registry.FilterForStrictMode(mode)) + if target.Kind == apicatalog.TargetMethod { + if len(refs) == 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "Method %s not available in current identity mode", target.Method.SchemaPath()). + WithHint("strict mode hides methods the active account identity cannot call; it is shown for an identity (user or bot) that has the required access token") } - envs := schema.AssembleService(parts[0], spec, filter) - output.PrintJson(out, envs) - return nil - default: - return runJSONForPath(out, parts, filter) - } -} - -// runJSONForPath handles len(parts) >= 2: try resource match first, fallback -// to single-method match. Uses embedded data only. -func runJSONForPath(out io.Writer, parts []string, filter schema.MethodFilter) error { - serviceName := parts[0] - spec := registry.EmbeddedSpec(serviceName) - if spec == nil { - return errUnknownEmbeddedService(serviceName) - } - resources, _ := spec["resources"].(map[string]interface{}) - resource, resName, remaining := findResourceByPath(resources, parts[1:]) - if resource == nil { - var names []string - for k := range resources { - names = append(names, k) - } - sort.Strings(names) - return output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")), - fmt.Sprintf("Available: %s", strings.Join(names, ", "))) - } - if len(remaining) == 0 { - // Resource-scoped envelope array - envs := assembleResource(serviceName, resName, resource, filter) - output.PrintJson(out, envs) + output.PrintJson(out, schema.EnvelopeOf(refs[0])) return nil } - methodName := remaining[0] - methods, _ := resource["methods"].(map[string]interface{}) - method, ok := methods[methodName].(map[string]interface{}) - if !ok { - var names []string - for k := range methods { - names = append(names, k) - } - sort.Strings(names) - return output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName), - fmt.Sprintf("Available: %s", strings.Join(names, ", "))) - } - if len(remaining) > 1 { - // Method exists but caller appended extra segments — reject so they - // don't silently get this method's schema when they typo'd the path. - return output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("Unknown path: %s.%s.%s", - serviceName, resName, strings.Join(remaining, ".")), - fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve", - methodName, strings.Join(remaining[1:], "."))) - } - if filter != nil && !filter(method) { - // Method exists in spec but filtered out by strict mode - return output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("Method %s.%s.%s not available in current identity mode", serviceName, resName, methodName), - "Use --as user / --as bot to switch") - } - env := schema.AssembleEnvelope(serviceName, []string{resName}, methodName, method) - output.PrintJson(out, env) + output.PrintJson(out, schema.Envelopes(refs)) return nil } -func assembleResource(serviceName, resName string, resource map[string]interface{}, filter schema.MethodFilter) []schema.Envelope { - methods, _ := resource["methods"].(map[string]interface{}) - resourcePath := []string{resName} - var envs []schema.Envelope - for methodName, raw := range methods { - method, ok := raw.(map[string]interface{}) - if !ok { - continue - } - if filter != nil && !filter(method) { - continue - } - envs = append(envs, schema.AssembleEnvelope(serviceName, resourcePath, methodName, method)) +// resolveError maps a catalog *ResolveError to a typed *errs.ValidationError +// (CategoryValidation drives the exit code; Hint promotes to the envelope), +// preserving the historical message + hint text. +func resolveError(err error) error { + var re *apicatalog.ResolveError + if !errors.As(err, &re) { + return err } - sort.Slice(envs, func(i, j int) bool { return envs[i].Name < envs[j].Name }) - return envs -} - -// runPrettyMode preserves the existing legacy pretty rendering verbatim. -// All printServices/printResourceList/printMethodDetail calls stay unchanged. -func runPrettyMode(out io.Writer, parts []string, mode core.StrictMode) error { - if len(parts) == 0 { - printServices(out) - return nil - } - serviceName := parts[0] - spec := registry.LoadFromMeta(serviceName) - if spec == nil { - return errUnknownService(serviceName) - } - if len(parts) == 1 { - printResourceList(out, spec, mode) - return nil - } - resources, _ := spec["resources"].(map[string]interface{}) - resource, resName, remaining := findResourceByPath(resources, parts[1:]) - if resource == nil { - var names []string - for k := range resources { - names = append(names, k) - } - sort.Strings(names) - return output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("Unknown resource: %s.%s", serviceName, strings.Join(parts[1:], ".")), - fmt.Sprintf("Available: %s", strings.Join(names, ", "))) - } - if len(remaining) == 0 { - fmt.Fprintf(out, "%s%s.%s%s\n\n", output.Bold, serviceName, resName, output.Reset) - methods, _ := resource["methods"].(map[string]interface{}) - methods = filterMethodsByStrictMode(methods, mode) - for _, mName := range sortedKeys(methods) { - m, _ := methods[mName].(map[string]interface{}) - httpMethod := registry.GetStrFromMap(m, "httpMethod") - desc := registry.GetStrFromMap(m, "description") - fmt.Fprintf(out, " %-7s %s%s%s %s%s%s\n", httpMethod, output.Bold, mName, output.Reset, output.Dim, desc, output.Reset) - } - fmt.Fprintf(out, "\n%sUsage: lark-cli schema %s.%s.%s\n", output.Dim, serviceName, resName, output.Reset) - return nil - } - methodName := remaining[0] - methods, _ := resource["methods"].(map[string]interface{}) - methods = filterMethodsByStrictMode(methods, mode) - method, ok := methods[methodName].(map[string]interface{}) - if !ok { - var names []string - for k := range methods { - names = append(names, k) - } - sort.Strings(names) - return output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("Unknown method: %s.%s.%s", serviceName, resName, methodName), - fmt.Sprintf("Available: %s", strings.Join(names, ", "))) - } - if len(remaining) > 1 { - return output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("Unknown path: %s.%s.%s", - serviceName, resName, strings.Join(remaining, ".")), - fmt.Sprintf("Method %q exists but the trailing segments %q do not resolve", - methodName, strings.Join(remaining[1:], "."))) - } - printMethodDetail(out, spec, resName, methodName, method) - return nil -} - -// strictModeFilter adapts core.StrictMode into a schema.MethodFilter, or returns -// nil if strict mode is not active. -func strictModeFilter(mode core.StrictMode) schema.MethodFilter { - if !mode.IsActive() { - return nil - } - token := registry.IdentityToAccessToken(string(mode.ForcedIdentity())) - return func(method map[string]interface{}) bool { - tokens, _ := method["accessTokens"].([]interface{}) - if tokens == nil { - return true // permissive when meta_data lacks accessTokens - } - for _, t := range tokens { - if s, _ := t.(string); s == token { - return true - } - } - return false - } -} - -func errUnknownService(name string) error { - return output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("Unknown service: %s", name), - fmt.Sprintf("Available: %s", strings.Join(registry.ListFromMetaProjects(), ", "))) -} - -// errUnknownEmbeddedService is the JSON-mode variant: it lists only embedded -// services (no overlay) because JSON mode itself bypasses overlay; suggesting -// overlay-only services would mislead callers when those services subsequently -// fail to resolve in envelope output. -func errUnknownEmbeddedService(name string) error { - return output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("Unknown service: %s", name), - fmt.Sprintf("Available: %s", strings.Join(registry.EmbeddedServiceNames(), ", "))) -} - -// filterSpecByStrictMode returns a shallow copy of spec with each resource's methods -// filtered by strict mode. Returns the original spec when strict mode is off. -func filterSpecByStrictMode(spec map[string]interface{}, mode core.StrictMode) map[string]interface{} { - if !mode.IsActive() { - return spec - } - result := make(map[string]interface{}, len(spec)) - for k, v := range spec { - result[k] = v - } - resources, _ := spec["resources"].(map[string]interface{}) - if resources == nil { - return result - } - filteredRes := make(map[string]interface{}, len(resources)) - for resName, resVal := range resources { - resMap, ok := resVal.(map[string]interface{}) - if !ok { - continue - } - methods, _ := resMap["methods"].(map[string]interface{}) - filtered := filterMethodsByStrictMode(methods, mode) - if len(filtered) == 0 { - continue - } - resCopy := make(map[string]interface{}, len(resMap)) - for k, v := range resMap { - resCopy[k] = v - } - resCopy["methods"] = filtered - filteredRes[resName] = resCopy - } - result["resources"] = filteredRes - return result -} - -// filterMethodsByStrictMode removes methods incompatible with the active strict mode. -// Returns the original map unmodified when strict mode is off. -func filterMethodsByStrictMode(methods map[string]interface{}, mode core.StrictMode) map[string]interface{} { - if !mode.IsActive() || methods == nil { - return methods - } - token := registry.IdentityToAccessToken(string(mode.ForcedIdentity())) - filtered := make(map[string]interface{}, len(methods)) - for name, val := range methods { - m, ok := val.(map[string]interface{}) - if !ok { - continue - } - tokens, _ := m["accessTokens"].([]interface{}) - if tokens == nil { - filtered[name] = val - continue - } - for _, t := range tokens { - if ts, ok := t.(string); ok && ts == token { - filtered[name] = val - break - } - } - } - return filtered + switch re.Kind { + case apicatalog.ErrService: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown service: %s", re.Subject). + WithHint("Available: %s", strings.Join(re.Candidates, ", ")) + case apicatalog.ErrResource: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown resource: %s", re.Subject). + WithHint("Available: %s", strings.Join(re.Candidates, ", ")) + case apicatalog.ErrMethod: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown method: %s", re.Subject). + WithHint("Available: %s", strings.Join(re.Candidates, ", ")) + case apicatalog.ErrPath: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "Unknown path: %s", re.Subject). + WithHint("Method %q exists but the trailing segments %q do not resolve", re.Method, re.Trailing) + } + return err } diff --git a/cmd/schema/schema_test.go b/cmd/schema/schema_test.go index cb9e51c8b..c4eea4c58 100644 --- a/cmd/schema/schema_test.go +++ b/cmd/schema/schema_test.go @@ -4,7 +4,6 @@ package schema import ( - "bytes" "encoding/json" "strings" "testing" @@ -21,29 +20,46 @@ func TestSchemaCmd_FlagParsing(t *testing.T) { gotOpts = opts return nil }) - cmd.SetArgs([]string{"calendar.events.list", "--format", "pretty"}) + cmd.SetArgs([]string{"calendar.events.list"}) err := cmd.Execute() if err != nil { t.Fatalf("unexpected error: %v", err) } - if gotOpts.Path != "calendar.events.list" { - t.Errorf("expected path calendar.events.list, got %s", gotOpts.Path) - } - if gotOpts.Format != "pretty" { - t.Errorf("expected Format=pretty, got %s", gotOpts.Format) + if len(gotOpts.Args) != 1 || gotOpts.Args[0] != "calendar.events.list" { + t.Errorf("expected args [calendar.events.list], got %v", gotOpts.Args) } } -func TestSchemaCmd_NoArgs_Pretty(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, nil) - - cmd := NewCmdSchema(f, nil) - cmd.SetArgs([]string{"--format", "pretty"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) +func TestSchemaCmd_OutputFlagsAcceptedForCompat(t *testing.T) { + // Agents are habituated to --format/--json/--as from api/service commands. + // schema must accept them without erroring and always emit the JSON envelope — + // its output is structured JSON and identity-independent, so the values have + // no effect. + argSets := [][]string{ + {"--format", "json"}, + {"--format", "pretty"}, + {"--format", "table"}, // no table rendering for a nested schema -> JSON + {"--format", "csv"}, + {"--json"}, + {"--json", "--format", "ndjson"}, + {"--as", "user"}, + {"--as", "bot"}, + {"--as", "user", "--json"}, } - if !strings.Contains(stdout.String(), "Available services") { - t.Error("expected service list in pretty mode") + for _, extra := range argSets { + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + cmd := NewCmdSchema(f, nil) + cmd.SetArgs(append([]string{"im.images.create"}, extra...)) + if err := cmd.Execute(); err != nil { + t.Fatalf("args %v should be accepted, got error: %v", extra, err) + } + var env map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("args %v: output is not a JSON envelope: %v\n%s", extra, err, stdout.String()) + } + if env["name"] != "im images create" { + t.Errorf("args %v: expected the im images create envelope, got name=%v", extra, env["name"]) + } } } @@ -51,7 +67,7 @@ func TestSchemaCmd_NoArgs_JSON_IsArray(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, nil) cmd := NewCmdSchema(f, nil) - cmd.SetArgs([]string{}) // default --format json + cmd.SetArgs([]string{}) if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -76,7 +92,7 @@ func TestSchemaCmd_JSONIsEnvelope(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, nil) cmd := NewCmdSchema(f, nil) - cmd.SetArgs([]string{"im.images.create", "--format", "json"}) + cmd.SetArgs([]string{"im.images.create"}) if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) } @@ -179,23 +195,6 @@ func TestSchemaCmd_NoYesForReadRisk(t *testing.T) { } } -func TestSchemaCmd_PrettyUnchanged_KeyTextPresent(t *testing.T) { - f, stdout, _, _ := cmdutil.TestFactory(t, nil) - - cmd := NewCmdSchema(f, nil) - cmd.SetArgs([]string{"im.images.create", "--format", "pretty"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("unexpected error: %v", err) - } - out := stdout.String() - // Existing pretty rendering surfaces these markers — they must still appear - for _, want := range []string{"Parameters:", "Response:", "Identity:", "Scopes:", "CLI:"} { - if !strings.Contains(out, want) { - t.Errorf("pretty output missing marker %q", want) - } - } -} - func TestSchemaCmd_UnknownService(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, @@ -212,168 +211,6 @@ func TestSchemaCmd_UnknownService(t *testing.T) { } } -func TestPrintMethodDetail_FileUpload(t *testing.T) { - spec := map[string]interface{}{ - "name": "im", - "servicePath": "/open-apis/im/v1", - } - method := map[string]interface{}{ - "path": "images", - "httpMethod": "POST", - "description": "Upload an image", - "requestBody": map[string]interface{}{ - "image_type": map[string]interface{}{ - "type": "string", - "required": true, - }, - "image": map[string]interface{}{ - "type": "file", - "required": true, - }, - }, - "accessTokens": []interface{}{"user", "tenant"}, - } - - var buf bytes.Buffer - printMethodDetail(&buf, spec, "images", "create", method) - out := buf.String() - - if !strings.Contains(out, "file upload") { - t.Errorf("expected 'file upload' marker in output, got:\n%s", out) - } - if !strings.Contains(out, "--file") { - t.Errorf("expected '--file' in output, got:\n%s", out) - } - if !strings.Contains(out, `"image"`) { - t.Errorf("expected default field name 'image' in output, got:\n%s", out) - } - if !strings.Contains(out, "--file ") { - t.Errorf("expected CLI example with --file , got:\n%s", out) - } -} - -func TestPrintMethodDetail_NoFileUpload(t *testing.T) { - spec := map[string]interface{}{ - "name": "calendar", - "servicePath": "/open-apis/calendar/v4", - } - method := map[string]interface{}{ - "path": "events", - "httpMethod": "POST", - "description": "Create an event", - "requestBody": map[string]interface{}{ - "summary": map[string]interface{}{ - "type": "string", - "required": true, - }, - }, - } - - var buf bytes.Buffer - printMethodDetail(&buf, spec, "events", "create", method) - out := buf.String() - - if strings.Contains(out, "file upload") { - t.Errorf("did not expect 'file upload' marker for non-file method, got:\n%s", out) - } - if strings.Contains(out, "--file") { - t.Errorf("did not expect '--file' for non-file method, got:\n%s", out) - } -} - -func TestHasFileFields(t *testing.T) { - tests := []struct { - name string - method map[string]interface{} - wantBool bool - wantFields []string - }{ - { - name: "has file field", - method: map[string]interface{}{ - "requestBody": map[string]interface{}{ - "image": map[string]interface{}{"type": "file"}, - "name": map[string]interface{}{"type": "string"}, - }, - }, - wantBool: true, - wantFields: []string{"image"}, - }, - { - name: "no file field", - method: map[string]interface{}{ - "requestBody": map[string]interface{}{ - "name": map[string]interface{}{"type": "string"}, - }, - }, - wantBool: false, - wantFields: nil, - }, - { - name: "no requestBody", - method: map[string]interface{}{}, - wantBool: false, - wantFields: nil, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, names := hasFileFields(tt.method) - if got != tt.wantBool { - t.Errorf("hasFileFields() = %v, want %v", got, tt.wantBool) - } - if tt.wantFields == nil && names != nil { - t.Errorf("expected nil names, got %v", names) - } - if tt.wantFields != nil && len(names) != len(tt.wantFields) { - t.Errorf("expected %d field names, got %d", len(tt.wantFields), len(names)) - } - }) - } -} - -func TestCompleteSchemaPathForSpec(t *testing.T) { - resources := map[string]interface{}{ - "records": map[string]interface{}{ - "methods": map[string]interface{}{ - "create": map[string]interface{}{}, - "list": map[string]interface{}{}, - }, - }, - "record_permissions": map[string]interface{}{ - "methods": map[string]interface{}{ - "get": map[string]interface{}{}, - }, - }, - } - - got := completeSchemaPathForSpec("base", resources, "records.cr") - if len(got) != 1 || got[0] != "base.records.create" { - t.Fatalf("completions = %v, want [base.records.create]", got) - } - - got = completeSchemaPathForSpec("base", resources, "record") - if len(got) != 2 || got[0] != "base.record_permissions." || got[1] != "base.records." { - t.Fatalf("resource completions = %v", got) - } -} - -func TestFilterSpecByStrictMode_RemovesIncompatibleMethodsFromCompletionSource(t *testing.T) { - spec := map[string]interface{}{ - "resources": map[string]interface{}{ - "records": map[string]interface{}{ - "methods": map[string]interface{}{ - "list": map[string]interface{}{"accessTokens": []interface{}{"tenant"}}, - "create": map[string]interface{}{"accessTokens": []interface{}{"user"}}, - }, - }, - }, - } - - filtered := filterSpecByStrictMode(spec, core.StrictModeBot) - resources, _ := filtered["resources"].(map[string]interface{}) - got := completeSchemaPathForSpec("base", resources, "records.") - if len(got) != 1 || got[0] != "base.records.list" { - t.Fatalf("filtered completions = %v, want [base.records.list]", got) - } -} +// Completion candidate generation (dotted + space forms, strict-mode filtering, +// dotted-resource handling) now lives in internal/apicatalog and is covered by +// apicatalog's TestComplete. cmd/schema only adapts catalog.Complete to cobra. diff --git a/cmd/service/affordance.go b/cmd/service/affordance.go new file mode 100644 index 000000000..53fd1a2f2 --- /dev/null +++ b/cmd/service/affordance.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "fmt" + "strings" + + "github.com/larksuite/cli/internal/meta" +) + +// methodLong composes a method command's long help in one place: the +// description, the affordance guidance block (when the method has one), the +// pointer to the full schema, and the params-only addendum (params whose flag +// name is taken — paramFlagBinder.paramsOnlyHelp, "" when none). Affordance +// sits near the top so an agent sees when-to-use and few-shot examples before +// the flag list. +func methodLong(description, affordance, schemaPath, paramsOnly string) string { + var b strings.Builder + b.WriteString(description) + if affordance != "" { + b.WriteString("\n\n") + b.WriteString(affordance) + } + fmt.Fprintf(&b, "\n\nView parameter definitions before calling:\n lark-cli schema %s", schemaPath) + b.WriteString(paramsOnly) + return b.String() +} + +// renderAffordance renders a method's affordance as a help block — when to use, +// prerequisites, and (most importantly for agents) few-shot Examples — or "" when +// the method carries no affordance. It reads the single typed model +// (meta.Method.ParsedAffordance) so the help and the envelope agree on shape. +func renderAffordance(m meta.Method) string { + a, ok := m.ParsedAffordance() + if !ok { + return "" + } + + var b strings.Builder + bullets := func(title string, items []string) { + var nonEmpty []string + for _, it := range items { + if strings.TrimSpace(it) != "" { + nonEmpty = append(nonEmpty, it) + } + } + if len(nonEmpty) == 0 { + return + } + fmt.Fprintf(&b, "%s:\n", title) + for _, it := range nonEmpty { + fmt.Fprintf(&b, " • %s\n", it) + } + } + + bullets("When to use", a.UseWhen) + bullets("Avoid when", a.DoNotUseWhen) + bullets("Prerequisites", a.Prerequisites) + if len(a.Examples) > 0 { + var lines []string + for _, ex := range a.Examples { + if ex.Command == "" { + continue + } + if ex.Description != "" { + lines = append(lines, fmt.Sprintf(" • %s\n %s", ex.Description, ex.Command)) + } else { + lines = append(lines, fmt.Sprintf(" • %s", ex.Command)) + } + } + if len(lines) > 0 { + fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n")) + } + } + bullets("Related", a.Related) + + return strings.TrimRight(b.String(), "\n") +} diff --git a/cmd/service/affordance_test.go b/cmd/service/affordance_test.go new file mode 100644 index 000000000..e3111f62c --- /dev/null +++ b/cmd/service/affordance_test.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/meta" +) + +func TestRenderAffordance(t *testing.T) { + raw := json.RawMessage(`{ + "use_when": ["发送文本消息"], + "do_not_use_when": ["群已解散"], + "prerequisites": ["已获取 chat_id"], + "examples": [ + {"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"}, + {"command":"lark-cli im messages list"}, + {"description":"no command, skipped","command":""} + ], + "related": ["im.messages.list"] + }`) + out := renderAffordance(meta.Method{Affordance: raw}) + for _, want := range []string{ + "When to use:", "发送文本消息", + "Avoid when:", "群已解散", + "Prerequisites:", "已获取 chat_id", + "Examples:", "发一条文本", "lark-cli im messages create --params '{...}'", + "lark-cli im messages list", // example with no description -> bare command line + "Related:", "im.messages.list", + } { + if !strings.Contains(out, want) { + t.Errorf("renderAffordance missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "no command, skipped") { + t.Errorf("example with empty command should be skipped:\n%s", out) + } + + // Absent or empty affordance renders nothing (so methods without an overlay + // add nothing to their help). + if renderAffordance(meta.Method{}) != "" || renderAffordance(meta.Method{Affordance: json.RawMessage(`{}`)}) != "" { + t.Error("empty affordance should render nothing") + } +} + +func TestServiceMethod_AffordanceInLong(t *testing.T) { + withAff := map[string]interface{}{ + "path": "messages", "httpMethod": "POST", "description": "发送消息", + "affordance": map[string]interface{}{ + "examples": []interface{}{ + map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."}, + }, + }, + } + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withAff), "create", "messages", nil) + if !strings.Contains(cmd.Long, "Examples:") || !strings.Contains(cmd.Long, "lark-cli im messages create ...") { + t.Errorf("affordance examples not in command Long:\n%s", cmd.Long) + } + + // A method with no affordance adds no guidance block. + plain := map[string]interface{}{"path": "x", "httpMethod": "GET", "description": "d"} + cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(plain), "list", "x", nil) + if strings.Contains(cmd2.Long, "Examples:") { + t.Errorf("no-affordance method should have no Examples in Long:\n%s", cmd2.Long) + } +} diff --git a/cmd/service/flaggroups.go b/cmd/service/flaggroups.go new file mode 100644 index 000000000..8d1669e6d --- /dev/null +++ b/cmd/service/flaggroups.go @@ -0,0 +1,211 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// Flag annotations the grouped service-method help renderer reads. +const ( + flagGroupAnnotation = "lark_flag_group" // display group key + flagSubAnnotation = "lark_flag_sub" // "required" | "optional" within API Parameters + flagNoteAnnotation = "lark_flag_note" // extra lines shown indented under a flag + + groupParams = "params" // typed path/query flags + groupBody = "body" // --data, --file + groupRaw = "raw" // --params + groupExecution = "execution" // --as/--dry-run/--page-*/--yes + groupOutput = "output" // --output/--format/--jq + + subRequired = "required" + subOptional = "optional" +) + +// serviceFlagGroupOrder is the display order + titles of the flag groups. API +// Parameters carries only typed path/query flags; raw --params, request body and +// execution/output controls each get their own group so an agent can tell the +// distinct input kinds apart. +var serviceFlagGroupOrder = []struct{ key, title string }{ + {groupParams, "API Parameters"}, + {groupBody, "Request Body"}, + {groupRaw, "Raw Parameter Input"}, + {groupExecution, "Execution"}, + {groupOutput, "Output"}, +} + +// applyGroupedUsage installs the grouped usage renderer on a service method +// cmd: local flags via the grouped renderer instead of cobra's flat Flags: +// list; global (inherited) flags and the Risk/Tips sections appended by the +// root help func are unaffected. Rendered by hand rather than via +// cmd.SetUsageTemplate: cobra lazy-links text/template on the first +// SetUsageTemplate call, whose executor reaches reflect.Value.MethodByName — +// that disables the linker's method-level dead-code elimination and costs +// ~19 MB of binary size. +func applyGroupedUsage(cmd *cobra.Command) { + cmd.SetUsageFunc(func(c *cobra.Command) error { + w := c.OutOrStderr() + fmt.Fprintf(w, "Usage:\n %s\n", c.UseLine()) + if c.HasAvailableLocalFlags() { + fmt.Fprintf(w, "\n%s\n", renderServiceFlagGroups(c)) + } + if c.HasAvailableInheritedFlags() { + fmt.Fprintf(w, "\nGlobal Flags:\n%s\n", strings.TrimRight(c.InheritedFlags().FlagUsages(), " \t\n")) + } + return nil + }) +} + +func annotate(f *pflag.Flag, key string, vals []string) { + if f.Annotations == nil { + f.Annotations = map[string][]string{} + } + f.Annotations[key] = vals +} + +// tagFlagGroup records a flag's display group (no-op if the flag is absent). +func tagFlagGroup(fs *pflag.FlagSet, name, group string) { + if f := fs.Lookup(name); f != nil { + annotate(f, flagGroupAnnotation, []string{group}) + } +} + +func annotationOf(f *pflag.Flag, key string) []string { + if f.Annotations != nil { + return f.Annotations[key] + } + return nil +} + +func flagGroupOf(f *pflag.Flag) string { + if v := annotationOf(f, flagGroupAnnotation); len(v) > 0 { + return v[0] + } + return "" +} + +func flagSubOf(f *pflag.Flag) string { + if v := annotationOf(f, flagSubAnnotation); len(v) > 0 { + return v[0] + } + return "" +} + +// renderServiceFlagGroups renders the command's local flags into ordered, +// titled groups; the API Parameters group is further split into Required / +// Optional. It is the body of the usage func applyGroupedUsage installs. +func renderServiceFlagGroups(cmd *cobra.Command) string { + var b strings.Builder + seen := map[*pflag.Flag]bool{} + for _, g := range serviceFlagGroupOrder { + flags := groupFlags(cmd, g.key, seen) + if len(flags) == 0 { + continue + } + fmt.Fprintf(&b, "%s:\n", g.title) + if g.key == groupParams { + writeSection(&b, " Required:", subFlags(flags, subRequired)) + writeSection(&b, " Optional:", subFlags(flags, subOptional)) + } else { + writeSection(&b, "", flags) + } + fmt.Fprintln(&b) + } + // Anything untagged (e.g. -h/--help) goes last under "Other". + var other []*pflag.Flag + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden || seen[f] { + return + } + other = append(other, f) + }) + if len(other) > 0 { + fmt.Fprintln(&b, "Other:") + writeSection(&b, "", other) + } + return strings.TrimRight(b.String(), "\n") +} + +// groupFlags returns the visible local flags tagged with group key, marking them +// seen so the trailing "Other" bucket only catches genuinely untagged flags. +func groupFlags(cmd *cobra.Command, key string, seen map[*pflag.Flag]bool) []*pflag.Flag { + var flags []*pflag.Flag + cmd.LocalFlags().VisitAll(func(f *pflag.Flag) { + if f.Hidden || flagGroupOf(f) != key { + return + } + flags = append(flags, f) + seen[f] = true + }) + return flags +} + +func subFlags(flags []*pflag.Flag, sub string) []*pflag.Flag { + var out []*pflag.Flag + for _, f := range flags { + s := flagSubOf(f) + // Untagged subgroup defaults to Optional so nothing is dropped. + if s == sub || (s == "" && sub == subOptional) { + out = append(out, f) + } + } + return out +} + +// writeSection prints an optional (sub)header and the flags, aligned in a +// column, each flag row followed by its note lines indented under the usage. +func writeSection(b *strings.Builder, header string, flags []*pflag.Flag) { + if len(flags) == 0 { + return + } + if header != "" { + fmt.Fprintf(b, "%s\n", header) + } + specs := make([]string, len(flags)) + maxSpec := 0 + for i, f := range flags { + specs[i] = flagSpec(f) + if len(specs[i]) > maxSpec { + maxSpec = len(specs[i]) + } + } + for i, f := range flags { + _, usage := pflag.UnquoteUsage(f) + if showsDefault(f) { + usage += fmt.Sprintf(" (default %s)", f.DefValue) + } + fmt.Fprintf(b, "%-*s %s\n", maxSpec, specs[i], strings.TrimSpace(usage)) + for _, note := range annotationOf(f, flagNoteAnnotation) { + fmt.Fprintf(b, "%*s%s\n", maxSpec+3+4, "", note) + } + } +} + +// flagSpec is pflag's " --name type" / " -x, --name type" left column. +func flagSpec(f *pflag.Flag) string { + typeName, _ := pflag.UnquoteUsage(f) + spec := " --" + f.Name + if f.Shorthand != "" && f.ShorthandDeprecated == "" { + spec = " -" + f.Shorthand + ", --" + f.Name + } + if typeName != "" { + spec += " " + typeName + } + return spec +} + +// showsDefault mirrors pflag's "non-zero default" rule for the flag types these +// commands use, so the grouped rendering shows the same "(default x)" hints as +// cobra's flat list. +func showsDefault(f *pflag.Flag) bool { + switch f.DefValue { + case "", "0", "false", "[]": + return false + } + return true +} diff --git a/cmd/service/flaggroups_test.go b/cmd/service/flaggroups_test.go new file mode 100644 index 000000000..59d741a42 --- /dev/null +++ b/cmd/service/flaggroups_test.go @@ -0,0 +1,115 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/meta" +) + +func TestServiceFlagGroups_AgentContract(t *testing.T) { + method := map[string]interface{}{ + "path": "chats/:chat_id/members", + "httpMethod": "POST", + "parameters": map[string]interface{}{ + "chat_id": map[string]interface{}{"type": "string", "location": "path", "required": true}, + "member_id_type": map[string]interface{}{ + "type": "string", "location": "query", + "options": []interface{}{ + map[string]interface{}{"value": "open_id", "description": "以 open_id 标识用户"}, + map[string]interface{}{"value": "user_id", "description": "以 user_id 标识用户"}, + }, + }, + }, + // Documented body field -> --data belongs under Request Body. + "requestBody": map[string]interface{}{ + "id_list": map[string]interface{}{"type": "list", "required": true}, + }, + } + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "create", "chat.members", nil) + out := renderServiceFlagGroups(cmd) + + idx := func(s string) int { return strings.Index(out, s) } + + // Section order: API Parameters → Request Body → Raw Parameter Input → Execution → Output. + iParams, iBody, iRaw, iExec, iOut := idx("API Parameters:"), idx("Request Body:"), idx("Raw Parameter Input:"), idx("Execution:"), idx("Output:") + for name, i := range map[string]int{"API Parameters": iParams, "Request Body": iBody, "Raw Parameter Input": iRaw, "Execution": iExec, "Output": iOut} { + if i < 0 { + t.Fatalf("missing section %q in:\n%s", name, out) + } + } + if !(iParams < iBody && iBody < iRaw && iRaw < iExec && iExec < iOut) { + t.Errorf("section order wrong:\n%s", out) + } + + // Required/Optional subsections under API Parameters. + if i := idx(" Required:"); i < iParams || i > iBody { + t.Errorf("Required subsection misplaced:\n%s", out) + } + if i := idx(" Optional:"); i < iParams || i > iBody { + t.Errorf("Optional subsection misplaced:\n%s", out) + } + + // Typed flags are API Parameters; required path flag under Required, enum + // flag under Optional with an inline "enum: ..." (not multi-line meanings). + if i := idx("--chat-id"); i < iParams || i > iBody { + t.Errorf("--chat-id not under API Parameters:\n%s", out) + } + if !strings.Contains(out, "chat_id, required") { + t.Errorf("typed flag help format wrong:\n%s", out) + } + if !strings.Contains(out, "enum: open_id=以 open_id 标识用户|user_id=以 user_id 标识用户") { + t.Errorf("expected compact enum value=meaning inline:\n%s", out) + } + + // --data is Request Body; --params is Raw Parameter Input (NOT API Parameters) + // and carries the precedence rule. + if i := idx("--data"); i < iBody || i > iRaw { + t.Errorf("--data not under Request Body:\n%s", out) + } + if i := idx("--params"); i < iRaw || i > iExec { + t.Errorf("--params not under Raw Parameter Input:\n%s", out) + } + if !strings.Contains(out, "typed flags override matching keys in --params") { + t.Errorf("missing --params precedence rule:\n%s", out) + } + + // Control flags land in Execution/Output. + if i := idx("--dry-run"); i < iExec || i > iOut { + t.Errorf("--dry-run not under Execution:\n%s", out) + } + if idx("--format") < iOut { + t.Errorf("--format not under Output:\n%s", out) + } + + // The usage template is wired to the grouped renderer (no flat Flags: list). + if u := cmd.UsageString(); !strings.Contains(u, "API Parameters:") || strings.Contains(u, "\nFlags:\n") { + t.Errorf("usage template not grouped:\n%s", u) + } +} + +// TestServiceFlagGroups_UndocumentedBodyIsRaw: a POST with no documented body +// fields still offers --data (escape hatch) but must NOT imply a declared body — +// it goes under Raw Parameter Input, not "Request Body". +func TestServiceFlagGroups_UndocumentedBodyIsRaw(t *testing.T) { + method := map[string]interface{}{"path": "things/do", "httpMethod": "POST"} // POST, no requestBody, no params + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "do", "things", nil) + out := renderServiceFlagGroups(cmd) + + if strings.Contains(out, "Request Body:") { + t.Errorf("undocumented body must not render a Request Body section:\n%s", out) + } + iRaw, iData := strings.Index(out, "Raw Parameter Input:"), strings.Index(out, "--data") + if iRaw < 0 || iData < iRaw { + t.Errorf("--data not under Raw Parameter Input:\n%s", out) + } + if !strings.Contains(out, "no documented fields") { + t.Errorf("--data should be labeled a raw escape hatch:\n%s", out) + } +} diff --git a/cmd/service/paramflags.go b/cmd/service/paramflags.go new file mode 100644 index 000000000..8c7590dc5 --- /dev/null +++ b/cmd/service/paramflags.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "fmt" + "strings" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/meta" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +type boundParamFlag struct { + field meta.Field + read func() interface{} +} + +// paramsOnlyField is a path/query parameter that got no typed flag because its +// kebab name is already taken by another flag (a standard flag like --format, or +// a root persistent flag). It stays reachable via --params; the binder keeps it, +// with the flag that claimed the name, so --help can show the exact --params form +// and steer the reader off the wrong flag. +type paramsOnlyField struct { + field meta.Field + claimed *pflag.Flag +} + +// paramFlagBinder owns one service method's generated typed param flags: it +// registers them (kind, help, enum completion, reserved-name skip) and applies +// the --params overlay, where a changed typed flag overrides its key in the +// --params JSON. Holding the field<->flag binding here keeps the request builder +// from re-deriving which flags map to which param keys. +type paramFlagBinder struct { + bound []boundParamFlag + paramsOnly []paramsOnlyField +} + +// newParamFlagBinder registers one typed kebab flag per path/query parameter on +// cmd and returns a binder for the --params overlay. A name already taken by +// another flag is skipped — pflag panics on a local duplicate and a generated +// flag would silently shadow a persistent one — and recorded as paramsOnly so +// the parameter stays reachable (and discoverable) via --params. The taken set +// is derived, not hand-listed: local flags (the standard set, registered before +// this runs) via cmd, the lazily-added --help materialized here, and the root's +// persistent flags via reserved (nil for direct callers that have no root). +func newParamFlagBinder(cmd *cobra.Command, params []meta.Field, reserved *pflag.FlagSet) *paramFlagBinder { + cmd.InitDefaultHelpFlag() // materialize --help/-h so the local guard below sees it + b := ¶mFlagBinder{} + for _, f := range params { + name := f.FlagName() + if claimed := flagClaiming(cmd, reserved, name); claimed != nil { + b.paramsOnly = append(b.paramsOnly, paramsOnlyField{field: f, claimed: claimed}) + continue + } + read := registerTypedFlag(cmd.Flags(), name, f.CanonicalType(), paramFlagUsage(f)) + if values := enumStrings(f.EnumValues()); len(values) > 0 { + cmdutil.RegisterFlagCompletion(cmd, name, func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return values, cobra.ShellCompDirectiveNoFileComp + }) + } + // Group as an API parameter and mark required/optional for the + // Required/Optional subsections of the grouped --help renderer. + if fl := cmd.Flags().Lookup(name); fl != nil { + annotate(fl, flagGroupAnnotation, []string{groupParams}) + sub := subOptional + if f.Required { + sub = subRequired + } + annotate(fl, flagSubAnnotation, []string{sub}) + } + b.bound = append(b.bound, boundParamFlag{field: f, read: read}) + } + return b +} + +// flagClaiming returns the flag already occupying name (so a typed param flag +// would collide), or nil when the name is free. It checks the command's own +// flags (the standard set + the materialized --help) and the root's persistent +// flags — so the reserved set is whatever is actually registered, never a +// hand-kept list that drifts when a global flag is added. +func flagClaiming(cmd *cobra.Command, reserved *pflag.FlagSet, name string) *pflag.Flag { + if fl := cmd.Flags().Lookup(name); fl != nil { + return fl + } + if reserved != nil { + return reserved.Lookup(name) + } + return nil +} + +// paramsOnlyHelp renders the --help addendum for parameters that have no typed +// flag, or "" when there are none. Per field: a copy-pasteable --params form, +// the same fieldFacts a typed flag would show on its usage line, and what the +// colliding flag actually does — so neither a human nor an agent sets the +// wrong one (e.g. --format, which is the output format, not the API parameter). +func (b *paramFlagBinder) paramsOnlyHelp() string { + if len(b.paramsOnly) == 0 { + return "" + } + var sb strings.Builder + sb.WriteString("\nParameters set via --params (no typed flag; the name is taken by another flag):\n") + for _, p := range b.paramsOnly { + name := p.field.Name + fmt.Fprintf(&sb, " %s: --params '{%q: %s}'\n", name, name, paramExample(p.field)) + for _, fact := range fieldFacts(p.field) { + fmt.Fprintf(&sb, " %s\n", fact) + } + if p.claimed != nil { + fmt.Fprintf(&sb, " do not use --%s (%s)\n", p.claimed.Name, p.claimed.Usage) + } + } + return sb.String() +} + +// hasTypedFlag reports whether the binder registered a typed flag for the +// param named name. False for params-only fields — a flag with the same kebab +// name may exist (that's the collision), but it is not this param's input. +// Nil-safe for direct buildServiceRequest callers that have no binder. +func (b *paramFlagBinder) hasTypedFlag(name string) bool { + if b == nil { + return false + } + for _, pf := range b.bound { + if pf.field.Name == name { + return true + } + } + return false +} + +// overlay lets an explicit typed flag override the same key in --params +// (--params is the base). Only changed flags apply, so the --params-only path is +// unchanged. A nil binder or cmd is a no-op. +func (b *paramFlagBinder) overlay(cmd *cobra.Command, params map[string]interface{}) { + if b == nil || cmd == nil { + return + } + for _, pf := range b.bound { + if cmd.Flags().Changed(pf.field.FlagName()) { + params[pf.field.Name] = pf.read() + } + } +} + +// registerTypedFlag registers one flag of the given canonical JSON-Schema kind +// and returns a reader for its parsed value; the kind→pflag-type switch lives +// only here. +func registerTypedFlag(fs *pflag.FlagSet, name, kind, usage string) func() interface{} { + switch kind { + case "integer": + return flagReader(fs.Int(name, 0, usage)) + case "boolean": + return flagReader(fs.Bool(name, false, usage)) + case "array": + return flagReader(fs.StringArray(name, nil, usage)) + default: + return flagReader(fs.String(name, "", usage)) + } +} + +func flagReader[T any](p *T) func() interface{} { + return func() interface{} { return *p } +} diff --git a/cmd/service/paramflags_test.go b/cmd/service/paramflags_test.go new file mode 100644 index 000000000..abae220da --- /dev/null +++ b/cmd/service/paramflags_test.go @@ -0,0 +1,626 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/meta" + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// imChatMembersCreate: POST chats/{chat_id}/members with one path param and one +// optional enum query param — the canonical case from the screenshot feedback. +func imChatMembersCreate() meta.Method { + return meta.FromMap(map[string]interface{}{ + "path": "chats/{chat_id}/members", + "httpMethod": "POST", + "parameters": map[string]interface{}{ + "chat_id": map[string]interface{}{ + "type": "string", "location": "path", "required": true, + }, + "member_id_type": map[string]interface{}{ + "type": "string", "location": "query", "required": false, + "options": []interface{}{ + map[string]interface{}{"value": "open_id"}, + map[string]interface{}{"value": "user_id"}, + }, + }, + }, + }) +} + +func TestServiceMethod_TypedFlagRegistered(t *testing.T) { + f := &cmdutil.Factory{} + cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil) + + if cmd.Flags().Lookup("chat-id") == nil { + t.Error("expected generated --chat-id flag for path param chat_id") + } + if cmd.Flags().Lookup("member-id-type") == nil { + t.Error("expected generated --member-id-type flag for query param member_id_type") + } +} + +// A query param literally named "format" kebab-collides with the global +// --format flag. Generation must skip it (never re-register, never panic) and +// leave the standard --format flag intact. +func TestServiceMethod_TypedFlagReservedCollisionSkipped(t *testing.T) { + method := map[string]interface{}{ + "path": "messages", + "httpMethod": "GET", + "parameters": map[string]interface{}{ + "format": map[string]interface{}{"type": "string", "location": "query"}, + }, + } + + var cmd *cobra.Command + func() { + defer func() { + if r := recover(); r != nil { + t.Fatalf("flag generation panicked on reserved-name collision: %v", r) + } + }() + cmd = NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "list", "messages", nil) + }() + + fl := cmd.Flags().Lookup("format") + if fl == nil || fl.DefValue != "json" { + t.Fatalf("standard --format flag must be preserved, got %+v", fl) + } +} + +func TestServiceMethod_TypedFlag_DrivesPathParam(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil) + cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--data", `{"id_list":["ou_x"]}`, "--dry-run"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "chats/oc_abc123/members") { + t.Errorf("expected URL with chat_id substituted from --chat-id, got:\n%s", stdout.String()) + } +} + +func TestServiceMethod_TypedFlag_DrivesQueryParam(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil) + cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--member-id-type", "open_id", "--data", `{}`, "--dry-run"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "member_id_type") || !strings.Contains(out, "open_id") { + t.Errorf("expected query param member_id_type=open_id from flag, got:\n%s", out) + } +} + +func TestServiceMethod_TypedFlag_AgreesWithParams(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil) + cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("same value via flag and --params should be accepted, got: %v", err) + } + if !strings.Contains(stdout.String(), "chats/oc_abc123/members") { + t.Errorf("expected URL with chat_id, got:\n%s", stdout.String()) + } +} + +// --params is the base; an explicit typed flag overrides the same key. +func TestServiceMethod_TypedFlag_OverridesParams(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil) + cmd.SetArgs([]string{"--chat-id", "oc_flag", "--params", `{"chat_id":"oc_params"}`, "--data", `{}`, "--dry-run"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "chats/oc_flag/members") { + t.Errorf("expected --chat-id to override --params chat_id, got:\n%s", out) + } + if strings.Contains(out, "oc_params") { + t.Errorf("--params value should have been overridden by the flag, got:\n%s", out) + } +} + +// Override works for a non-string (integer) param too, exercising the int +// register/read path end to end. +func TestServiceMethod_TypedFlag_IntegerOverridesParams(t *testing.T) { + method := map[string]interface{}{ + "path": "messages", + "httpMethod": "GET", + "parameters": map[string]interface{}{ + "page_size": map[string]interface{}{"type": "integer", "location": "query"}, + }, + } + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "messages", nil) + cmd.SetArgs([]string{"--page-size", "100", "--params", `{"page_size":5}`, "--dry-run"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if !strings.Contains(out, "page_size") || !strings.Contains(out, "100") { + t.Errorf("expected --page-size 100 to override --params page_size=5, got:\n%s", out) + } +} + +// Regression: with no typed flags passed, behavior is byte-identical to today. +func TestServiceMethod_TypedFlag_OnlyParamsStillWorks(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil) + cmd.SetArgs([]string{"--params", `{"chat_id":"oc_abc123"}`, "--data", `{}`, "--dry-run"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "chats/oc_abc123/members") { + t.Errorf("expected URL with chat_id from --params, got:\n%s", stdout.String()) + } +} + +// Regression: --params null is valid JSON that unmarshals to a nil map. A typed +// flag overlaying onto it must not panic (assignment to a nil map) — null is +// treated as "no base params", with the flag value applied on top. +func TestServiceMethod_TypedFlag_OverridesNullParams(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil) + cmd.SetArgs([]string{"--chat-id", "oc_abc123", "--params", "null", "--data", `{}`, "--dry-run"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("--params null with a typed flag should not error, got: %v", err) + } + if !strings.Contains(stdout.String(), "chats/oc_abc123/members") { + t.Errorf("expected chat_id from --chat-id over null --params, got:\n%s", stdout.String()) + } +} + +// Startup smoke test: registering every embedded method must not panic on a +// generated-flag name collision (pflag panics on duplicate registration, which +// would crash the whole CLI at startup), and a known path param must surface as +// a typed flag end to end. +func TestRegisterServiceCommands_GeneratesFlagsNoPanic(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + f := &cmdutil.Factory{} + + defer func() { + if r := recover(); r != nil { + t.Fatalf("registering all service commands panicked: %v", r) + } + }() + RegisterServiceCommands(root, f) + + create, _, err := root.Find([]string{"im", "chat.members", "create"}) + if err != nil { + t.Fatalf("im chat.members create not registered: %v", err) + } + if create.Flags().Lookup("chat-id") == nil { + t.Error("expected generated --chat-id flag on im chat.members create") + } +} + +// Locks the boolean and array branches of bindParamFlag end to end (string and +// integer are covered above): a bool flag yields true and a repeatable array +// flag yields all its elements in the request. +func TestServiceMethod_TypedFlag_BoolAndArrayKinds(t *testing.T) { + method := map[string]interface{}{ + "path": "items", + "httpMethod": "GET", + "parameters": map[string]interface{}{ + "with_deleted": map[string]interface{}{"type": "boolean", "location": "query"}, + "ids": map[string]interface{}{"type": "list", "location": "query"}, + }, + } + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil) + cmd.SetArgs([]string{"--with-deleted", "--ids", "a", "--ids", "b", "--dry-run"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{"with_deleted", "true", "ids", "\"a\"", "\"b\""} { + if !strings.Contains(out, want) { + t.Errorf("expected dry-run output to contain %q, got:\n%s", want, out) + } + } +} + +// Override (--params base, typed flag wins) is covered for string and integer +// above; this locks the same semantics for the boolean and array kinds. +func TestServiceMethod_TypedFlag_BoolAndArrayOverrideParams(t *testing.T) { + method := map[string]interface{}{ + "path": "items", + "httpMethod": "GET", + "parameters": map[string]interface{}{ + "with_deleted": map[string]interface{}{"type": "boolean", "location": "query"}, + "ids": map[string]interface{}{"type": "list", "location": "query"}, + }, + } + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(method), "list", "items", nil) + cmd.SetArgs([]string{ + "--params", `{"with_deleted":false,"ids":["from_params"]}`, + "--with-deleted", "--ids", "a", "--ids", "b", + "--dry-run", + }) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{"with_deleted", "true", "\"a\"", "\"b\""} { + if !strings.Contains(out, want) { + t.Errorf("expected flag to override --params (want %q), got:\n%s", want, out) + } + } + if strings.Contains(out, "from_params") { + t.Errorf("--params array value should have been overridden by --ids, got:\n%s", out) + } +} + +// A param whose kebab name collides with a global flag (here "format" vs the +// global --format) gets no typed flag, but the collision is no longer silent: +// non-colliding params still get flags, the global --format is untouched, and +// --help shows the exact --params form and steers the reader off --format. +func TestServiceMethod_ParamsOnly_HelpSteersToParams(t *testing.T) { + method := map[string]interface{}{ + "path": "things/{thing_id}", + "httpMethod": "GET", + "parameters": map[string]interface{}{ + "thing_id": map[string]interface{}{"type": "string", "location": "path", "required": true}, + "format": map[string]interface{}{"type": "string", "location": "query", "min": "1", "max": "64", "description": "返回的消息体格式。", "options": []interface{}{ + map[string]interface{}{"value": "full"}, + map[string]interface{}{"value": "metadata"}, + }}, + }, + } + cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), meta.FromMap(method), "get", "things", nil) + + if cmd.Flags().Lookup("thing-id") == nil { + t.Error("non-colliding param should still get a typed --thing-id flag") + } + if fl := cmd.Flags().Lookup("format"); fl == nil || fl.DefValue != "json" { + t.Fatalf("global --format must be preserved (not shadowed), got %+v", fl) + } + for _, want := range []string{`--params '{"format"`, "返回的消息体格式", "full", "metadata", "min: 1, max: 64", "do not use --format"} { + if !strings.Contains(cmd.Long, want) { + t.Errorf("help should contain %q so the reader uses --params, not --format; got:\n%s", want, cmd.Long) + } + } +} + +// The collision guard derives reserved names from the actual flag sets — local +// flags plus the root's persistent flags passed in — so a future persistent +// flag is covered with no hand-maintained list. Here a param named "profile" +// (a root persistent flag) is skipped while a normal param is bound. +func TestParamFlagBinder_PersistentFlagReserved(t *testing.T) { + cmd := &cobra.Command{Use: "x"} + reserved := pflag.NewFlagSet("root", pflag.ContinueOnError) + reserved.String("profile", "", "use a specific profile") + + m := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{ + "profile": map[string]interface{}{"type": "string", "location": "query"}, + "id": map[string]interface{}{"type": "string", "location": "path"}, + }}) + b := newParamFlagBinder(cmd, m.Params(), reserved) + + if cmd.Flags().Lookup("id") == nil { + t.Error("non-colliding param should get a typed flag") + } + if cmd.Flags().Lookup("profile") != nil { + t.Error("param colliding with a reserved persistent flag must not be registered") + } + found := false + for _, p := range b.paramsOnly { + if p.field.Name == "profile" { + found = true + } + } + if !found { + t.Error("colliding param should be recorded for the --params help note") + } +} + +// boolIntQueryMethod is the fixture for the zero-value semantics tests: one +// boolean and one integer query param, where false and 0 are meaningful values. +func boolIntQueryMethod(required bool) meta.Method { + return meta.FromMap(map[string]interface{}{ + "path": "items", + "httpMethod": "GET", + "parameters": map[string]interface{}{ + "with_deleted": map[string]interface{}{"type": "boolean", "location": "query", "required": required}, + "page_size": map[string]interface{}{"type": "integer", "location": "query"}, + }, + }) +} + +// Presence is intent: a typed flag is only overlaid when explicitly Changed, +// so --flag=false / --flag 0 are real values and must be sent — not silently +// dropped as "empty", which would let the API default win over an explicit +// user choice. +func TestServiceMethod_TypedFlag_ExplicitFalseAndZeroAreSent(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil) + cmd.SetArgs([]string{"--with-deleted=false", "--page-size", "0", "--dry-run"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} { + if !strings.Contains(out, want) { + t.Errorf("explicit zero value must be sent (want %s), got:\n%s", want, out) + } + } +} + +// An explicitly provided false satisfies a required query parameter — the +// pre-flight must not report "missing" for a value the user just set. +func TestServiceMethod_TypedFlag_ExplicitFalseSatisfiesRequired(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(true), "list", "items", nil) + cmd.SetArgs([]string{"--with-deleted=false", "--dry-run"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("required param explicitly set to false must pass pre-flight, got: %v", err) + } + if !strings.Contains(stdout.String(), `"with_deleted": false`) { + t.Errorf("explicit false must be sent, got:\n%s", stdout.String()) + } +} + +// The same presence-is-intent rule applies to the --params JSON base: a key +// deliberately written as false/0 is sent. (Zero values used to be silently +// dropped; this locks the corrected semantics as the contract.) +func TestServiceMethod_Params_JSONZeroValuesAreSent(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), boolIntQueryMethod(false), "list", "items", nil) + cmd.SetArgs([]string{"--params", `{"with_deleted":false,"page_size":0}`, "--dry-run"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{`"with_deleted": false`, `"page_size": 0`} { + if !strings.Contains(out, want) { + t.Errorf("--params zero value must be sent (want %s), got:\n%s", want, out) + } + } +} + +// "" stays unusable: a required parameter fed an empty-string placeholder is +// still caught by the friendly pre-flight error, not sent as an empty value. +func TestServiceMethod_Params_EmptyStringStillMissing(t *testing.T) { + method := meta.FromMap(map[string]interface{}{ + "path": "items", + "httpMethod": "GET", + "parameters": map[string]interface{}{ + "user_id_type": map[string]interface{}{"type": "string", "location": "query", "required": true}, + }, + }) + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil) + cmd.SetArgs([]string{"--params", `{"user_id_type":""}`, "--dry-run"}) + + err := cmd.Execute() + if err == nil || !strings.Contains(err.Error(), "missing required query parameter") { + t.Fatalf("empty string for a required param should still pre-flight error, got: %v", err) + } +} + +// A declared optional query param fed "" is dropped (unusable value), not sent +// as an empty query value — the declared-param loop owns the decision and the +// undeclared passthrough must not resurrect it. Undeclared keys stay the +// verbatim raw escape hatch. +func TestServiceMethod_Params_EmptyOptionalDroppedUndeclaredKept(t *testing.T) { + method := meta.FromMap(map[string]interface{}{ + "path": "items", + "httpMethod": "GET", + "parameters": map[string]interface{}{ + "user_id_type": map[string]interface{}{"type": "string", "location": "query"}, + }, + }) + f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "items", nil) + cmd.SetArgs([]string{"--params", `{"user_id_type":"","custom_key":"v1"}`, "--dry-run"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + if strings.Contains(out, "user_id_type") { + t.Errorf("declared optional param with empty value must be dropped, got:\n%s", out) + } + if !strings.Contains(out, `"custom_key": "v1"`) { + t.Errorf("undeclared key must pass through verbatim, got:\n%s", out) + } +} + +// min/max from the metadata surface on the typed flag's help line, in the same +// vocabulary as the envelope's minimum/maximum. +func TestParamFlagUsage_Bounds(t *testing.T) { + cases := []struct{ name, min, max, want string }{ + {"both", "1", "100", "min: 1, max: 100"}, + {"min only", "1", "", "min: 1"}, + {"max only", "", "64", "max: 64"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{ + "page_size": map[string]interface{}{"type": "integer", "location": "query", "min": tc.min, "max": tc.max}, + }}).Params() + if usage := paramFlagUsage(fields[0]); !strings.Contains(usage, tc.want) { + t.Errorf("usage = %q, want contains %q", usage, tc.want) + } + }) + } + t.Run("no bounds, no clause", func(t *testing.T) { + fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{ + "page_token": map[string]interface{}{"type": "string", "location": "query"}, + }}).Params() + if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "min:") || strings.Contains(usage, "max:") { + t.Errorf("usage without bounds should not mention min/max, got %q", usage) + } + }) +} + +// The sanitized field description rides the help line — a bare name like +// user_mailbox_id carries no meaning. The cut is at note separators (;), NOT +// at sentence ends (。): the later sentence often holds the key affordance. +func TestParamFlagUsage_Description(t *testing.T) { + fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{ + "user_mailbox_id": map[string]interface{}{ + "type": "string", "location": "path", "required": true, + "description": `用户邮箱地址。当使用用户身份访问时,可以输入"me"代表当前调用接口用户;后续补充说明不该出现`, + }, + }}).Params() + usage := paramFlagUsage(fields[0]) + if !strings.Contains(usage, `可以输入"me"代表当前调用接口用户`) { + t.Errorf("description must keep full sentences up to the note separator, got %q", usage) + } + if strings.Contains(usage, "补充说明") { + t.Errorf("text after the note separator must be cut, got %q", usage) + } + + t.Run("long description truncated", func(t *testing.T) { + fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{ + "x": map[string]interface{}{ + "type": "string", "location": "query", + "description": strings.Repeat("长", 80), + }, + }}).Params() + usage := paramFlagUsage(fields[0]) + if !strings.Contains(usage, "...") { + t.Errorf("long description should be truncated with ellipsis, got %q", usage) + } + if strings.Contains(usage, strings.Repeat("长", 61)) { + t.Errorf("description should not exceed the cap, got %q", usage) + } + }) + + t.Run("trailing sentence punctuation trimmed", func(t *testing.T) { + fields := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{ + "x": map[string]interface{}{ + "type": "string", "location": "query", "description": "返回格式。", + }, + }}).Params() + if usage := paramFlagUsage(fields[0]); strings.Contains(usage, "。.") { + t.Errorf("clause join must not double the punctuation, got %q", usage) + } + }) +} + +// Pins the convergence contract: the params-only addendum renders the SAME +// fieldFacts list the typed flag's usage line joins inline — a fact added to +// fieldFacts reaches both surfaces, and neither can drift over what a param's +// help says (the addendum once rendered values-only enums and silently lacked +// the API default). +func TestParamHelp_BothSurfacesRenderFieldFacts(t *testing.T) { + f := meta.FromMap(map[string]interface{}{"parameters": map[string]interface{}{ + "mode": map[string]interface{}{ + "type": "string", "location": "query", + "description": "模式选择。", + "default": "fast", + "min": "1", "max": "8", + "options": []interface{}{ + map[string]interface{}{"value": "fast", "description": "快速"}, + map[string]interface{}{"value": "full"}, + }, + }, + }}).Params()[0] + + facts := fieldFacts(f) + if len(facts) != 4 { // description, enum, bounds, API default + t.Fatalf("fieldFacts = %v, want 4 facts", facts) + } + usage := paramFlagUsage(f) + help := (¶mFlagBinder{paramsOnly: []paramsOnlyField{{field: f}}}).paramsOnlyHelp() + for _, fact := range facts { + if !strings.Contains(usage, fact) { + t.Errorf("usage line missing fact %q: %q", fact, usage) + } + if !strings.Contains(help, fact) { + t.Errorf("params-only addendum missing fact %q:\n%s", fact, help) + } + } +} + +// Bounds reach the registered flag's help end to end. +func TestServiceMethod_TypedFlag_HelpShowsBounds(t *testing.T) { + method := meta.FromMap(map[string]interface{}{ + "path": "items", + "httpMethod": "GET", + "parameters": map[string]interface{}{ + "page_size": map[string]interface{}{"type": "integer", "location": "query", "min": "1", "max": "100", "default": "20"}, + }, + }) + cmd := NewCmdServiceMethod(&cmdutil.Factory{}, imSpec(), method, "list", "items", nil) + fl := cmd.Flags().Lookup("page-size") + if fl == nil { + t.Fatal("expected generated --page-size flag") + } + if !strings.Contains(fl.Usage, "min: 1, max: 100") { + t.Errorf("flag usage should carry bounds, got %q", fl.Usage) + } +} + +// The missing-required hint must name both recovery paths — the typed flag and +// the --params fallback — so a reader who only knows one input style can +// proceed without a round-trip through schema. +func TestServiceMethod_MissingRequired_HintNamesFlagAndParams(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), imChatMembersCreate(), "create", "chat.members", nil) + cmd.SetArgs([]string{"--data", `{"id_list":["ou_x"]}`, "--dry-run"}) + + err := cmd.Execute() + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + for _, want := range []string{"--chat-id", `--params '{"chat_id": ""}'`, "lark-cli schema im.chat.members.create"} { + if !strings.Contains(ve.Hint, want) { + t.Errorf("hint %q should contain %q", ve.Hint, want) + } + } +} + +// A params-only required field (kebab name claimed by the standard --format +// flag) has no typed flag to offer: the hint must give only the --params form, +// never steer the reader to the colliding flag. +func TestServiceMethod_MissingRequired_ParamsOnlyHintSkipsFlag(t *testing.T) { + method := meta.FromMap(map[string]interface{}{ + "path": "messages", + "httpMethod": "GET", + "parameters": map[string]interface{}{ + "format": map[string]interface{}{"type": "string", "location": "query", "required": true}, + }, + }) + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + cmd := NewCmdServiceMethod(f, imSpec(), method, "list", "messages", nil) + cmd.SetArgs([]string{"--dry-run"}) + + err := cmd.Execute() + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if !strings.Contains(ve.Hint, `--params '{"format": ""}'`) { + t.Errorf("hint %q should carry the --params form", ve.Hint) + } + if strings.Contains(ve.Hint, "set --format") { + t.Errorf("hint %q must not steer to the colliding --format flag", ve.Hint) + } +} diff --git a/cmd/service/paramhelp.go b/cmd/service/paramhelp.go new file mode 100644 index 000000000..e4a915218 --- /dev/null +++ b/cmd/service/paramhelp.go @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Help rendering for generated param flags. fieldFacts is the single list of +// agent-relevant facts a param exposes; every help surface (the typed flag's +// usage line, the params-only --params addendum) renders that one list, so the +// surfaces cannot drift over which facts exist. Values come from the +// meta.Field accessors, so nothing here depends on internal/schema. + +package service + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/larksuite/cli/internal/meta" + "github.com/larksuite/cli/internal/util" +) + +// fieldFacts returns a param field's facts in display order, each as a compact +// one-line clause: the sanitized description, the allowed enum values (with +// meanings), the min/max constraint, and the API default. This is the ONE +// place that decides what a param's help says — add a fact here (e.g. a future +// deprecation marker) and every surface shows it. Unabridged prose and +// per-option detail stay in `lark-cli schema`. +func fieldFacts(f meta.Field) []string { + var facts []string + if d := sanitizeFieldDesc(f.Description); d != "" { + facts = append(facts, d) + } + if opts := f.EnumOptions(); len(opts) > 0 { + facts = append(facts, "enum: "+formatEnumInline(opts)) + } + if b := formatBoundsInline(f); b != "" { + facts = append(facts, b) + } + if s := literalStr(f.CoercedDefault()); s != "" { + facts = append(facts, "API default: "+s) + } + return facts +} + +// paramFlagUsage renders the typed param flag's help line: +// +// , required|optional[. ]... +// +// It leads with the canonical underscore param name (the key this flag +// overrides in --params) and required/optional, then joins the field's facts +// inline. +func paramFlagUsage(f meta.Field) string { + req := "optional" + if f.Required { + req = "required" + } + parts := append([]string{fmt.Sprintf("%s, %s", f.Name, req)}, fieldFacts(f)...) + return strings.Join(parts, ". ") + "." +} + +// paramExample picks a concrete sample for a params-only field's --help snippet: +// its first allowed enum value, else its example, else a placeholder. +func paramExample(f meta.Field) string { + if vals := enumStrings(f.EnumValues()); len(vals) > 0 { + return fmt.Sprintf("%q", vals[0]) + } + if s := literalStr(f.CoercedExample()); s != "" { + return fmt.Sprintf("%q", s) + } + return `""` +} + +var markdownLinkRe = regexp.MustCompile(`\[([^\]]*)\]\([^)]*\)`) + +// inlineClause compresses metadata prose into one help clause: markdown links +// keep their text, the clause cuts at the first rune in stops, whitespace +// collapses, trailing punctuation goes — sentence enders (the clause join adds +// its own) and connectors a cut can strand, like a colon introducing a list the +// newline cut dropped — and the result caps at max runes. The two policies +// below differ only in where they cut and how much they keep. +func inlineClause(s, stops string, max int) string { + if s == "" { + return "" + } + s = markdownLinkRe.ReplaceAllString(s, "$1") + // Backquotes must go: pflag's UnquoteUsage treats a backquoted word in a + // flag's usage string as the flag's metavar, so a description like wiki + // space_id's "可替换为`my_library`" would render the flag as + // "--space-id my_library" instead of "--space-id string". + s = strings.ReplaceAll(s, "`", "") + if i := strings.IndexAny(s, stops); i >= 0 { + s = s[:i] + } + s = strings.Join(strings.Fields(s), " ") + s = strings.TrimRight(s, "。.::,,、") + return util.TruncateStrWithEllipsis(s, max) +} + +// sanitizeOptionDesc is the enum-option policy: many values share one line, so +// keep only the first clause (cut at 。 too) and stay ultra-compact. +func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r", 40) } + +// sanitizeFieldDesc is the field-description policy: one line per field, so +// keep full sentences and cut only at note separators (meta_data appends +// bullet notes after ;/;) — the later sentence often carries the key +// affordance, e.g. user_mailbox_id's `可以输入"me"`. +func sanitizeFieldDesc(s string) string { return inlineClause(s, ";;\n\r", 60) } + +// formatEnumInline renders allowed values for the help line: "v=meaning" when +// the value carries a (sanitized, truncated) description — so opaque numeric +// enums like succeed_type read as "0=…|1=…|2=…" — else just "v". Full meanings +// live in the envelope's enumDescriptions / `lark-cli schema`. +func formatEnumInline(opts []meta.EnumOption) string { + items := make([]string, len(opts)) + for i, o := range opts { + if d := sanitizeOptionDesc(o.Description); d != "" { + items[i] = fmt.Sprintf("%v=%s", o.Value, d) + } else { + items[i] = fmt.Sprintf("%v", o.Value) + } + } + return strings.Join(items, "|") +} + +// formatBoundsInline renders the field's min/max constraint ("min: 1, max: +// 100", or the single declared side), or "" when the field declares neither. +// The vocabulary matches the envelope's minimum/maximum, so help and `lark-cli +// schema` state the same constraint. +func formatBoundsInline(f meta.Field) string { + min, max := f.MinBound(), f.MaxBound() + switch { + case min != nil && max != nil: + return fmt.Sprintf("min: %s, max: %s", formatBound(*min), formatBound(*max)) + case min != nil: + return "min: " + formatBound(*min) + case max != nil: + return "max: " + formatBound(*max) + } + return "" +} + +// formatBound renders a bound without a float artifact (100 not 100.000000). +func formatBound(v float64) string { + return strconv.FormatFloat(v, 'f', -1, 64) +} + +// literalStr renders a coerced literal (default/example) for flag help, +// returning "" for a nil or empty value so the caller can omit the clause. +func literalStr(v interface{}) string { + if v == nil { + return "" + } + return fmt.Sprintf("%v", v) +} + +func enumStrings(enum []interface{}) []string { + out := make([]string, 0, len(enum)) + for _, e := range enum { + out = append(out, fmt.Sprintf("%v", e)) + } + return out +} diff --git a/cmd/service/sanitize_test.go b/cmd/service/sanitize_test.go new file mode 100644 index 000000000..d7dc798b9 --- /dev/null +++ b/cmd/service/sanitize_test.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package service + +import ( + "strings" + "testing" +) + +func TestSanitizeOptionDesc(t *testing.T) { + cases := map[string]string{ + "": "", + "以 open_id 标识用户": "以 open_id 标识用户", + "中文。English second clause": "中文", // first clause only (。) + "head;tail": "head", // first clause (;) + "line one\nline two": "line one", // first clause (newline) + " spaced out ": "spaced out", // whitespace collapsed + "see [飞书后台](https://x/admin) 详情": "see 飞书后台 详情", // markdown link -> text, url dropped + } + for in, want := range cases { + if got := sanitizeOptionDesc(in); got != want { + t.Errorf("sanitizeOptionDesc(%q) = %q, want %q", in, got, want) + } + } + + // Truncation: a long single clause is cut to 40 runes with an ellipsis, + // rune-safe (no split mid-character). + long := strings.Repeat("文", 60) + got := sanitizeOptionDesc(long) + if r := []rune(got); len(r) != 40 || !strings.HasSuffix(got, "...") { + t.Errorf("truncation = %q (%d runes), want 40 runes ending in ...", got, len(r)) + } +} + +func TestSanitizeFieldDesc_TrimsDanglingPunctuation(t *testing.T) { + // A clause cut can strand a connector (e.g. a colon introducing a list the + // newline cut drops, as in im.reactions.list's message_id); the help line + // joiner then renders "…获取方式:." — so dangling punctuation must go too. + cases := map[string]string{ + "待查询的消息ID。ID 获取方式:\n- 调用接口获取": "待查询的消息ID。ID 获取方式", + "see the list below:\nitem": "see the list below", + "逗号结尾,\n下一行": "逗号结尾", + } + for in, want := range cases { + if got := sanitizeFieldDesc(in); got != want { + t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want) + } + } +} + +func TestSanitizeFieldDesc_StripsBackquotes(t *testing.T) { + // pflag's UnquoteUsage takes a backquoted word in a flag's usage string as + // the flag's metavar: wiki space_id's description rendered the flag as + // "--space-id my_library" instead of "--space-id string". + in := "[知识空间id](https://x/wiki),如果查询我的文档库可替换为`my_library`" + want := "知识空间id,如果查询我的文档库可替换为my_library" + if got := sanitizeFieldDesc(in); got != want { + t.Errorf("sanitizeFieldDesc(%q) = %q, want %q", in, got, want) + } +} diff --git a/cmd/service/service.go b/cmd/service/service.go index 50a6e97b2..fdfadd07a 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -10,18 +10,20 @@ import ( "strings" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/errclass" + "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" - "github.com/larksuite/cli/internal/util" "github.com/larksuite/cli/internal/validate" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" "github.com/spf13/cobra" + "github.com/spf13/pflag" ) // RegisterServiceCommands registers all service commands from from_meta specs. @@ -30,85 +32,74 @@ func RegisterServiceCommands(parent *cobra.Command, f *cmdutil.Factory) { } func RegisterServiceCommandsWithContext(ctx context.Context, parent *cobra.Command, f *cmdutil.Factory) { - for _, project := range registry.ListFromMetaProjects() { - spec := registry.LoadFromMeta(project) - if spec == nil { + // Drive the service list from the same navigation catalog the method walk + // uses — RuntimeCatalog().Services() is the deterministic, sorted view of the + // merged metadata — so registration is catalog-sourced end to end. Kept as a + // per-service loop rather than a flat WalkMethods(nil) drive precisely so a + // service with no methods still gets its bare command (WalkMethods yields one + // ref per method, so empty services would vanish). + for _, svc := range registry.RuntimeCatalog().Services() { + if svc.Name == "" || svc.ServicePath == "" { continue } - specName := registry.GetStrFromMap(spec, "name") - servicePath := registry.GetStrFromMap(spec, "servicePath") - if specName == "" || servicePath == "" { - continue - } - resources, _ := spec["resources"].(map[string]interface{}) - if resources == nil { - continue - } - registerServiceWithContext(ctx, parent, spec, resources, f) + registerServiceWithContext(ctx, parent, svc, f) } } -func registerService(parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) { - registerServiceWithContext(context.Background(), parent, spec, resources, f) +func registerService(parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) { + registerServiceWithContext(context.Background(), parent, svc, f) } -func registerServiceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, resources map[string]interface{}, f *cmdutil.Factory) { - specName := registry.GetStrFromMap(spec, "name") - specDesc := registry.GetServiceDescription(specName, "en") - if specDesc == "" { - specDesc = registry.GetStrFromMap(spec, "description") - } +func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc meta.Service, f *cmdutil.Factory) { + svcCmd := ensureChildCommand(parent, svc.Name, serviceShort(svc)) - // Find existing service command or create one - var svc *cobra.Command + // Build the service's subtree from the catalog's method walk + // (apicatalog.ServiceMethods recurses nested resources), so the command tree + // is sourced from the same navigation Module as schema/scope rather than a + // hand-rolled resource/method walk. Each ref's ResourcePath becomes the + // resource-command chain — one level for a flat dotted resource like + // "chat.members", deeper for genuinely nested resources. A service with no + // methods keeps its bare command (svcCmd is created above regardless). + for _, ref := range apicatalog.ServiceMethods(svc, nil) { + resCmd := svcCmd + for _, seg := range ref.ResourcePath { + resCmd = ensureChildCommand(resCmd, seg, seg+" operations") + } + resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags())) + } +} + +// serviceShort is the service command's help summary: the localized description +// from the registry, falling back to the metadata's own description. +func serviceShort(svc meta.Service) string { + if d := registry.GetServiceDescription(svc.Name, "en"); d != "" { + return d + } + return svc.Description +} + +// ensureChildCommand returns the child of parent named name, creating it (with +// short) when absent — so re-registration merges into an existing command tree +// instead of duplicating a level. +func ensureChildCommand(parent *cobra.Command, name, short string) *cobra.Command { for _, c := range parent.Commands() { - if c.Name() == specName { - svc = c - break + if c.Name() == name { + return c } } - if svc == nil { - svc = &cobra.Command{ - Use: specName, - Short: specDesc, - } - parent.AddCommand(svc) - } - - for resName, resource := range resources { - resMap, _ := resource.(map[string]interface{}) - if resMap == nil { - continue - } - registerResourceWithContext(ctx, svc, spec, resName, resMap, f) - } -} - -func registerResourceWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, name string, resource map[string]interface{}, f *cmdutil.Factory) { - res := &cobra.Command{ - Use: name, - Short: name + " operations", - } - parent.AddCommand(res) - - methods, _ := resource["methods"].(map[string]interface{}) - for methodName, method := range methods { - methodMap, _ := method.(map[string]interface{}) - if methodMap == nil { - continue - } - registerMethodWithContext(ctx, res, spec, methodMap, methodName, name, f) - } + cmd := &cobra.Command{Use: name, Short: short} + parent.AddCommand(cmd) + return cmd } // ServiceMethodOptions holds all inputs for a dynamically registered service method command. type ServiceMethodOptions struct { - Factory *cmdutil.Factory - Cmd *cobra.Command - Ctx context.Context - Spec map[string]interface{} - Method map[string]interface{} - SchemaPath string + Factory *cmdutil.Factory + Cmd *cobra.Command + Ctx context.Context + ServicePath string + Method meta.Method + SchemaPath string // Flags Params string @@ -123,41 +114,113 @@ type ServiceMethodOptions struct { DryRun bool File string // --file flag value FileFields []string // auto-detected file field names from metadata + + // binder owns the generated typed param flags — registration and the + // --params overlay — replacing the raw paramFlags side-channel. + binder *paramFlagBinder } -// detectFileFields delegates to the shared cmdutil.DetectFileFields helper. -func detectFileFields(method map[string]interface{}) []string { - return cmdutil.DetectFileFields(method) -} - -func registerMethodWithContext(ctx context.Context, parent *cobra.Command, spec map[string]interface{}, method map[string]interface{}, name string, resName string, f *cmdutil.Factory) { - parent.AddCommand(NewCmdServiceMethodWithContext(ctx, f, spec, method, name, resName, nil)) +// detectFileFields returns the request-body file-upload field names. +func detectFileFields(m meta.Method) []string { + files := m.Files() + if len(files) == 0 { + return nil + } + names := make([]string, len(files)) + for i, f := range files { + names[i] = f.Name + } + return names } // NewCmdServiceMethod creates a command for a dynamically registered service method. -func NewCmdServiceMethod(f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command { - return NewCmdServiceMethodWithContext(context.Background(), f, spec, method, name, resName, runF) +func NewCmdServiceMethod(f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command { + return NewCmdServiceMethodWithContext(context.Background(), f, svc, m, name, resName, runF) } -func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spec, method map[string]interface{}, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command { - desc := registry.GetStrFromMap(method, "description") - httpMethod := registry.GetStrFromMap(method, "httpMethod") - risk := registry.GetStrFromMap(method, "risk") - specName := registry.GetStrFromMap(spec, "name") - schemaPath := fmt.Sprintf("%s.%s.%s", specName, resName, name) +// NewCmdServiceMethodWithContext builds the command for one service method from +// its (service, resource, method) coordinates, deriving the methodCommandSpec +// via an apicatalog.MethodRef so direct callers and the catalog-driven +// registration assemble the command identically. +func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, svc meta.Service, m meta.Method, name, resName string, runF func(*ServiceMethodOptions) error) *cobra.Command { + m.Name = name + ref := apicatalog.MethodRef{Service: svc, ResourcePath: []string{resName}, Method: m} + // No root in scope here; persistent-flag collisions don't apply to a + // standalone command, and local/standard-flag collisions are still caught. + return buildMethodCommand(ctx, f, newMethodCommandSpec(ref), runF, nil) +} +// methodCommandSpec is the static description of one generated service method +// command, read off an apicatalog.MethodRef — the single place command +// construction gets the method's facts (schema path, HTTP base path, risk, +// identities, params, file fields, request-body support), so the cobra command +// is assembled from a typed spec rather than recomputing paths/flags inline. +type methodCommandSpec struct { + method meta.Method + schemaPath string // "service.resource.method", for the --help hint + servicePath string // service HTTP base path + risk string // RiskRead | RiskWrite | RiskHighRiskWrite + restricts bool // method declares accessTokens (identity-restricted) + identities []string // permitted --as values; empty when unrestricted + params []meta.Field // path/query params -> typed flags + fileFields []string // request-body file-upload field names + // acceptsBody is whether the HTTP method allows a request body at all (so + // --data is offered as a raw escape hatch). declaresBody is whether the + // metadata documents body fields (data or file). They differ for e.g. a POST + // with no documented requestBody: --data still works, but help must not imply + // the API declares a body. + acceptsBody bool + declaresBody bool + affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none +} + +func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec { + m := ref.Method + return methodCommandSpec{ + method: m, + schemaPath: ref.SchemaPath(), + servicePath: ref.Service.ServicePath, + risk: m.Risk, + restricts: m.RestrictsIdentity(), + identities: m.Identities(), + params: m.Params(), + fileFields: detectFileFields(m), + acceptsBody: methodTakesBody(m.HTTPMethod), + declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0, + affordance: renderAffordance(m), + } +} + +// methodTakesBody reports whether the HTTP method allows a request body, i.e. +// whether --data applies (as a raw escape hatch even when no body is declared). +func methodTakesBody(httpMethod string) bool { + switch httpMethod { + case "POST", "PUT", "PATCH", "DELETE": + return true + } + return false +} + +// buildMethodCommand assembles the cobra command for a service method from its +// static spec: the standard flags, the conditional --data/--file/--yes flags, +// the generated typed param flags (via paramFlagBinder), and the risk/identity +// policy annotations. +func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodCommandSpec, runF func(*ServiceMethodOptions) error, reserved *pflag.FlagSet) *cobra.Command { + m := spec.method opts := &ServiceMethodOptions{ - Factory: f, - Spec: spec, - Method: method, - SchemaPath: schemaPath, + Factory: f, + ServicePath: spec.servicePath, + Method: m, + SchemaPath: spec.schemaPath, + FileFields: spec.fileFields, } var asStr string cmd := &cobra.Command{ - Use: name, - Short: desc, - Long: fmt.Sprintf("%s\n\nView parameter definitions before calling:\n lark-cli schema %s", desc, schemaPath), + Use: m.Name, + Short: m.Description, + // Long is assembled below, once the binder knows which params got no + // typed flag. RunE: func(cmd *cobra.Command, args []string) error { opts.Cmd = cmd opts.Ctx = cmd.Context() @@ -169,10 +232,15 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe }, } - cmd.Flags().StringVar(&opts.Params, "params", "", "URL/query parameters JSON (supports - for stdin, @file for file input)") - switch httpMethod { - case "POST", "PUT", "PATCH", "DELETE": - cmd.Flags().StringVar(&opts.Data, "data", "", "request body JSON (supports - for stdin, @file for file input)") + cmd.Flags().StringVar(&opts.Params, "params", "", "Raw URL/query params JSON. Supports - and @file.") + if spec.acceptsBody { + dataUsage := "JSON request body. Supports - and @file." + if !spec.declaresBody { + // POST/etc. with no documented body fields: --data is a raw escape + // hatch, not a declared body — say so rather than imply structure. + dataUsage = "Raw JSON request body (no documented fields; see schema). Supports - and @file." + } + cmd.Flags().StringVar(&opts.Data, "data", "", dataUsage) } cmdutil.AddAPIIdentityFlag(ctx, cmd, f, &asStr) cmd.Flags().StringVarP(&opts.Output, "output", "o", "", "output file path for binary responses") @@ -183,27 +251,61 @@ func NewCmdServiceMethodWithContext(ctx context.Context, f *cmdutil.Factory, spe cmd.Flags().Bool("json", false, "shorthand for --format json") cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output") cmd.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print request without executing") - if risk == "high-risk-write" { + if spec.risk == cmdutil.RiskHighRiskWrite { cmd.Flags().Bool("yes", false, "confirm high-risk operation") } - - // Conditionally register --file for methods with file-type fields. - fileFields := detectFileFields(method) - opts.FileFields = fileFields - if len(fileFields) > 0 { - switch httpMethod { - case "POST", "PUT", "PATCH", "DELETE": - cmd.Flags().StringVar(&opts.File, "file", "", "file to upload ([field=]path, supports - for stdin)") - } + // --file only for body methods that actually declare file-type fields. + if len(spec.fileFields) > 0 && spec.acceptsBody { + cmd.Flags().StringVar(&opts.File, "file", "", "File upload [field=]path. Supports - and stdin.") } cmdutil.RegisterFlagCompletion(cmd, "format", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { return []string{"json", "ndjson", "table", "csv"}, cobra.ShellCompDirectiveNoFileComp }) - cmdutil.SetTips(cmd, registry.GetStrSliceFromMap(method, "tips")) - cmdutil.SetRisk(cmd, risk) - if tokens, ok := method["accessTokens"].([]interface{}); ok && len(tokens) > 0 { - cmdutil.SetSupportedIdentities(cmd, cmdutil.AccessTokensToIdentities(tokens)) + // Registered last so the collision guard sees the standard flags above. + opts.binder = newParamFlagBinder(cmd, spec.params, reserved) + // Single composition point for Long: description, affordance, schema + // pointer, and the binder's params-only addendum (params whose flag name is + // taken, reachable via --params only). + cmd.Long = methodLong(m.Description, spec.affordance, spec.schemaPath, opts.binder.paramsOnlyHelp()) + + // Group flags for the grouped --help renderer (typed param flags are grouped + // as API Parameters by the binder). tagFlagGroup is a no-op for flags not + // registered above (e.g. --data/--file/--yes only exist for some methods). + // --data sits under Request Body only when the metadata documents body + // fields; otherwise it's a raw escape hatch, grouped with --params so help + // doesn't imply a declared body the API doesn't have. + if fl := cmd.Flags().Lookup("data"); fl != nil { + if spec.declaresBody { + annotate(fl, flagGroupAnnotation, []string{groupBody}) + } else { + annotate(fl, flagGroupAnnotation, []string{groupRaw}) + } + } + tagFlagGroup(cmd.Flags(), "file", groupBody) + if fl := cmd.Flags().Lookup("params"); fl != nil { + annotate(fl, flagGroupAnnotation, []string{groupRaw}) + // State the precedence rule where the agent reads it: --params is the + // base, typed flags override. Only meaningful when typed flags exist. + if len(spec.params) > 0 { + annotate(fl, flagNoteAnnotation, []string{ + "Typed API parameter flags above are preferred.", + "If both are set, typed flags override matching keys in --params.", + }) + } + } + for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} { + tagFlagGroup(cmd.Flags(), name, groupExecution) + } + for _, name := range []string{"output", "format", "jq"} { + tagFlagGroup(cmd.Flags(), name, groupOutput) + } + applyGroupedUsage(cmd) + + cmdutil.SetTips(cmd, m.Tips) + cmdutil.SetRisk(cmd, spec.risk) + if spec.restricts { + cmdutil.SetSupportedIdentities(cmd, spec.identities) } return cmd @@ -218,8 +320,8 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { } // Check if this API method supports the resolved identity. - if tokens, ok := opts.Method["accessTokens"].([]interface{}); ok && len(tokens) > 0 { - if err := f.CheckIdentity(opts.As, cmdutil.AccessTokensToIdentities(tokens)); err != nil { + if opts.Method.RestrictsIdentity() { + if err := f.CheckIdentity(opts.As, opts.Method.Identities()); err != nil { return err } } @@ -235,12 +337,10 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { if err != nil { return err } - // Identity info is now included in the JSON envelope; skip stderr printing. - // cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected) + // Identity is not printed to stderr here: it is part of the JSON envelope. - scopes, _ := opts.Method["scopes"].([]interface{}) if !opts.As.IsBot() { - if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method, scopes); err != nil { + if err := checkServiceScopes(opts.Ctx, f.Credential, opts.As, config, opts.Method); err != nil { return err } } @@ -257,7 +357,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { return serviceDryRun(f, request, config, opts.Format) } - if registry.GetStrFromMap(opts.Method, "risk") == "high-risk-write" { + if opts.Method.Risk == cmdutil.RiskHighRiskWrite { if yes, _ := opts.Cmd.Flags().GetBool("yes"); !yes { return cmdutil.RequireConfirmation(opts.SchemaPath) } @@ -302,7 +402,7 @@ func serviceMethodRun(opts *ServiceMethodOptions) error { } // checkServiceScopes pre-checks user scopes before making the API call. -func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method map[string]interface{}, scopes []interface{}) error { +func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider, identity core.Identity, config *core.CliConfig, method meta.Method) error { if ctx.Err() != nil { return ctx.Err() } @@ -311,23 +411,15 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider return nil //nolint:nilerr // skip scope check when token resolution fails or has no scopes } - requiredScopes, hasRequired := method["requiredScopes"].([]interface{}) - - if hasRequired && len(requiredScopes) > 0 { + if len(method.RequiredScopes) > 0 { // Strict: ALL requiredScopes must be present - required := make([]string, 0, len(requiredScopes)) - for _, s := range requiredScopes { - if str, ok := s.(string); ok { - required = append(required, str) - } - } - if missing := auth.MissingScopes(result.Scopes, required); len(missing) > 0 { + if missing := auth.MissingScopes(result.Scopes, method.RequiredScopes); len(missing) > 0 { return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), missing) } return nil } - if len(scopes) == 0 { + if len(method.Scopes) == 0 { return nil } @@ -336,12 +428,12 @@ func checkServiceScopes(ctx context.Context, cred *credential.CredentialProvider for _, s := range strings.Fields(result.Scopes) { grantedSet[s] = true } - for _, s := range scopes { - if str, ok := s.(string); ok && grantedSet[str] { + for _, s := range method.Scopes { + if grantedSet[s] { return nil } } - recommended := registry.SelectRecommendedScope(scopes, "user") + recommended := registry.SelectRecommendedScopeFromStrings(method.Scopes, "user") return newPreflightMissingScopeError(string(config.Brand), config.AppID, string(identity), []string{recommended}) } @@ -362,14 +454,44 @@ func newPreflightMissingScopeError(brand, appID, identity string, missing []stri WithIdentity(identity) } +// unusableParamValue reports whether a provided path/query parameter value +// cannot form a usable request value: nil or an empty string. A key's presence +// in params is the intent signal — a typed flag is overlaid only when +// explicitly Changed, and a --params JSON key is deliberately written — so +// false and 0 are real values and must not be conflated with "unset" +// (reflect.IsZero would drop an explicit --with-deleted=false or --foo 0). +// Only nil/"" stay treated as missing: that keeps the friendly pre-flight +// error when a required param is fed an empty placeholder, and never emits a +// declared param as an empty path segment or query value. Undeclared keys are +// not judged by this rule — they pass through verbatim as the raw escape hatch. +func unusableParamValue(v interface{}) bool { + if v == nil { + return true + } + s, ok := v.(string) + return ok && s == "" +} + +// missingParamHint is the recovery hint for a missing required parameter. It +// names both input paths — the typed flag when the binder registered one, and +// the --params fallback — plus the schema pointer. A params-only field gets +// only the --params form: a flag with its kebab name exists but belongs to +// something else (e.g. the output --format), and the hint must not steer +// there. Asking the binder, not cmd.Flags(), is what tells those apart. +func missingParamHint(opts *ServiceMethodOptions, f meta.Field) string { + paramsForm := fmt.Sprintf("--params '{%q: \"\"}'", f.Name) + if opts.binder.hasTypedFlag(f.Name) { + return fmt.Sprintf("set --%s (or %s); see: lark-cli schema %s", f.FlagName(), paramsForm, opts.SchemaPath) + } + return fmt.Sprintf("set %s; see: lark-cli schema %s", paramsForm, opts.SchemaPath) +} + // buildServiceRequest parses flags, builds the URL with path/query params, and returns a RawApiRequest. // When dryRun is true and a file is provided, file reading is skipped and // FileUploadMeta is returned instead so the caller can render dry-run output. func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmdutil.FileUploadMeta, error) { - spec := opts.Spec method := opts.Method - schemaPath := opts.SchemaPath - httpMethod := registry.GetStrFromMap(method, "httpMethod") + httpMethod := method.HTTPMethod // stdin is an io.Reader consumed at most once. Only one of --params/--data // may use "-" (stdin); the conflict check below prevents silent data loss. @@ -387,53 +509,55 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd if err != nil { return client.RawApiRequest{}, nil, err } + opts.binder.overlay(opts.Cmd, params) - url := registry.GetStrFromMap(spec, "servicePath") + "/" + registry.GetStrFromMap(method, "path") + url := opts.ServicePath + "/" + method.Path - parameters, _ := method["parameters"].(map[string]interface{}) - for name, param := range parameters { - p, _ := param.(map[string]interface{}) - if registry.GetStrFromMap(p, "location") != "path" { + specs := method.Params() + for _, s := range specs { + if s.Location != "path" { continue } - val, ok := params[name] - if !ok || util.IsEmptyValue(val) { + val, ok := params[s.Name] + if !ok || unusableParamValue(val) { return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, - "missing required path parameter: %s", name). - WithHint("lark-cli schema %s", schemaPath). - WithParam(name) + "missing required path parameter: %s", s.Name). + WithHint("%s", missingParamHint(opts, s)). + WithParam(s.Name) } valStr := fmt.Sprintf("%v", val) - if err := validate.ResourceName(valStr, name); err != nil { - return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(name).WithCause(err) + if err := validate.ResourceName(valStr, s.Name); err != nil { + return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(s.Name).WithCause(err) } - url = strings.Replace(url, "{"+name+"}", validate.EncodePathSegment(valStr), 1) - delete(params, name) + url = strings.Replace(url, "{"+s.Name+"}", validate.EncodePathSegment(valStr), 1) + delete(params, s.Name) } queryParams := map[string]interface{}{} - for name, param := range parameters { - p, _ := param.(map[string]interface{}) - if registry.GetStrFromMap(p, "location") != "query" { + for _, s := range specs { + if s.Location != "query" { continue } - value, exists := params[name] - required, _ := p["required"].(bool) - isPaginationParam := opts.PageAll && (name == "page_token" || name == "page_size") - if required && !isPaginationParam && (!exists || util.IsEmptyValue(value)) { + value, exists := params[s.Name] + isPaginationParam := opts.PageAll && (s.Name == "page_token" || s.Name == "page_size") + if s.Required && !isPaginationParam && (!exists || unusableParamValue(value)) { return client.RawApiRequest{}, nil, errs.NewValidationError(errs.SubtypeInvalidArgument, - "missing required query parameter: %s", name). - WithHint("lark-cli schema %s", schemaPath). - WithParam(name) + "missing required query parameter: %s", s.Name). + WithHint("%s", missingParamHint(opts, s)). + WithParam(s.Name) } - if exists && !util.IsEmptyValue(value) { - queryParams[name] = value + if exists && !unusableParamValue(value) { + queryParams[s.Name] = value } + // This loop owns declared query params: consume the key so the + // passthrough below can't resurrect a value the gate dropped (an + // unusable "" would otherwise be sent as an empty query value). + delete(params, s.Name) } + // Whatever remains is undeclared — the raw escape hatch for params the + // metadata doesn't (yet) describe; passed through verbatim, no filtering. for name, value := range params { - if _, ok := queryParams[name]; !ok { - queryParams[name] = value - } + queryParams[name] = value } request := client.RawApiRequest{ diff --git a/cmd/service/service_risk_test.go b/cmd/service/service_risk_test.go index 636bfa692..87b278a99 100644 --- a/cmd/service/service_risk_test.go +++ b/cmd/service/service_risk_test.go @@ -8,13 +8,14 @@ import ( "testing" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/meta" ) // highRiskDeleteMethod mirrors a simple DELETE API with a required path -// parameter and risk metadata. The returned map is what service registration -// reads; the test exercises --yes registration and the gate behavior. -func highRiskDeleteMethod() map[string]interface{} { - return map[string]interface{}{ +// parameter and risk metadata. The test exercises --yes registration and the +// gate behavior. +func highRiskDeleteMethod() meta.Method { + return meta.FromMap(map[string]interface{}{ "path": "files/{file_token}", "httpMethod": "DELETE", "risk": "high-risk-write", @@ -23,11 +24,11 @@ func highRiskDeleteMethod() map[string]interface{} { "type": "string", "location": "path", "required": true, }, }, - } + }) } -func writeMethodNoRisk() map[string]interface{} { - return map[string]interface{}{ +func writeMethodNoRisk() meta.Method { + return meta.FromMap(map[string]interface{}{ "path": "files/{file_token}", "httpMethod": "DELETE", "parameters": map[string]interface{}{ @@ -35,7 +36,7 @@ func writeMethodNoRisk() map[string]interface{} { "type": "string", "location": "path", "required": true, }, }, - } + }) } func TestServiceMethod_YesFlagRegisteredForHighRisk(t *testing.T) { diff --git a/cmd/service/service_test.go b/cmd/service/service_test.go index 172ed7f8a..f1f48ea53 100644 --- a/cmd/service/service_test.go +++ b/cmd/service/service_test.go @@ -11,6 +11,7 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/internal/meta" "github.com/spf13/cobra" ) @@ -20,14 +21,14 @@ var testConfig = &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, } -func driveSpec() map[string]interface{} { - return map[string]interface{}{ +func driveSpec() meta.Service { + return meta.ServiceFromMap(map[string]interface{}{ "name": "drive", "servicePath": "/open-apis/drive/v1", - } + }) } -func driveMethod(httpMethod string, params map[string]interface{}) map[string]interface{} { +func driveMethod(httpMethod string, params map[string]interface{}) meta.Method { m := map[string]interface{}{ "path": "files/{file_token}/copy", "httpMethod": httpMethod, @@ -41,7 +42,7 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in }, } } - return m + return meta.FromMap(m) } // ── registerService ── @@ -49,23 +50,23 @@ func driveMethod(httpMethod string, params map[string]interface{}) map[string]in func TestRegisterService(t *testing.T) { parent := &cobra.Command{Use: "root"} f := &cmdutil.Factory{} - spec := map[string]interface{}{ + base := meta.ServiceFromMap(map[string]interface{}{ "name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3", - } - resources := map[string]interface{}{ - "tables": map[string]interface{}{ - "methods": map[string]interface{}{ - "list": map[string]interface{}{ - "description": "List tables", - "httpMethod": "GET", + "resources": map[string]interface{}{ + "tables": map[string]interface{}{ + "methods": map[string]interface{}{ + "list": map[string]interface{}{ + "description": "List tables", + "httpMethod": "GET", + }, }, }, }, - } + }) - registerService(parent, spec, resources, f) + registerService(parent, base, f) // service command exists svc, _, err := parent.Find([]string{"base"}) @@ -90,18 +91,18 @@ func TestRegisterService_MergesExistingCommand(t *testing.T) { parent.AddCommand(existing) f := &cmdutil.Factory{} - spec := map[string]interface{}{ + svc := meta.ServiceFromMap(map[string]interface{}{ "name": "base", "description": "Base API", "servicePath": "/open-apis/base/v3", - } - resources := map[string]interface{}{ - "tables": map[string]interface{}{ - "methods": map[string]interface{}{ - "list": map[string]interface{}{"description": "List", "httpMethod": "GET"}, + "resources": map[string]interface{}{ + "tables": map[string]interface{}{ + "methods": map[string]interface{}{ + "list": map[string]interface{}{"description": "List", "httpMethod": "GET"}, + }, }, }, - } + }) - registerService(parent, spec, resources, f) + registerService(parent, svc, f) // Should reuse existing, not duplicate count := 0 @@ -143,7 +144,7 @@ func TestNewCmdServiceMethod_StrictModeHidesAsFlag(t *testing.T) { func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) { f := &cmdutil.Factory{} cmd := NewCmdServiceMethod(f, driveSpec(), - map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", nil) + meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", nil) if cmd.Flags().Lookup("data") != nil { t.Error("GET method should not have --data flag") @@ -159,7 +160,7 @@ func TestNewCmdServiceMethod_GETHasNoDataFlag(t *testing.T) { func TestNewCmdServiceMethod_POSTHasDataFlag(t *testing.T) { f := &cmdutil.Factory{} cmd := NewCmdServiceMethod(f, driveSpec(), - map[string]interface{}{"description": "desc", "httpMethod": "POST"}, "create", "files", nil) + meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "POST"}), "create", "files", nil) if cmd.Flags().Lookup("data") == nil { t.Error("POST method should have --data flag") @@ -171,7 +172,7 @@ func TestNewCmdServiceMethod_RunFCallback(t *testing.T) { var captured *ServiceMethodOptions cmd := NewCmdServiceMethod(f, driveSpec(), - map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", + meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", func(opts *ServiceMethodOptions) error { captured = opts return nil @@ -268,15 +269,15 @@ func TestServiceMethod_MissingPathParam(t *testing.T) { } func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) { - spec := map[string]interface{}{ + spec := meta.ServiceFromMap(map[string]interface{}{ "name": "svc", "servicePath": "/open-apis/svc/v1", - } - method := map[string]interface{}{ + }) + method := meta.FromMap(map[string]interface{}{ "path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{ "q": map[string]interface{}{"location": "query", "required": true}, }, - } + }) f, _, _, _ := cmdutil.TestFactory(t, testConfig) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--params", `{}`, "--dry-run"}) @@ -291,15 +292,15 @@ func TestServiceMethod_MissingRequiredQueryParam(t *testing.T) { } func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) { - spec := map[string]interface{}{ + spec := meta.ServiceFromMap(map[string]interface{}{ "name": "svc", "servicePath": "/open-apis/svc/v1", - } - method := map[string]interface{}{ + }) + method := meta.FromMap(map[string]interface{}{ "path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{ "page_size": map[string]interface{}{"location": "query", "required": true}, }, - } + }) f, stdout, _, _ := cmdutil.TestFactory(t, testConfig) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--params", `{}`, "--page-all", "--dry-run"}) @@ -315,10 +316,10 @@ func TestServiceMethod_PaginationParamSkippedWithPageAll(t *testing.T) { func TestServiceMethod_InvalidParamsJSON(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, testConfig) - spec := map[string]interface{}{ + spec := meta.ServiceFromMap(map[string]interface{}{ "name": "svc", "servicePath": "/open-apis/svc/v1", - } - method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + }) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"}) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--params", "{bad", "--dry-run"}) @@ -333,10 +334,10 @@ func TestServiceMethod_InvalidParamsJSON(t *testing.T) { func TestServiceMethod_InvalidDataJSON(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, testConfig) - spec := map[string]interface{}{ + spec := meta.ServiceFromMap(map[string]interface{}{ "name": "svc", "servicePath": "/open-apis/svc/v1", - } - method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}} + }) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}) cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil) cmd.SetArgs([]string{"--data", "{bad", "--dry-run"}) @@ -351,10 +352,10 @@ func TestServiceMethod_InvalidDataJSON(t *testing.T) { func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, testConfig) - spec := map[string]interface{}{ + spec := meta.ServiceFromMap(map[string]interface{}{ "name": "svc", "servicePath": "/open-apis/svc/v1", - } - method := map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}} + }) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "POST", "parameters": map[string]interface{}{}}) cmd := NewCmdServiceMethod(f, spec, method, "create", "items", nil) cmd.SetArgs([]string{"--params", "-", "--data", "-", "--dry-run"}) @@ -369,10 +370,10 @@ func TestServiceMethod_ParamsAndDataBothStdinConflict(t *testing.T) { func TestServiceMethod_OutputAndPageAllConflict(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, testConfig) - spec := map[string]interface{}{ + spec := meta.ServiceFromMap(map[string]interface{}{ "name": "svc", "servicePath": "/open-apis/svc/v1", - } - method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + }) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"}) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--page-all", "--output", "file.bin", "--as", "bot"}) @@ -398,8 +399,8 @@ func TestServiceMethod_BotMode_Success(t *testing.T) { }, }) - spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} - method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--as", "bot"}) @@ -427,8 +428,8 @@ func TestServiceMethod_BotMode_PageAll_JSON(t *testing.T) { }, }) - spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} - method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--as", "bot", "--page-all"}) @@ -450,8 +451,8 @@ func TestServiceMethod_UnknownFormat_Warning(t *testing.T) { Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}}, }) - spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} - method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"}) @@ -470,7 +471,7 @@ func TestNewCmdServiceMethod_JqFlag(t *testing.T) { var captured *ServiceMethodOptions cmd := NewCmdServiceMethod(f, driveSpec(), - map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", + meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", func(opts *ServiceMethodOptions) error { captured = opts return nil @@ -492,7 +493,7 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) { var captured *ServiceMethodOptions cmd := NewCmdServiceMethod(f, driveSpec(), - map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", + meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", func(opts *ServiceMethodOptions) error { captured = opts return nil @@ -508,10 +509,10 @@ func TestNewCmdServiceMethod_JqShortForm(t *testing.T) { func TestServiceMethod_JqAndOutputConflict(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, testConfig) - spec := map[string]interface{}{ + spec := meta.ServiceFromMap(map[string]interface{}{ "name": "svc", "servicePath": "/open-apis/svc/v1", - } - method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + }) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"}) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--jq", ".data", "--output", "file.bin", "--as", "bot"}) @@ -542,8 +543,8 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) { }, }) - spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} - method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--as", "bot", "--jq", ".data.items[].name"}) @@ -561,10 +562,10 @@ func TestServiceMethod_JqFilter_AppliesExpression(t *testing.T) { func TestServiceMethod_JqAndFormatConflict(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, testConfig) - spec := map[string]interface{}{ + spec := meta.ServiceFromMap(map[string]interface{}{ "name": "svc", "servicePath": "/open-apis/svc/v1", - } - method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + }) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"}) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--jq", ".data", "--format", "ndjson", "--as", "bot"}) @@ -579,10 +580,10 @@ func TestServiceMethod_JqAndFormatConflict(t *testing.T) { func TestServiceMethod_JqInvalidExpression(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, testConfig) - spec := map[string]interface{}{ + spec := meta.ServiceFromMap(map[string]interface{}{ "name": "svc", "servicePath": "/open-apis/svc/v1", - } - method := map[string]interface{}{"path": "items", "httpMethod": "GET"} + }) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET"}) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--jq", "invalid[", "--as", "bot"}) @@ -611,8 +612,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) { }, }) - spec := map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"} - method := map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}} + spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"}) + method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}}) cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil) cmd.SetArgs([]string{"--as", "bot", "--page-all", "--jq", ".data.items[].id"}) @@ -630,8 +631,8 @@ func TestServiceMethod_PageAll_WithJq(t *testing.T) { // ── file upload ── -func imImageMethod() map[string]interface{} { - return map[string]interface{}{ +func imImageMethod() meta.Method { + return meta.FromMap(map[string]interface{}{ "path": "images", "httpMethod": "POST", "requestBody": map[string]interface{}{ @@ -645,14 +646,14 @@ func imImageMethod() map[string]interface{} { }, }, "accessTokens": []interface{}{"user", "tenant"}, - } + }) } -func imSpec() map[string]interface{} { - return map[string]interface{}{ +func imSpec() meta.Service { + return meta.ServiceFromMap(map[string]interface{}{ "name": "im", "servicePath": "/open-apis/im/v1", - } + }) } func TestServiceMethod_FileFlagRegistered(t *testing.T) { @@ -684,7 +685,7 @@ func TestServiceMethod_FileFlagNotRegisteredForGET(t *testing.T) { }, } f, _, _, _ := cmdutil.TestFactory(t, testConfig) - cmd := NewCmdServiceMethod(f, imSpec(), getMethod, "get", "images", nil) + cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(getMethod), "get", "images", nil) flag := cmd.Flags().Lookup("file") if flag != nil { t.Fatal("expected --file flag NOT to be registered for GET method") @@ -752,7 +753,7 @@ func TestDetectFileFields(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := detectFileFields(tt.method) + got := detectFileFields(meta.FromMap(tt.method)) if len(got) != len(tt.want) { t.Errorf("detectFileFields() = %v, want %v", got, tt.want) return @@ -771,7 +772,7 @@ func TestServiceMethod_JsonFlag_Accepted(t *testing.T) { var captured *ServiceMethodOptions cmd := NewCmdServiceMethod(f, driveSpec(), - map[string]interface{}{"description": "desc", "httpMethod": "GET"}, "list", "files", + meta.FromMap(map[string]interface{}{"description": "desc", "httpMethod": "GET"}), "list", "files", func(opts *ServiceMethodOptions) error { captured = opts return nil diff --git a/errs/subtypes.go b/errs/subtypes.go index 68a5dd377..913170d62 100644 --- a/errs/subtypes.go +++ b/errs/subtypes.go @@ -80,6 +80,7 @@ const ( SubtypeSDKError Subtype = "sdk_error" // lark SDK Do() returned an unexpected error SubtypeInvalidResponse Subtype = "invalid_response" // SDK response body not parsable as JSON SubtypeFileIO Subtype = "file_io" // local file I/O failure (mkdir / write / read) + SubtypeExternalTool Subtype = "external_tool" // an external tool the CLI shells out to (git, npx) failed at runtime; the tool output is in the message SubtypeStorage Subtype = "storage" // local persistence failure (e.g. config file save) // Generic untyped error lifted to InternalError uses SubtypeUnknown. ) diff --git a/events/minutes/preconsume.go b/events/minutes/preconsume.go index 2429bc53b..b396f6375 100644 --- a/events/minutes/preconsume.go +++ b/events/minutes/preconsume.go @@ -13,8 +13,8 @@ import ( const cleanupTimeout = 5 * time.Second -func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) { - return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) { +func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { + return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) { if rt == nil { return nil, errs.NewInternalError(errs.SubtypeUnknown, "runtime API client is required for pre-consume subscription") @@ -25,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu return nil, err } - return func() { + return func() error { cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) defer cancel() - _, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body) + if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { + return err + } + return nil }, nil } } diff --git a/events/vc/preconsume.go b/events/vc/preconsume.go index 1fa78cbcf..ce7e16f74 100644 --- a/events/vc/preconsume.go +++ b/events/vc/preconsume.go @@ -13,8 +13,8 @@ import ( const cleanupTimeout = 5 * time.Second -func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func(), error) { - return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func(), error) { +func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { + return func(ctx context.Context, rt event.APIClient, _ map[string]string) (func() error, error) { if rt == nil { return nil, errs.NewInternalError(errs.SubtypeUnknown, "runtime API client is required for pre-consume subscription") @@ -25,10 +25,13 @@ func subscriptionPreConsume(eventType, subscribePath, unsubscribePath string) fu return nil, err } - return func() { + return func() error { cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) defer cancel() - _, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body) + if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { + return err + } + return nil }, nil } } diff --git a/events/vc/recording_ended.go b/events/vc/recording_ended.go new file mode 100644 index 000000000..bc0a4e3c7 --- /dev/null +++ b/events/vc/recording_ended.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// VCRecordingEndedOutput is the flattened shape for vc.recording.recording_ended_v1. +type VCRecordingEndedOutput struct { + Type string `json:"type" desc:"Event type; always vc.recording.recording_ended_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + EventTime string `json:"event_time,omitempty" desc:"Time when the recording ended and uploaded successfully, in RFC3339 / ISO 8601 with the current system timezone"` + UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"` + Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` +} + +type recordingEndedEnvelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event recordingEndedEvent `json:"event"` +} + +type recordingEndedEvent struct { + UniqueKey string `json:"unique_key"` + Source string `json:"source"` +} + +func processVCRecordingEnded(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + envelope, ok := parseRecordingEndedEnvelope(raw) + if !ok { + return raw.Payload, nil + } + if !isRecordingEndedBeanEvent(envelope) { + return nil, nil + } + out := &VCRecordingEndedOutput{ + Type: recordingEndedEventType(envelope, raw), + EventID: envelope.Header.EventID, + EventTime: recordingEndedEventTime(envelope.Header.CreateTime), + UniqueKey: envelope.Event.UniqueKey, + Source: envelope.Event.Source, + } + return json.Marshal(out) +} + +func parseRecordingEndedEnvelope(raw *event.RawEvent) (*recordingEndedEnvelope, bool) { + var envelope recordingEndedEnvelope + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return nil, false + } + return &envelope, true +} + +func isRecordingEndedBeanEvent(envelope *recordingEndedEnvelope) bool { + return envelope != nil && envelope.Event.Source == "recording_bean" +} + +func recordingEndedEventType(envelope *recordingEndedEnvelope, raw *event.RawEvent) string { + if envelope != nil && envelope.Header.EventType != "" { + return envelope.Header.EventType + } + return raw.EventType +} + +func recordingEndedEventTime(raw string) string { + if raw == "" { + return "" + } + millis, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} diff --git a/events/vc/recording_started.go b/events/vc/recording_started.go new file mode 100644 index 000000000..00d51caf0 --- /dev/null +++ b/events/vc/recording_started.go @@ -0,0 +1,84 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// VCRecordingStartedOutput is the flattened shape for vc.recording.recording_started_v1. +type VCRecordingStartedOutput struct { + Type string `json:"type" desc:"Event type; always vc.recording.recording_started_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + EventTime string `json:"event_time,omitempty" desc:"Recording start time in RFC3339 / ISO 8601 with the current system timezone"` + UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"` + Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` +} + +type recordingStartedEnvelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event recordingStartedEvent `json:"event"` +} + +type recordingStartedEvent struct { + UniqueKey string `json:"unique_key"` + Source string `json:"source"` +} + +func processVCRecordingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + envelope, ok := parseRecordingStartedEnvelope(raw) + if !ok { + return raw.Payload, nil + } + if !isRecordingStartedBeanEvent(envelope) { + return nil, nil + } + out := &VCRecordingStartedOutput{ + Type: recordingStartedEventType(envelope, raw), + EventID: envelope.Header.EventID, + EventTime: recordingStartedEventTime(envelope.Header.CreateTime), + UniqueKey: envelope.Event.UniqueKey, + Source: envelope.Event.Source, + } + return json.Marshal(out) +} + +func parseRecordingStartedEnvelope(raw *event.RawEvent) (*recordingStartedEnvelope, bool) { + var envelope recordingStartedEnvelope + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return nil, false + } + return &envelope, true +} + +func isRecordingStartedBeanEvent(envelope *recordingStartedEnvelope) bool { + return envelope != nil && envelope.Event.Source == "recording_bean" +} + +func recordingStartedEventType(envelope *recordingStartedEnvelope, raw *event.RawEvent) string { + if envelope != nil && envelope.Header.EventType != "" { + return envelope.Header.EventType + } + return raw.EventType +} + +func recordingStartedEventTime(raw string) string { + if raw == "" { + return "" + } + millis, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} diff --git a/events/vc/recording_test.go b/events/vc/recording_test.go new file mode 100644 index 000000000..89ba24487 --- /dev/null +++ b/events/vc/recording_test.go @@ -0,0 +1,468 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" +) + +func TestVCKeys_RecordingEventsRegistered(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + eventType string + }{ + {eventTypeRecordingStarted}, + {eventTypeRecordingTranscriptGenerated}, + {eventTypeRecordingEnded}, + } { + t.Run(tc.eventType, func(t *testing.T) { + def, ok := event.Lookup(tc.eventType) + if !ok { + t.Fatalf("%s should be registered via Keys()", tc.eventType) + } + if def.Schema.Custom == nil { + t.Error("Processed key must set Schema.Custom") + } + if def.Schema.Native != nil { + t.Error("Processed key must not set Schema.Native") + } + if def.Process == nil { + t.Error("Process must not be nil for processed key") + } + if def.PreConsume == nil { + t.Error("PreConsume must not be nil for processed key") + } + if len(def.Scopes) != 1 || def.Scopes[0] != "vc:recording:read" { + t.Errorf("Scopes = %v", def.Scopes) + } + if len(def.AuthTypes) != 1 || def.AuthTypes[0] != "user" { + t.Errorf("AuthTypes = %v", def.AuthTypes) + } + if len(def.RequiredConsoleEvents) != 1 || def.RequiredConsoleEvents[0] != tc.eventType { + t.Errorf("RequiredConsoleEvents = %v", def.RequiredConsoleEvents) + } + if !strings.Contains(def.Description, "recording_bean") { + t.Errorf("Description should document recording_bean source, got %q", def.Description) + } + if !strings.Contains(def.Description, "connected to Feishu software") { + t.Errorf("Description should document Feishu software connection requirement, got %q", def.Description) + } + if strings.Contains(def.Description, "future") || strings.Contains(def.Description, "software_recording") { + t.Errorf("Description should not mention future sources, got %q", def.Description) + } + if tc.eventType == eventTypeRecordingEnded && (strings.Contains(def.Description, "object_type") || strings.Contains(def.Description, "object_id")) { + t.Errorf("ended Description should not document object metadata, got %q", def.Description) + } + wantSchemaType := reflect.TypeOf(VCRecordingStartedOutput{}) + switch tc.eventType { + case eventTypeRecordingTranscriptGenerated: + wantSchemaType = reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{}) + case eventTypeRecordingEnded: + wantSchemaType = reflect.TypeOf(VCRecordingEndedOutput{}) + } + if def.Schema.Custom.Type != wantSchemaType { + t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, wantSchemaType) + } + }) + } +} + +func TestProcessVCRecordingStarted(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + out := runRecordingProcess[VCRecordingStartedOutput](t, eventTypeRecordingStarted, processVCRecordingStarted, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_start_001", + "event_type": "vc.recording.recording_started_v1", + "create_time": "1761782400000" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean" + } + }`) + + if out.Type != eventTypeRecordingStarted { + t.Errorf("Type = %q", out.Type) + } + if out.EventID != "ev_rec_start_001" || out.EventTime != recordingTestEventTime(1761782400000) { + t.Errorf("EventID/EventTime = %q/%q", out.EventID, out.EventTime) + } + if out.UniqueKey != "recording_001" || out.Source != "recording_bean" { + t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source) + } +} + +func TestProcessVCRecordingTranscriptGenerated(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + got := runRecordingProcessRaw(t, eventTypeRecordingTranscriptGenerated, processVCRecordingTranscriptGenerated, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_transcript_001", + "event_type": "vc.recording.recording_transcript_generated_v1", + "create_time": "1761782400100" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean", + "transcript_items": [ + { + "speaker": { + "id": { + "open_id": "ou_0f8bf7acdf2ae69553ecbdbfbbd10a53", + "union_id": "on_bc03f16d781bff4178a5d11e48eb1867", + "user_id": null + }, + "user_type": 100, + "user_role": 1, + "user_name": "Alice" + }, + "text": "hello world", + "language": "en_us", + "start_time_ms": "1761782399000", + "end_time_ms": "1761782400000", + "sentence_id": "987654321" + }, + { + "speaker": { + "user_name": "Bob" + }, + "text": "second sentence", + "language": "en_us", + "start_time_ms": "1761782401000", + "end_time_ms": "1761782402000", + "sentence_id": "987654322" + } + ] + } + }`) + if got == nil { + t.Fatal("Process output is nil") + } + var out VCRecordingTranscriptGeneratedOutput + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got)) + } + + if out.Type != eventTypeRecordingTranscriptGenerated { + t.Errorf("Type = %q", out.Type) + } + if out.UniqueKey != "recording_001" || out.Source != "recording_bean" { + t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source) + } + if out.EventTime != recordingTestEventTime(1761782400100) { + t.Errorf("EventTime = %q", out.EventTime) + } + if len(out.TranscriptItems) != 2 { + t.Fatalf("TranscriptItems len = %d, want 2", len(out.TranscriptItems)) + } + item := out.TranscriptItems[0] + if item.SpeakerName != "Alice" || item.Text != "hello world" { + t.Errorf("Transcript speaker/text = %q/%q", item.SpeakerName, item.Text) + } + if item.StartTime != recordingTestEventTime(1761782399000) || item.EndTime != recordingTestEventTime(1761782400000) { + t.Errorf("Transcript timing = %q/%q", item.StartTime, item.EndTime) + } + if item.SentenceID != "987654321" { + t.Errorf("SentenceID = %q, want 987654321", item.SentenceID) + } + if out.TranscriptItems[1].SpeakerName != "Bob" || out.TranscriptItems[1].SentenceID != "987654322" { + t.Errorf("second transcript item = %+v", out.TranscriptItems[1]) + } + itemJSON, err := json.Marshal(item) + if err != nil { + t.Fatalf("marshal transcript item: %v", err) + } + var itemFields map[string]any + if err := json.Unmarshal(itemJSON, &itemFields); err != nil { + t.Fatalf("unmarshal transcript item JSON: %v", err) + } + wantItemFields := map[string]bool{ + "speaker_name": true, + "text": true, + "start_time": true, + "end_time": true, + "sentence_id": true, + } + for gotField := range itemFields { + if !wantItemFields[gotField] { + t.Errorf("Transcript item should not contain field %q, got %s", gotField, string(itemJSON)) + } + } + for wantField := range wantItemFields { + if _, ok := itemFields[wantField]; !ok { + t.Errorf("Transcript item missing field %q, got %s", wantField, string(itemJSON)) + } + } + for _, unexpected := range []string{ + `"seq_id"`, + `"speaker"`, + `"user_open_id"`, + `"user_type"`, + `"user_role"`, + `"language"`, + `"start_time_ms"`, + `"end_time_ms"`, + `"sequence_id"`, + `"transcript_item"`, + } { + if strings.Contains(string(got), unexpected) { + t.Errorf("Transcript output should not contain %s, got %s", unexpected, string(got)) + } + } + if !strings.Contains(string(got), `"sentence_id":"987654321"`) { + t.Errorf("Transcript output should contain sentence_id, got %s", string(got)) + } +} + +func TestProcessVCRecordingEnded(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + out := runRecordingProcess[VCRecordingEndedOutput](t, eventTypeRecordingEnded, processVCRecordingEnded, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_end_001", + "event_type": "vc.recording.recording_ended_v1", + "create_time": "1761782400200" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean", + "object_type": "minutes", + "object_id": "minute_token_001" + } + }`) + + if out.Type != eventTypeRecordingEnded { + t.Errorf("Type = %q", out.Type) + } + if out.UniqueKey != "recording_001" || out.Source != "recording_bean" { + t.Errorf("UniqueKey/Source = %q/%q", out.UniqueKey, out.Source) + } + if out.EventTime != recordingTestEventTime(1761782400200) { + t.Errorf("EventTime = %q", out.EventTime) + } +} + +func TestProcessVCRecordingEnded_DropsObjectMetadata(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + got := runRecordingProcessRaw(t, eventTypeRecordingEnded, processVCRecordingEnded, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_end_001", + "event_type": "vc.recording.recording_ended_v1", + "create_time": "1761782400200" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean", + "object_type": "minutes", + "object_id": "minute_token_001" + } + }`) + + if strings.Contains(string(got), "object_type") || strings.Contains(string(got), "object_id") { + t.Fatalf("ended output should drop object metadata, got %s", string(got)) + } +} + +func TestProcessVCRecording_DropsTimestampField(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + got := runRecordingProcessRaw(t, eventTypeRecordingStarted, processVCRecordingStarted, `{ + "schema": "2.0", + "header": { + "event_id": "ev_rec_start_001", + "event_type": "vc.recording.recording_started_v1", + "create_time": "1761782400000" + }, + "event": { + "unique_key": "recording_001", + "source": "recording_bean" + } + }`) + + if strings.Contains(string(got), `"timestamp"`) { + t.Fatalf("recording output should use event_time instead of timestamp, got %s", string(got)) + } + if !strings.Contains(string(got), `"event_time":"`+recordingTestEventTime(1761782400000)+`"`) { + t.Fatalf("recording output should include ISO 8601 event_time, got %s", string(got)) + } +} + +func TestProcessVCRecording_NonRecordingBeanFiltered(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + name string + eventType string + process event.ProcessFunc + payload string + }{ + { + name: "started", + eventType: eventTypeRecordingStarted, + process: processVCRecordingStarted, + payload: `{ + "schema": "2.0", + "header": {"event_id": "ev_rec_start_001", "event_type": "vc.recording.recording_started_v1"}, + "event": {"unique_key": "recording_001", "source": "software_recording"} + }`, + }, + { + name: "transcript", + eventType: eventTypeRecordingTranscriptGenerated, + process: processVCRecordingTranscriptGenerated, + payload: `{ + "schema": "2.0", + "header": {"event_id": "ev_rec_transcript_001", "event_type": "vc.recording.recording_transcript_generated_v1"}, + "event": {"unique_key": "recording_001", "source": "software_recording", "transcript_items": []} + }`, + }, + { + name: "ended", + eventType: eventTypeRecordingEnded, + process: processVCRecordingEnded, + payload: `{ + "schema": "2.0", + "header": {"event_id": "ev_rec_end_001", "event_type": "vc.recording.recording_ended_v1"}, + "event": {"unique_key": "recording_001", "source": "software_recording"} + }`, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got := runRecordingProcessRaw(t, tc.eventType, tc.process, tc.payload) + if got != nil { + t.Fatalf("non-recording_bean event should be filtered, got %s", string(got)) + } + }) + } +} + +func TestProcessVCRecording_MalformedPayloadPassthrough(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + name string + eventType string + process event.ProcessFunc + }{ + {name: "started", eventType: eventTypeRecordingStarted, process: processVCRecordingStarted}, + {name: "transcript", eventType: eventTypeRecordingTranscriptGenerated, process: processVCRecordingTranscriptGenerated}, + {name: "ended", eventType: eventTypeRecordingEnded, process: processVCRecordingEnded}, + } { + t.Run(tc.name, func(t *testing.T) { + raw := &event.RawEvent{ + EventType: tc.eventType, + Payload: json.RawMessage(`not json`), + Timestamp: time.Now(), + } + got, err := tc.process(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process should swallow parse errors, got %v", err) + } + if string(got) != "not json" { + t.Errorf("malformed fallback output = %q, want original bytes", string(got)) + } + }) + } +} + +func TestVCRecording_PreConsumeSubscriptionLifecycle(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + eventType string + }{ + {eventTypeRecordingStarted}, + {eventTypeRecordingTranscriptGenerated}, + {eventTypeRecordingEnded}, + } { + t.Run(tc.eventType, func(t *testing.T) { + def, ok := event.Lookup(tc.eventType) + if !ok { + t.Fatalf("%s should be registered via Keys()", tc.eventType) + } + + type call struct { + method string + path string + body any + } + var calls []call + rt := &stubAPIClient{ + callFn: func(_ context.Context, method, path string, body any) (json.RawMessage, error) { + calls = append(calls, call{method: method, path: path, body: body}) + return json.RawMessage(`{"code":0,"msg":"success","data":{}}`), nil + }, + } + + cleanup, err := def.PreConsume(context.Background(), rt, nil) + if err != nil { + t.Fatalf("PreConsume error: %v", err) + } + if cleanup == nil { + t.Fatal("cleanup must not be nil") + } + if len(calls) != 1 { + t.Fatalf("calls after subscribe = %d, want 1", len(calls)) + } + if calls[0].method != "POST" || calls[0].path != pathRecordingSubscribe { + t.Fatalf("subscribe call = %+v", calls[0]) + } + assertSubscriptionRequest(t, calls[0].body, tc.eventType) + + cleanup() + if len(calls) != 2 { + t.Fatalf("calls after cleanup = %d, want 2", len(calls)) + } + if calls[1].method != "POST" || calls[1].path != pathRecordingUnsubscribe { + t.Fatalf("unsubscribe call = %+v", calls[1]) + } + assertSubscriptionRequest(t, calls[1].body, tc.eventType) + }) + } +} + +func runRecordingProcess[T any](t *testing.T, eventType string, process event.ProcessFunc, payload string) T { + t.Helper() + got := runRecordingProcessRaw(t, eventType, process, payload) + if got == nil { + t.Fatal("Process output is nil") + } + var out T + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid JSON: %v\nraw=%s", err, string(got)) + } + return out +} + +func runRecordingProcessRaw(t *testing.T, eventType string, process event.ProcessFunc, payload string) json.RawMessage { + t.Helper() + raw := &event.RawEvent{ + EventType: eventType, + Payload: json.RawMessage(payload), + Timestamp: time.Now(), + } + got, err := process(context.Background(), nil, raw, nil) + if err != nil { + t.Fatalf("Process error: %v", err) + } + return got +} + +func recordingTestEventTime(millis int64) string { + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} diff --git a/events/vc/recording_transcript_generated.go b/events/vc/recording_transcript_generated.go new file mode 100644 index 000000000..fe609bbff --- /dev/null +++ b/events/vc/recording_transcript_generated.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "strconv" + "time" + + "github.com/larksuite/cli/internal/event" +) + +// VCRecordingTranscriptItemOutput is one flattened transcript item for recording events. +type VCRecordingTranscriptItemOutput struct { + SpeakerName string `json:"speaker_name,omitempty" desc:"Speaker display name"` + Text string `json:"text,omitempty" desc:"Transcript text"` + StartTime string `json:"start_time,omitempty" desc:"Transcript item start time in RFC3339 / ISO 8601 with the current system timezone"` + EndTime string `json:"end_time,omitempty" desc:"Transcript item end time in RFC3339 / ISO 8601 with the current system timezone"` + SentenceID string `json:"sentence_id,omitempty" desc:"Transcript sentence ID"` +} + +// VCRecordingTranscriptGeneratedOutput is the flattened shape for vc.recording.recording_transcript_generated_v1. +type VCRecordingTranscriptGeneratedOutput struct { + Type string `json:"type" desc:"Event type; always vc.recording.recording_transcript_generated_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + EventTime string `json:"event_time,omitempty" desc:"Time when this batch of transcript items was generated, in RFC3339 / ISO 8601 with the current system timezone"` + UniqueKey string `json:"unique_key,omitempty" desc:"Unique key generated for one recording_bean recording session"` + Source string `json:"source,omitempty" desc:"Recording source; always recording_bean"` + TranscriptItems []VCRecordingTranscriptItemOutput `json:"transcript_items,omitempty" desc:"Generated transcript items"` +} + +type recordingTranscriptGeneratedEnvelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event recordingTranscriptGeneratedEvent `json:"event"` +} + +type recordingTranscriptGeneratedEvent struct { + UniqueKey string `json:"unique_key"` + Source string `json:"source"` + TranscriptItems []recordingTranscriptGeneratedItemIn `json:"transcript_items"` +} + +type recordingTranscriptGeneratedItemIn struct { + Speaker *recordingTranscriptGeneratedSpeakerIn `json:"speaker"` + Text string `json:"text"` + StartTimeMs recordingTranscriptGeneratedString `json:"start_time_ms"` + EndTimeMs recordingTranscriptGeneratedString `json:"end_time_ms"` + SentenceID string `json:"sentence_id"` +} + +type recordingTranscriptGeneratedSpeakerIn struct { + UserName string `json:"user_name"` +} + +type recordingTranscriptGeneratedString string + +func processVCRecordingTranscriptGenerated(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + envelope, ok := parseRecordingTranscriptGeneratedEnvelope(raw) + if !ok { + return raw.Payload, nil + } + if !isRecordingTranscriptGeneratedBeanEvent(envelope) { + return nil, nil + } + out := &VCRecordingTranscriptGeneratedOutput{ + Type: recordingTranscriptGeneratedEventType(envelope, raw), + EventID: envelope.Header.EventID, + EventTime: recordingTranscriptGeneratedEventTime(envelope.Header.CreateTime), + UniqueKey: envelope.Event.UniqueKey, + Source: envelope.Event.Source, + TranscriptItems: recordingTranscriptItems(envelope.Event.TranscriptItems), + } + return json.Marshal(out) +} + +func parseRecordingTranscriptGeneratedEnvelope(raw *event.RawEvent) (*recordingTranscriptGeneratedEnvelope, bool) { + var envelope recordingTranscriptGeneratedEnvelope + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return nil, false + } + return &envelope, true +} + +func isRecordingTranscriptGeneratedBeanEvent(envelope *recordingTranscriptGeneratedEnvelope) bool { + return envelope != nil && envelope.Event.Source == "recording_bean" +} + +func recordingTranscriptGeneratedEventType(envelope *recordingTranscriptGeneratedEnvelope, raw *event.RawEvent) string { + if envelope != nil && envelope.Header.EventType != "" { + return envelope.Header.EventType + } + return raw.EventType +} + +func recordingTranscriptGeneratedEventTime(raw string) string { + return recordingTranscriptGeneratedMillisToLocalRFC3339(raw) +} + +func recordingTranscriptGeneratedMillisToLocalRFC3339(raw string) string { + if raw == "" { + return "" + } + millis, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return "" + } + return time.UnixMilli(millis).Local().Format(time.RFC3339) +} + +func recordingTranscriptItems(items []recordingTranscriptGeneratedItemIn) []VCRecordingTranscriptItemOutput { + if len(items) == 0 { + return nil + } + out := make([]VCRecordingTranscriptItemOutput, 0, len(items)) + for _, item := range items { + out = append(out, recordingTranscriptItem(item)) + } + return out +} + +func recordingTranscriptItem(item recordingTranscriptGeneratedItemIn) VCRecordingTranscriptItemOutput { + return VCRecordingTranscriptItemOutput{ + SpeakerName: recordingSpeakerName(item.Speaker), + Text: item.Text, + StartTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.StartTimeMs.String()), + EndTime: recordingTranscriptGeneratedMillisToLocalRFC3339(item.EndTimeMs.String()), + SentenceID: item.SentenceID, + } +} + +func recordingSpeakerName(speaker *recordingTranscriptGeneratedSpeakerIn) string { + if speaker == nil { + return "" + } + return speaker.UserName +} + +func (s *recordingTranscriptGeneratedString) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return nil + } + var str string + if err := json.Unmarshal(data, &str); err == nil { + *s = recordingTranscriptGeneratedString(str) + return nil + } + var num json.Number + if err := json.Unmarshal(data, &num); err != nil { + return err + } + *s = recordingTranscriptGeneratedString(num.String()) + return nil +} + +func (s recordingTranscriptGeneratedString) String() string { + return string(s) +} diff --git a/events/vc/register.go b/events/vc/register.go index d392daf23..bfee1241b 100644 --- a/events/vc/register.go +++ b/events/vc/register.go @@ -11,13 +11,18 @@ import ( ) const ( - eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1" - eventTypeNoteGenerated = "vc.note.generated_v1" + eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1" + eventTypeNoteGenerated = "vc.note.generated_v1" + eventTypeRecordingStarted = "vc.recording.recording_started_v1" + eventTypeRecordingTranscriptGenerated = "vc.recording.recording_transcript_generated_v1" + eventTypeRecordingEnded = "vc.recording.recording_ended_v1" - pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription" - pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription" - pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription" - pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription" + pathMeetingSubscribe = "/open-apis/vc/v1/meetings/subscription" + pathMeetingUnsubscribe = "/open-apis/vc/v1/meetings/unsubscription" + pathNoteSubscribe = "/open-apis/vc/v1/notes/subscription" + pathNoteUnsubscribe = "/open-apis/vc/v1/notes/unsubscription" + pathRecordingSubscribe = "/open-apis/vc/v1/recordings/subscription" + pathRecordingUnsubscribe = "/open-apis/vc/v1/recordings/unsubscription" pathNoteDetailFmt = "/open-apis/vc/v1/notes/%s" ) @@ -57,5 +62,53 @@ func Keys() []event.KeyDefinition { }, RequiredConsoleEvents: []string{eventTypeNoteGenerated}, }, + { + Key: eventTypeRecordingStarted, + DisplayName: "Recording started", + Description: "Triggered when a recording_bean recording starts; only generated when connected to Feishu software.", + EventType: eventTypeRecordingStarted, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingStartedOutput{})}, + }, + Process: processVCRecordingStarted, + PreConsume: subscriptionPreConsume(eventTypeRecordingStarted, pathRecordingSubscribe, pathRecordingUnsubscribe), + Scopes: []string{"vc:recording:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeRecordingStarted}, + }, + { + Key: eventTypeRecordingTranscriptGenerated, + DisplayName: "Recording transcript generated", + Description: "Triggered when recording_bean transcript items are generated; only generated when connected to Feishu software.", + EventType: eventTypeRecordingTranscriptGenerated, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingTranscriptGeneratedOutput{})}, + }, + Process: processVCRecordingTranscriptGenerated, + PreConsume: subscriptionPreConsume(eventTypeRecordingTranscriptGenerated, pathRecordingSubscribe, pathRecordingUnsubscribe), + Scopes: []string{"vc:recording:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeRecordingTranscriptGenerated}, + }, + { + Key: eventTypeRecordingEnded, + DisplayName: "Recording ended", + Description: "Triggered when a recording_bean recording ends and uploads successfully; only generated when connected to Feishu software.", + EventType: eventTypeRecordingEnded, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCRecordingEndedOutput{})}, + }, + Process: processVCRecordingEnded, + PreConsume: subscriptionPreConsume(eventTypeRecordingEnded, pathRecordingSubscribe, pathRecordingUnsubscribe), + Scopes: []string{"vc:recording:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeRecordingEnded}, + }, } } diff --git a/events/whiteboard/preconsume.go b/events/whiteboard/preconsume.go index 6e60416ce..02c27fe6b 100644 --- a/events/whiteboard/preconsume.go +++ b/events/whiteboard/preconsume.go @@ -22,8 +22,8 @@ const cleanupTimeout = 5 * time.Second // // board.whiteboard.updated_v1 is subscribed per-whiteboard (by whiteboard_id), // so the path contains a :whiteboard_id placeholder that must be supplied via params. -func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func(), error) { - return func(ctx context.Context, rt event.APIClient, params map[string]string) (func(), error) { +func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, event.APIClient, map[string]string) (func() error, error) { + return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) { if rt == nil { return nil, errs.NewInternalError(errs.SubtypeUnknown, "runtime API client is required for pre-consume subscription") @@ -44,10 +44,13 @@ func whiteboardSubscriptionPreConsume(eventType string) func(context.Context, ev return nil, err } - return func() { + return func() error { cleanupCtx, cancel := context.WithTimeout(context.Background(), cleanupTimeout) defer cancel() - _, _ = rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body) + if _, err := rt.CallAPI(cleanupCtx, "POST", unsubscribePath, body); err != nil { + return err + } + return nil }, nil } } diff --git a/internal/apicatalog/catalog.go b/internal/apicatalog/catalog.go new file mode 100644 index 000000000..cf31c8a72 --- /dev/null +++ b/internal/apicatalog/catalog.go @@ -0,0 +1,396 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package apicatalog is the single navigation Module over the API metadata. It +// owns every "which services/resources/methods exist and how does a path +// resolve" question that was previously duplicated across cmd/schema, +// cmd/service, internal/schema and internal/registry. It depends only on +// internal/meta; registry is the source Adapter (EmbeddedCatalog/RuntimeCatalog), +// so apicatalog never imports registry. +package apicatalog + +import ( + "sort" + "strings" + + "github.com/larksuite/cli/internal/meta" +) + +// Source records whether a catalog includes the remote overlay. It is carried +// so callers (and tests) can assert determinism instead of guessing. +type Source string + +const ( + SourceEmbedded Source = "embedded" // compiled-in metadata only; deterministic + SourceRuntime Source = "runtime" // embedded + remote overlay +) + +// MethodFilter optionally drops methods (e.g. by identity in strict mode). +// A nil filter includes everything. +type MethodFilter func(meta.Method) bool + +// Catalog is a navigation view over services with a name index. It owns its +// ordering — New sorts by name — so WalkMethods/Resolve/Complete are +// deterministic regardless of how the source adapter ordered its input. +type Catalog struct { + source Source + services []meta.Service + byName map[string]meta.Service +} + +// New builds a Catalog over the given services, owning its navigation order: +// the slice is copied and sorted by name so callers may pass any order and the +// ordering contract is not delegated to the adapter. The copy is shallow — +// meta.Service values share their Resources maps, which are treated as +// read-only. +func New(source Source, services []meta.Service) Catalog { + sorted := append([]meta.Service(nil), services...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name }) + byName := make(map[string]meta.Service, len(sorted)) + for _, s := range sorted { + byName[s.Name] = s + } + return Catalog{source: source, services: sorted, byName: byName} +} + +// Source reports embedded vs runtime. +func (c Catalog) Source() Source { return c.source } + +// Services returns the services in name order. Treat the result as read-only: +// it is the Catalog's own ordered slice and its element Resources maps are +// shared. +func (c Catalog) Services() []meta.Service { return c.services } + +// Service looks up one service by name. +func (c Catalog) Service(name string) (meta.Service, bool) { + s, ok := c.byName[name] + return s, ok +} + +// Resolve maps a path (already split into segments) to a Target. An empty path +// is TargetAll. Failures return a *ResolveError carrying the available +// candidates so the command layer can render a hint. +func (c Catalog) Resolve(parts []string) (Target, error) { + if len(parts) == 0 { + return Target{Kind: TargetAll}, nil + } + svc, ok := c.byName[parts[0]] + if !ok { + return Target{}, &ResolveError{Kind: ErrService, Subject: parts[0], Candidates: c.serviceNames()} + } + if len(parts) == 1 { + return Target{Kind: TargetService, Service: svc}, nil + } + res, path, remaining, ok := findResource(svc, parts[1:]) + if !ok { + return Target{}, &ResolveError{ + Kind: ErrResource, + Subject: svc.Name + "." + strings.Join(parts[1:], "."), + Candidates: resourceNames(svc), + } + } + resPath := strings.Join(path, ".") + if len(remaining) == 0 { + return Target{Kind: TargetResource, Service: svc, Resource: &ResourceRef{Service: svc, Resource: res, Path: path}}, nil + } + methodName := remaining[0] + m, ok := res.Method(methodName) + if !ok { + return Target{}, &ResolveError{ + Kind: ErrMethod, + Subject: svc.Name + "." + resPath + "." + methodName, + Candidates: methodNames(res), + } + } + if len(remaining) > 1 { + // Method exists but trailing segments don't resolve — reject so a typo + // doesn't silently return this method's schema. + return Target{}, &ResolveError{ + Kind: ErrPath, + Subject: svc.Name + "." + resPath + "." + strings.Join(remaining, "."), + Method: methodName, + Trailing: strings.Join(remaining[1:], "."), + } + } + return Target{Kind: TargetMethod, Service: svc, Method: &MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m}}, nil +} + +// MethodRefs returns the method refs selected by a resolved Target, filtered: +// TargetAll -> every method, TargetService / TargetResource -> that subtree, +// TargetMethod -> the single method if it passes the filter (else empty). It +// unifies WalkMethods/ServiceMethods/ResourceMethods so the command layer maps a +// Target to refs in one call instead of re-deciding the walker per Kind. +func (c Catalog) MethodRefs(target Target, filter MethodFilter) []MethodRef { + switch target.Kind { + case TargetService: + return ServiceMethods(target.Service, filter) + case TargetResource: + return ResourceMethods(*target.Resource, filter) + case TargetMethod: + if filter != nil && !filter(target.Method.Method) { + return nil + } + return []MethodRef{*target.Method} + case TargetAll: + return c.WalkMethods(filter) + default: + // Unknown / zero-value Kind: return nothing rather than silently + // dumping every method (the safe direction for an invalid Target). + return nil + } +} + +// WalkMethods returns one MethodRef per method across all services (optionally +// filtered), recursing nested resources, in a deterministic order: services by +// name, resources by name, methods by name. +func (c Catalog) WalkMethods(filter MethodFilter) []MethodRef { + var out []MethodRef + for _, svc := range c.services { + out = append(out, ServiceMethods(svc, filter)...) + } + return out +} + +// ServiceMethods returns the method refs of one service (filtered), recursing +// nested resources, in deterministic resource/method name order. +func ServiceMethods(svc meta.Service, filter MethodFilter) []MethodRef { + var out []MethodRef + walkResources(svc, svc.ResourceList(), nil, filter, &out) + return out +} + +// ResourceMethods returns the method refs under one resource (filtered), using +// the resource's resolved path as the base and recursing nested resources. +func ResourceMethods(r ResourceRef, filter MethodFilter) []MethodRef { + var out []MethodRef + for _, m := range r.Resource.MethodList() { + if filter == nil || filter(m) { + out = append(out, MethodRef{Service: r.Service, Resource: r.Resource, ResourcePath: r.Path, Method: m}) + } + } + walkResources(r.Service, r.Resource.SubResources(), r.Path, filter, &out) + return out +} + +func walkResources(svc meta.Service, resources []meta.Resource, parentPath []string, filter MethodFilter, out *[]MethodRef) { + for _, res := range resources { + path := append(append([]string(nil), parentPath...), res.Name) + for _, m := range res.MethodList() { + if filter == nil || filter(m) { + *out = append(*out, MethodRef{Service: svc, Resource: res, ResourcePath: path, Method: m}) + } + } + walkResources(svc, res.SubResources(), path, filter, out) + } +} + +// Complete returns shell-completion candidates for the schema path argument, +// supporting both the legacy single dotted arg ("im.reac") and the +// space-separated form ("im reactions"). noSpace mirrors cobra's +// ShellCompDirectiveNoSpace (so "service." / "service.resource." stay open for +// the next segment). Filtering uses the caller's MethodFilter so strict-mode +// unavailable methods are hidden. +func (c Catalog) Complete(args []string, toComplete string, filter MethodFilter) (completions []string, noSpace bool) { + // Case 1: legacy single dotted arg — no resolved args yet. + if len(args) == 0 { + parts := strings.Split(toComplete, ".") + if len(parts) <= 1 { + for _, name := range c.serviceNames() { + if strings.HasPrefix(name, toComplete) { + completions = append(completions, name+".") + } + } + return completions, true + } + svc, ok := c.byName[parts[0]] + if !ok { + return nil, false + } + completions = c.completeDotted(svc, strings.Join(parts[1:], "."), filter) + allTrailingDot := len(completions) > 0 + for _, comp := range completions { + if !strings.HasSuffix(comp, ".") { + allTrailingDot = false + break + } + } + return completions, allTrailingDot + } + + // Case 2: space-separated form — args holds resolved segments. + svc, ok := c.byName[args[0]] + if !ok { + return nil, false + } + resource, _, _, ok := findResource(svc, args[1:]) + if !ok { + // No resource matched yet — suggest top-level resources reachable in the + // current identity mode. + return completeChildren(svc.ResourceList(), nil, toComplete, filter), false + } + // Positioned in a resource — offer its methods and its sub-resources, so the + // next segment can drill deeper, symmetric to findResource's descent. + return completeChildren(resource.SubResources(), resource.MethodList(), toComplete, filter), false +} + +// completeDotted suggests dotted completions for the text after the service +// segment. It descends fully-typed "resource." segments (longest match per +// level, so flat dotted keys like "chat.members" and genuinely nested resources +// both resolve), then offers the reachable sub-resources (as "…name.") and the +// methods (as "…name") of the level it lands in whose names extend the trailing +// partial token. This descent is symmetric to findResource, so completion can +// reach every method Resolve can. +func (c Catalog) completeDotted(svc meta.Service, afterService string, filter MethodFilter) []string { + subs := svc.ResourceList() + base := svc.Name + rest := afterService + var here *meta.Resource // resource we're positioned in; nil at the service root + for { + matched, n, ok := longestResourceFollowedByDot(subs, rest) + if !ok { + break + } + base += "." + matched.Name + rest = rest[n:] + r := matched + here = &r + subs = matched.SubResources() + } + + var out []string + for _, sub := range subs { + if strings.HasPrefix(sub.Name, rest) && resourceReachable(sub, filter) { + out = append(out, base+"."+sub.Name+".") + } + } + if here != nil { + for _, m := range here.MethodList() { + if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, rest) { + out = append(out, base+"."+m.Name) + } + } + } + sort.Strings(out) + return out +} + +// completeChildren returns the sorted next-segment candidates at one level: the +// (filtered) methods and the reachable sub-resources whose names extend prefix. +// Methods are terminal; sub-resources are bare names the caller drills into on +// the next segment. +func completeChildren(subResources []meta.Resource, methods []meta.Method, prefix string, filter MethodFilter) []string { + var out []string + for _, m := range methods { + if (filter == nil || filter(m)) && strings.HasPrefix(m.Name, prefix) { + out = append(out, m.Name) + } + } + for _, sub := range subResources { + if strings.HasPrefix(sub.Name, prefix) && resourceReachable(sub, filter) { + out = append(out, sub.Name) + } + } + sort.Strings(out) + return out +} + +// longestResourceFollowedByDot finds the longest resource in resources whose +// name is a fully-typed segment of text (text begins with "name."), returning +// it, the byte length consumed (incl. the dot), and whether one matched. +func longestResourceFollowedByDot(resources []meta.Resource, text string) (meta.Resource, int, bool) { + best := meta.Resource{} + bestLen := -1 + for _, r := range resources { + if len(r.Name) > bestLen && strings.HasPrefix(text, r.Name+".") { + best = r + bestLen = len(r.Name) + } + } + if bestLen < 0 { + return meta.Resource{}, 0, false + } + return best, len(best.Name) + 1, true +} + +// findResource resolves a resource path against a service, descending nested +// resources. At each level it consumes the longest leading run of parts that +// names a resource at that level, so both flat dotted keys ("chat.members") +// and genuinely nested resources ("spaces" > "items") resolve. This descent is +// symmetric to walkResources, which guarantees every path WalkMethods emits +// resolves back (the round-trip contract). Returns the deepest matched resource +// (Name injected), its path segments, the unconsumed remainder, and whether +// anything matched. +// +// Descent is greedy and resource-first: the one ambiguous case is a resource +// that has BOTH a method and a sub-resource of the same name — the sub-resource +// wins and shadows the method, so Resolve can never reach that method. Real +// metadata never collides the two, so this is theoretical. +func findResource(svc meta.Service, parts []string) (res meta.Resource, path []string, remaining []string, ok bool) { + level := svc.Resources + remaining = parts + for len(remaining) > 0 { + matched, name, n := longestResourcePrefix(level, remaining) + if n == 0 { + break + } + matched.Name = name + res = matched + path = append(path, name) + remaining = remaining[n:] + level = matched.Resources + ok = true + } + return res, path, remaining, ok +} + +// longestResourcePrefix finds the longest leading run of segs (joined by ".") +// that names a resource in level, returning the resource, its dotted name, and +// the number of segments consumed (0 if none match). Longest-first lets a flat +// dotted key win over its single leading segment when present. +func longestResourcePrefix(level map[string]meta.Resource, segs []string) (meta.Resource, string, int) { + for i := len(segs); i >= 1; i-- { + name := strings.Join(segs[:i], ".") + if r, ok := level[name]; ok { + return r, name, i + } + } + return meta.Resource{}, "", 0 +} + +// resourceReachable reports whether a resource exposes a method reachable under +// the filter — directly or in any nested sub-resource (a nil filter accepts any +// method). A resource whose methods are all filtered out but which contains a +// reachable nested method is still offerable, so completion can drill into it. +func resourceReachable(res meta.Resource, filter MethodFilter) bool { + for _, m := range res.MethodList() { + if filter == nil || filter(m) { + return true + } + } + for _, sub := range res.SubResources() { + if resourceReachable(sub, filter) { + return true + } + } + return false +} + +func (c Catalog) serviceNames() []string { + names := make([]string, len(c.services)) + for i, s := range c.services { + names[i] = s.Name + } + return names // c.services is already name-sorted +} + +func resourceNames(svc meta.Service) []string { return sortedKeys(svc.Resources) } +func methodNames(res meta.Resource) []string { return sortedKeys(res.Methods) } + +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} diff --git a/internal/apicatalog/catalog_test.go b/internal/apicatalog/catalog_test.go new file mode 100644 index 000000000..97a866f07 --- /dev/null +++ b/internal/apicatalog/catalog_test.go @@ -0,0 +1,340 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apicatalog_test + +import ( + "errors" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/internal/apicatalog" + "github.com/larksuite/cli/internal/meta" +) + +// testCatalog builds a small embedded catalog: services drive (no resources) +// and im with a dotted resource (chat.members), a multi-method resource +// (reactions, where list is user-only), and images. +func testCatalog() apicatalog.Catalog { + im := meta.ServiceFromMap(map[string]interface{}{ + "name": "im", + "resources": map[string]interface{}{ + "chat.members": map[string]interface{}{ + "methods": map[string]interface{}{"create": map[string]interface{}{}}, + }, + "reactions": map[string]interface{}{ + "methods": map[string]interface{}{ + "create": map[string]interface{}{}, + "list": map[string]interface{}{"accessTokens": []interface{}{"user"}}, + }, + }, + "images": map[string]interface{}{ + "methods": map[string]interface{}{"create": map[string]interface{}{}}, + }, + }, + }) + drive := meta.ServiceFromMap(map[string]interface{}{"name": "drive"}) + return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{drive, im}) // already name-sorted +} + +func TestNew_PreservesOrderAndLookup(t *testing.T) { + c := testCatalog() + if c.Source() != apicatalog.SourceEmbedded { + t.Fatalf("source = %q", c.Source()) + } + names := []string{} + for _, s := range c.Services() { + names = append(names, s.Name) + } + if !reflect.DeepEqual(names, []string{"drive", "im"}) { + t.Errorf("Services order = %v, want [drive im]", names) + } + if _, ok := c.Service("im"); !ok { + t.Error("Service(im) not found") + } + if _, ok := c.Service("nope"); ok { + t.Error("Service(nope) should not be found") + } +} + +// TestNew_SortsAndIsolatesInput pins the ordering contract New owns: it sorts +// arbitrary input by service name and shallow-copies the slice so later caller +// mutation can't reorder the Catalog. +func TestNew_SortsAndIsolatesInput(t *testing.T) { + in := []meta.Service{ + meta.ServiceFromMap(map[string]interface{}{"name": "zeta"}), + meta.ServiceFromMap(map[string]interface{}{"name": "alpha"}), + } + c := apicatalog.New(apicatalog.SourceEmbedded, in) + + names := func() []string { + var out []string + for _, s := range c.Services() { + out = append(out, s.Name) + } + return out + } + if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) { + t.Errorf("New did not sort unsorted input: %v", got) + } + + // Mutating the caller's slice afterward must not reorder the Catalog. + in[0] = meta.ServiceFromMap(map[string]interface{}{"name": "MUTATED"}) + if got := names(); !reflect.DeepEqual(got, []string{"alpha", "zeta"}) { + t.Errorf("Catalog order changed after caller mutated its input slice: %v", got) + } +} + +func TestWalkMethods_AllAndFiltered(t *testing.T) { + c := testCatalog() + + all := c.WalkMethods(nil) + got := map[string]bool{} + for _, r := range all { + got[r.SchemaPath()] = true + } + want := []string{ + "im.chat.members.create", + "im.images.create", + "im.reactions.create", + "im.reactions.list", + } + if len(all) != len(want) { + t.Fatalf("WalkMethods(nil) = %d refs, want %d (%v)", len(all), len(want), got) + } + for _, w := range want { + if !got[w] { + t.Errorf("WalkMethods(nil) missing %q", w) + } + } + + // Deterministic order: services by name, resources by name, methods by name. + var order []string + for _, r := range all { + order = append(order, r.SchemaPath()) + } + if !reflect.DeepEqual(order, want) { + t.Errorf("WalkMethods order = %v, want %v", order, want) + } + + // Filter to bot-only ("tenant"): reactions.list (user-only) drops; methods + // with no accessTokens are permissive and stay. + botOnly := func(m meta.Method) bool { + if m.AccessTokens == nil { + return true + } + for _, tok := range m.AccessTokens { + if tok == "tenant" { + return true + } + } + return false + } + filtered := c.WalkMethods(botOnly) + for _, r := range filtered { + if r.SchemaPath() == "im.reactions.list" { + t.Error("filtered walk should drop user-only im.reactions.list") + } + } + if len(filtered) != len(all)-1 { + t.Errorf("filtered walk = %d, want %d", len(filtered), len(all)-1) + } +} + +func TestMethodRef_Paths_DottedResourceStaysOneSegment(t *testing.T) { + c := testCatalog() + target, err := c.Resolve([]string{"im", "chat.members", "create"}) + if err != nil { + t.Fatalf("resolve: %v", err) + } + if target.Kind != apicatalog.TargetMethod { + t.Fatalf("kind = %v", target.Kind) + } + m := target.Method + if m.SchemaPath() != "im.chat.members.create" { + t.Errorf("SchemaPath = %q", m.SchemaPath()) + } + if !reflect.DeepEqual(m.CommandPath(), []string{"im", "chat.members", "create"}) { + t.Errorf("CommandPath = %v", m.CommandPath()) + } + if m.ResourceName() != "chat.members" { + t.Errorf("ResourceName = %q, want chat.members (one segment)", m.ResourceName()) + } + if m.Method.Name != "create" { + t.Errorf("Method.Name not injected: %q", m.Method.Name) + } +} + +func TestResolve_DottedAndSplitFormsEquivalent(t *testing.T) { + c := testCatalog() + // schema.ParsePath splits both "im.chat.members.create" and + // "im chat.members create" into segments; findResource's longest-prefix + // must resolve the dotted resource either way. + a, errA := c.Resolve([]string{"im", "chat", "members", "create"}) // fully split + b, errB := c.Resolve([]string{"im", "chat.members", "create"}) // resource as one segment + if errA != nil || errB != nil { + t.Fatalf("errA=%v errB=%v", errA, errB) + } + if a.Method.SchemaPath() != b.Method.SchemaPath() || a.Method.SchemaPath() != "im.chat.members.create" { + t.Errorf("forms diverged: %q vs %q", a.Method.SchemaPath(), b.Method.SchemaPath()) + } +} + +func TestResolve_Targets(t *testing.T) { + c := testCatalog() + if tg, _ := c.Resolve(nil); tg.Kind != apicatalog.TargetAll { + t.Errorf("empty -> %v, want all", tg.Kind) + } + if tg, _ := c.Resolve([]string{"im"}); tg.Kind != apicatalog.TargetService || tg.Service.Name != "im" { + t.Errorf("[im] -> %v/%q", tg.Kind, tg.Service.Name) + } + if tg, _ := c.Resolve([]string{"im", "reactions"}); tg.Kind != apicatalog.TargetResource || tg.Resource.SchemaPath() != "im.reactions" { + t.Errorf("[im reactions] -> %v", tg.Kind) + } +} + +func TestResolve_Errors(t *testing.T) { + c := testCatalog() + cases := []struct { + parts []string + kind apicatalog.ResolveErrorKind + }{ + {[]string{"nope"}, apicatalog.ErrService}, + {[]string{"im", "nope"}, apicatalog.ErrResource}, + {[]string{"im", "reactions", "nope"}, apicatalog.ErrMethod}, + {[]string{"im", "reactions", "list", "extra"}, apicatalog.ErrPath}, + } + for _, tc := range cases { + _, err := c.Resolve(tc.parts) + var re *apicatalog.ResolveError + if !errors.As(err, &re) { + t.Errorf("%v -> err %v, want *ResolveError", tc.parts, err) + continue + } + if re.Kind != tc.kind { + t.Errorf("%v -> kind %q, want %q", tc.parts, re.Kind, tc.kind) + } + if tc.kind != apicatalog.ErrPath && len(re.Candidates) == 0 { + t.Errorf("%v -> expected candidates", tc.parts) + } + } +} + +// nestedCatalog adds a genuinely nested resource (spaces > items) on top of a +// flat dotted resource (chat.members), so the round-trip contract is exercised +// for real nesting — not just flat dotted keys. +func nestedCatalog() apicatalog.Catalog { + im := meta.ServiceFromMap(map[string]interface{}{ + "name": "im", + "resources": map[string]interface{}{ + "chat.members": map[string]interface{}{ + "methods": map[string]interface{}{"create": map[string]interface{}{}}, + }, + "spaces": map[string]interface{}{ + "methods": map[string]interface{}{"create": map[string]interface{}{}}, + "resources": map[string]interface{}{ + "items": map[string]interface{}{ + "methods": map[string]interface{}{"get": map[string]interface{}{}}, + }, + }, + }, + }, + }) + return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{im}) +} + +// TestResolve_WalkMethodsRoundTrip is the core catalog contract: every method +// WalkMethods emits must Resolve back to the same method — both from its dotted +// SchemaPath (fully split) and from its CommandPath (resource as one segment). +// This pins findResource's nested-resource descent symmetric to walkResources, +// so "traversable" implies "resolvable". +func TestResolve_WalkMethodsRoundTrip(t *testing.T) { + for _, c := range []apicatalog.Catalog{testCatalog(), nestedCatalog()} { + for _, ref := range c.WalkMethods(nil) { + want := ref.SchemaPath() + for _, parts := range [][]string{ + strings.Split(want, "."), // fully-split dotted form + ref.CommandPath(), // command form (resource stays one segment) + } { + tg, err := c.Resolve(parts) + if err != nil { + t.Errorf("round-trip %v: %v", parts, err) + continue + } + if tg.Kind != apicatalog.TargetMethod { + t.Errorf("round-trip %v: kind=%v, want method", parts, tg.Kind) + continue + } + if tg.Method.SchemaPath() != want { + t.Errorf("round-trip %v: resolved to %q, want %q", parts, tg.Method.SchemaPath(), want) + } + } + } + } +} + +// TestComplete_Nested pins completion closure for genuinely nested resources: +// both the dotted and space forms must reach a nested method, symmetric to +// Resolve (findResource descends, so completion must too). +func TestComplete_Nested(t *testing.T) { + c := nestedCatalog() + + // dotted: under a resource, offer its methods AND its sub-resources + if comps, ns := c.Complete(nil, "im.spaces.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.create", "im.spaces.items."}) || ns { + t.Errorf("Complete([], im.spaces.) = %v noSpace=%v, want [im.spaces.create im.spaces.items.] false", comps, ns) + } + // dotted: drill into the nested sub-resource's method + if comps, ns := c.Complete(nil, "im.spaces.items.", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items.get"}) || ns { + t.Errorf("Complete([], im.spaces.items.) = %v noSpace=%v, want [im.spaces.items.get] false", comps, ns) + } + // dotted: partial sub-resource name -> the sub-resource (NoSpace, more to type) + if comps, ns := c.Complete(nil, "im.spaces.it", nil); !reflect.DeepEqual(comps, []string{"im.spaces.items."}) || !ns { + t.Errorf("Complete([], im.spaces.it) = %v noSpace=%v, want [im.spaces.items.] true", comps, ns) + } + // space form: under a resource, offer methods AND sub-resources + if comps, _ := c.Complete([]string{"im", "spaces"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "items"}) { + t.Errorf("Complete([im spaces], '') = %v, want [create items]", comps) + } + // space form: drill into the nested sub-resource's methods + if comps, _ := c.Complete([]string{"im", "spaces", "items"}, "", nil); !reflect.DeepEqual(comps, []string{"get"}) { + t.Errorf("Complete([im spaces items], '') = %v, want [get]", comps) + } +} + +func TestComplete(t *testing.T) { + c := testCatalog() + + // dotted: service prefix -> "im." (NoSpace) + if comps, ns := c.Complete(nil, "i", nil); !reflect.DeepEqual(comps, []string{"im."}) || !ns { + t.Errorf("Complete([], i) = %v noSpace=%v", comps, ns) + } + // dotted: resource prefix -> "im.reactions." (NoSpace) + if comps, _ := c.Complete(nil, "im.rea", nil); !reflect.DeepEqual(comps, []string{"im.reactions."}) { + t.Errorf("Complete([], im.rea) = %v", comps) + } + // space form: resource candidates under im (deterministic order) + comps, ns := c.Complete([]string{"im"}, "", nil) + if !reflect.DeepEqual(comps, []string{"chat.members", "images", "reactions"}) || ns { + t.Errorf("Complete([im], '') = %v noSpace=%v", comps, ns) + } + // space form: method candidates under reactions + if comps, _ := c.Complete([]string{"im", "reactions"}, "", nil); !reflect.DeepEqual(comps, []string{"create", "list"}) { + t.Errorf("Complete([im reactions], '') = %v", comps) + } + // filter applied: bot-only hides user-only list + botOnly := func(m meta.Method) bool { + if m.AccessTokens == nil { + return true + } + for _, tok := range m.AccessTokens { + if tok == "tenant" { + return true + } + } + return false + } + if comps, _ := c.Complete([]string{"im", "reactions"}, "", botOnly); !reflect.DeepEqual(comps, []string{"create"}) { + t.Errorf("Complete with bot filter = %v, want [create]", comps) + } +} diff --git a/internal/apicatalog/methodref.go b/internal/apicatalog/methodref.go new file mode 100644 index 000000000..4bcea96e7 --- /dev/null +++ b/internal/apicatalog/methodref.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apicatalog + +import ( + "strings" + + "github.com/larksuite/cli/internal/meta" +) + +// TargetKind classifies what a schema/command path resolves to. +type TargetKind string + +const ( + TargetAll TargetKind = "all" // empty path: every method + TargetService TargetKind = "service" // + TargetResource TargetKind = "resource" // + TargetMethod TargetKind = "method" // +) + +// Target is the result of Catalog.Resolve. Resource and Method are populated +// only for TargetResource and TargetMethod respectively. +type Target struct { + Kind TargetKind + Service meta.Service + Resource *ResourceRef + Method *MethodRef +} + +// ResourceRef identifies one resource within a service. Path holds the resource +// path segments (one element for the common flat dotted resource like +// "chat.members"; multiple for genuinely nested resources). +type ResourceRef struct { + Service meta.Service + Resource meta.Resource + Path []string +} + +// MethodRef identifies one method, carrying the full navigation context so the +// command path and schema path can be derived without re-walking the catalog. +type MethodRef struct { + Service meta.Service + Resource meta.Resource + ResourcePath []string + Method meta.Method +} + +// SchemaPath is the dotted "service.resource" identifier. +func (r ResourceRef) SchemaPath() string { + return r.Service.Name + "." + strings.Join(r.Path, ".") +} + +// ServiceName returns the owning service name. +func (r MethodRef) ServiceName() string { return r.Service.Name } + +// ResourceName is the dotted resource path, e.g. "chat.members". +func (r MethodRef) ResourceName() string { return strings.Join(r.ResourcePath, ".") } + +// MethodName returns the method's own name. +func (r MethodRef) MethodName() string { return r.Method.Name } + +// SchemaPath is the dotted "service.resource.method" identifier, e.g. +// "im.chat.members.create". +func (r MethodRef) SchemaPath() string { + return r.Service.Name + "." + strings.Join(r.ResourcePath, ".") + "." + r.Method.Name +} + +// CommandPath is the CLI argv segments, e.g. ["im", "chat.members", "create"]. +func (r MethodRef) CommandPath() []string { + out := make([]string, 0, len(r.ResourcePath)+2) + out = append(out, r.Service.Name) + out = append(out, r.ResourcePath...) + return append(out, r.Method.Name) +} diff --git a/internal/apicatalog/path.go b/internal/apicatalog/path.go new file mode 100644 index 000000000..ec085bd18 --- /dev/null +++ b/internal/apicatalog/path.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apicatalog + +import "strings" + +// ParsePath normalizes positional command arguments into the path segments +// Resolve consumes. It accepts two equivalent forms: +// +// im.messages.reply -> single arg, split on "." +// im messages reply -> multiple args, used as-is +// +// "im chat.members bots" as a single quoted arg is NOT supported; quote +// arguments individually if your shell needs it. A resource keeps its internal +// dots when passed as one segment (e.g. "chat.members"); findResource's +// longest-prefix descent resolves both the split and the one-segment forms to +// the same target. Returns nil for zero args (bare invocation -> TargetAll). +func ParsePath(args []string) []string { + switch len(args) { + case 0: + return nil + case 1: + if strings.Contains(args[0], ".") { + return strings.Split(args[0], ".") + } + return []string{args[0]} + default: + return args + } +} diff --git a/internal/schema/path_test.go b/internal/apicatalog/path_test.go similarity index 90% rename from internal/schema/path_test.go rename to internal/apicatalog/path_test.go index ec8934450..fdcb9d2de 100644 --- a/internal/schema/path_test.go +++ b/internal/apicatalog/path_test.go @@ -1,11 +1,13 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT -package schema +package apicatalog_test import ( "reflect" "testing" + + "github.com/larksuite/cli/internal/apicatalog" ) func TestParsePath(t *testing.T) { @@ -25,7 +27,7 @@ func TestParsePath(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := ParsePath(tt.args) + got := apicatalog.ParsePath(tt.args) if !reflect.DeepEqual(got, tt.want) { t.Errorf("ParsePath(%v) = %v, want %v", tt.args, got, tt.want) } diff --git a/internal/apicatalog/resolveerror.go b/internal/apicatalog/resolveerror.go new file mode 100644 index 000000000..e81863bad --- /dev/null +++ b/internal/apicatalog/resolveerror.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apicatalog + +// ResolveErrorKind classifies a Resolve failure so the command layer can render +// the right hint without re-deriving what was being looked up. +type ResolveErrorKind string + +const ( + ErrService ResolveErrorKind = "service" + ErrResource ResolveErrorKind = "resource" + ErrMethod ResolveErrorKind = "method" + ErrPath ResolveErrorKind = "path" // method exists but trailing segments don't resolve +) + +// ResolveError is returned by Catalog.Resolve. Subject is the dotted thing that +// failed to resolve; Candidates lists the available names at that level (nil for +// ErrPath, which instead carries the matched Method and the unresolved Trailing). +type ResolveError struct { + Kind ResolveErrorKind + Subject string + Candidates []string + Method string + Trailing string +} + +func (e *ResolveError) Error() string { + return "unknown " + string(e.Kind) + ": " + e.Subject +} diff --git a/internal/auth/device_flow.go b/internal/auth/device_flow.go index fb611d121..7acdad859 100644 --- a/internal/auth/device_flow.go +++ b/internal/auth/device_flow.go @@ -47,6 +47,7 @@ type DeviceFlowResult struct { // OAuthEndpoints contains the OAuth endpoint URLs. type OAuthEndpoints struct { DeviceAuthorization string + Revoke string Token string } @@ -55,6 +56,7 @@ func ResolveOAuthEndpoints(brand core.LarkBrand) OAuthEndpoints { ep := core.ResolveEndpoints(brand) return OAuthEndpoints{ DeviceAuthorization: ep.Accounts + PathDeviceAuthorization, + Revoke: ep.Accounts + PathOAuthRevoke, Token: ep.Open + PathOAuthTokenV2, } } diff --git a/internal/auth/device_flow_test.go b/internal/auth/device_flow_test.go index 8b3f4d0c9..397098cc1 100644 --- a/internal/auth/device_flow_test.go +++ b/internal/auth/device_flow_test.go @@ -31,6 +31,9 @@ func TestResolveOAuthEndpoints_Feishu(t *testing.T) { if ep.DeviceAuthorization != "https://accounts.feishu.cn/oauth/v1/device_authorization" { t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization) } + if ep.Revoke != "https://accounts.feishu.cn/oauth/v1/revoke" { + t.Errorf("Revoke = %q", ep.Revoke) + } if ep.Token != "https://open.feishu.cn/open-apis/authen/v2/oauth/token" { t.Errorf("Token = %q", ep.Token) } @@ -42,6 +45,9 @@ func TestResolveOAuthEndpoints_Lark(t *testing.T) { if ep.DeviceAuthorization != "https://accounts.larksuite.com/oauth/v1/device_authorization" { t.Errorf("DeviceAuthorization = %q", ep.DeviceAuthorization) } + if ep.Revoke != "https://accounts.larksuite.com/oauth/v1/revoke" { + t.Errorf("Revoke = %q", ep.Revoke) + } if ep.Token != "https://open.larksuite.com/open-apis/authen/v2/oauth/token" { t.Errorf("Token = %q", ep.Token) } diff --git a/internal/auth/paths.go b/internal/auth/paths.go index 26441c640..453ebe146 100644 --- a/internal/auth/paths.go +++ b/internal/auth/paths.go @@ -7,6 +7,8 @@ package auth const ( // PathDeviceAuthorization is the endpoint for device authorization. PathDeviceAuthorization = "/oauth/v1/device_authorization" + // PathOAuthRevoke is the endpoint for revoking an OAuth token. + PathOAuthRevoke = "/oauth/v1/revoke" // PathAppRegistration is the endpoint for application registration. PathAppRegistration = "/oauth/v1/app/registration" // PathOAuthTokenV2 is the endpoint for requesting an OAuth token (v2). diff --git a/internal/auth/revoke.go b/internal/auth/revoke.go new file mode 100644 index 000000000..f1f046daf --- /dev/null +++ b/internal/auth/revoke.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/url" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" +) + +// RevokeToken revokes a previously issued OAuth token. +func RevokeToken(httpClient *http.Client, appId, appSecret string, brand core.LarkBrand, token, tokenTypeHint string) error { + endpoints := ResolveOAuthEndpoints(brand) + + form := url.Values{} + form.Set("client_id", appId) + form.Set("client_secret", appSecret) + form.Set("token", token) + if tokenTypeHint != "" { + form.Set("token_type_hint", tokenTypeHint) + } + + req, err := http.NewRequest(http.MethodPost, endpoints.Revoke, strings.NewReader(form.Encode())) + if err != nil { + return errs.NewInternalError(errs.SubtypeUnknown, "token revoke request creation failed: %v", err).WithCause(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := httpClient.Do(req) + if err != nil { + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "token revoke transport error: %v", err).WithCause(err) + } + defer resp.Body.Close() + logHTTPResponse(resp) + + body, err := io.ReadAll(resp.Body) + if err != nil { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "token revoke read error: %v", err).WithCause(err) + } + + if resp.StatusCode >= 400 { + return revokeHTTPStatusError(resp.StatusCode, body) + } + + if len(body) == 0 { + return nil + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return nil + } + + if code := getInt(data, "code", 0); code != 0 { + msg := getStr(data, "msg") + if msg == "" { + msg = getStr(data, "message") + } + if msg == "" { + msg = "unknown error" + } + return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed [%d]: %s", code, msg). + WithCode(code). + WithCause(errors.New(msg)) + } + + if errStr := getStr(data, "error"); errStr != "" { + msg := getStr(data, "error_description") + if msg == "" { + msg = errStr + } + return errs.NewAPIError(errs.SubtypeUnknown, "token revoke failed: %s", msg). + WithCause(errors.New(msg)) + } + + return nil +} + +func revokeHTTPStatusError(status int, body []byte) error { + msg := formatOAuthErrorBody(body) + cause := errors.New(strings.TrimSpace(string(body))) + if strings.TrimSpace(string(body)) == "" { + cause = errors.New(msg) + } + if status >= http.StatusInternalServerError { + return errs.NewNetworkError(errs.SubtypeNetworkServer, "token revoke failed: HTTP %d: %s", status, msg). + WithCode(status). + WithRetryable(). + WithCause(cause) + } + subtype := errs.SubtypeUnknown + if status == http.StatusNotFound { + subtype = errs.SubtypeNotFound + } + return errs.NewAPIError(subtype, "token revoke failed: HTTP %d: %s", status, msg). + WithCode(status). + WithCause(cause) +} + +func formatOAuthErrorBody(body []byte) string { + trimmed := strings.TrimSpace(string(body)) + if trimmed == "" { + return "empty response" + } + + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return trimmed + } + + if msg := getStr(data, "error_description"); msg != "" { + return msg + } + if msg := getStr(data, "msg"); msg != "" { + return msg + } + if msg := getStr(data, "message"); msg != "" { + return msg + } + if msg := getStr(data, "error"); msg != "" { + return msg + } + return trimmed +} diff --git a/internal/auth/revoke_test.go b/internal/auth/revoke_test.go new file mode 100644 index 000000000..28184bfa8 --- /dev/null +++ b/internal/auth/revoke_test.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "errors" + "net/http" + "net/url" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" +) + +type revokeRoundTripFunc func(*http.Request) (*http.Response, error) + +func (fn revokeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +type errReadCloser struct { + err error +} + +func (r errReadCloser) Read(_ []byte) (int, error) { + return 0, r.err +} + +func (r errReadCloser) Close() error { + return nil +} + +func TestRevokeToken_PostsExpectedForm(t *testing.T) { + reg := &httpmock.Registry{} + t.Cleanup(func() { reg.Verify(t) }) + + stub := &httpmock.Stub{ + Method: "POST", + URL: PathOAuthRevoke, + Body: map[string]interface{}{"code": 0}, + BodyFilter: func(body []byte) bool { + values, err := url.ParseQuery(string(body)) + if err != nil { + return false + } + return values.Get("client_id") == "cli_a" && + values.Get("client_secret") == "secret_b" && + values.Get("token") == "user-access-token" && + values.Get("token_type_hint") == "access_token" + }, + } + reg.Register(stub) + + err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token") + if err != nil { + t.Fatalf("RevokeToken() error = %v", err) + } + if got := stub.CapturedHeaders.Get("Content-Type"); got != "application/x-www-form-urlencoded" { + t.Fatalf("Content-Type = %q", got) + } +} + +func TestRevokeToken_DoFailureReturnsTypedNetworkError(t *testing.T) { + sentinel := errors.New("transport down") + httpClient := &http.Client{ + Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return nil, sentinel + }), + } + + err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token") + if err == nil { + t.Fatal("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T", err) + } + if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport { + t.Fatalf("problem = %#v, want network/transport", p) + } + if !errors.Is(err, sentinel) { + t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err) + } +} + +func TestRevokeToken_ReportsHTTPError(t *testing.T) { + reg := &httpmock.Registry{} + t.Cleanup(func() { reg.Verify(t) }) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: PathOAuthRevoke, + Status: 400, + Body: map[string]interface{}{"error": "invalid_token"}, + }) + + err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token") + if err == nil { + t.Fatal("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T", err) + } + if p.Category != errs.CategoryAPI || p.Code != 400 { + t.Fatalf("problem = %#v, want api error with HTTP 400", p) + } + if !strings.Contains(err.Error(), "invalid_token") { + t.Fatalf("expected invalid_token error, got %v", err) + } +} + +func TestRevokeToken_ReportsOAuthCodeErrorAsTypedAPIError(t *testing.T) { + reg := &httpmock.Registry{} + t.Cleanup(func() { reg.Verify(t) }) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: PathOAuthRevoke, + Body: map[string]interface{}{ + "code": 12345, + "msg": "invalid revoke state", + }, + }) + + err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token") + if err == nil { + t.Fatal("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T", err) + } + if p.Category != errs.CategoryAPI || p.Code != 12345 { + t.Fatalf("problem = %#v, want api error with code 12345", p) + } + if !strings.Contains(err.Error(), "invalid revoke state") { + t.Fatalf("expected oauth error message, got %v", err) + } +} + +func TestRevokeToken_ReportsOAuthErrorFieldAsTypedAPIError(t *testing.T) { + reg := &httpmock.Registry{} + t.Cleanup(func() { reg.Verify(t) }) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: PathOAuthRevoke, + Body: map[string]interface{}{ + "error": "invalid_token", + "error_description": "token already expired", + }, + }) + + err := RevokeToken(httpmock.NewClient(reg), "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token") + if err == nil { + t.Fatal("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T", err) + } + if p.Category != errs.CategoryAPI { + t.Fatalf("problem = %#v, want api error", p) + } + if !strings.Contains(err.Error(), "token already expired") { + t.Fatalf("expected oauth error_description, got %v", err) + } +} + +func TestRevokeToken_ReadFailureReturnsTypedInternalError(t *testing.T) { + sentinel := errors.New("read failed") + httpClient := &http.Client{ + Transport: revokeRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: errReadCloser{err: sentinel}, + Header: make(http.Header), + }, nil + }), + } + + err := RevokeToken(httpClient, "cli_a", "secret_b", core.BrandFeishu, "user-access-token", "access_token") + if err == nil { + t.Fatal("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T", err) + } + if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("problem = %#v, want internal/invalid_response", p) + } + if !errors.Is(err, sentinel) { + t.Fatalf("expected cause %v to be preserved, got %v", sentinel, err) + } + if !strings.Contains(err.Error(), "token revoke read error") { + t.Fatalf("expected read error message, got %v", err) + } + if _, ok := err.(*errs.InternalError); !ok { + t.Fatalf("expected *errs.InternalError, got %T", err) + } +} diff --git a/internal/cmdutil/factory_default.go b/internal/cmdutil/factory_default.go index 514aaf93f..2051e0bea 100644 --- a/internal/cmdutil/factory_default.go +++ b/internal/cmdutil/factory_default.go @@ -100,9 +100,19 @@ func safeRedirectPolicy(req *http.Request, via []*http.Request) error { return nil } +// warnIfProxied is a test seam for the proxy-warning gate. Production wires it +// to transport.WarnIfProxied; tests swap in a spy to count invocations. It is +// needed because the real function is guarded by an internal sync.Once, so +// calling it directly would only fire on the first test (see +// factory_proxy_warn_test.go). The terminal check is the IOStreams +// .StderrIsTerminal field, which tests set directly. +var warnIfProxied = transport.WarnIfProxied + func cachedHttpClientFunc(f *Factory) func() (*http.Client, error) { return sync.OnceValues(func() (*http.Client, error) { - transport.WarnIfProxied(f.IOStreams.ErrOut) + if f.IOStreams.StderrIsTerminal { + warnIfProxied(f.IOStreams.ErrOut) + } var rt http.RoundTripper = transport.Shared() rt = &RetryTransport{Base: rt} @@ -129,7 +139,9 @@ func cachedLarkClientFunc(f *Factory) func() (*lark.Client, error) { lark.WithLogLevel(larkcore.LogLevelError), lark.WithHeaders(BaseSecurityHeaders()), } - transport.WarnIfProxied(f.IOStreams.ErrOut) + if f.IOStreams.StderrIsTerminal { + warnIfProxied(f.IOStreams.ErrOut) + } opts = append(opts, lark.WithHttpClient(&http.Client{ Transport: buildSDKTransport(), CheckRedirect: safeRedirectPolicy, diff --git a/internal/cmdutil/factory_proxy_warn_test.go b/internal/cmdutil/factory_proxy_warn_test.go new file mode 100644 index 000000000..ffdeb488f --- /dev/null +++ b/internal/cmdutil/factory_proxy_warn_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "io" + "testing" + + _ "github.com/larksuite/cli/extension/credential/env" // registers the env-backed account provider + "github.com/larksuite/cli/internal/envvars" +) + +// installProxyWarnSpy replaces warnIfProxied with a counter for one test and +// restores it on cleanup. Returns a pointer to the call count so the caller can +// assert how many times the warning fired. The terminal state is controlled via +// the IOStreams.StderrIsTerminal field, not a seam. +func installProxyWarnSpy(t *testing.T) *int { + t.Helper() + prevWarn := warnIfProxied + t.Cleanup(func() { warnIfProxied = prevWarn }) + calls := 0 + warnIfProxied = func(io.Writer) { calls++ } + return &calls +} + +var proxyWarnGateCases = []struct { + name string + terminal bool + want int +}{ + {"terminal stderr warns once", true, 1}, + {"non-terminal stderr stays silent", false, 0}, +} + +// TestCachedHttpClientFunc_ProxyWarnGate verifies the http-client init path +// invokes WarnIfProxied only when stderr is an interactive terminal. +func TestCachedHttpClientFunc_ProxyWarnGate(t *testing.T) { + for _, tc := range proxyWarnGateCases { + t.Run(tc.name, func(t *testing.T) { + calls := installProxyWarnSpy(t) + + fn := cachedHttpClientFunc(&Factory{IOStreams: &IOStreams{ + ErrOut: io.Discard, StderrIsTerminal: tc.terminal, + }}) + if _, err := fn(); err != nil { + t.Fatalf("http client init: %v", err) + } + + if *calls != tc.want { + t.Errorf("WarnIfProxied calls = %d, want %d", *calls, tc.want) + } + }) + } +} + +// TestCachedLarkClientFunc_ProxyWarnGate verifies the lark-client init path +// invokes WarnIfProxied only when stderr is an interactive terminal. The gate +// runs after ResolveAccount, so an env-backed credential is wired up to let +// account resolution succeed without network or config files. +func TestCachedLarkClientFunc_ProxyWarnGate(t *testing.T) { + for _, tc := range proxyWarnGateCases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(envvars.CliAppID, "env-app") + t.Setenv(envvars.CliAppSecret, "env-secret") + t.Setenv(envvars.CliDefaultAs, "") + t.Setenv(envvars.CliUserAccessToken, "") + t.Setenv(envvars.CliTenantAccessToken, "") + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + calls := installProxyWarnSpy(t) + + // normalizeStreams copies the struct (out := *s), so the + // StderrIsTerminal field survives into f.IOStreams. + f := NewDefault(&IOStreams{ErrOut: io.Discard, StderrIsTerminal: tc.terminal}, InvocationContext{}) + if _, err := cachedLarkClientFunc(f)(); err != nil { + t.Fatalf("lark client init: %v", err) + } + + if *calls != tc.want { + t.Errorf("WarnIfProxied calls = %d, want %d", *calls, tc.want) + } + }) + } +} diff --git a/internal/cmdutil/fileupload.go b/internal/cmdutil/fileupload.go index 15a99980d..9dfc22e2a 100644 --- a/internal/cmdutil/fileupload.go +++ b/internal/cmdutil/fileupload.go @@ -12,23 +12,9 @@ import ( "github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/internal/output" - "github.com/larksuite/cli/internal/registry" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" ) -// DetectFileFields returns field names with type "file" in the method's requestBody. -func DetectFileFields(method map[string]interface{}) []string { - rb, _ := method["requestBody"].(map[string]interface{}) - var fields []string - for name, field := range rb { - f, _ := field.(map[string]interface{}) - if registry.GetStrFromMap(f, "type") == "file" { - fields = append(fields, name) - } - } - return fields -} - // ParseFileFlag parses a --file flag value into its components. // The format is either "path" or "field=path". When no explicit "field=" // prefix is present, defaultField is used as the field name. diff --git a/internal/cmdutil/identity.go b/internal/cmdutil/identity.go index 040f9e621..897cac5bd 100644 --- a/internal/cmdutil/identity.go +++ b/internal/cmdutil/identity.go @@ -10,22 +10,6 @@ import ( "github.com/larksuite/cli/internal/core" ) -// AccessTokensToIdentities converts from_meta accessTokens (e.g. ["tenant", "user"]) -// to CLI identity names (e.g. ["bot", "user"]). -func AccessTokensToIdentities(tokens []interface{}) []string { - var identities []string - for _, t := range tokens { - if ts, ok := t.(string); ok { - if ts == "tenant" { - identities = append(identities, "bot") - } else { - identities = append(identities, ts) - } - } - } - return identities -} - // PrintIdentity outputs the current identity to stderr so callers (including AI agents) // can see which identity is being used for the API call. func PrintIdentity(w io.Writer, as core.Identity, config *core.CliConfig, autoDetected bool) { diff --git a/internal/cmdutil/identity_test.go b/internal/cmdutil/identity_test.go index d65e2b08f..bb6e4aee6 100644 --- a/internal/cmdutil/identity_test.go +++ b/internal/cmdutil/identity_test.go @@ -11,54 +11,6 @@ import ( "github.com/larksuite/cli/internal/core" ) -func TestAccessTokensToIdentities(t *testing.T) { - tests := []struct { - name string - tokens []interface{} - want []string - }{ - { - name: "tenant becomes bot", - tokens: []interface{}{"tenant"}, - want: []string{"bot"}, - }, - { - name: "user stays user", - tokens: []interface{}{"user"}, - want: []string{"user"}, - }, - { - name: "tenant and user", - tokens: []interface{}{"tenant", "user"}, - want: []string{"bot", "user"}, - }, - { - name: "empty list", - tokens: []interface{}{}, - want: nil, - }, - { - name: "non-string values skipped", - tokens: []interface{}{"tenant", 42, "user"}, - want: []string{"bot", "user"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := AccessTokensToIdentities(tt.tokens) - if len(got) != len(tt.want) { - t.Fatalf("len: want %d, got %d (%v)", len(tt.want), len(got), got) - } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("[%d] want %s, got %s", i, tt.want[i], got[i]) - } - } - }) - } -} - func TestPrintIdentity_BotExplicit(t *testing.T) { var buf bytes.Buffer PrintIdentity(&buf, core.AsBot, nil, false) diff --git a/internal/cmdutil/iostreams.go b/internal/cmdutil/iostreams.go index 864300f52..a067b67da 100644 --- a/internal/cmdutil/iostreams.go +++ b/internal/cmdutil/iostreams.go @@ -18,17 +18,28 @@ type IOStreams struct { Out io.Writer ErrOut io.Writer IsTerminal bool + // StderrIsTerminal reports whether ErrOut is an interactive terminal. + // Advisory warnings written to stderr (e.g. the proxy notice) gate on this + // so they stay out of non-interactive output (pipes, CI, agent runs). + // Computed once in NewIOStreams, mirroring IsTerminal; tests assign it + // directly like cmd/config/bind_test.go does for IsTerminal. + StderrIsTerminal bool } // NewIOStreams builds an IOStreams from arbitrary readers/writers. -// IsTerminal is derived from in's underlying *os.File, if any; non-file -// readers (bytes.Buffer, strings.Reader, …) yield IsTerminal=false. +// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying +// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield +// false. func NewIOStreams(in io.Reader, out, errOut io.Writer) *IOStreams { isTerminal := false if f, ok := in.(*os.File); ok { isTerminal = term.IsTerminal(int(f.Fd())) } - return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal} + stderrIsTerminal := false + if f, ok := errOut.(*os.File); ok { + stderrIsTerminal = term.IsTerminal(int(f.Fd())) + } + return &IOStreams{In: in, Out: out, ErrOut: errOut, IsTerminal: isTerminal, StderrIsTerminal: stderrIsTerminal} } // SystemIO creates an IOStreams wired to the process's standard file descriptors. diff --git a/internal/cmdutil/json.go b/internal/cmdutil/json.go index 817aad446..fef4ea4fe 100644 --- a/internal/cmdutil/json.go +++ b/internal/cmdutil/json.go @@ -34,7 +34,9 @@ func ParseOptionalBody(httpMethod, data string, stdin io.Reader, fileIO fileio.F return body, nil } -// ParseJSONMap parses a JSON string into a map. Returns an empty map if input is empty. +// ParseJSONMap parses a JSON string into a map. Returns an empty (never nil) map +// for empty input or the JSON literal null, so callers can always overlay onto +// the result without a nil-map panic. // Supports stdin (-), @file, @@-escape, and single-quote stripping via ResolveInput. func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (map[string]any, error) { resolved, err := ResolveInput(input, stdin, fileIO) @@ -48,5 +50,10 @@ func ParseJSONMap(input, label string, stdin io.Reader, fileIO fileio.FileIO) (m if err := json.Unmarshal([]byte(resolved), &result); err != nil { return nil, output.ErrValidation("%s invalid format, expected JSON object", label) } + if result == nil { + // `null` unmarshals into a nil map without error; normalize it so the + // returned map is always writable, matching the empty-input case. + return map[string]any{}, nil + } return result, nil } diff --git a/internal/cmdutil/json_test.go b/internal/cmdutil/json_test.go index 44a7a51d7..687c652c4 100644 --- a/internal/cmdutil/json_test.go +++ b/internal/cmdutil/json_test.go @@ -47,6 +47,7 @@ func TestParseJSONMap(t *testing.T) { wantErr bool }{ {"empty input", "", "--params", 0, false}, + {"json null", "null", "--params", 0, false}, {"valid json", `{"a":"1","b":"2"}`, "--params", 2, false}, {"invalid json", `{bad}`, "--params", 0, true}, {"json array", `[1,2]`, "--data", 0, true}, @@ -61,6 +62,12 @@ func TestParseJSONMap(t *testing.T) { if !tt.wantErr && len(got) != tt.wantLen { t.Errorf("ParseJSONMap() returned map with %d keys, want %d", len(got), tt.wantLen) } + // A successful parse must yield a non-nil, writable map: callers + // overlay onto it (params[k]=v), so `null` — which unmarshals to a + // nil map without error — must normalize to {} like empty input. + if !tt.wantErr && got == nil { + t.Error("ParseJSONMap() = nil map on success, want non-nil") + } }) } } diff --git a/internal/cmdutil/risk.go b/internal/cmdutil/risk.go index 22fb092c3..29ce402bb 100644 --- a/internal/cmdutil/risk.go +++ b/internal/cmdutil/risk.go @@ -3,17 +3,20 @@ package cmdutil -import "github.com/spf13/cobra" +import ( + "github.com/larksuite/cli/internal/core" + "github.com/spf13/cobra" +) const riskLevelAnnotationKey = "risk_level" -// Risk level constants — the three-tier convention used across the CLI. -// Use these in place of string literals so the typo radius is one place, -// not every call site. +// Risk level constants — aliases of the canonical core.Risk* values, re-exported +// here so command code gets the risk vocabulary and the SetRisk/GetRisk helpers +// from one package. core is the single source of truth. const ( - RiskRead = "read" - RiskWrite = "write" - RiskHighRiskWrite = "high-risk-write" + RiskRead = core.RiskRead + RiskWrite = core.RiskWrite + RiskHighRiskWrite = core.RiskHighRiskWrite ) // SetRisk stores a command's static risk level on cobra annotations so the diff --git a/internal/core/risk.go b/internal/core/risk.go new file mode 100644 index 000000000..4c9014010 --- /dev/null +++ b/internal/core/risk.go @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package core + +// Risk levels — the three-tier convention used across the CLI. They live here, +// at the leaf, so the envelope renderer (internal/schema) and the command +// toolkit (internal/cmdutil) share one vocabulary without a renderer depending +// on command utilities. Framework confirmation gating acts only on +// RiskHighRiskWrite. +const ( + RiskRead = "read" + RiskWrite = "write" + RiskHighRiskWrite = "high-risk-write" +) diff --git a/internal/core/types.go b/internal/core/types.go index cf842e6a4..26236df73 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -22,6 +22,12 @@ func ParseBrand(value string) LarkBrand { return BrandFeishu } +// OAuthTokenV3Path is the unified OAuth 2.0 Token Endpoint path on the accounts +// domain. It serves every grant type (client_credentials for TAT, +// authorization_code / device_code / refresh_token for UAT) and replaces the +// legacy per-token endpoints (e.g. /open-apis/auth/v3/tenant_access_token/internal). +const OAuthTokenV3Path = "/oauth/v3/token" + // Endpoints holds resolved endpoint URLs for different Lark services. type Endpoints struct { Open string // e.g. "https://open.feishu.cn" diff --git a/internal/core/types_test.go b/internal/core/types_test.go index 72f331700..55cdd1f11 100644 --- a/internal/core/types_test.go +++ b/internal/core/types_test.go @@ -42,6 +42,11 @@ func TestResolveEndpoints_EmptyDefaultsToFeishu(t *testing.T) { if ep.Open != "https://open.feishu.cn" { t.Errorf("Open = %q, want feishu.cn for empty brand", ep.Open) } + // The unified OAuth v3 Token Endpoint mints TAT on the accounts domain; + // pin the default-brand host so a stray non-production domain revert is caught. + if ep.Accounts != "https://accounts.feishu.cn" { + t.Errorf("Accounts = %q, want accounts.feishu.cn for empty brand", ep.Accounts) + } } func TestResolveOpenBaseURL(t *testing.T) { diff --git a/internal/credential/default_provider.go b/internal/credential/default_provider.go index 9482b2845..d7401b62e 100644 --- a/internal/credential/default_provider.go +++ b/internal/credential/default_provider.go @@ -19,33 +19,44 @@ import ( extcred "github.com/larksuite/cli/extension/credential" ) -// classifyTATResponseCode wraps a non-zero TAT endpoint response code into the -// canonical typed error. The TAT mint endpoint reports invalid credentials -// with two distinct codes: +// classifyTATResponseCode wraps a deterministic (non-transient) failure from the +// unified Token Endpoint into the canonical typed errs.* error. The v3 endpoint +// reports failures using the OAuth 2.0 model — an `error` string plus an +// optional numeric `code` — instead of the legacy `{code, msg}` shape. // -// - 10003: bad app_id format or non-existent app_id ("invalid param") -// - 10014: invalid app_secret ("app secret invalid") -// -// Both surface as CategoryConfig/InvalidClient from the user's perspective — -// the configured credentials cannot mint a tenant access token. 10014 is -// globally mapped in codemeta (TAT-mint-specific variant of OAuth 99991543). -// 10003 is NOT globally mapped because in other Lark endpoints it carries -// unrelated semantics (e.g. task API uses 10003 for permission denied), so -// the override stays local to this TAT call site instead of leaking into the -// shared codemeta table. -func classifyTATResponseCode(code int, msg, brand, appID string) error { - if code == 10003 { +// invalid_client / unauthorized_client mean the configured app_id/app_secret +// cannot mint a token; from the user's perspective that is the same actionable +// CategoryConfig/InvalidClient failure the legacy 10003/10014 codes produced. +// Every other deterministic error falls through to BuildAPIError, which still +// yields a typed error so probe callers (errs.IsTyped) surface it rather than +// swallowing it. Transient/server-side failures (5xx / server_error) are +// filtered out by FetchTAT before this is called, so they stay untyped. +func classifyTATResponseCode(code int, oauthErr, errDesc, brand, appID string) error { + msg := errDesc + if msg == "" { + msg = oauthErr + } + switch oauthErr { + case "invalid_client", "unauthorized_client": return errs.NewConfigError(errs.SubtypeInvalidClient, "%s", msg). WithCode(code). WithHint("%s", errclass.ConfigHint(errs.SubtypeInvalidClient)) } - return errclass.BuildAPIError(map[string]any{ + if err := errclass.BuildAPIError(map[string]any{ "code": code, "msg": msg, }, errclass.ClassifyContext{ Brand: brand, AppID: appID, - }) + }); err != nil { + return err + } + // BuildAPIError returns nil for code 0 (Feishu's success convention), but this + // function is only reached once FetchTAT has ruled out success — a non-credential + // OAuth error (e.g. invalid_scope) can arrive with code 0 and is still a + // deterministic rejection. Back it with a typed APIError so callers never receive + // the ("", nil) "empty token, no error" pair. + return errs.NewAPIError(errs.SubtypeUnknown, "%s", msg).WithCode(code) } // DefaultAccountProvider resolves account from config.json via keychain. @@ -146,8 +157,8 @@ func (p *DefaultTokenProvider) resolveUAT(ctx context.Context) (*TokenResult, er return &TokenResult{Token: token, Scopes: scopes}, nil } -// resolveTAT resolves a tenant access token. Result is cached after first call. -// NOTE: Uses sync.Once — only the context from the first call is used. +// resolveTAT resolves a tenant access token. The result is cached after the first +// call via sync.Once — only the context from the first call is used. func (p *DefaultTokenProvider) resolveTAT(ctx context.Context) (*TokenResult, error) { p.tatOnce.Do(func() { p.tatResult, p.tatErr = p.doResolveTAT(ctx) diff --git a/internal/credential/default_provider_test.go b/internal/credential/default_provider_test.go index 46e21f066..057f1dba8 100644 --- a/internal/credential/default_provider_test.go +++ b/internal/credential/default_provider_test.go @@ -19,18 +19,16 @@ func TestDefaultAccountProvider_Implements(t *testing.T) { var _ DefaultAccountResolver = &DefaultAccountProvider{} } -// TestClassifyTATResponseCode_10003_MapsToInvalidClient pins that the TAT -// endpoint's "invalid param" code surfaces as CategoryConfig/InvalidClient. -// Reason: a bad or non-existent app_id triggers 10003 on the TAT mint endpoint, -// which from the user's perspective is the same actionable failure as 10014 -// ("app secret invalid") — both mean the configured credentials cannot mint a -// tenant access token. The global codemeta intentionally does not map 10003 -// because in other Lark endpoints 10003 carries unrelated semantics (e.g. task -// API uses it for permission denied), so the override is local to this site. -func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) { - err := classifyTATResponseCode(10003, "invalid param", "feishu", "cli_app_x") +// TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient pins that the +// unified Token Endpoint's OAuth2 invalid_client error surfaces as +// CategoryConfig/InvalidClient — the configured app_id/app_secret cannot mint a +// tenant access token, the same actionable failure the legacy 10003/10014 codes +// produced. The numeric code is intentionally not asserted: the v3 endpoint may +// return invalid_client with no Lark code (code defaults to 0). +func TestClassifyTATResponseCode_InvalidClient_MapsToInvalidClient(t *testing.T) { + err := classifyTATResponseCode(0, "invalid_client", "client authentication failed", "feishu", "cli_app_x") if err == nil { - t.Fatal("expected non-nil error for code=10003") + t.Fatal("expected non-nil error for invalid_client") } var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { @@ -42,22 +40,16 @@ func TestClassifyTATResponseCode_10003_MapsToInvalidClient(t *testing.T) { if cfgErr.Subtype != errs.SubtypeInvalidClient { t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) } - if cfgErr.Code != 10003 { - t.Errorf("Code = %d, want 10003", cfgErr.Code) - } if cfgErr.Hint == "" { t.Error("Hint must be non-empty so the user gets a recovery action") } } -// TestClassifyTATResponseCode_10014_RoutesViaCodeMeta pins that 10014 still -// goes through the global BuildAPIError path (codemeta entry) so the override -// for 10003 does not regress the existing mapping. -func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) { - err := classifyTATResponseCode(10014, "app secret invalid", "feishu", "cli_app_x") - if err == nil { - t.Fatal("expected non-nil error for code=10014") - } +// TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient pins that +// unauthorized_client is treated as the same credential failure as +// invalid_client. +func TestClassifyTATResponseCode_UnauthorizedClient_MapsToInvalidClient(t *testing.T) { + err := classifyTATResponseCode(0, "unauthorized_client", "client not authorized", "feishu", "cli_app_x") var cfgErr *errs.ConfigError if !errors.As(err, &cfgErr) { t.Fatalf("expected *errs.ConfigError, got %T: %v", err, err) @@ -65,21 +57,38 @@ func TestClassifyTATResponseCode_10014_RoutesViaCodeMeta(t *testing.T) { if cfgErr.Subtype != errs.SubtypeInvalidClient { t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) } - if cfgErr.Code != 10014 { - t.Errorf("Code = %d, want 10014", cfgErr.Code) - } } -// TestClassifyTATResponseCode_UnknownCodeFallsThrough pins that codes outside -// the credential set fall through to the generic BuildAPIError fallback -// (CategoryAPI/SubtypeUnknown) — the override is narrow and intentional. -func TestClassifyTATResponseCode_UnknownCodeFallsThrough(t *testing.T) { - err := classifyTATResponseCode(99999999, "some unknown failure", "feishu", "cli_app_x") +// TestClassifyTATResponseCode_OtherErrorFallsThrough pins that OAuth errors +// outside the credential set fall through to the generic BuildAPIError fallback +// — still typed, but not a ConfigError. The mapping is narrow and intentional. +func TestClassifyTATResponseCode_OtherErrorFallsThrough(t *testing.T) { + err := classifyTATResponseCode(20068, "invalid_scope", "unauthorized scope", "feishu", "cli_app_x") if err == nil { - t.Fatal("expected non-nil error for unmapped code") + t.Fatal("expected non-nil error for invalid_scope") } var cfgErr *errs.ConfigError if errors.As(err, &cfgErr) { - t.Fatalf("unmapped code must not be classified as ConfigError, got %T", err) + t.Fatalf("invalid_scope must not be classified as ConfigError, got %T", err) + } +} + +// TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped pins the code-0 +// backstop: a non-credential OAuth error (e.g. invalid_scope) that arrives with no +// numeric code (code 0) must still produce a non-nil typed error. BuildAPIError +// returns nil for code 0 (Feishu's success convention); without the backstop, +// FetchTAT would surface this deterministic rejection as ("", nil) — an empty token +// with no error. +func TestClassifyTATResponseCode_CodeZeroOtherError_StillTyped(t *testing.T) { + err := classifyTATResponseCode(0, "invalid_scope", "the requested scope is not granted", "feishu", "cli_app_x") + if err == nil { + t.Fatal("expected non-nil error for code-0 invalid_scope (must not be swallowed as success)") + } + if !errs.IsTyped(err) { + t.Fatalf("expected a typed errs.* error, got %T %v", err, err) + } + var cfgErr *errs.ConfigError + if errors.As(err, &cfgErr) { + t.Fatalf("code-0 invalid_scope must not be a ConfigError, got %T", err) } } diff --git a/internal/credential/tat_fetch.go b/internal/credential/tat_fetch.go index eecaed20e..0d916245d 100644 --- a/internal/credential/tat_fetch.go +++ b/internal/credential/tat_fetch.go @@ -4,46 +4,47 @@ package credential import ( - "bytes" "context" "encoding/json" "fmt" + "io" "net/http" + "net/url" + "strings" "github.com/larksuite/cli/internal/core" ) -// FetchTAT performs a single HTTP POST to mint a tenant access token with the -// given credentials. It does not read configuration or keychain, so callers -// that already hold plaintext credentials (e.g. the post-`config init` probe) -// can validate them without a second keychain round-trip. +// FetchTAT performs a single HTTP POST to mint a tenant access token via the +// unified OAuth 2.0 Token Endpoint ({accounts}/oauth/v3/token) using the +// client_credentials grant with client_secret_post authentication. It does not +// read configuration or keychain, so callers that already hold plaintext +// credentials (e.g. the post-`config init` probe) can validate them without a +// second keychain round-trip. // -// A non-zero TAT response code means the server inspected the payload and -// rejected the credentials; FetchTAT returns the canonical typed error from -// classifyTATResponseCode — the SAME classification doResolveTAT (and thus -// every token-resolving command) produces, so callers see one consistent -// envelope (CategoryConfig / SubtypeInvalidClient for 10003 / 10014, etc.). -// Transport, HTTP-status and JSON-parse failures are returned raw (untyped), -// leaving them ambiguous; a caller can use errs.IsTyped to tell a deterministic -// credential rejection apart from upstream/transport noise. +// A deterministic client-side rejection (e.g. invalid_client) returns the +// canonical typed error from classifyTATResponseCode — the SAME classification +// doResolveTAT (and thus every token-resolving command) produces, so callers +// see one consistent envelope. Transport failures, unreadable/unparseable +// bodies, and transient server-side failures (5xx / server_error) are returned +// raw (untyped), leaving them ambiguous; a caller can use errs.IsTyped to tell a +// deterministic credential rejection apart from upstream/transport noise. // // The caller owns the context timeout. func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID, appSecret string) (string, error) { ep := core.ResolveEndpoints(brand) - url := ep.Open + "/open-apis/auth/v3/tenant_access_token/internal" + endpoint := ep.Accounts + core.OAuthTokenV3Path - body, err := json.Marshal(map[string]string{ - "app_id": appID, - "app_secret": appSecret, - }) - if err != nil { - return "", fmt.Errorf("failed to marshal TAT request: %w", err) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + form := url.Values{} + form.Set("grant_type", "client_credentials") + form.Set("client_id", appID) + form.Set("client_secret", appSecret) + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode())) if err != nil { return "", err } - req.Header.Set("Content-Type", "application/json") + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") resp, err := httpClient.Do(req) if err != nil { @@ -51,20 +52,51 @@ func FetchTAT(ctx context.Context, httpClient *http.Client, brand core.LarkBrand } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("TAT API returned HTTP %d", resp.StatusCode) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("failed to read TAT response: %w", err) } var result struct { - Code int `json:"code"` - Msg string `json:"msg"` - TenantAccessToken string `json:"tenant_access_token"` + Code int `json:"code"` + AccessToken string `json:"access_token"` + Error string `json:"error"` + ErrorDescription string `json:"error_description"` + Msg string `json:"msg"` } - if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { - return "", fmt.Errorf("failed to parse TAT response: %w", err) + if err := json.Unmarshal(body, &result); err != nil { + // An unparseable body is ambiguous (covers non-JSON error pages and + // truncated payloads); stay untyped so probe callers treat it as noise. + return "", fmt.Errorf("failed to parse TAT response (HTTP %d): %w", resp.StatusCode, err) } - if result.Code != 0 { - return "", classifyTATResponseCode(result.Code, result.Msg, string(brand), appID) + + if result.Code == 0 && result.AccessToken != "" { + return result.AccessToken, nil } - return result.TenantAccessToken, nil + + // Transient/server-side failures stay untyped so probe callers stay silent and + // retryers can back off; only deterministic client rejections are typed. Covers + // 5xx, HTTP 429 rate-limit, and the OAuth transient error strings (server_error, + // temporarily_unavailable, slow_down) — matching the legacy "non-2xx is noise" + // behavior so a rate-limited probe is not surfaced as a hard credential error. + if resp.StatusCode >= 500 || resp.StatusCode == http.StatusTooManyRequests || + result.Error == "server_error" || result.Error == "temporarily_unavailable" || + result.Error == "slow_down" { + return "", fmt.Errorf("TAT endpoint transient failure (HTTP %d, code=%d, error=%q): %s", + resp.StatusCode, result.Code, result.Error, result.ErrorDescription) + } + + // A 2xx with neither token nor error is a malformed success — ambiguous, untyped. + if result.Code == 0 && result.Error == "" { + return "", fmt.Errorf("TAT response missing access_token (HTTP %d)", resp.StatusCode) + } + + // Prefer the OAuth error_description; fall back to the legacy Lark `msg` so a + // gateway-level {code, msg} response (carrying no OAuth fields) still yields a + // non-empty typed message instead of a bare "API error: [code]". + desc := result.ErrorDescription + if desc == "" { + desc = result.Msg + } + return "", classifyTATResponseCode(result.Code, result.Error, desc, string(brand), appID) } diff --git a/internal/credential/tat_fetch_test.go b/internal/credential/tat_fetch_test.go index 686d98cc9..a899d205a 100644 --- a/internal/credential/tat_fetch_test.go +++ b/internal/credential/tat_fetch_test.go @@ -44,7 +44,7 @@ func (s *stubRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) func TestFetchTAT_Success(t *testing.T) { rt := &stubRoundTripper{ respCode: 200, - respBody: `{"code":0,"tenant_access_token":"t-abc","msg":"ok"}`, + respBody: `{"code":0,"access_token":"t-abc","token_type":"Bearer","expires_in":7200}`, } hc := &http.Client{Transport: rt} @@ -55,24 +55,33 @@ func TestFetchTAT_Success(t *testing.T) { if token != "t-abc" { t.Errorf("token = %q, want t-abc", token) } - if rt.gotReq.URL.String() != "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal" { + if rt.gotReq.URL.String() != "https://accounts.feishu.cn/oauth/v3/token" { t.Errorf("url = %s", rt.gotReq.URL.String()) } - if !strings.Contains(rt.gotBody, `"app_id":"cli_app"`) || !strings.Contains(rt.gotBody, `"app_secret":"secret_x"`) { - t.Errorf("request body missing credentials: %s", rt.gotBody) + if ct := rt.gotReq.Header.Get("Content-Type"); ct != "application/x-www-form-urlencoded" { + t.Errorf("Content-Type = %q, want application/x-www-form-urlencoded", ct) + } + // client_secret_post: grant_type + client_id + client_secret in the form body. + for _, want := range []string{"grant_type=client_credentials", "client_id=cli_app", "client_secret=secret_x"} { + if !strings.Contains(rt.gotBody, want) { + t.Errorf("request body missing %q: %s", want, rt.gotBody) + } } } -// 10003 (bad / non-existent app_id, "invalid param") is classified locally by +// invalid_client (wrong app_id/app_secret on the client_credentials grant) is a +// deterministic client-side rejection that FetchTAT routes to // classifyTATResponseCode as CategoryConfig / SubtypeInvalidClient — the same // typed error doResolveTAT (and thus every token-resolving command) returns. -func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) { - rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10003,"msg":"invalid param"}`} +// The v3 endpoint reports it as HTTP 400 with the OAuth2 error body (wrong +// secret → code 20002, unknown app → code 20048). +func TestFetchTAT_InvalidClient_ConfigInvalidClient(t *testing.T) { + rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_client","error_description":"The client secret is invalid.","code":20002}`} hc := &http.Client{Transport: rt} token, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") if err == nil { - t.Fatal("expected error for code 10003") + t.Fatal("expected error for invalid_client") } if token != "" { t.Errorf("token = %q, want empty", token) @@ -87,52 +96,115 @@ func TestFetchTAT_Code10003_ConfigInvalidClient(t *testing.T) { if cfgErr.Subtype != errs.SubtypeInvalidClient { t.Errorf("Subtype = %q, want %q", cfgErr.Subtype, errs.SubtypeInvalidClient) } - if cfgErr.Code != 10003 { - t.Errorf("Code = %d, want 10003", cfgErr.Code) - } } -// 10014 ("app secret invalid") — the most common real-world rejection (real -// app_id + wrong secret) — is globally mapped in codemeta to -// CategoryConfig / SubtypeInvalidClient via BuildAPIError. -func TestFetchTAT_Code10014_ConfigInvalidClient(t *testing.T) { - rt := &stubRoundTripper{respCode: 200, respBody: `{"code":10014,"msg":"app secret invalid"}`} - hc := &http.Client{Transport: rt} - - _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") - var cfgErr *errs.ConfigError - if !errors.As(err, &cfgErr) { - t.Fatalf("error not *errs.ConfigError: %T %v", err, err) - } - if cfgErr.Subtype != errs.SubtypeInvalidClient || cfgErr.Code != 10014 { - t.Errorf("got Subtype=%q Code=%d, want invalid_client/10014", cfgErr.Subtype, cfgErr.Code) - } -} - -// Any non-zero body code is a deterministic server-side rejection, so it -// always yields a typed error (errs.IsTyped). An unrecognized code falls back -// to CategoryAPI / SubtypeUnknown via BuildAPIError — still typed, so a probe -// caller still surfaces it rather than silently swallowing. -func TestFetchTAT_UnknownBodyCode_Typed(t *testing.T) { - rt := &stubRoundTripper{respCode: 200, respBody: `{"code":99999,"msg":"future-unknown"}`} +// Any other deterministic client-side OAuth error (e.g. invalid_scope) still +// yields a typed error (errs.IsTyped) via BuildAPIError — so a probe caller +// surfaces it rather than silently swallowing it — but is NOT classified as a +// credential (invalid_client) problem. +func TestFetchTAT_OtherClientError_Typed(t *testing.T) { + rt := &stubRoundTripper{respCode: 400, respBody: `{"code":20068,"error":"invalid_scope","error_description":"unauthorized scope"}`} hc := &http.Client{Transport: rt} _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") if err == nil { - t.Fatal("expected error for code 99999") + t.Fatal("expected error for invalid_scope") } if !errs.IsTyped(err) { t.Fatalf("expected a typed errs.* error, got %T %v", err, err) } - var apiErr *errs.APIError - if !errors.As(err, &apiErr) { - t.Errorf("unknown code should fall back to *errs.APIError, got %T", err) + var cfgErr *errs.ConfigError + if errors.As(err, &cfgErr) { + t.Errorf("invalid_scope must not be classified as ConfigError/InvalidClient, got %T", err) } } -// Non-2xx HTTP is ambiguous (not a payload-level credential rejection) — it -// must stay UNTYPED so a probe caller treats it as upstream noise and stays -// silent. +// A deterministic OAuth error that arrives WITHOUT a numeric code (code defaults to +// 0) must still surface as a non-nil typed error — never the ("", nil) success pair. +// Guards the code-0 backstop in classifyTATResponseCode: BuildAPIError returns nil +// for code 0, which would otherwise swallow this rejection into an empty-token success. +func TestFetchTAT_OtherClientError_CodeZero_Typed(t *testing.T) { + rt := &stubRoundTripper{respCode: 400, respBody: `{"error":"invalid_scope","error_description":"the requested scope is not granted"}`} + hc := &http.Client{Transport: rt} + + tok, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatal("expected non-nil error for code-0 invalid_scope (must not return empty token + nil error)") + } + if tok != "" { + t.Errorf("token = %q, want empty", tok) + } + if !errs.IsTyped(err) { + t.Fatalf("expected a typed errs.* error, got %T %v", err, err) + } +} + +// A gateway-style {code, msg} error (no OAuth error / error_description fields) +// must still surface its msg on the typed error, not degrade to a generic +// "API error: [code]". Guards the legacy-msg fallback in FetchTAT. +func TestFetchTAT_LarkStyleMsg_FallsBackOnTypedError(t *testing.T) { + rt := &stubRoundTripper{respCode: 400, respBody: `{"code":99999,"msg":"app ticket invalid"}`} + hc := &http.Client{Transport: rt} + + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatal("expected error for {code, msg} response") + } + if !errs.IsTyped(err) { + t.Fatalf("expected a typed errs.* error, got %T %v", err, err) + } + if !strings.Contains(err.Error(), "app ticket invalid") { + t.Errorf("typed error must carry the Lark msg, got: %v", err) + } +} + +// Transient server-side failures (5xx / server_error) are NOT deterministic +// credential rejections — they must stay UNTYPED so a probe caller treats them +// as upstream noise and stays silent (and retryers can back off). +func TestFetchTAT_ServerError_Untyped(t *testing.T) { + rt := &stubRoundTripper{respCode: 500, respBody: `{"code":20050,"error":"server_error","error_description":"please retry"}`} + hc := &http.Client{Transport: rt} + + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatal("expected error for server_error") + } + if errs.IsTyped(err) { + t.Errorf("server_error must be UNTYPED (transient), got typed %T %v", err, err) + } +} + +// Rate-limiting is transient, not a deterministic credential rejection — an HTTP +// 429 (even with a parseable OAuth body) and the OAuth slow_down error must both +// stay UNTYPED so a rate-limited probe stays silent and retryers can back off. +func TestFetchTAT_RateLimit_Untyped(t *testing.T) { + cases := []struct { + name string + code int + body string + }{ + {"http 429", 429, `{"code":99991400,"error":"too_many_requests","error_description":"rate limit exceeded"}`}, + {"oauth slow_down", 200, `{"error":"slow_down","error_description":"polling too fast"}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rt := &stubRoundTripper{respCode: tc.code, respBody: tc.body} + hc := &http.Client{Transport: rt} + + _, err := FetchTAT(context.Background(), hc, core.BrandFeishu, "cli_app", "secret_x") + if err == nil { + t.Fatal("expected error for rate-limit") + } + if errs.IsTyped(err) { + t.Errorf("rate-limit must be UNTYPED (transient), got typed %T %v", err, err) + } + }) + } +} + +// Non-2xx HTTP with a non-JSON body is ambiguous (not a structured OAuth +// rejection) — it must stay UNTYPED so a probe caller treats it as upstream +// noise and stays silent. func TestFetchTAT_HTTPNon200_Untyped(t *testing.T) { for _, code := range []int{401, 403, 500, 503} { rt := &stubRoundTripper{respCode: code, respBody: `whatever`} @@ -182,12 +254,12 @@ func TestFetchTAT_BrandRouting(t *testing.T) { brand core.LarkBrand wantURL string }{ - {core.BrandFeishu, "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"}, - {core.BrandLark, "https://open.larksuite.com/open-apis/auth/v3/tenant_access_token/internal"}, + {core.BrandFeishu, "https://accounts.feishu.cn/oauth/v3/token"}, + {core.BrandLark, "https://accounts.larksuite.com/oauth/v3/token"}, } for _, tc := range tests { t.Run(string(tc.brand), func(t *testing.T) { - rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"tenant_access_token":"t"}`} + rt := &stubRoundTripper{respCode: 200, respBody: `{"code":0,"access_token":"t","token_type":"Bearer"}`} hc := &http.Client{Transport: rt} if _, err := FetchTAT(context.Background(), hc, tc.brand, "a", "b"); err != nil { t.Fatal(err) diff --git a/internal/errclass/codemeta.go b/internal/errclass/codemeta.go index 7c41c875a..d0b1d7644 100644 --- a/internal/errclass/codemeta.go +++ b/internal/errclass/codemeta.go @@ -65,7 +65,7 @@ var codeMeta = map[int]CodeMeta{ // CategoryConfig 99991543: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // RFC 6749 §5.2 — app_id / app_secret incorrect (Open API) - 10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // TAT endpoint — "app secret invalid" (TAT-mint variant of 99991543) + 10014: {Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidClient}, // legacy TAT endpoint — "app secret invalid" (pre-v3 variant of 99991543; CLI now reports invalid_client) // CategoryPolicy 21000: {Category: errs.CategoryPolicy, Subtype: errs.SubtypeChallengeRequired}, diff --git a/internal/event/bus/bus.go b/internal/event/bus/bus.go index 469e9f12d..d018ce868 100644 --- a/internal/event/bus/bus.go +++ b/internal/event/bus/bus.go @@ -262,19 +262,23 @@ func (b *Bus) handleConn(conn net.Conn) { // handleHello registers a consume connection with the hub; reader carries bytes already pulled off conn. func (b *Bus) handleHello(conn net.Conn, reader *bufio.Reader, hello *protocol.Hello) { - bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID) + subID := hello.SubscriptionID + if subID == "" { + subID = hello.EventKey + } + bc := NewConn(conn, reader, hello.EventKey, hello.EventTypes, hello.PID, subID) bc.SetLogger(b.logger) // Register + isFirst under one lock; blocks on any in-progress cleanup lock for the same EventKey. firstForKey := b.hub.RegisterAndIsFirst(bc) - bc.SetCheckLastForKey(func(eventKey string) bool { - return b.hub.AcquireCleanupLock(eventKey) + bc.SetCheckLastForKey(func(scope string) bool { + return b.hub.AcquireCleanupLock(scope) }) bc.SetOnClose(func(c *Conn) { b.hub.UnregisterAndIsLast(c) // Release is idempotent and must fire on every disconnect path so waiters don't block forever. - b.hub.ReleaseCleanupLock(c.EventKey()) + b.hub.ReleaseCleanupLock(c.SubscriptionID()) b.mu.Lock() delete(b.conns, c) remaining := len(b.conns) diff --git a/internal/event/bus/bus_shutdown_test.go b/internal/event/bus/bus_shutdown_test.go index 989caefca..98ff07401 100644 --- a/internal/event/bus/bus_shutdown_test.go +++ b/internal/event/bus/bus_shutdown_test.go @@ -33,7 +33,7 @@ func TestRunShutdownWithMultipleConns(t *testing.T) { server, client := net.Pipe() pipes = append(pipes, server, client) - bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i) + bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 1000+i, "") bc.SetLogger(logger) hub.RegisterAndIsFirst(bc) diff --git a/internal/event/bus/conn.go b/internal/event/bus/conn.go index 833231ff2..c827d8008 100644 --- a/internal/event/bus/conn.go +++ b/internal/event/bus/conn.go @@ -29,9 +29,10 @@ type Conn struct { writeMu sync.Mutex // serialises all net.Conn writes (Encode+SetWriteDeadline is a 2-call sequence) eventKey string eventTypes []string + subID string pid int onClose func(*Conn) - checkLastForKey func(eventKey string) bool + checkLastForKey func(scope string) bool logger *log.Logger closed chan struct{} closeOnce sync.Once @@ -41,7 +42,7 @@ type Conn struct { } // NewConn creates a Conn; pass a reader with pre-buffered bytes (handoff from Bus.handleConn) or nil for a fresh one. -func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int) *Conn { +func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes []string, pid int, subID string) *Conn { if reader == nil { reader = bufio.NewReader(conn) } @@ -52,10 +53,20 @@ func NewConn(conn net.Conn, reader *bufio.Reader, eventKey string, eventTypes [] eventKey: eventKey, eventTypes: eventTypes, pid: pid, + subID: subID, closed: make(chan struct{}), } } +// SubscriptionID returns the subscription identity. Falls back to EventKey +// when the stored subID is empty (legacy clients / no-SubscriptionKey EventKeys). +func (c *Conn) SubscriptionID() string { + if c.subID == "" { + return c.eventKey + } + return c.subID +} + func (c *Conn) SetOnClose(fn func(*Conn)) { c.onClose = fn } // SetCheckLastForKey: returning true means "you are the last subscriber, run cleanup". @@ -132,13 +143,19 @@ func (c *Conn) ReaderLoop() { } func (c *Conn) handleControlMessage(msg interface{}) { - switch m := msg.(type) { + switch msg.(type) { case *protocol.Bye: c.shutdown() case *protocol.PreShutdownCheck: + // Use the connection's own authoritative subscription identity rather + // than recomputing from the incoming message: a stale or mismatched + // PreShutdownCheck must not ask about the wrong scope (which would + // suppress or mistrigger per-subscription cleanup). Conn.SubscriptionID() + // already falls back to EventKey when its stored subID is empty. + scope := c.SubscriptionID() lastForKey := true if c.checkLastForKey != nil { - lastForKey = c.checkLastForKey(m.EventKey) + lastForKey = c.checkLastForKey(scope) } ack := protocol.NewPreShutdownAck(lastForKey) if err := c.writeFrame(ack); err != nil && c.logger != nil { diff --git a/internal/event/bus/conn_test.go b/internal/event/bus/conn_test.go index aaa2d4f9d..1beaf3489 100644 --- a/internal/event/bus/conn_test.go +++ b/internal/event/bus/conn_test.go @@ -21,7 +21,7 @@ func TestConn_SenderWritesEvents(t *testing.T) { defer server.Close() defer client.Close() - bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345) + bc := NewConn(server, nil, "im.msg", []string{"im.message.receive_v1"}, 12345, "") go bc.SenderLoop() bc.SendCh() <- &protocol.Event{ @@ -62,7 +62,7 @@ func TestConn_ConcurrentWritesSerialised(t *testing.T) { defer client.Close() det := &serializingDetector{Conn: server} - bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345) + bc := NewConn(det, nil, "im.msg", []string{"im.msg"}, 12345, "") go func() { _, _ = io.Copy(io.Discard, client) }() @@ -106,7 +106,7 @@ func TestConn_TrySend_NonEvicting(t *testing.T) { server, client := net.Pipe() defer server.Close() defer client.Close() - bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345) + bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "") for i := 0; i < sendChCap; i++ { if !bc.TrySend(i) { @@ -126,7 +126,7 @@ func TestConn_ReaderDetectsEOF(t *testing.T) { server, client := net.Pipe() defer server.Close() - bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345) + bc := NewConn(server, nil, "im.msg", []string{"im.msg"}, 12345, "") done := make(chan struct{}) go func() { @@ -142,3 +142,23 @@ func TestConn_ReaderDetectsEOF(t *testing.T) { t.Fatal("ReaderLoop did not exit on EOF") } } + +func TestConn_SubscriptionID(t *testing.T) { + c1, c2 := net.Pipe() + defer c1.Close() + defer c2.Close() + conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "mail.x:abc") + if got := conn.SubscriptionID(); got != "mail.x:abc" { + t.Errorf("SubscriptionID() = %q, want %q", got, "mail.x:abc") + } +} + +func TestConn_SubscriptionID_EmptyFallsBackToEventKey(t *testing.T) { + c1, c2 := net.Pipe() + defer c1.Close() + defer c2.Close() + conn := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 999, "") + if got := conn.SubscriptionID(); got != "mail.x" { + t.Errorf("SubscriptionID() with empty input = %q, want fallback %q", got, "mail.x") + } +} diff --git a/internal/event/bus/handle_hello_test.go b/internal/event/bus/handle_hello_test.go index 7be844dea..034fbd1b4 100644 --- a/internal/event/bus/handle_hello_test.go +++ b/internal/event/bus/handle_hello_test.go @@ -63,3 +63,134 @@ func TestHandleHello_HelloAckWriteFailureUnregisters(t *testing.T) { t.Errorf("b.conns after failed HelloAck = %d entries, want 0", remaining) } } + +// TestHandleHello_LegacyClient_FallsBackToEventKey: a Hello with empty +// subscription_id registers under EventKey (today's behavior preserved). +func TestHandleHello_LegacyClient_FallsBackToEventKey(t *testing.T) { + logger := log.New(io.Discard, "", 0) + hub := NewHub() + b := &Bus{ + hub: hub, + logger: logger, + conns: make(map[*Conn]struct{}), + idleTimer: time.NewTimer(30 * time.Second), + shutdownCh: make(chan struct{}, 1), + } + + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + // Legacy client: no subscription_id field (empty string). + hello := &protocol.Hello{ + PID: 9999, + EventKey: "im.message", + EventTypes: []string{"im.message.receive_v1"}, + SubscriptionID: "", // legacy: empty, should fallback to EventKey + } + + br := bufio.NewReader(server) + + done := make(chan struct{}) + go func() { + b.handleHello(server, br, hello) + close(done) + }() + + // Read the HelloAck from client side to let handleHello complete. + clientReader := bufio.NewReader(client) + ackLine, err := clientReader.ReadString('\n') + if err != nil { + t.Fatalf("failed to read HelloAck: %v", err) + } + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("handleHello did not return within 3s") + } + + // Assertions: registered under EventKey (not a qualified subscription ID). + if got := hub.ConnCount(); got != 1 { + t.Errorf("hub.ConnCount = %d, want 1", got) + } + if got := hub.EventKeyCount("im.message"); got != 1 { + t.Errorf("hub.EventKeyCount(im.message) = %d, want 1", got) + } + if got := hub.SubCount("im.message"); got != 1 { + t.Errorf("hub.SubCount(im.message) = %d, want 1 (legacy fallback to EventKey)", got) + } + if got := hub.SubCount("im.message:something"); got != 0 { + t.Errorf("hub.SubCount(im.message:something) = %d, want 0 (should not exist)", got) + } + + if ackLine == "" { + t.Fatal("HelloAck was empty") + } +} + +// TestHandleHello_ModernClient_UsesSubscriptionID: a Hello with +// non-empty subscription_id registers under that ID, not EventKey. +func TestHandleHello_ModernClient_UsesSubscriptionID(t *testing.T) { + logger := log.New(io.Discard, "", 0) + hub := NewHub() + b := &Bus{ + hub: hub, + logger: logger, + conns: make(map[*Conn]struct{}), + idleTimer: time.NewTimer(30 * time.Second), + shutdownCh: make(chan struct{}, 1), + } + + server, client := net.Pipe() + defer server.Close() + defer client.Close() + + // Modern client: subscription_id explicitly set. + subscriptionID := "mail.message:alice@example.com" + hello := &protocol.Hello{ + PID: 8888, + EventKey: "mail.message", + EventTypes: []string{"mail.message.receive_v1"}, + SubscriptionID: subscriptionID, // modern: per-resource subscription + } + + br := bufio.NewReader(server) + + done := make(chan struct{}) + go func() { + b.handleHello(server, br, hello) + close(done) + }() + + // Read the HelloAck from client side to let handleHello complete. + clientReader := bufio.NewReader(client) + ackLine, err := clientReader.ReadString('\n') + if err != nil { + t.Fatalf("failed to read HelloAck: %v", err) + } + + select { + case <-done: + case <-time.After(3 * time.Second): + t.Fatal("handleHello did not return within 3s") + } + + // Assertions: registered under the subscription_id, not bare EventKey. + if got := hub.ConnCount(); got != 1 { + t.Errorf("hub.ConnCount = %d, want 1", got) + } + if got := hub.EventKeyCount("mail.message"); got != 1 { + t.Errorf("hub.EventKeyCount(mail.message) = %d, want 1", got) + } + if got := hub.SubCount(subscriptionID); got != 1 { + t.Errorf("hub.SubCount(%q) = %d, want 1 (modern: uses SubscriptionID)", subscriptionID, got) + } + if got := hub.SubCount("mail.message"); got != 0 { + t.Errorf("hub.SubCount(mail.message) = %d, want 0 (modern: NOT registered under bare EventKey)", got) + } + + if ackLine == "" { + t.Fatal("HelloAck was empty") + } +} diff --git a/internal/event/bus/hub.go b/internal/event/bus/hub.go index 76b7d22e1..620d3df64 100644 --- a/internal/event/bus/hub.go +++ b/internal/event/bus/hub.go @@ -16,6 +16,9 @@ import ( // Subscriber is the interface a connection must satisfy for Hub registration. type Subscriber interface { EventKey() string + // SubscriptionID identifies the per-resource subscription for dedup purposes. + // When no resource qualifier is needed it equals EventKey. + SubscriptionID() string EventTypes() []string SendCh() chan interface{} PID() int @@ -34,8 +37,11 @@ type Subscriber interface { type Hub struct { mu sync.RWMutex subscribers map[Subscriber]struct{} - keyCounts map[string]int - // cleanupInProgress[key] holds a channel closed on release; presence means a cleanup lock is held. + // subCounts is keyed by SubscriptionID (not EventKey) so that different + // per-resource subscriptions sharing the same EventKey are deduped independently. + subCounts map[string]int + // cleanupInProgress[subscriptionID] holds a channel closed on release; + // presence means a cleanup lock is held for that subscription. cleanupInProgress map[string]chan struct{} logger atomic.Pointer[log.Logger] } @@ -43,7 +49,7 @@ type Hub struct { func NewHub() *Hub { return &Hub{ subscribers: make(map[Subscriber]struct{}), - keyCounts: make(map[string]int), + subCounts: make(map[string]int), cleanupInProgress: make(map[string]chan struct{}), } } @@ -51,7 +57,7 @@ func NewHub() *Hub { // SetLogger attaches a logger (nil tolerated). func (h *Hub) SetLogger(l *log.Logger) { h.logger.Store(l) } -// UnregisterAndIsLast removes s and reports whether it was last for its EventKey; stale unregisters are no-ops. +// UnregisterAndIsLast removes s and reports whether it was last for its SubscriptionID; stale unregisters are no-ops. func (h *Hub) UnregisterAndIsLast(s Subscriber) bool { h.mu.Lock() defer h.mu.Unlock() @@ -59,34 +65,35 @@ func (h *Hub) UnregisterAndIsLast(s Subscriber) bool { return false } delete(h.subscribers, s) - h.keyCounts[s.EventKey()]-- - isLast := h.keyCounts[s.EventKey()] == 0 + sid := s.SubscriptionID() + h.subCounts[sid]-- + isLast := h.subCounts[sid] == 0 if isLast { - delete(h.keyCounts, s.EventKey()) + delete(h.subCounts, sid) } return isLast } -// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for eventKey and no lock is held. +// AcquireCleanupLock reserves cleanup rights iff exactly one subscriber exists for subscriptionID and no lock is held. // Count==0 is rejected (would block future Register calls). On true return, caller MUST Release. -func (h *Hub) AcquireCleanupLock(eventKey string) bool { +func (h *Hub) AcquireCleanupLock(subscriptionID string) bool { h.mu.Lock() defer h.mu.Unlock() - if h.keyCounts[eventKey] != 1 { + if h.subCounts[subscriptionID] != 1 { return false } - if _, alreadyLocked := h.cleanupInProgress[eventKey]; alreadyLocked { + if _, alreadyLocked := h.cleanupInProgress[subscriptionID]; alreadyLocked { return false } - h.cleanupInProgress[eventKey] = make(chan struct{}) + h.cleanupInProgress[subscriptionID] = make(chan struct{}) return true } // ReleaseCleanupLock is idempotent; OnClose calls unconditionally. -func (h *Hub) ReleaseCleanupLock(eventKey string) { +func (h *Hub) ReleaseCleanupLock(subscriptionID string) { h.mu.Lock() - ch := h.cleanupInProgress[eventKey] - delete(h.cleanupInProgress, eventKey) + ch := h.cleanupInProgress[subscriptionID] + delete(h.cleanupInProgress, subscriptionID) h.mu.Unlock() if ch != nil { close(ch) @@ -94,23 +101,24 @@ func (h *Hub) ReleaseCleanupLock(eventKey string) { } // RegisterAndIsFirst adds s to the hub and reports whether it's the first -// subscriber for its EventKey. If a cleanup is in progress for -// s.EventKey() (another conn holds the cleanup lock), this waits until +// subscriber for its SubscriptionID. If a cleanup is in progress for +// s.SubscriptionID() (another conn holds the cleanup lock), this waits until // cleanup releases before registering — closing the PreShutdownCheck × // Hello TOCTOU race. The wait releases h.mu before blocking on the -// channel, so concurrent operations on other keys aren't stalled. +// channel, so concurrent operations on other subscriptions aren't stalled. func (h *Hub) RegisterAndIsFirst(s Subscriber) bool { + sid := s.SubscriptionID() for { h.mu.Lock() - ch, locked := h.cleanupInProgress[s.EventKey()] + ch, locked := h.cleanupInProgress[sid] if locked { h.mu.Unlock() <-ch // wait for release, then re-check (defensive against races) continue } - isFirst := h.keyCounts[s.EventKey()] == 0 + isFirst := h.subCounts[sid] == 0 h.subscribers[s] = struct{}{} - h.keyCounts[s.EventKey()]++ + h.subCounts[sid]++ h.mu.Unlock() return isFirst } @@ -176,11 +184,25 @@ func (h *Hub) ConnCount() int { return len(h.subscribers) } -// EventKeyCount returns the number of subscribers registered for eventKey. +// EventKeyCount returns total subscribers for the given EventKey, aggregating +// across all SubscriptionIDs. For per-subscription counts use SubCount. func (h *Hub) EventKeyCount(eventKey string) int { h.mu.RLock() defer h.mu.RUnlock() - return h.keyCounts[eventKey] + count := 0 + for s := range h.subscribers { + if s.EventKey() == eventKey { + count++ + } + } + return count +} + +// SubCount returns the count of subscribers for the given SubscriptionID. +func (h *Hub) SubCount(subscriptionID string) int { + h.mu.RLock() + defer h.mu.RUnlock() + return h.subCounts[subscriptionID] } // BroadcastSourceStatus fans out a source-level status change to every @@ -205,10 +227,11 @@ func (h *Hub) Consumers() []protocol.ConsumerInfo { result := make([]protocol.ConsumerInfo, 0, len(h.subscribers)) for s := range h.subscribers { result = append(result, protocol.ConsumerInfo{ - PID: s.PID(), - EventKey: s.EventKey(), - Received: s.Received(), - Dropped: s.DroppedCount(), + PID: s.PID(), + EventKey: s.EventKey(), + SubscriptionID: s.SubscriptionID(), + Received: s.Received(), + Dropped: s.DroppedCount(), }) } return result diff --git a/internal/event/bus/hub_observability_test.go b/internal/event/bus/hub_observability_test.go index 051e3501f..0134fe2a5 100644 --- a/internal/event/bus/hub_observability_test.go +++ b/internal/event/bus/hub_observability_test.go @@ -17,7 +17,7 @@ func TestHubDroppedCountIncrements(t *testing.T) { server, client := testNetPipe(t) defer server.Close() defer client.Close() - c := NewConn(server, nil, "k", []string{"t"}, 1) + c := NewConn(server, nil, "k", []string{"t"}, 1, "") c.sendCh = make(chan interface{}, 1) h.RegisterAndIsFirst(c) @@ -35,7 +35,7 @@ func TestPublishAssignsIncrementalSeq(t *testing.T) { server, client := testNetPipe(t) defer server.Close() defer client.Close() - c := NewConn(server, nil, "k", []string{"t"}, 1) + c := NewConn(server, nil, "k", []string{"t"}, 1, "") c.sendCh = make(chan interface{}, 10) h.RegisterAndIsFirst(c) @@ -60,7 +60,7 @@ func TestPublishPopulatesEventIDAndSourceTime(t *testing.T) { server, client := testNetPipe(t) defer server.Close() defer client.Close() - c := NewConn(server, nil, "k", []string{"t"}, 1) + c := NewConn(server, nil, "k", []string{"t"}, 1, "") c.sendCh = make(chan interface{}, 1) h.RegisterAndIsFirst(c) @@ -87,7 +87,7 @@ func TestPublishSourceTimeTakesPrecedence(t *testing.T) { server, client := testNetPipe(t) defer server.Close() defer client.Close() - c := NewConn(server, nil, "k", []string{"t"}, 1) + c := NewConn(server, nil, "k", []string{"t"}, 1, "") c.sendCh = make(chan interface{}, 1) h.RegisterAndIsFirst(c) @@ -111,7 +111,7 @@ func TestPublishSourceTimeFallback(t *testing.T) { server, client := testNetPipe(t) defer server.Close() defer client.Close() - c := NewConn(server, nil, "k", []string{"t"}, 1) + c := NewConn(server, nil, "k", []string{"t"}, 1, "") c.sendCh = make(chan interface{}, 1) h.RegisterAndIsFirst(c) diff --git a/internal/event/bus/hub_publish_race_test.go b/internal/event/bus/hub_publish_race_test.go index b91987f5d..f0eb08661 100644 --- a/internal/event/bus/hub_publish_race_test.go +++ b/internal/event/bus/hub_publish_race_test.go @@ -111,6 +111,7 @@ type alwaysFailSubscriber struct { } func (s *alwaysFailSubscriber) EventKey() string { return s.eventKey } +func (s *alwaysFailSubscriber) SubscriptionID() string { return s.eventKey } func (s *alwaysFailSubscriber) EventTypes() []string { return s.eventTypes } func (s *alwaysFailSubscriber) SendCh() chan interface{} { return s.sendCh } func (s *alwaysFailSubscriber) PID() int { return 0 } @@ -153,6 +154,7 @@ func newRaceSubscriber(key string, types []string, capacity int) *raceSubscriber } func (s *raceSubscriber) EventKey() string { return s.eventKey } +func (s *raceSubscriber) SubscriptionID() string { return s.eventKey } func (s *raceSubscriber) EventTypes() []string { return s.eventTypes } func (s *raceSubscriber) SendCh() chan interface{} { return s.sendCh } func (s *raceSubscriber) PID() int { return s.pid } diff --git a/internal/event/bus/hub_test.go b/internal/event/bus/hub_test.go index e135d436c..04fe53641 100644 --- a/internal/event/bus/hub_test.go +++ b/internal/event/bus/hub_test.go @@ -5,6 +5,7 @@ package bus import ( "encoding/json" + "net" "sync" "sync/atomic" "testing" @@ -235,7 +236,10 @@ func newTestConn(eventKey string, eventTypes []string) *testConn { } } -func (c *testConn) EventKey() string { return c.eventKey } +func (c *testConn) EventKey() string { return c.eventKey } + +// SubscriptionID falls back to EventKey for test mocks that don't set a separate subscription ID. +func (c *testConn) SubscriptionID() string { return c.eventKey } func (c *testConn) EventTypes() []string { return c.eventTypes } func (c *testConn) SendCh() chan interface{} { return c.sendCh } func (c *testConn) PID() int { return c.pid } @@ -275,3 +279,79 @@ func (c *testConn) TrySend(msg interface{}) bool { return false } } + +func TestHub_SubscriptionID_Isolation(t *testing.T) { + h := NewHub() + c1, _ := net.Pipe() + c2, _ := net.Pipe() + defer c1.Close() + defer c2.Close() + s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice") + s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob") + + if !h.RegisterAndIsFirst(s1) { + t.Error("s1 should be first for its subscription") + } + if !h.RegisterAndIsFirst(s2) { + t.Error("s2 should ALSO be first (different SubscriptionID)") + } + if !h.UnregisterAndIsLast(s1) { + t.Error("s1 should be last for mail.x:alice") + } + if !h.UnregisterAndIsLast(s2) { + t.Error("s2 should be last for mail.x:bob") + } +} + +func TestHub_SameSubscriptionID_NotFirst(t *testing.T) { + h := NewHub() + c1, _ := net.Pipe() + c2, _ := net.Pipe() + defer c1.Close() + defer c2.Close() + s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice") + s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:alice") + + if !h.RegisterAndIsFirst(s1) { + t.Error("s1 first") + } + if h.RegisterAndIsFirst(s2) { + t.Error("s2 same SubscriptionID should NOT be first") + } +} + +func TestHub_EventKeyCount_AggregatesAcrossSubscriptions(t *testing.T) { + h := NewHub() + c1, _ := net.Pipe() + c2, _ := net.Pipe() + defer c1.Close() + defer c2.Close() + s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice") + s2 := NewConn(c2, nil, "mail.x", []string{"mail.x"}, 2, "mail.x:bob") + h.RegisterAndIsFirst(s1) + h.RegisterAndIsFirst(s2) + if got := h.EventKeyCount("mail.x"); got != 2 { + t.Errorf("EventKeyCount(mail.x) = %d, want 2 (aggregated across subscriptions)", got) + } + if got := h.SubCount("mail.x:alice"); got != 1 { + t.Errorf("SubCount(mail.x:alice) = %d, want 1", got) + } + if got := h.SubCount("mail.x:bob"); got != 1 { + t.Errorf("SubCount(mail.x:bob) = %d, want 1", got) + } +} + +func TestHub_Consumers_PopulatesSubscriptionID(t *testing.T) { + h := NewHub() + c1, _ := net.Pipe() + defer c1.Close() + s1 := NewConn(c1, nil, "mail.x", []string{"mail.x"}, 1, "mail.x:alice") + h.RegisterAndIsFirst(s1) + consumers := h.Consumers() + if len(consumers) != 1 { + t.Fatalf("got %d consumers, want 1", len(consumers)) + } + if consumers[0].SubscriptionID != "mail.x:alice" { + t.Errorf("Consumers()[0].SubscriptionID = %q, want %q", consumers[0].SubscriptionID, "mail.x:alice") + } +} diff --git a/internal/event/consume/consume.go b/internal/event/consume/consume.go index de4be2766..76a5b5c39 100644 --- a/internal/event/consume/consume.go +++ b/internal/event/consume/consume.go @@ -61,6 +61,22 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin } } + // Normalize params (resolve aliases like "me" -> real email) before fingerprint + // compute, PreConsume, Match, Process. Must happen BEFORE doHello so the + // SubscriptionID we send to bus reflects canonical values. + if keyDef.NormalizeParams != nil { + if err := keyDef.NormalizeParams(ctx, opts.Runtime, opts.Params); err != nil { + if _, ok := errs.ProblemOf(err); ok { + return err + } + return errs.NewInternalError(errs.SubtypeUnknown, + "normalize params for %s: %s", opts.EventKey, err).WithCause(err) + } + } + + // Compute subscription identity from normalized params + SubscriptionKey flags. + subscriptionID := ComputeSubscriptionID(keyDef, opts.Params) + if opts.Timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, opts.Timeout) @@ -81,13 +97,13 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin } defer conn.Close() - ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType}) + ack, br, err := doHello(conn, opts.EventKey, []string{keyDef.EventType}, subscriptionID) if err != nil { return errs.NewInternalError(errs.SubtypeUnknown, "event bus handshake failed: %s", err).WithCause(err) } - var cleanup func() + var cleanup func() error if ack.FirstForKey && keyDef.PreConsume != nil { if !opts.Quiet { fmt.Fprintf(errOut, "[event] running pre-consume setup...\n") @@ -113,14 +129,22 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin if cleanup != nil { switch { case r != nil: - fmt.Fprintf(errOut, "WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n", opts.EventKey) - cleanup() + fmt.Fprintf(errOut, + "WARN: panic recovered; running cleanup unconditionally (may affect other consumers of %s)\n", + opts.EventKey) + if cleanupErr := cleanup(); cleanupErr != nil { + fmt.Fprintf(errOut, + "WARN: cleanup also failed during panic recovery: %v\n", cleanupErr) + } case lastForKey: if !opts.Quiet { fmt.Fprintf(errOut, "[event] running cleanup...\n") } - cleanup() - if !opts.Quiet { + if cleanupErr := cleanup(); cleanupErr != nil { + fmt.Fprintf(errOut, + "WARN: cleanup failed: %v (server-side subscribe is idempotent — residual record will be overwritten on next subscribe)\n", + cleanupErr) + } else if !opts.Quiet { fmt.Fprintf(errOut, "[event] cleanup done.\n") } } @@ -144,7 +168,7 @@ func Run(ctx context.Context, tr transport.IPC, appID, profileName, domain strin writeReadyMarker(errOut, opts) - return consumeLoop(ctx, conn, br, keyDef, opts, &lastForKey, &emitted) + return consumeLoop(ctx, conn, br, keyDef, opts, subscriptionID, &lastForKey, &emitted) } func truncateDuration(d time.Duration) time.Duration { diff --git a/internal/event/consume/consume_test.go b/internal/event/consume/consume_test.go new file mode 100644 index 000000000..a082e71f6 --- /dev/null +++ b/internal/event/consume/consume_test.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "net" + "strings" + "testing" + + "github.com/larksuite/cli/internal/event" + "github.com/larksuite/cli/internal/event/protocol" + "github.com/larksuite/cli/internal/event/transport" +) + +// fakeRT is a minimal event.APIClient mock. +type fakeRT struct { + err error +} + +func (f *fakeRT) CallAPI(_ context.Context, _, _ string, _ interface{}) (json.RawMessage, error) { + return nil, f.err +} + +func TestNormalizeParams_ErrorIsWrappedWithEventKey(t *testing.T) { + // Drives the real Run() path: NormalizeParams fails before EnsureBus, so no + // bus is contacted, yet the production error-wrapping is exercised — if Run() + // ever stops wrapping, this test fails. + const key = "test.evt_normalize_fail" + event.RegisterKey(event.KeyDefinition{ + Key: key, + EventType: key, + Schema: event.SchemaDef{Custom: &event.SchemaSpec{Raw: json.RawMessage(`{"type":"object"}`)}}, + NormalizeParams: func(_ context.Context, _ event.APIClient, _ map[string]string) error { + return errors.New("simulated normalize failure") + }, + }) + defer event.UnregisterKeyForTest(key) + + err := Run(context.Background(), transport.New(), "app", "", "", Options{ + EventKey: key, + Runtime: &fakeRT{}, + Quiet: true, + }) + if err == nil { + t.Fatal("expected Run to fail when NormalizeParams errors") + } + if !strings.Contains(err.Error(), "normalize params for "+key+":") { + t.Errorf("error not wrapped with EventKey prefix: %v", err) + } + if !strings.Contains(err.Error(), "simulated normalize failure") { + t.Errorf("underlying error not propagated: %v", err) + } +} + +func TestDoHello_PassesSubscriptionIDToWire(t *testing.T) { + a, b := net.Pipe() + defer a.Close() + defer b.Close() + + // Server-side: read Hello, decode, assert SubscriptionID, send ack + done := make(chan string, 1) + go func() { + br := bufio.NewReader(b) + line, err := protocol.ReadFrame(br) + if err != nil { + done <- "READ_ERR:" + err.Error() + return + } + msg, err := protocol.Decode(bytes.TrimRight(line, "\n")) + if err != nil { + done <- "DECODE_ERR:" + err.Error() + return + } + if hello, ok := msg.(*protocol.Hello); ok { + done <- hello.SubscriptionID + // send ack so client can return + ack := protocol.NewHelloAck("v1", true) + _ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout) + } else { + done <- "WRONG_TYPE" + } + }() + + ack, _, err := doHello(a, "mail.x", []string{"mail.x"}, "mail.x:alice") + if err != nil { + t.Fatalf("doHello error: %v", err) + } + if ack == nil { + t.Fatal("got nil ack") + } + got := <-done + if got != "mail.x:alice" { + t.Errorf("Hello.SubscriptionID on wire = %q, want %q", got, "mail.x:alice") + } +} diff --git a/internal/event/consume/fingerprint.go b/internal/event/consume/fingerprint.go new file mode 100644 index 000000000..a452f1aad --- /dev/null +++ b/internal/event/consume/fingerprint.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/json" + "sort" + + "github.com/larksuite/cli/internal/event" +) + +// ComputeSubscriptionID returns a stable identifier scoped to (EventKey, values +// of the ParamDefs marked SubscriptionKey); the framework uses it to dedup +// PreConsume/cleanup gates and key Hub counts per-subscription. No SubscriptionKey +// params -> returns def.Key verbatim (legacy one-dimensional behavior). +// +// Stability contract: same EventKey + same normalized param values -> same ID +// across CLI versions; changing the encoding requires a wire-format bump. +func ComputeSubscriptionID(def *event.KeyDefinition, params map[string]string) string { + type kv struct { + Name string `json:"name"` + Value string `json:"value"` + } + var subParams []kv + for _, p := range def.Params { + if !p.SubscriptionKey { + continue + } + subParams = append(subParams, kv{Name: p.Name, Value: params[p.Name]}) + } + if len(subParams) == 0 { + return def.Key + } + sort.Slice(subParams, func(i, j int) bool { return subParams[i].Name < subParams[j].Name }) + raw, _ := json.Marshal(subParams) // err impossible: kv has no unmarshalable fields + sum := sha256.Sum256(raw) + return def.Key + ":" + base64.RawURLEncoding.EncodeToString(sum[:12]) +} diff --git a/internal/event/consume/fingerprint_test.go b/internal/event/consume/fingerprint_test.go new file mode 100644 index 000000000..1440ff5dd --- /dev/null +++ b/internal/event/consume/fingerprint_test.go @@ -0,0 +1,126 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package consume + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/event" +) + +func TestComputeSubscriptionID(t *testing.T) { + makeDef := func(subKeyNames ...string) *event.KeyDefinition { + def := &event.KeyDefinition{Key: "test.evt"} + marked := make(map[string]bool, len(subKeyNames)) + for _, n := range subKeyNames { + marked[n] = true + } + for _, n := range []string{"alpha", "beta", "gamma"} { + def.Params = append(def.Params, event.ParamDef{Name: n, SubscriptionKey: marked[n]}) + } + return def + } + + t.Run("no SubscriptionKey params returns EventKey verbatim", func(t *testing.T) { + def := makeDef() + got := ComputeSubscriptionID(def, map[string]string{"alpha": "x", "beta": "y"}) + if got != "test.evt" { + t.Errorf("got %q, want %q", got, "test.evt") + } + }) + + t.Run("single SubscriptionKey param: non-sub params do not leak into ID", func(t *testing.T) { + def := makeDef("alpha") + id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "ignored"}) + id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "value1", "beta": "different"}) + if id1 != id2 { + t.Errorf("non-SubscriptionKey param change leaked into ID: %q vs %q", id1, id2) + } + }) + + t.Run("different SubscriptionKey value produces different ID", func(t *testing.T) { + def := makeDef("alpha") + id1 := ComputeSubscriptionID(def, map[string]string{"alpha": "v1"}) + id2 := ComputeSubscriptionID(def, map[string]string{"alpha": "v2"}) + if id1 == id2 { + t.Errorf("different values produced same ID: %q", id1) + } + }) +} + +func TestComputeSubscriptionID_Stability(t *testing.T) { + // Param order in the ParamDef list must not affect the result (sorted by name internally). + def1 := &event.KeyDefinition{ + Key: "test.evt", + Params: []event.ParamDef{ + {Name: "b", SubscriptionKey: true}, + {Name: "a", SubscriptionKey: true}, + }, + } + def2 := &event.KeyDefinition{ + Key: "test.evt", + Params: []event.ParamDef{ + {Name: "a", SubscriptionKey: true}, + {Name: "b", SubscriptionKey: true}, + }, + } + id1 := ComputeSubscriptionID(def1, map[string]string{"a": "1", "b": "2"}) + id2 := ComputeSubscriptionID(def2, map[string]string{"a": "1", "b": "2"}) + if id1 != id2 { + t.Errorf("order-sensitive: id1=%q id2=%q", id1, id2) + } +} + +func TestComputeSubscriptionID_Format(t *testing.T) { + def := &event.KeyDefinition{ + Key: "mail.user_mailbox.event.message_received_v1", + Params: []event.ParamDef{{Name: "mailbox", SubscriptionKey: true}}, + } + id := ComputeSubscriptionID(def, map[string]string{"mailbox": "liuxinyang@example.com"}) + prefix := "mail.user_mailbox.event.message_received_v1:" + if !strings.HasPrefix(id, prefix) { + t.Fatalf("missing prefix: %q", id) + } + suffix := strings.TrimPrefix(id, prefix) + if len(suffix) != 16 { + t.Errorf("fingerprint length = %d, want 16", len(suffix)) + } + for _, c := range suffix { + isValid := (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-' || c == '_' + if !isValid { + t.Errorf("non-base64URL char in fingerprint: %q", suffix) + break + } + } +} + +func TestComputeSubscriptionID_UnicodeAndSpecialChars(t *testing.T) { + def := &event.KeyDefinition{ + Key: "test.evt", + Params: []event.ParamDef{{Name: "value", SubscriptionKey: true}}, + } + for _, val := range []string{"中文", "emoji🚀", "with spaces", "with:colons", "with\"quotes"} { + id := ComputeSubscriptionID(def, map[string]string{"value": val}) + if !strings.HasPrefix(id, "test.evt:") || len(id) != len("test.evt:")+16 { + t.Errorf("ID malformed for value=%q: %q (len=%d)", val, id, len(id)) + } + } +} + +func TestComputeSubscriptionID_EmptyValue(t *testing.T) { + def := &event.KeyDefinition{ + Key: "test.evt", + Params: []event.ParamDef{{Name: "x", SubscriptionKey: true}}, + } + id1 := ComputeSubscriptionID(def, map[string]string{"x": ""}) + id2 := ComputeSubscriptionID(def, map[string]string{}) // missing entirely + if id1 != id2 { + t.Errorf("empty value should be indistinguishable from missing: %q vs %q", id1, id2) + } + id3 := ComputeSubscriptionID(def, map[string]string{"x": "nonempty"}) + if id1 == id3 { + t.Errorf("empty and nonempty produced same ID: %q", id1) + } +} diff --git a/internal/event/consume/handshake.go b/internal/event/consume/handshake.go index 7aac408de..18aeac959 100644 --- a/internal/event/consume/handshake.go +++ b/internal/event/consume/handshake.go @@ -18,8 +18,8 @@ const helloAckTimeout = 5 * time.Second // symmetric with bus-side hello read de // doHello returns a bufio.Reader holding any bytes already pulled off conn so events // buffered with the ack in one TCP segment aren't dropped. -func doHello(conn net.Conn, eventKey string, eventTypes []string) (*protocol.HelloAck, *bufio.Reader, error) { - hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1") +func doHello(conn net.Conn, eventKey string, eventTypes []string, subscriptionID string) (*protocol.HelloAck, *bufio.Reader, error) { + hello := protocol.NewHello(os.Getpid(), eventKey, eventTypes, "v1", subscriptionID) if err := protocol.EncodeWithDeadline(conn, hello, protocol.WriteTimeout); err != nil { return nil, nil, err } diff --git a/internal/event/consume/handshake_test.go b/internal/event/consume/handshake_test.go index b434a9a97..918c849e6 100644 --- a/internal/event/consume/handshake_test.go +++ b/internal/event/consume/handshake_test.go @@ -27,7 +27,7 @@ func TestDoHello_ReadDeadline(t *testing.T) { start := time.Now() done := make(chan error, 1) go func() { - _, _, err := doHello(client, "im.msg", []string{"im.msg"}) + _, _, err := doHello(client, "im.msg", []string{"im.msg"}, "") done <- err }() diff --git a/internal/event/consume/loop.go b/internal/event/consume/loop.go index 6a3f844ca..849caddd8 100644 --- a/internal/event/consume/loop.go +++ b/internal/event/consume/loop.go @@ -22,7 +22,7 @@ import ( ) // consumeLoop reads events and dispatches to workers; cancels on terminal sink errors. -func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, lastForKey *bool, emitted *atomic.Int64) error { +func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *event.KeyDefinition, opts Options, subscriptionID string, lastForKey *bool, emitted *atomic.Int64) error { ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -185,7 +185,7 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e close(stopReader) <-readerDone conn.SetReadDeadline(time.Time{}) - *lastForKey = checkLastForKey(conn, opts.EventKey) + *lastForKey = checkLastForKey(conn, opts.EventKey, subscriptionID) conn.Close() case <-allDone: // bus-side close; can't query, assume last @@ -199,13 +199,19 @@ func consumeLoop(ctx context.Context, conn net.Conn, br *bufio.Reader, keyDef *e // processAndOutput returns (wrote, err); err non-nil only for sink.Write failures. func processAndOutput(ctx context.Context, keyDef *event.KeyDefinition, evt *protocol.Event, opts Options, sink Sink, jqCode *gojq.Code) (bool, error) { + raw := &event.RawEvent{ + EventType: evt.EventType, + Payload: evt.Payload, + } + + // Synchronous Match filter runs before any work (Process / sink write). + if keyDef.Match != nil && !keyDef.Match(raw, opts.Params) { + return false, nil + } + var result json.RawMessage if keyDef.Process != nil { - raw := &event.RawEvent{ - EventType: evt.EventType, - Payload: evt.Payload, - } var err error result, err = keyDef.Process(ctx, opts.Runtime, raw, opts.Params) if err != nil { diff --git a/internal/event/consume/loop_test.go b/internal/event/consume/loop_test.go index 9b098da7f..ace554fd7 100644 --- a/internal/event/consume/loop_test.go +++ b/internal/event/consume/loop_test.go @@ -89,7 +89,7 @@ func TestConsumeLoop_DeliversEventsAndExitsOnMaxEvents(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted) + err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted) if err != nil { t.Fatalf("consumeLoop: %v", err) } @@ -132,7 +132,7 @@ func TestConsumeLoop_SeqGapEmitsWarning(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil { + if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil { t.Fatalf("consumeLoop: %v", err) } if got := emitted.Load(); got != 2 { @@ -169,7 +169,7 @@ func TestConsumeLoop_JQFilterAppliedPerEvent(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() - if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted); err != nil { + if err := consumeLoop(ctx, client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted); err != nil { t.Fatalf("consumeLoop: %v", err) } if got := emitted.Load(); got != 1 { @@ -196,12 +196,96 @@ func TestConsumeLoop_CompileJQFailsEarly(t *testing.T) { var lastForKey bool var emitted atomic.Int64 - err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, &lastForKey, &emitted) + err := consumeLoop(context.Background(), client, bufio.NewReader(client), echoKeyDef("test.key"), opts, "", &lastForKey, &emitted) if err == nil { t.Fatal("consumeLoop should fail immediately on bad jq expression") } } +// captureSink is a minimal Sink for unit-testing processAndOutput directly. +type captureSink struct { + written []json.RawMessage +} + +func (s *captureSink) Write(data json.RawMessage) error { + s.written = append(s.written, data) + return nil +} + +func TestProcessAndOutput_Match_DropsEvent(t *testing.T) { + calledProcess := false + keyDef := &event.KeyDefinition{ + Key: "test.evt", + Match: func(raw *event.RawEvent, params map[string]string) bool { + return false + }, + Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) { + calledProcess = true + return json.RawMessage(`{}`), nil + }, + } + sink := &captureSink{} + wrote, err := processAndOutput(context.Background(), keyDef, + &protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)}, + Options{}, sink, nil) + if err != nil { + t.Fatal(err) + } + if wrote { + t.Error("Match returned false but event was written") + } + if calledProcess { + t.Error("Process was called even though Match returned false") + } + if len(sink.written) != 0 { + t.Errorf("sink received %d events, want 0", len(sink.written)) + } +} + +func TestProcessAndOutput_Match_NilAcceptsAll(t *testing.T) { + keyDef := &event.KeyDefinition{Key: "test.evt"} // no Match, no Process + sink := &captureSink{} + wrote, err := processAndOutput(context.Background(), keyDef, + &protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{"x":1}`)}, + Options{}, sink, nil) + if err != nil || !wrote { + t.Errorf("expected wrote=true err=nil; got wrote=%v err=%v", wrote, err) + } + if len(sink.written) != 1 { + t.Errorf("sink received %d events, want 1", len(sink.written)) + } +} + +func TestProcessAndOutput_Match_RunsBeforeProcess(t *testing.T) { + // Record the actual call sequence — a bare call-count check would still + // pass if Process ran before Match. + var order []string + keyDef := &event.KeyDefinition{ + Key: "test.evt", + Match: func(raw *event.RawEvent, params map[string]string) bool { + order = append(order, "match") + return true + }, + Process: func(ctx context.Context, rt event.APIClient, raw *event.RawEvent, params map[string]string) (json.RawMessage, error) { + order = append(order, "process") + return raw.Payload, nil + }, + } + sink := &captureSink{} + wrote, err := processAndOutput(context.Background(), keyDef, + &protocol.Event{Type: protocol.MsgTypeEvent, EventType: "test.evt", Payload: json.RawMessage(`{}`)}, + Options{}, sink, nil) + if err != nil { + t.Fatal(err) + } + if !wrote { + t.Error("expected wrote=true") + } + if len(order) != 2 || order[0] != "match" || order[1] != "process" { + t.Errorf("call order = %v, want [match process]", order) + } +} + func TestIsTerminalSinkError(t *testing.T) { for _, tc := range []struct { name string diff --git a/internal/event/consume/shutdown.go b/internal/event/consume/shutdown.go index e41209e2c..3c07f9869 100644 --- a/internal/event/consume/shutdown.go +++ b/internal/event/consume/shutdown.go @@ -16,8 +16,8 @@ const preShutdownAckTimeout = 2 * time.Second // checkLastForKey atomically reserves a cleanup lock; on any error defaults to true // (cleanup-on-error is safer than leaking server state). Discards non-ack frames in flight. -func checkLastForKey(conn net.Conn, eventKey string) bool { - msg := protocol.NewPreShutdownCheck(eventKey) +func checkLastForKey(conn net.Conn, eventKey string, subscriptionID string) bool { + msg := protocol.NewPreShutdownCheck(eventKey, subscriptionID) if err := protocol.EncodeWithDeadline(conn, msg, protocol.WriteTimeout); err != nil { return true } diff --git a/internal/event/consume/shutdown_test.go b/internal/event/consume/shutdown_test.go index c77754a49..76e28ad66 100644 --- a/internal/event/consume/shutdown_test.go +++ b/internal/event/consume/shutdown_test.go @@ -4,6 +4,8 @@ package consume import ( + "bufio" + "bytes" "encoding/json" "io" "net" @@ -38,7 +40,7 @@ func TestCheckLastForKey_IgnoresNonAckFrames(t *testing.T) { } }() - got := checkLastForKey(client, "im.msg") + got := checkLastForKey(client, "im.msg", "") if got != false { t.Errorf("checkLastForKey = %v, want false", got) } @@ -62,7 +64,7 @@ func TestCheckLastForKey_ReturnsAckValue(t *testing.T) { _ = protocol.Encode(server, ack) }() - got := checkLastForKey(client, "im.msg") + got := checkLastForKey(client, "im.msg", "") if got != true { t.Errorf("checkLastForKey = %v, want true", got) } @@ -83,7 +85,7 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) { }() start := time.Now() - got := checkLastForKey(client, "im.msg") + got := checkLastForKey(client, "im.msg", "") elapsed := time.Since(start) if got != true { @@ -93,3 +95,39 @@ func TestCheckLastForKey_DefaultsToTrueOnTimeout(t *testing.T) { t.Errorf("elapsed = %v, expected ~%v (timeout-bounded)", elapsed, preShutdownAckTimeout) } } + +func TestCheckLastForKey_SendsSubscriptionID(t *testing.T) { + a, b := net.Pipe() + defer a.Close() + defer b.Close() + + done := make(chan string, 1) + go func() { + br := bufio.NewReader(b) + line, err := protocol.ReadFrame(br) + if err != nil { + done <- "READ_ERR" + return + } + msg, err := protocol.Decode(bytes.TrimRight(line, "\n")) + if err != nil { + done <- "DECODE_ERR" + return + } + check, ok := msg.(*protocol.PreShutdownCheck) + if !ok { + done <- "WRONG_TYPE" + return + } + done <- check.SubscriptionID + // Reply with ack so client returns + ack := protocol.NewPreShutdownAck(true) + _ = protocol.EncodeWithDeadline(b, ack, protocol.WriteTimeout) + }() + + _ = checkLastForKey(a, "mail.x", "mail.x:alice") + got := <-done + if got != "mail.x:alice" { + t.Errorf("PreShutdownCheck.SubscriptionID on wire = %q, want %q", got, "mail.x:alice") + } +} diff --git a/internal/event/protocol/codec_test.go b/internal/event/protocol/codec_test.go index 91dbe01f6..979c487a9 100644 --- a/internal/event/protocol/codec_test.go +++ b/internal/event/protocol/codec_test.go @@ -77,3 +77,88 @@ func TestDecodeUnknownType(t *testing.T) { t.Error("expected error for unknown type") } } + +func TestEncodeDecodeHello_WithSubscriptionID(t *testing.T) { + msg := &Hello{ + Type: MsgTypeHello, + PID: 12345, + EventKey: "mail.user_mailbox.event.message_received_v1", + EventTypes: []string{"mail.user_mailbox.event.message_received_v1"}, + Version: "v1", + SubscriptionID: "mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs", + } + buf := &bytes.Buffer{} + if err := Encode(buf, msg); err != nil { + t.Fatal(err) + } + line := buf.Bytes() + if !bytes.Contains(line, []byte(`"subscription_id":"mail.user_mailbox.event.message_received_v1:a7Bx9Kp2Lm3Qv4Rs"`)) { + t.Errorf("subscription_id not serialized: %s", string(line)) + } + decoded, err := Decode(bytes.TrimRight(line, "\n")) + if err != nil { + t.Fatal(err) + } + hello, ok := decoded.(*Hello) + if !ok { + t.Fatalf("expected *Hello, got %T", decoded) + } + if hello.SubscriptionID != msg.SubscriptionID { + t.Errorf("roundtrip subscription_id: got %q want %q", hello.SubscriptionID, msg.SubscriptionID) + } +} + +func TestEncodeDecodeHello_EmptySubscriptionIDOmitted(t *testing.T) { + msg := &Hello{ + Type: MsgTypeHello, + PID: 1, + EventKey: "k", + EventTypes: []string{"k"}, + Version: "v1", + } + buf := &bytes.Buffer{} + if err := Encode(buf, msg); err != nil { + t.Fatal(err) + } + if bytes.Contains(buf.Bytes(), []byte("subscription_id")) { + t.Errorf("empty subscription_id should be omitted: %s", buf.String()) + } + decoded, _ := Decode(bytes.TrimRight(buf.Bytes(), "\n")) + hello := decoded.(*Hello) + if hello.SubscriptionID != "" { + t.Errorf("got %q, want empty", hello.SubscriptionID) + } +} + +func TestEncodeDecodePreShutdownCheck_WithSubscriptionID(t *testing.T) { + msg := &PreShutdownCheck{ + Type: MsgTypePreShutdownCheck, + EventKey: "mail.x", + SubscriptionID: "mail.x:abc", + } + buf := &bytes.Buffer{} + if err := Encode(buf, msg); err != nil { + t.Fatal(err) + } + decoded, err := Decode(bytes.TrimRight(buf.Bytes(), "\n")) + if err != nil { + t.Fatal(err) + } + got := decoded.(*PreShutdownCheck) + if got.SubscriptionID != msg.SubscriptionID { + t.Errorf("roundtrip: got %q want %q", got.SubscriptionID, msg.SubscriptionID) + } +} + +func TestStatusResponse_ConsumerInfo_SubscriptionID(t *testing.T) { + msg := NewStatusResponse(7, 120, 1, []ConsumerInfo{ + {PID: 99, EventKey: "mail.x", SubscriptionID: "mail.x:abc", Received: 5, Dropped: 0}, + }) + buf := &bytes.Buffer{} + if err := Encode(buf, msg); err != nil { + t.Fatal(err) + } + if !bytes.Contains(buf.Bytes(), []byte(`"subscription_id":"mail.x:abc"`)) { + t.Errorf("ConsumerInfo.SubscriptionID missing from JSON: %s", buf.String()) + } +} diff --git a/internal/event/protocol/messages.go b/internal/event/protocol/messages.go index 0effa36c0..9274c6476 100644 --- a/internal/event/protocol/messages.go +++ b/internal/event/protocol/messages.go @@ -34,11 +34,12 @@ type SourceStatus struct { } type Hello struct { - Type string `json:"type"` - PID int `json:"pid"` - EventKey string `json:"event_key"` - EventTypes []string `json:"event_types"` - Version string `json:"version"` + Type string `json:"type"` + PID int `json:"pid"` + EventKey string `json:"event_key"` + EventTypes []string `json:"event_types"` + Version string `json:"version"` + SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey on bus side } type HelloAck struct { @@ -61,10 +62,11 @@ type Bye struct { Type string `json:"type"` } -// PreShutdownCheck atomically reserves the cleanup lock for EventKey. +// PreShutdownCheck atomically reserves the cleanup lock for (EventKey, SubscriptionID). type PreShutdownCheck struct { - Type string `json:"type"` - EventKey string `json:"event_key"` + Type string `json:"type"` + EventKey string `json:"event_key"` + SubscriptionID string `json:"subscription_id,omitempty"` // empty = fallback to EventKey } type PreShutdownAck struct { @@ -77,10 +79,11 @@ type StatusQuery struct { } type ConsumerInfo struct { - PID int `json:"pid"` - EventKey string `json:"event_key"` - Received int64 `json:"received"` - Dropped int64 `json:"dropped"` + PID int `json:"pid"` + EventKey string `json:"event_key"` + SubscriptionID string `json:"subscription_id,omitempty"` + Received int64 `json:"received"` + Dropped int64 `json:"dropped"` } type StatusResponse struct { @@ -95,13 +98,14 @@ type Shutdown struct { Type string `json:"type"` } -func NewHello(pid int, eventKey string, eventTypes []string, version string) *Hello { +func NewHello(pid int, eventKey string, eventTypes []string, version string, subscriptionID string) *Hello { return &Hello{ - Type: MsgTypeHello, - PID: pid, - EventKey: eventKey, - EventTypes: eventTypes, - Version: version, + Type: MsgTypeHello, + PID: pid, + EventKey: eventKey, + EventTypes: eventTypes, + Version: version, + SubscriptionID: subscriptionID, } } @@ -124,8 +128,8 @@ func NewEvent(eventType, eventID, sourceTime string, seq uint64, payload json.Ra } } -func NewPreShutdownCheck(eventKey string) *PreShutdownCheck { - return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey} +func NewPreShutdownCheck(eventKey, subscriptionID string) *PreShutdownCheck { + return &PreShutdownCheck{Type: MsgTypePreShutdownCheck, EventKey: eventKey, SubscriptionID: subscriptionID} } func NewPreShutdownAck(lastForKey bool) *PreShutdownAck { diff --git a/internal/event/protocol/messages_test.go b/internal/event/protocol/messages_test.go index c6ca2bd95..2893789fa 100644 --- a/internal/event/protocol/messages_test.go +++ b/internal/event/protocol/messages_test.go @@ -17,7 +17,7 @@ import ( // Every NewXxx helper must set the Type discriminator (Decode rejects messages without it). func TestConstructors_PinTypeField(t *testing.T) { - if got := NewHello(1, "k", []string{"t"}, "v1"); got.Type != MsgTypeHello { + if got := NewHello(1, "k", []string{"t"}, "v1", ""); got.Type != MsgTypeHello { t.Errorf("NewHello.Type = %q, want %q", got.Type, MsgTypeHello) } if got := NewHelloAck("v1", true); got.Type != MsgTypeHelloAck || !got.FirstForKey { @@ -26,7 +26,7 @@ func TestConstructors_PinTypeField(t *testing.T) { if got := NewEvent("im.msg", "e1", "", 7, json.RawMessage(`{}`)); got.Type != MsgTypeEvent || got.Seq != 7 { t.Errorf("NewEvent mismatch: %+v", got) } - if got := NewPreShutdownCheck("k"); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" { + if got := NewPreShutdownCheck("k", ""); got.Type != MsgTypePreShutdownCheck || got.EventKey != "k" { t.Errorf("NewPreShutdownCheck mismatch: %+v", got) } if got := NewPreShutdownAck(true); got.Type != MsgTypePreShutdownAck || !got.LastForKey { @@ -63,7 +63,7 @@ func TestEncode_DecodeRoundtripAllTypes(t *testing.T) { } } roundtrip(t, NewHelloAck("v1", true), &HelloAck{}) - roundtrip(t, NewPreShutdownCheck("im.msg"), &PreShutdownCheck{}) + roundtrip(t, NewPreShutdownCheck("im.msg", ""), &PreShutdownCheck{}) roundtrip(t, NewPreShutdownAck(false), &PreShutdownAck{}) roundtrip(t, NewStatusQuery(), &StatusQuery{}) roundtrip(t, NewStatusResponse(7, 120, 1, []ConsumerInfo{{PID: 99, EventKey: "k"}}), &StatusResponse{}) diff --git a/internal/event/types.go b/internal/event/types.go index 4dde301ae..1121c11dc 100644 --- a/internal/event/types.go +++ b/internal/event/types.go @@ -55,6 +55,23 @@ type ParamDef struct { Default string `json:"default,omitempty"` Description string `json:"description"` Values []ParamValue `json:"values,omitempty"` + + // SubscriptionKey marks this param as part of the subscription identity. + // Two consumers of the same EventKey but different values for any + // SubscriptionKey-marked param are treated as DISTINCT subscriptions: + // PreConsume runs once per (EventKey, SubscriptionID), cleanup runs once per + // (EventKey, SubscriptionID). + // + // CONTRACT: only mark a param SubscriptionKey if the EventKey's server-side + // subscribe/unsubscribe API is itself scoped to that resource. Lark keys the + // subscription record by (app, user, event_type) and overwrites it rather + // than reference-counting, so for a non-per-resource API the cleanup of one + // resource's last consumer unsubscribes the shared record and silently cuts + // off every other resource sharing that event_type. + // + // Default false = the param is a filter / formatting / metadata param + // and does not affect subscription identity. + SubscriptionKey bool `json:"subscription_key,omitempty"` } type ProcessFunc = func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) @@ -83,10 +100,44 @@ type KeyDefinition struct { Schema SchemaDef `json:"schema"` + // NormalizeParams canonicalizes param values BEFORE fingerprint compute, + // PreConsume, Match, and Process. Mutates the params map in place. + // May call OAPI; runs once per consumer at startup. + // + // Use cases: resolve aliases ("me" -> real email, a name -> an ID), + // trim whitespace. On error, consume fails (no retry); caller gets the + // wrapped error. + // + // Default nil = no normalization, params pass through unchanged. + NormalizeParams func(ctx context.Context, rt APIClient, params map[string]string) error `json:"-"` + // Process required when Schema.Custom is Processed output; must be nil when Native is used. + // + // Convention: returning (nil, nil) signals "drop this event" — the + // consumer loop will skip writing it to sink and not advance the + // emitted counter. Useful for async filtering (e.g. fetch metadata, + // drop if folder doesn't match). For sync filters that don't need + // OAPI, use Match instead. Process func(ctx context.Context, rt APIClient, raw *RawEvent, params map[string]string) (json.RawMessage, error) `json:"-"` - PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func(), err error) `json:"-"` + // Match is a synchronous payload filter run on every received event + // BEFORE Process. Return false to drop the event without further work. + // + // Signature deliberately omits ctx/rt to physically enforce "no OAPI + // calls in Match". For filters that need a metadata fetch first, use + // Process and return nil to drop. + // + // Default nil = accept all events. + Match func(raw *RawEvent, params map[string]string) bool `json:"-"` + + // PreConsume runs once per (EventKey, SubscriptionID) when this consumer + // is first for that scope. Returns a cleanup function that the framework + // invokes when this consumer is the last for its scope. + // + // The cleanup's error return is honored: on nil the framework prints + // "[event] cleanup done."; on non-nil it prints a WARN with an + // idempotency note. + PreConsume func(ctx context.Context, rt APIClient, params map[string]string) (cleanup func() error, err error) `json:"-"` Scopes []string `json:"scopes,omitempty"` diff --git a/internal/meta/affordance.go b/internal/meta/affordance.go new file mode 100644 index 000000000..ef0de6614 --- /dev/null +++ b/internal/meta/affordance.go @@ -0,0 +1,44 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import "encoding/json" + +// Affordance is the hand-authored usage guidance overlaid on a method: when to +// use it, when not to, prerequisites, few-shot examples, and related methods. +// It is the single typed model of the affordance shape; the envelope renderer +// and the command help both parse through ParsedAffordance so the vocabulary +// is defined once. The JSON tags double as the envelope's wire shape. +type Affordance struct { + UseWhen []string `json:"use_when,omitempty"` + DoNotUseWhen []string `json:"do_not_use_when,omitempty"` + Prerequisites []string `json:"prerequisites,omitempty"` + Examples []AffordanceCase `json:"examples,omitempty"` + Related []string `json:"related,omitempty"` +} + +// AffordanceCase is one few-shot example: a one-line description and a +// ready-to-run command. +type AffordanceCase struct { + Description string `json:"description"` + Command string `json:"command"` +} + +// ParsedAffordance decodes the method's raw affordance overlay into the typed +// Affordance. ok is false when the method carries no affordance, the JSON is +// malformed, or every section is empty — so callers can treat "no guidance" +// uniformly. +func (m Method) ParsedAffordance() (Affordance, bool) { + if len(m.Affordance) == 0 { + return Affordance{}, false + } + var a Affordance + if json.Unmarshal(m.Affordance, &a) != nil { + return Affordance{}, false + } + if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 { + return Affordance{}, false + } + return a, true +} diff --git a/internal/meta/affordance_test.go b/internal/meta/affordance_test.go new file mode 100644 index 000000000..4dd7665a3 --- /dev/null +++ b/internal/meta/affordance_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import ( + "encoding/json" + "testing" +) + +func TestMethod_ParsedAffordance(t *testing.T) { + // absent / empty / malformed all resolve to ok=false. + t.Run("nil affordance", func(t *testing.T) { + if _, ok := (Method{}).ParsedAffordance(); ok { + t.Error("ParsedAffordance on a method without affordance ok=true, want false") + } + }) + + notOK := map[string]string{ + "empty payload": ``, + "empty object": `{}`, + "all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`, + "malformed string": `"not an object"`, + "malformed number": `42`, + "nested type mismatch": `{"examples":"should be a list"}`, + } + for name, raw := range notOK { + t.Run(name, func(t *testing.T) { + if _, ok := (Method{Affordance: json.RawMessage(raw)}).ParsedAffordance(); ok { + t.Errorf("ParsedAffordance(%s) ok=true, want false", raw) + } + }) + } + + // Populated affordance parses with all fields. + raw := `{ + "use_when": ["需要拿到当前用户的主日历 ID"], + "do_not_use_when": ["已知具体 calendar_id"], + "prerequisites": ["user 身份登录"], + "examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}], + "related": ["calendars.list"] + }` + a, ok := (Method{Affordance: json.RawMessage(raw)}).ParsedAffordance() + if !ok { + t.Fatal("ParsedAffordance ok=false, want populated") + } + if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" { + t.Errorf("UseWhen = %v", a.UseWhen) + } + if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || a.Examples[0].Command != "lark-cli calendar calendars primary" { + t.Errorf("Examples = %+v", a.Examples) + } + if len(a.Related) != 1 || a.Related[0] != "calendars.list" { + t.Errorf("Related = %v", a.Related) + } +} diff --git a/internal/meta/identity.go b/internal/meta/identity.go new file mode 100644 index 000000000..392e0c4ed --- /dev/null +++ b/internal/meta/identity.go @@ -0,0 +1,94 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import "sort" + +// Token is the metadata accessTokens vocabulary: which token kind a method +// accepts. It is a distinct type so the two directions of the token<->identity +// mapping below cannot be swapped silently — a bare string compiles on either +// side of a string/string signature, a Token does not. The CLI identity +// vocabulary ("bot"/"user") already has a home in internal/core (core.Identity); +// meta is a leaf and must not import core, so the identity side stays a plain +// string here and is typed at the core boundary. +type Token string + +const ( + TokenTenant Token = "tenant" // bot calls use tenant_access_token + TokenUser Token = "user" +) + +// IdentityForToken maps a metadata access token to the CLI identity (--as +// value) that uses it: tenant -> "bot", user -> "user". ok is false for +// unrecognized tokens. This is the single source of truth for the +// token<->identity vocabulary; schema, registry and command code all go +// through it instead of re-spelling the mapping. +func IdentityForToken(token Token) (string, bool) { + switch token { + case TokenTenant: + return "bot", true + case TokenUser: + return "user", true + } + return "", false +} + +// TokenForIdentity is the inverse of IdentityForToken: "bot" -> TokenTenant; +// everything else (notably "user") maps to itself. +func TokenForIdentity(identity string) Token { + if identity == "bot" { + return TokenTenant + } + return Token(identity) +} + +// RestrictsIdentity reports whether the method limits which identities may call +// it: true exactly when it declares one or more accessTokens. nil OR an empty +// slice means unrestricted (any identity). This is the single rule that both +// the strict-mode predicate (SupportsToken) and command identity gates use, so +// nil and [] never diverge across schema/scope and execution. +func (m Method) RestrictsIdentity() bool { + return len(m.AccessTokens) > 0 +} + +// SupportsToken reports whether this method is reachable with the given access +// token (see TokenForIdentity). An unrestricted method (RestrictsIdentity == +// false, i.e. nil or empty accessTokens) is reachable by any token. This is +// the single source of truth for the predicate; registry scope policy and +// command identity checks build on it. +func (m Method) SupportsToken(token Token) bool { + if !m.RestrictsIdentity() { + return true + } + for _, t := range m.AccessTokens { + if t == token { + return true + } + } + return false +} + +// Identities returns the CLI identities (--as values) that can call this +// method, derived from its metadata accessTokens: tenant -> "bot", user +// stays "user"; unrecognized tokens are dropped; the result is deduped and +// name-sorted. The slice is always non-nil so callers rendering it (e.g. the +// envelope's access_tokens) emit [] rather than null. +// +// An empty result does NOT imply unrestricted — use RestrictsIdentity() for +// that. Identities() lists only CLI-known identities, so a method restricted +// solely to unrecognized tokens returns empty yet RestrictsIdentity() is true. +func (m Method) Identities() []string { + seen := make(map[string]bool, len(m.AccessTokens)) + for _, t := range m.AccessTokens { + if id, ok := IdentityForToken(t); ok { + seen[id] = true + } + } + out := make([]string, 0, len(seen)) + for id := range seen { + out = append(out, id) + } + sort.Strings(out) + return out +} diff --git a/internal/meta/identity_test.go b/internal/meta/identity_test.go new file mode 100644 index 000000000..e38fac273 --- /dev/null +++ b/internal/meta/identity_test.go @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import ( + "reflect" + "testing" +) + +func TestIdentityTokenBijection(t *testing.T) { + if got := TokenForIdentity("bot"); got != "tenant" { + t.Errorf("TokenForIdentity(bot) = %q, want tenant", got) + } + if got := TokenForIdentity("user"); got != "user" { + t.Errorf("TokenForIdentity(user) = %q, want user", got) + } + if id, ok := IdentityForToken("tenant"); id != "bot" || !ok { + t.Errorf("IdentityForToken(tenant) = %q,%v want bot,true", id, ok) + } + if id, ok := IdentityForToken("user"); id != "user" || !ok { + t.Errorf("IdentityForToken(user) = %q,%v want user,true", id, ok) + } + if _, ok := IdentityForToken("weird"); ok { + t.Error("IdentityForToken(weird) ok=true, want false") + } +} + +func TestMethod_RestrictsIdentity(t *testing.T) { + // nil and empty both mean "unrestricted"; only a populated list restricts. + if (Method{}).RestrictsIdentity() { + t.Error("nil accessTokens must be unrestricted") + } + if (Method{AccessTokens: []Token{}}).RestrictsIdentity() { + t.Error("empty accessTokens must be unrestricted (same as nil)") + } + if !(Method{AccessTokens: []Token{"tenant"}}).RestrictsIdentity() { + t.Error("populated accessTokens must restrict identity") + } +} + +func TestMethod_SupportsToken(t *testing.T) { + // unrestricted (nil OR empty) -> permissive for any token; the two must not + // diverge, else strict/scope and the command gate disagree. + for _, m := range []Method{{}, {AccessTokens: []Token{}}} { + if !m.SupportsToken("tenant") || !m.SupportsToken("user") { + t.Errorf("unrestricted method %#v should support any token", m.AccessTokens) + } + } + // restricted: only the declared tokens are reachable + m := Method{AccessTokens: []Token{"tenant"}} + if !m.SupportsToken("tenant") { + t.Error("tenant-declared method should support tenant") + } + if m.SupportsToken("user") { + t.Error("tenant-only method must NOT support user") + } +} + +func TestMethod_Identities(t *testing.T) { + // tenant->bot, user stays; deduped + name-sorted (so order-independent); + // unrecognized dropped; absent tokens -> empty but NON-nil so the envelope + // renders [] not null. + tests := []struct { + name string + tokens []Token + want []string + }{ + {"tenant only", []Token{"tenant"}, []string{"bot"}}, + {"user only", []Token{"user"}, []string{"user"}}, + {"tenant then user", []Token{"tenant", "user"}, []string{"bot", "user"}}, + {"user then tenant", []Token{"user", "tenant"}, []string{"bot", "user"}}, + {"deduped", []Token{"tenant", "tenant", "user"}, []string{"bot", "user"}}, + {"empty", []Token{}, []string{}}, + {"nil", nil, []string{}}, + {"unknown skipped", []Token{"user", "admin"}, []string{"user"}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := (Method{AccessTokens: tt.tokens}).Identities(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("Identities(%v) = %#v, want %#v", tt.tokens, got, tt.want) + } + }) + } +} diff --git a/internal/meta/meta.go b/internal/meta/meta.go new file mode 100644 index 000000000..96f8a4b73 --- /dev/null +++ b/internal/meta/meta.go @@ -0,0 +1,215 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package meta is the typed model of the API metadata registry and the single +// place that parses it. The metadata is a fixed, regular vocabulary, so a plain +// typed json.Unmarshal replaces hand-rolled map[string]interface{} walking. Map +// key order is not preserved (Go maps are unordered); callers that need a +// deterministic sequence get fields/methods/resources sorted by name via the +// list accessors below. +package meta + +import ( + "encoding/json" + "sort" + "strings" +) + +// Option is one enum option of a field. Value is `any` (not string) so a +// metadata value that arrives as a JSON number — rather than the usual quoted +// string — coerces like Field.Enum / EnumOption.Value instead of failing the +// whole registry unmarshal and blanking the entire catalog. coerceLiteral +// normalizes it to the field's declared type. +type Option struct { + Value any `json:"value"` + Description string `json:"description"` +} + +// Field is one parameter or body/response field. Name is the parent map key, +// populated by the list accessors (not a JSON field). ref/annotations/enumName +// exist in the metadata but are intentionally not modeled (unused downstream). +type Field struct { + Name string `json:"-"` + Type string `json:"type"` + Location string `json:"location"` // "path" | "query"; empty for body/response + Required bool `json:"required"` + Description string `json:"description"` + Default any `json:"default"` + Example any `json:"example"` + Min string `json:"min"` + Max string `json:"max"` + Enum []any `json:"enum"` + Options []Option `json:"options"` + Properties map[string]Field `json:"properties"` +} + +// FlagName is the kebab-case CLI flag for this field (chat_id -> chat-id). +func (f Field) FlagName() string { return strings.ReplaceAll(f.Name, "_", "-") } + +// Children returns the field's nested properties sorted by name. +func (f Field) Children() []Field { return fieldsOf(f.Properties, nil) } + +// Method is one API operation. Name is the parent map key. Affordance is kept +// raw so this package stays free of envelope concerns. +type Method struct { + Name string `json:"-"` + ID string `json:"id"` + Path string `json:"path"` + HTTPMethod string `json:"httpMethod"` + Description string `json:"description"` + Risk string `json:"risk"` + DocURL string `json:"docUrl"` + Danger bool `json:"danger"` + Tips []string `json:"tips"` + Scopes []string `json:"scopes"` + RequiredScopes []string `json:"requiredScopes"` + AccessTokens []Token `json:"accessTokens"` + Affordance json.RawMessage `json:"affordance"` + Parameters map[string]Field `json:"parameters"` + RequestBody map[string]Field `json:"requestBody"` + ResponseBody map[string]Field `json:"responseBody"` +} + +// Params are the path/query parameters, sorted by name. +func (m Method) Params() []Field { + return fieldsOf(m.Parameters, func(f Field) bool { + return f.Location == "path" || f.Location == "query" + }) +} + +// Data are the non-file request-body fields (--data JSON), sorted by name. +func (m Method) Data() []Field { + return fieldsOf(m.RequestBody, func(f Field) bool { return f.Type != "file" }) +} + +// Files are the file-typed request-body fields (--file uploads), sorted by name. +func (m Method) Files() []Field { + return fieldsOf(m.RequestBody, func(f Field) bool { return f.Type == "file" }) +} + +// Response are the response-body fields, sorted by name. +func (m Method) Response() []Field { return fieldsOf(m.ResponseBody, nil) } + +// fieldsOf materializes a name->field map into a name-injected slice, optionally +// filtered, sorted by name for deterministic output. +func fieldsOf(byName map[string]Field, keep func(Field) bool) []Field { + out := make([]Field, 0, len(byName)) + for name, f := range byName { + f.Name = name + if keep == nil || keep(f) { + out = append(out, f) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Resource groups methods (and may nest sub-resources). Name is the parent key. +type Resource struct { + Name string `json:"-"` + Methods map[string]Method `json:"methods"` + Resources map[string]Resource `json:"resources"` +} + +// MethodList returns the resource's methods, name-injected and sorted by name. +func (r Resource) MethodList() []Method { + out := make([]Method, 0, len(r.Methods)) + for name, m := range r.Methods { + m.Name = name + out = append(out, m) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Method looks up one method by name with Name injected, or false if absent. +// Use this instead of indexing Methods directly so Name is never left empty. +func (r Resource) Method(name string) (Method, bool) { + m, ok := r.Methods[name] + if !ok { + return Method{}, false + } + m.Name = name + return m, true +} + +// SubResources returns nested resources, name-injected and sorted by name. +func (r Resource) SubResources() []Resource { return resourcesOf(r.Resources) } + +// Service is one API service. Name is a real JSON field (services is an array). +type Service struct { + Name string `json:"name"` + Version string `json:"version"` + Title string `json:"title"` + Description string `json:"description"` + ServicePath string `json:"servicePath"` + Resources map[string]Resource `json:"resources"` +} + +// ResourceList returns the service's top-level resources, name-injected and +// sorted by name. +func (s Service) ResourceList() []Resource { return resourcesOf(s.Resources) } + +// Resource looks up one (possibly dotted) resource by name with Name injected, +// or false if absent. Use this instead of indexing Resources directly. +func (s Service) Resource(name string) (Resource, bool) { + r, ok := s.Resources[name] + if !ok { + return Resource{}, false + } + r.Name = name + return r, true +} + +func resourcesOf(byName map[string]Resource) []Resource { + out := make([]Resource, 0, len(byName)) + for name, r := range byName { + r.Name = name + out = append(out, r) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// Registry is the top-level metadata document. +type Registry struct { + Services []Service `json:"services"` + Version string `json:"version"` +} + +// Parse decodes the metadata JSON into the typed Registry. Returns a zero +// Registry for empty input. +func Parse(data []byte) (Registry, error) { + if len(data) == 0 { + return Registry{}, nil + } + var reg Registry + if err := json.Unmarshal(data, ®); err != nil { + return Registry{}, err + } + return reg, nil +} + +// FromMap decodes a single method spec from its map form into a typed Method. +// Convenience constructor for building typed values from map literals (tests). +func FromMap(method map[string]interface{}) Method { + b, err := json.Marshal(method) + if err != nil { + return Method{} + } + var m Method + _ = json.Unmarshal(b, &m) + return m +} + +// ServiceFromMap decodes a service spec from its map form into a typed Service. +// Convenience constructor for building typed values from map literals (tests). +func ServiceFromMap(svc map[string]interface{}) Service { + b, err := json.Marshal(svc) + if err != nil { + return Service{} + } + var s Service + _ = json.Unmarshal(b, &s) + return s +} diff --git a/internal/meta/meta_test.go b/internal/meta/meta_test.go new file mode 100644 index 000000000..16ab5aeaa --- /dev/null +++ b/internal/meta/meta_test.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import ( + "reflect" + "testing" +) + +const sampleJSON = `{ + "version": "1.0.0", + "services": [ + { + "name": "im", + "servicePath": "/open-apis/im/v1", + "resources": { + "chat.members": { + "methods": { + "create": { + "httpMethod": "POST", + "risk": "high-risk-write", + "parameters": { + "member_id_type": {"type": "string", "location": "query", "options": [{"value": "open_id"}, {"value": "user_id"}]}, + "chat_id": {"type": "string", "location": "path", "required": true, "example": "oc_x"}, + "x_header": {"type": "string", "location": "header"} + }, + "requestBody": { + "id_list": {"type": "list", "required": true}, + "avatar": {"type": "file"} + } + } + } + } + } + } + ] +}` + +func loadSample(t *testing.T) (Resource, Method) { + t.Helper() + reg, err := Parse([]byte(sampleJSON)) + if err != nil { + t.Fatalf("Parse: %v", err) + } + res := reg.Services[0].ResourceList() + if len(res) != 1 { + t.Fatalf("want 1 resource, got %d", len(res)) + } + methods := res[0].MethodList() + if len(methods) != 1 { + t.Fatalf("want 1 method, got %d", len(methods)) + } + return res[0], methods[0] +} + +func TestParse_TypedAndNameInjected(t *testing.T) { + res, m := loadSample(t) + if res.Name != "chat.members" { + t.Errorf("resource name = %q, want chat.members", res.Name) + } + if m.Name != "create" || m.HTTPMethod != "POST" || m.Risk != "high-risk-write" { + t.Errorf("method = %+v", m) + } +} + +func TestMethod_AccessorsSortedByName(t *testing.T) { + _, m := loadSample(t) + + // Params: path/query only (header dropped), sorted by name. + var params []string + for _, f := range m.Params() { + params = append(params, f.Name) + } + if want := []string{"chat_id", "member_id_type"}; !reflect.DeepEqual(params, want) { + t.Errorf("Params() = %v, want %v (sorted, header dropped)", params, want) + } + + if d := m.Data(); len(d) != 1 || d[0].Name != "id_list" { + t.Errorf("Data() = %+v, want [id_list]", d) + } + if f := m.Files(); len(f) != 1 || f[0].Name != "avatar" { + t.Errorf("Files() = %+v, want [avatar]", f) + } +} + +func TestField_FlagNameAndOptions(t *testing.T) { + _, m := loadSample(t) + by := make(map[string]Field) + for _, f := range m.Params() { + by[f.Name] = f + } + + if got := by["chat_id"].FlagName(); got != "chat-id" { + t.Errorf("FlagName = %q, want chat-id", got) + } + if !by["chat_id"].Required || by["chat_id"].Example != "oc_x" { + t.Errorf("chat_id required/example wrong: %+v", by["chat_id"]) + } + opts := by["member_id_type"].Options + if len(opts) != 2 || opts[0].Value != "open_id" || opts[1].Value != "user_id" { + t.Errorf("member_id_type options = %+v", opts) + } +} + +// TestParse_TolerantOptionValue guards against whole-catalog blanking: a single +// options[].value that arrives as a JSON number (not the usual quoted string) +// must NOT fail the entire registry unmarshal. Option.Value is `any`, so it +// parses and coerces like Enum instead of returning an empty Registry. +func TestParse_TolerantOptionValue(t *testing.T) { + data := []byte(`{"services":[{"name":"im","servicePath":"/x","resources":{ + "chat":{"methods":{"create":{"parameters":{ + "flag":{"type":"integer","location":"query","options":[{"value":0,"description":"off"},{"value":1,"description":"on"}]} + }}}}}}]}`) + reg, err := Parse(data) + if err != nil { + t.Fatalf("Parse failed on numeric option value (would blank the catalog): %v", err) + } + if len(reg.Services) != 1 { + t.Fatalf("expected 1 service, got %d (catalog blanked)", len(reg.Services)) + } + // The numeric option coerces into the typed enum as sorted int64 (not + // float64): the integer field's canonical type drives normalization. + m, _ := reg.Services[0].Resource("chat") + method, _ := m.Method("create") + by := map[string]Field{} + for _, f := range method.Params() { + by[f.Name] = f + } + if got := by["flag"].EnumValues(); !reflect.DeepEqual(got, []any{int64(0), int64(1)}) { + t.Errorf("numeric-valued enum did not coerce to sorted int64: %#v", got) + } +} + +func TestParse_Empty(t *testing.T) { + reg, err := Parse(nil) + if err != nil || len(reg.Services) != 0 { + t.Fatalf("Parse(nil) = %+v, %v", reg, err) + } +} diff --git a/internal/meta/normalize.go b/internal/meta/normalize.go new file mode 100644 index 000000000..fdd2454b3 --- /dev/null +++ b/internal/meta/normalize.go @@ -0,0 +1,202 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import ( + "fmt" + "sort" + "strconv" +) + +// CanonicalType maps meta_data's non-standard type names to the standard +// JSON-Schema/type vocabulary used downstream (envelope render, flag kinds): +// "file" -> "string", "list" -> "array"; other types pass through unchanged. +func (f Field) CanonicalType() string { + switch f.Type { + case "file": + return "string" + case "list": + return "array" + default: + return f.Type + } +} + +// coerceLiteral converts a meta_data literal (default/enum/example) to the +// field's canonical type. Literals may arrive as strings (meta_data's usual +// form) OR already typed — a JSON number unmarshals to float64, a JSON bool to +// bool — so both must be normalized to the SAME Go type the canonical type +// implies (int64 for "integer", float64 for "number", bool for "boolean"). +// Otherwise enumLess, which type-asserts on that Go type, can't order the +// values. Returns (value, true) on success, (nil, false) when the literal +// cannot be represented in the declared type. +func coerceLiteral(canonicalType string, raw any) (any, bool) { + switch canonicalType { + case "integer": + switch v := raw.(type) { + case string: + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + return n, true + } + case float64: // JSON number; accept only when it's a whole value + if v == float64(int64(v)) { + return int64(v), true + } + case int64: + return v, true + case int: + return int64(v), true + } + return nil, false + case "number": + switch v := raw.(type) { + case string: + if n, err := strconv.ParseFloat(v, 64); err == nil { + return n, true + } + case float64: + return v, true + case int64: + return float64(v), true + case int: + return float64(v), true + } + return nil, false + case "boolean": + switch v := raw.(type) { + case string: + switch v { + case "true": + return true, true + case "false": + return false, true + } + case bool: + return v, true + } + return nil, false + default: // "string", "array", "" (objects), or unknown — pass through as-is + return raw, true + } +} + +// enumLess orders two coerced enum values for the canonical type, so integer +// enums end up [1 2 10] not lexicographic [1 10 2]. +func enumLess(canonicalType string, a, b any) bool { + switch canonicalType { + case "integer": + ai, _ := a.(int64) + bi, _ := b.(int64) + return ai < bi + case "number": + af, _ := a.(float64) + bf, _ := b.(float64) + return af < bf + case "boolean": + ab, _ := a.(bool) + bb, _ := b.(bool) + return !ab && bb + default: + as, _ := a.(string) + bs, _ := b.(string) + return as < bs + } +} + +// EnumOption is one allowed value paired with its human description. The +// description comes from options[].description and is empty for the bare `enum` +// form (which carries no descriptions). +type EnumOption struct { + Value any + Description string +} + +// EnumOptions returns the field's allowed values paired with their descriptions +// — from enum, or from options when enum is absent — coerced to the canonical +// type and ordered: numeric and boolean values are sorted; string values keep +// source order (which can encode priority). Uncoercible literals are dropped. +// Returns nil when the field declares no enum constraint. +func (f Field) EnumOptions() []EnumOption { + ct := f.CanonicalType() + var out []EnumOption + switch { + case len(f.Enum) > 0: + for _, e := range f.Enum { + if v, ok := coerceLiteral(ct, e); ok { + out = append(out, EnumOption{Value: v}) + } + } + case len(f.Options) > 0: + seen := make(map[string]bool) + for _, o := range f.Options { + key := fmt.Sprintf("%v", o.Value) + if seen[key] { + continue + } + seen[key] = true + if v, ok := coerceLiteral(ct, o.Value); ok { + out = append(out, EnumOption{Value: v, Description: o.Description}) + } + } + } + if len(out) > 0 && ct != "string" && ct != "" { + sort.SliceStable(out, func(i, j int) bool { return enumLess(ct, out[i].Value, out[j].Value) }) + } + return out +} + +// EnumValues returns the field's allowed values — the value projection of +// EnumOptions, in the same order. nil when the field declares no enum +// constraint. (Kept as the values-only accessor for the envelope and flag +// completion, which don't need descriptions.) +func (f Field) EnumValues() []any { + opts := f.EnumOptions() + if len(opts) == 0 { + return nil + } + out := make([]any, len(opts)) + for i, o := range opts { + out[i] = o.Value + } + return out +} + +// CoercedDefault returns Default coerced to the canonical type, or nil when the +// field has no default or the literal cannot be coerced. +func (f Field) CoercedDefault() any { return f.coerce(f.Default) } + +// CoercedExample returns Example coerced to the canonical type, or nil when the +// field has no example or the literal cannot be coerced. +func (f Field) CoercedExample() any { return f.coerce(f.Example) } + +func (f Field) coerce(raw any) any { + if raw == nil { + return nil + } + if v, ok := coerceLiteral(f.CanonicalType(), raw); ok { + return v + } + return nil +} + +// MinBound returns the field's min constraint as a number, or nil when absent +// or unparseable. meta_data carries min/max as strings and does not say +// whether they bound a value or a string's length; the accessors stay equally +// agnostic, so every renderer (envelope minimum/maximum, flag help) presents +// the same numbers without inventing a semantic the source doesn't declare. +func (f Field) MinBound() *float64 { return parseBound(f.Min) } + +// MaxBound returns the field's max constraint as a number, or nil when absent +// or unparseable. See MinBound. +func (f Field) MaxBound() *float64 { return parseBound(f.Max) } + +func parseBound(s string) *float64 { + if s == "" { + return nil + } + if v, err := strconv.ParseFloat(s, 64); err == nil { + return &v + } + return nil +} diff --git a/internal/meta/normalize_test.go b/internal/meta/normalize_test.go new file mode 100644 index 000000000..93e35ca64 --- /dev/null +++ b/internal/meta/normalize_test.go @@ -0,0 +1,160 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package meta + +import ( + "reflect" + "testing" +) + +func TestField_CanonicalType(t *testing.T) { + cases := map[string]string{ + "file": "string", // meta_data's non-standard "file" is a string with binary format + "list": "array", // "list" is meta_data's spelling of a JSON array + "integer": "integer", + "boolean": "boolean", + "string": "string", + "": "", + } + for in, want := range cases { + if got := (Field{Type: in}).CanonicalType(); got != want { + t.Errorf("CanonicalType(%q) = %q, want %q", in, got, want) + } + } +} + +func TestField_EnumValues(t *testing.T) { + // string enum keeps source order (order can encode priority) + if got := (Field{Type: "string", Enum: []any{"b", "a", "c"}}).EnumValues(); !reflect.DeepEqual(got, []any{"b", "a", "c"}) { + t.Errorf("string enum = %v, want source order [b a c]", got) + } + // integer enum: string-stored literals coerced to int64 and numerically sorted + if got := (Field{Type: "integer", Enum: []any{"10", "2", "1"}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2), int64(10)}) { + t.Errorf("integer enum = %v, want [1 2 10] coerced+sorted", got) + } + // options used when enum absent, deduped, source order for strings + if got := (Field{Type: "string", Options: []Option{{Value: "x"}, {Value: "x"}, {Value: "y"}}}).EnumValues(); !reflect.DeepEqual(got, []any{"x", "y"}) { + t.Errorf("options enum = %v, want [x y] deduped", got) + } + // uncoercible literal dropped + if got := (Field{Type: "integer", Enum: []any{"1", "nope", "2"}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2)}) { + t.Errorf("bad enum = %v, want [1 2] (nope dropped)", got) + } + // no enum/options -> nil + if got := (Field{Type: "string"}).EnumValues(); got != nil { + t.Errorf("empty enum = %v, want nil", got) + } +} + +func TestField_EnumOptions(t *testing.T) { + // options carry descriptions, kept paired with their (string) value in source order + fo := Field{Type: "string", Options: []Option{ + {Value: "open_id", Description: "以 open_id 标识"}, + {Value: "open_id", Description: "dup ignored"}, // dedup keeps first + {Value: "user_id", Description: "以 user_id 标识"}, + }} + got := fo.EnumOptions() + want := []EnumOption{ + {Value: "open_id", Description: "以 open_id 标识"}, + {Value: "user_id", Description: "以 user_id 标识"}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("EnumOptions = %+v, want %+v", got, want) + } + + // integer enum (bare form): values coerced + numerically sorted, no descriptions + fi := Field{Type: "integer", Enum: []any{"10", "2", "1"}} + gi := fi.EnumOptions() + if len(gi) != 3 || gi[0].Value != int64(1) || gi[2].Value != int64(10) || gi[0].Description != "" { + t.Errorf("EnumOptions(integer) = %+v, want [1 2 10] coerced+sorted, no desc", gi) + } + + // EnumValues stays the value projection of EnumOptions (golden-critical) + if !reflect.DeepEqual(fo.EnumValues(), []any{"open_id", "user_id"}) { + t.Errorf("EnumValues diverged from EnumOptions values: %v", fo.EnumValues()) + } + // unconstrained -> nil + if (Field{Type: "string"}).EnumOptions() != nil { + t.Error("EnumOptions should be nil when unconstrained") + } +} + +func TestField_Enum_NumberAndBoolean(t *testing.T) { + // number: string-stored floats coerced to float64 and numerically sorted + if got := (Field{Type: "number", Enum: []any{"2.5", "1.5", "10"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5, float64(10)}) { + t.Errorf("number enum = %v, want [1.5 2.5 10] coerced+sorted", got) + } + // number: uncoercible literal dropped + if got := (Field{Type: "number", Enum: []any{"1.5", "x", "2.5"}}).EnumValues(); !reflect.DeepEqual(got, []any{1.5, 2.5}) { + t.Errorf("number enum with bad value = %v, want [1.5 2.5]", got) + } + // boolean: true/false coerced and sorted (false before true); invalid dropped + if got := (Field{Type: "boolean", Enum: []any{"true", "maybe", "false"}}).EnumValues(); !reflect.DeepEqual(got, []any{false, true}) { + t.Errorf("boolean enum = %v, want [false true]", got) + } +} + +func TestField_EnumOptions_NonStringValuesNormalized(t *testing.T) { + // JSON numbers/bools arrive already-typed (a number is float64, not a + // string) — e.g. options[].value: 0. They must still be normalized to the + // field's canonical type (int64 for "integer") and sorted numerically; + // leaving them as float64 both yields the wrong type and defeats enumLess, + // whose integer branch asserts int64 and would otherwise treat every value + // as zero (no sort). + if got := (Field{Type: "integer", Options: []Option{ + {Value: float64(10)}, {Value: float64(2)}, {Value: float64(1)}, + }}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2), int64(10)}) { + t.Errorf("integer options from float64 = %#v, want [int64(1) int64(2) int64(10)]", got) + } + // bare enum form, JSON numbers + if got := (Field{Type: "integer", Enum: []any{float64(3), float64(1), float64(2)}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(2), int64(3)}) { + t.Errorf("integer enum from float64 = %#v, want [int64(1) int64(2) int64(3)]", got) + } + // number field: a whole-valued float stays float64 + if got := (Field{Type: "number", Enum: []any{float64(2), float64(1)}}).EnumValues(); !reflect.DeepEqual(got, []any{float64(1), float64(2)}) { + t.Errorf("number enum from float64 = %#v, want [float64(1) float64(2)]", got) + } + // boolean field: native bools coerce + sort (false < true) + if got := (Field{Type: "boolean", Enum: []any{true, false}}).EnumValues(); !reflect.DeepEqual(got, []any{false, true}) { + t.Errorf("boolean enum from bool = %#v, want [false true]", got) + } + // non-integral float under integer is uncoercible -> dropped (mirrors how "2.5" fails ParseInt) + if got := (Field{Type: "integer", Enum: []any{float64(1), float64(2.5), float64(3)}}).EnumValues(); !reflect.DeepEqual(got, []any{int64(1), int64(3)}) { + t.Errorf("integer enum with fractional float = %#v, want [int64(1) int64(3)]", got) + } +} + +func TestField_CoercedDefaultAndExample(t *testing.T) { + if got := (Field{Type: "integer", Default: "5"}).CoercedDefault(); got != int64(5) { + t.Errorf("CoercedDefault integer = %v (%T), want int64(5)", got, got) + } + if got := (Field{Type: "integer", Default: "bad"}).CoercedDefault(); got != nil { + t.Errorf("CoercedDefault uncoercible = %v, want nil", got) + } + if got := (Field{Type: "string"}).CoercedDefault(); got != nil { + t.Errorf("CoercedDefault absent = %v, want nil", got) + } + if got := (Field{Type: "boolean", Example: "true"}).CoercedExample(); got != true { + t.Errorf("CoercedExample boolean = %v, want true", got) + } +} + +func TestField_Bounds(t *testing.T) { + f := Field{Min: "1", Max: "100"} + if v := f.MinBound(); v == nil || *v != 1 { + t.Errorf("MinBound = %v, want 1", v) + } + if v := f.MaxBound(); v == nil || *v != 100 { + t.Errorf("MaxBound = %v, want 100", v) + } + if v := (Field{Min: "0.5"}).MinBound(); v == nil || *v != 0.5 { + t.Errorf("MinBound fractional = %v, want 0.5", v) + } + if v := (Field{}).MinBound(); v != nil { + t.Errorf("MinBound absent = %v, want nil", v) + } + if v := (Field{Max: "not_a_number"}).MaxBound(); v != nil { + t.Errorf("MaxBound unparseable = %v, want nil", v) + } +} diff --git a/internal/output/lark_errors.go b/internal/output/lark_errors.go index 892c28177..137c342f2 100644 --- a/internal/output/lark_errors.go +++ b/internal/output/lark_errors.go @@ -35,9 +35,12 @@ const ( LarkErrAppNotInUse = 99991662 // app is disabled in this tenant LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation - // TAT-endpoint variant of the "wrong app credentials" condition. - // /open-apis/auth/v3/tenant_access_token/internal returns code 10014 - // ("app secret invalid") instead of 99991543 when the secret is wrong. + // "Wrong app credentials" code from the LEGACY TAT endpoint + // (/open-apis/auth/v3/tenant_access_token/internal returns 10014, "app secret + // invalid", instead of 99991543). Since the OAuth v3 migration the CLI mints + // TAT via accounts/oauth/v3/token and reports this as the OAuth invalid_client + // error, so it no longer emits 10014 itself; the constant + codemeta mapping + // are retained as a defensive fallback should 10014 still arrive. LarkErrTATInvalidSecret = 10014 // Rate limit. diff --git a/internal/registry/catalog.go b/internal/registry/catalog.go new file mode 100644 index 000000000..30a0af8c8 --- /dev/null +++ b/internal/registry/catalog.go @@ -0,0 +1,20 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package registry + +import "github.com/larksuite/cli/internal/apicatalog" + +// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free) +// metadata — deterministic across machines, for `lark-cli schema`, golden tests +// and schema lint. +func EmbeddedCatalog() apicatalog.Catalog { + return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped()) +} + +// RuntimeCatalog returns a navigation catalog over the merged (embedded + remote +// overlay) metadata — for service command registration and scope discovery, +// where overlay methods must be reachable. +func RuntimeCatalog() apicatalog.Catalog { + return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped()) +} diff --git a/internal/registry/helpers.go b/internal/registry/helpers.go index 39668f2ee..b10614b15 100644 --- a/internal/registry/helpers.go +++ b/internal/registry/helpers.go @@ -3,54 +3,17 @@ package registry -// GetStrFromMap extracts a string value from map[string]interface{}. -func GetStrFromMap(m map[string]interface{}, key string) string { - if m == nil { - return "" - } - if v, ok := m[key]; ok { - if s, ok := v.(string); ok { - return s - } - } - return "" -} +import "github.com/larksuite/cli/internal/meta" -// GetStrSliceFromMap extracts a []string value from map[string]interface{}. -// Returns nil if the key is missing or the value is not a string slice. -func GetStrSliceFromMap(m map[string]interface{}, key string) []string { - if m == nil { - return nil - } - raw, ok := m[key].([]interface{}) - if !ok { - return nil - } - result := make([]string, 0, len(raw)) - for _, v := range raw { - if s, ok := v.(string); ok { - result = append(result, s) - } - } - if len(result) == 0 { - return nil - } - return result -} - -// DeclaredScopesForMethod returns the scopes declared by a method's -// from_meta entry for the given identity. Prefers the explicit -// `requiredScopes` field when present; otherwise returns the single -// recommended scope from `scopes` (or the first scope as a final fallback). -// Returns nil when the method has no scope information. -func DeclaredScopesForMethod(method map[string]interface{}, identity string) []string { - if method == nil { - return nil - } - if requiredRaw, ok := method["requiredScopes"].([]interface{}); ok && len(requiredRaw) > 0 { - out := make([]string, 0, len(requiredRaw)) - for _, v := range requiredRaw { - if s, ok := v.(string); ok && s != "" { +// DeclaredScopesForMethod returns the scopes declared by a method for the given +// identity. Prefers the explicit `requiredScopes` field when present; otherwise +// returns the single recommended scope from `scopes` (or the first scope as a +// final fallback). Returns nil when the method has no scope information. +func DeclaredScopesForMethod(m meta.Method, identity string) []string { + if len(m.RequiredScopes) > 0 { + out := make([]string, 0, len(m.RequiredScopes)) + for _, s := range m.RequiredScopes { + if s != "" { out = append(out, s) } } @@ -58,54 +21,11 @@ func DeclaredScopesForMethod(method map[string]interface{}, identity string) []s return out } } - rawScopes, _ := method["scopes"].([]interface{}) - if len(rawScopes) == 0 { + if len(m.Scopes) == 0 { return nil } - recommended := SelectRecommendedScope(rawScopes, identity) - if recommended == "" { - for _, raw := range rawScopes { - if s, ok := raw.(string); ok && s != "" { - recommended = s - break - } - } + if recommended := SelectRecommendedScopeFromStrings(m.Scopes, identity); recommended != "" { + return []string{recommended} } - if recommended == "" { - return nil - } - return []string{recommended} -} - -// SelectRecommendedScope selects the known scope with the highest priority score -// (higher = more recommended / least privilege). -// Scopes not in the priority table are skipped to avoid recommending invalid/unknown scopes. -func SelectRecommendedScope(scopes []interface{}, identity string) string { - priorities := LoadScopePriorities() - bestScore := -1 - bestScope := "" - for _, s := range scopes { - str, ok := s.(string) - if !ok { - continue - } - score, exists := priorities[str] - if !exists { - continue // skip unknown scopes - } - if score > bestScore { - bestScore = score - bestScope = str - } - } - if bestScope != "" { - return bestScope - } - // Fallback: if no scope is in the priority table, return the first one. - if len(scopes) > 0 { - if s, ok := scopes[0].(string); ok { - return s - } - } - return "" + return nil } diff --git a/internal/registry/loader.go b/internal/registry/loader.go index 93360c2da..813526f46 100644 --- a/internal/registry/loader.go +++ b/internal/registry/loader.go @@ -14,6 +14,7 @@ import ( "sync" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/meta" ) //go:embed scope_priorities.json scope_overrides.json @@ -22,68 +23,42 @@ var registryFS embed.FS // embeddedMetaJSON is set by loader_embedded.go when meta_data.json is compiled in. var embeddedMetaJSON []byte -// EmbeddedMetaJSON returns the raw embedded meta_data.json bytes for callers -// that need to parse key order or other JSON-level structure not exposed by -// LoadFromMeta (which loses map insertion order). -func EmbeddedMetaJSON() []byte { - return embeddedMetaJSON -} - var ( - embeddedServicesMap map[string]map[string]interface{} // service name -> spec - embeddedServiceNames []string // sorted - embeddedParseOnce sync.Once + embeddedServices []meta.Service // parsed once, sorted by name (no overlay) + embeddedServicesByName map[string]meta.Service // same, keyed by name + embeddedVersion string // version from embedded meta_data.json + embeddedParseOnce sync.Once ) -// parseEmbeddedServices parses embeddedMetaJSON into a service name → spec map -// without touching mergedServices. Safe to call multiple times (sync.Once). -func parseEmbeddedServices() { +// parseEmbedded decodes the embedded meta_data.json into the typed model exactly +// once. It is the single parse of the embedded bytes: both the overlay-free +// envelope path (EmbeddedServicesTyped) and the merged command/scope path +// (loadEmbeddedIntoMerged) build from this result, so the JSON is never parsed +// twice and no map round-trip is needed downstream. +func parseEmbedded() { embeddedParseOnce.Do(func() { - embeddedServicesMap = make(map[string]map[string]interface{}) - if len(embeddedMetaJSON) == 0 { - return + reg, _ := meta.Parse(embeddedMetaJSON) + embeddedVersion = reg.Version + embeddedServices = reg.Services + sort.Slice(embeddedServices, func(i, j int) bool { return embeddedServices[i].Name < embeddedServices[j].Name }) + embeddedServicesByName = make(map[string]meta.Service, len(embeddedServices)) + for _, svc := range embeddedServices { + embeddedServicesByName[svc.Name] = svc } - var wrapper struct { - Services []map[string]interface{} `json:"services"` - } - if err := json.Unmarshal(embeddedMetaJSON, &wrapper); err != nil { - return - } - for _, svc := range wrapper.Services { - name, _ := svc["name"].(string) - if name == "" { - continue - } - embeddedServicesMap[name] = svc - } - embeddedServiceNames = make([]string, 0, len(embeddedServicesMap)) - for name := range embeddedServicesMap { - embeddedServiceNames = append(embeddedServiceNames, name) - } - sort.Strings(embeddedServiceNames) }) } -// EmbeddedSpec returns the embedded spec for one service, or nil if unknown. -// Bypasses remote overlay — used for deterministic envelope output. -func EmbeddedSpec(serviceName string) map[string]interface{} { - parseEmbeddedServices() - return embeddedServicesMap[serviceName] -} - -// EmbeddedServiceNames returns sorted embedded service names (no overlay). -// Returns a defensive copy — callers must not mutate the package-level slice. -func EmbeddedServiceNames() []string { - parseEmbeddedServices() - out := make([]string, len(embeddedServiceNames)) - copy(out, embeddedServiceNames) - return out +// EmbeddedServicesTyped returns the embedded services (no remote overlay) as the +// typed meta model, sorted by name. This is the overlay-free parse boundary the +// schema envelope builds from — deterministic across machines. +func EmbeddedServicesTyped() []meta.Service { + parseEmbedded() + return embeddedServices } var ( - mergedServices = make(map[string]map[string]interface{}) // project name → parsed spec - mergedProjectList []string // sorted project names - embeddedVersion string // version from embedded meta_data.json + mergedServices = make(map[string]meta.Service) // project name → typed service (embedded + overlay) + mergedProjectList []string // sorted project names initOnce sync.Once ) @@ -106,8 +81,8 @@ func InitWithBrand(brand core.LarkBrand) { // 2. Remote overlay if remoteEnabled() && cacheWritable() { // Check if brand changed since last cache - meta, metaErr := loadCacheMeta() - brandChanged := metaErr == nil && meta.Brand != "" && meta.Brand != string(brand) + cm, metaErr := loadCacheMeta() + brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand) if !brandChanged { if cached, err := loadCachedMerged(); err == nil { @@ -117,7 +92,7 @@ func InitWithBrand(brand core.LarkBrand) { if len(mergedServices) == 0 || brandChanged { // No data at all or brand changed — must sync fetch doSyncFetch() - } else if shouldRefresh(meta) || metaErr != nil { + } else if shouldRefresh(cm) || metaErr != nil { // Have embedded/cached data; refresh in background if TTL expired or first run triggerBackgroundRefresh() } @@ -127,18 +102,13 @@ func InitWithBrand(brand core.LarkBrand) { }) } -// loadEmbeddedIntoMerged parses the embedded meta_data.json and populates -// mergedServices. No-op if meta_data.json is not compiled in. +// loadEmbeddedIntoMerged seeds mergedServices from the embedded typed services +// (the same parse EmbeddedServicesTyped uses). No-op if no services compiled in. func loadEmbeddedIntoMerged() { - if len(embeddedMetaJSON) == 0 { - return + parseEmbedded() + for name, svc := range embeddedServicesByName { + mergedServices[name] = svc } - var reg MergedRegistry - if err := json.Unmarshal(embeddedMetaJSON, ®); err != nil { - return - } - embeddedVersion = reg.Version - overlayMergedServices(®) } // rebuildProjectList rebuilds the sorted list of project names from mergedServices. @@ -150,83 +120,32 @@ func rebuildProjectList() { sort.Strings(mergedProjectList) } -var cachedAllScopes map[string][]string +var ( + servicesTyped []meta.Service + servicesTypedOnce sync.Once +) -// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json -// for the given identity ("user" or "tenant"). Results are deduplicated and sorted. -func CollectAllScopesFromMeta(identity string) []string { - if cachedAllScopes == nil { - cachedAllScopes = make(map[string][]string) - } - if cached, ok := cachedAllScopes[identity]; ok { - return cached - } - - scopeSet := make(map[string]bool) - for _, project := range ListFromMetaProjects() { - spec := LoadFromMeta(project) - if spec == nil { - continue +// ServicesTyped returns the merged registry (embedded + remote overlay) as typed +// meta.Services, sorted by name. The merged store is already typed, so this just +// projects it into a sorted slice — no map round-trip. This is the typed entry +// the command tree and scope computation build from. +func ServicesTyped() []meta.Service { + servicesTypedOnce.Do(func() { + Init() + servicesTyped = make([]meta.Service, 0, len(mergedProjectList)) + for _, name := range mergedProjectList { + servicesTyped = append(servicesTyped, mergedServices[name]) } - resources, ok := spec["resources"].(map[string]interface{}) - if !ok { - continue - } - for _, resSpec := range resources { - resMap, ok := resSpec.(map[string]interface{}) - if !ok { - continue - } - methods, ok := resMap["methods"].(map[string]interface{}) - if !ok { - continue - } - for _, methodSpec := range methods { - methodMap, ok := methodSpec.(map[string]interface{}) - if !ok { - continue - } - // Check if method supports the requested identity - if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { - supported := false - for _, t := range tokens { - if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { - supported = true - break - } - } - if !supported { - continue - } - } - // Collect scopes - scopes, ok := methodMap["scopes"].([]interface{}) - if !ok { - continue - } - for _, s := range scopes { - if str, ok := s.(string); ok { - scopeSet[str] = true - } - } - } - } - } - - result := make([]string, 0, len(scopeSet)) - for s := range scopeSet { - result = append(result, s) - } - sort.Strings(result) - cachedAllScopes[identity] = result - return result + }) + return servicesTyped } -// LoadFromMeta loads a service schema by project name. -// It returns data from the merged registry (embedded + cached remote overlay). -func LoadFromMeta(project string) map[string]interface{} { +// ServiceTyped returns one merged service (embedded + overlay) by name, or false +// if unknown. +func ServiceTyped(name string) (meta.Service, bool) { Init() - return mergedServices[project] + svc, ok := mergedServices[name] + return svc, ok } // ListFromMetaProjects lists available service project names (sorted). diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go index 5a1e6756b..23fe27ae1 100644 --- a/internal/registry/registry_test.go +++ b/internal/registry/registry_test.go @@ -65,8 +65,7 @@ func TestSelectRecommendedScope_PicksHighestScore(t *testing.T) { } t.Logf("%s=%d, %s=%d", scopeA, scoreA, scopeB, scoreB) - scopes := []interface{}{scopeB, scopeA} - result := SelectRecommendedScope(scopes, "user") + result := bestScope([]string{scopeB, scopeA}, priorities) // Should pick the higher-scored one (higher = more recommended) if scoreA > scoreB { @@ -81,11 +80,11 @@ func TestSelectRecommendedScope_PicksHighestScore(t *testing.T) { } func TestSelectRecommendedScope_FallbackToFirst(t *testing.T) { - scopes := []interface{}{ + scopes := []string{ "zzz_unknown:scope:a", "zzz_unknown:scope:b", } - result := SelectRecommendedScope(scopes, "user") + result := bestScope(scopes, LoadScopePriorities()) // All unknown scopes get DefaultScopeScore; first one with that score wins if result != "zzz_unknown:scope:a" { t.Errorf("expected zzz_unknown:scope:a, got %s", result) @@ -93,13 +92,10 @@ func TestSelectRecommendedScope_FallbackToFirst(t *testing.T) { } func TestSelectRecommendedScope_Empty(t *testing.T) { - result := SelectRecommendedScope(nil, "user") - if result != "" { + if result := bestScope(nil, LoadScopePriorities()); result != "" { t.Errorf("expected empty string, got %s", result) } - - result = SelectRecommendedScope([]interface{}{}, "user") - if result != "" { + if result := bestScope([]string{}, LoadScopePriorities()); result != "" { t.Errorf("expected empty string, got %s", result) } } @@ -318,27 +314,6 @@ func TestFilterAutoApproveScopes_Empty(t *testing.T) { // --- Helper functions --- -func TestGetStrFromMap(t *testing.T) { - m := map[string]interface{}{ - "key1": "value1", - "key2": 42, - "key3": nil, - } - - if v := GetStrFromMap(m, "key1"); v != "value1" { - t.Errorf("expected value1, got %s", v) - } - if v := GetStrFromMap(m, "key2"); v != "" { - t.Errorf("expected empty for non-string value, got %s", v) - } - if v := GetStrFromMap(m, "missing"); v != "" { - t.Errorf("expected empty for missing key, got %s", v) - } - if v := GetStrFromMap(nil, "key"); v != "" { - t.Errorf("expected empty for nil map, got %s", v) - } -} - func TestGetRegistryDir(t *testing.T) { dir := GetRegistryDir() if dir == "" { diff --git a/internal/registry/remote.go b/internal/registry/remote.go index e13ec66d0..7e83003a4 100644 --- a/internal/registry/remote.go +++ b/internal/registry/remote.go @@ -17,6 +17,7 @@ import ( "github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/transport" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/vfs" @@ -35,16 +36,20 @@ type CacheMeta struct { Brand string `json:"brand,omitempty"` } -// MergedRegistry is the top-level structure of remote_meta.json. +// MergedRegistry is the top-level structure of remote_meta.json. Services are +// decoded straight into the typed meta model so embedded, cached, and remote +// data share one representation (no map intermediary, no re-parse). type MergedRegistry struct { - Version string `json:"version"` - Services []map[string]interface{} `json:"services"` + Version string `json:"version"` + Services []meta.Service `json:"services"` } -// remoteResponse is the envelope returned by the remote API. +// remoteResponse is the envelope returned by the remote API. Data stays raw: +// the cache must store the server payload verbatim — a typed re-marshal would +// strip every field the model doesn't (yet) declare and starve future binaries. type remoteResponse struct { - Msg string `json:"msg"` - Data MergedRegistry `json:"data"` + Msg string `json:"msg"` + Data json.RawMessage `json:"data"` } // configuredBrand is set by InitWithBrand and determines which API host to use. @@ -125,22 +130,22 @@ func cacheWritable() bool { // --- cache I/O --- func loadCacheMeta() (CacheMeta, error) { - var meta CacheMeta + var cm CacheMeta data, err := vfs.ReadFile(cacheMetaPath()) if err != nil { - return meta, err + return cm, err } - if err = json.Unmarshal(data, &meta); err != nil { - return meta, err + if err = json.Unmarshal(data, &cm); err != nil { + return cm, err } - return meta, nil + return cm, nil } -func saveCacheMeta(meta CacheMeta) error { +func saveCacheMeta(cm CacheMeta) error { if err := vfs.MkdirAll(cacheDir(), 0700); err != nil { return err } - data, err := json.Marshal(meta) + data, err := json.Marshal(cm) if err != nil { return err } @@ -163,14 +168,16 @@ func loadCachedMerged() (*MergedRegistry, error) { return ®, nil } -func saveCachedMerged(data []byte, meta CacheMeta) error { +// saveCachedMerged writes the cache file. data must be the server's data +// payload verbatim (see remoteResponse) — never a typed re-marshal. +func saveCachedMerged(data []byte, cm CacheMeta) error { if err := vfs.MkdirAll(cacheDir(), 0700); err != nil { return err } if err := validate.AtomicWrite(cachePath(), data, 0644); err != nil { return err } - return saveCacheMeta(meta) + return saveCacheMeta(cm) } // --- HTTP fetch --- @@ -211,18 +218,21 @@ func fetchRemoteMerged(localVersion string) (data []byte, reg *MergedRegistry, e return nil, nil, fmt.Errorf("remote meta: unexpected msg %q", envelope.Msg) } - // If data.Services is nil, the version is up-to-date (not modified) - if envelope.Data.Services == nil { + var parsed MergedRegistry + if len(envelope.Data) > 0 { + if err := json.Unmarshal(envelope.Data, &parsed); err != nil { + return nil, nil, fmt.Errorf("remote meta: parse data: %w", err) + } + } + + // If data.services is nil, the version is up-to-date (not modified) + if parsed.Services == nil { return nil, nil, nil } - // Re-marshal just the data portion for caching - dataBytes, err := json.Marshal(envelope.Data) - if err != nil { - return nil, nil, err - } - - return dataBytes, &envelope.Data, nil + // Cache the data portion verbatim (see remoteResponse for why not a typed + // re-marshal). + return envelope.Data, &parsed, nil } type httpError struct { @@ -247,12 +257,12 @@ func doSyncFetch() { }) return } - meta := CacheMeta{ + cm := CacheMeta{ LastCheckAt: time.Now().Unix(), Version: reg.Version, Brand: string(configuredBrand), } - _ = saveCachedMerged(data, meta) + _ = saveCachedMerged(data, cm) overlayMergedServices(reg) } @@ -275,22 +285,22 @@ func triggerBackgroundRefresh() { func doBackgroundRefresh() { defer func() { _ = recover() }() - meta, _ := loadCacheMeta() - version := meta.Version + cm, _ := loadCacheMeta() + version := cm.Version if version == "" { version = embeddedVersion } data, reg, err := fetchRemoteMerged(version) if err != nil { // On error, update last_check_at to avoid retrying every invocation - meta.LastCheckAt = time.Now().Unix() - _ = saveCacheMeta(meta) + cm.LastCheckAt = time.Now().Unix() + _ = saveCacheMeta(cm) return } if reg == nil { // Version unchanged — just update check time - meta.LastCheckAt = time.Now().Unix() - _ = saveCacheMeta(meta) + cm.LastCheckAt = time.Now().Unix() + _ = saveCacheMeta(cm) return } newMeta := CacheMeta{ @@ -302,21 +312,20 @@ func doBackgroundRefresh() { } // shouldRefresh returns true if the cache TTL has expired. -func shouldRefresh(meta CacheMeta) bool { - if meta.LastCheckAt == 0 { +func shouldRefresh(cm CacheMeta) bool { + if cm.LastCheckAt == 0 { return true } - return time.Since(time.Unix(meta.LastCheckAt, 0)) > metaTTL() + return time.Since(time.Unix(cm.LastCheckAt, 0)) > metaTTL() } // overlayMergedServices merges remote services into the in-memory map. // Remote entries override embedded entries with the same name. func overlayMergedServices(reg *MergedRegistry) { for _, svc := range reg.Services { - name, ok := svc["name"].(string) - if !ok || name == "" { + if svc.Name == "" { continue } - mergedServices[name] = svc + mergedServices[svc.Name] = svc } } diff --git a/internal/registry/remote_test.go b/internal/registry/remote_test.go index 0595ce9b9..98c2407df 100644 --- a/internal/registry/remote_test.go +++ b/internal/registry/remote_test.go @@ -15,6 +15,7 @@ import ( "time" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/meta" ) // waitBackgroundRefresh blocks until any in-flight background refresh started by @@ -30,7 +31,10 @@ func resetInit() { // reads globals this function mutates (see CI race: TestComputeMinimumScopeSet → Tenant). waitBackgroundRefresh() initOnce = sync.Once{} - mergedServices = make(map[string]map[string]interface{}) + embeddedParseOnce = sync.Once{} + servicesTypedOnce = sync.Once{} + servicesTyped = nil + mergedServices = make(map[string]meta.Service) mergedProjectList = nil embeddedVersion = "" cachedAllScopes = nil @@ -71,13 +75,12 @@ func hasEmbeddedServices() bool { func testRegistry(name string) MergedRegistry { return MergedRegistry{ Version: "test-1.0", - Services: []map[string]interface{}{ + Services: []meta.Service{ { - "name": name, - "version": "v1", - "title": name + " API", - "servicePath": "/open-apis/" + name + "/v1", - "resources": map[string]interface{}{}, + Name: name, + Version: "v1", + Title: name + " API", + ServicePath: "/open-apis/" + name + "/v1", }, }, } @@ -91,9 +94,10 @@ func testCacheJSON(name string) []byte { // testEnvelopeJSON returns the remote API envelope format: {"msg":"succeeded","data":{...}}. func testEnvelopeJSON(name string) []byte { + regData, _ := json.Marshal(testRegistry(name)) resp := remoteResponse{ Msg: "succeeded", - Data: testRegistry(name), + Data: regData, } data, _ := json.Marshal(resp) return data @@ -131,7 +135,7 @@ func TestColdStart_NoEmbedded_SyncFetch(t *testing.T) { Init() - if spec := LoadFromMeta("remote_calendar"); spec == nil { + if _, ok := ServiceTyped("remote_calendar"); !ok { t.Fatal("expected remote_calendar from sync fetch") } } @@ -150,7 +154,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) { Init() // "fake_remote_svc" should not be loaded when remote is off - if spec := LoadFromMeta("fake_remote_svc"); spec != nil { + if _, ok := ServiceTyped("fake_remote_svc"); ok { t.Error("expected fake_remote_svc to NOT be loaded when remote is off") } } @@ -181,12 +185,12 @@ func TestCacheHit_WithinTTL(t *testing.T) { Init() // custom_svc should be loaded from cache overlay - if spec := LoadFromMeta("custom_svc"); spec == nil { + if _, ok := ServiceTyped("custom_svc"); !ok { t.Error("expected custom_svc from cache overlay") } // Embedded projects should still be present (if compiled in) if hasEmbeddedServices() { - if spec := LoadFromMeta("calendar"); spec == nil { + if _, ok := ServiceTyped("calendar"); !ok { t.Error("expected calendar from embedded data") } } @@ -227,7 +231,7 @@ func TestNetworkError_SilentDegradation(t *testing.T) { if len(projects) == 0 { t.Fatal("expected projects after network error") } - if spec := LoadFromMeta("cached_svc"); spec == nil { + if _, ok := ServiceTyped("cached_svc"); !ok { t.Fatal("expected cached_svc after network error") } @@ -304,19 +308,19 @@ func TestMetaTTL(t *testing.T) { func TestOverlayMergedServices(t *testing.T) { resetInit() - mergedServices = make(map[string]map[string]interface{}) - mergedServices["existing"] = map[string]interface{}{"name": "existing", "version": "v1"} + mergedServices = make(map[string]meta.Service) + mergedServices["existing"] = meta.Service{Name: "existing", Version: "v1"} reg := &MergedRegistry{ - Services: []map[string]interface{}{ - {"name": "existing", "version": "v2"}, - {"name": "brand_new", "version": "v1"}, + Services: []meta.Service{ + {Name: "existing", Version: "v2"}, + {Name: "brand_new", Version: "v1"}, }, } overlayMergedServices(reg) // existing should be overridden - if v := mergedServices["existing"]["version"].(string); v != "v2" { + if v := mergedServices["existing"].Version; v != "v2" { t.Errorf("expected existing to be overridden to v2, got %s", v) } // brand_new should be added @@ -333,18 +337,18 @@ func TestOverlayMergedServicesDoesNotPolluteFollowingInit(t *testing.T) { const leakedExisting = "test_isolation_existing_sentinel" const leakedOverlay = "test_isolation_overlay_sentinel" - mergedServices = map[string]map[string]interface{}{ - leakedExisting: {"name": leakedExisting, "version": "v1"}, + mergedServices = map[string]meta.Service{ + leakedExisting: {Name: leakedExisting, Version: "v1"}, } - overlayMergedServices(&MergedRegistry{Services: []map[string]interface{}{{"name": leakedOverlay, "version": "v1"}}}) + overlayMergedServices(&MergedRegistry{Services: []meta.Service{{Name: leakedOverlay, Version: "v1"}}}) resetInit() Init() - if spec := LoadFromMeta(leakedExisting); spec != nil { + if _, ok := ServiceTyped(leakedExisting); ok { t.Fatalf("polluted service %q survived resetInit", leakedExisting) } - if spec := LoadFromMeta(leakedOverlay); spec != nil { + if _, ok := ServiceTyped(leakedOverlay); ok { t.Fatalf("polluted service %q survived resetInit", leakedOverlay) } } @@ -372,6 +376,31 @@ func TestFetchRemoteMerged_200(t *testing.T) { } } +// TestFetchRemoteMerged_CacheBytesPreserveUnmodeledKeys pins that the bytes +// destined for the on-disk cache are the server's data payload verbatim: a key +// the typed model does not (yet) declare must survive (see remoteResponse for +// why). +func TestFetchRemoteMerged_CacheBytesPreserveUnmodeledKeys(t *testing.T) { + const payload = `{"msg":"succeeded","data":{"version":"test-1.0","services":[{"name":"svc","servicePath":"/open-apis/svc/v1","resources":{"items":{"methods":{"list":{"httpMethod":"GET","path":"items","parameters":{"status":{"type":"string","enumName":"StatusEnum"}}}}}}}]}}` + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(200) + w.Write([]byte(payload)) + })) + defer ts.Close() + testMetaURL = ts.URL + + data, reg, err := fetchRemoteMerged("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if reg == nil || len(reg.Services) != 1 || reg.Services[0].Name != "svc" { + t.Fatalf("typed registry not decoded, got %+v", reg) + } + if !strings.Contains(string(data), `"enumName":"StatusEnum"`) { + t.Errorf("cache payload dropped unmodeled key enumName, got:\n%s", data) + } +} + func TestFetchRemoteMerged_VersionMatch(t *testing.T) { ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) @@ -484,7 +513,7 @@ func TestBrandSwitchInvalidatesCache(t *testing.T) { // The old feishu_svc should NOT be loaded from stale cache // The new lark_svc from sync fetch should be available - if spec := LoadFromMeta("lark_svc"); spec == nil { + if _, ok := ServiceTyped("lark_svc"); !ok { t.Error("expected lark_svc after brand switch sync fetch") } } diff --git a/internal/registry/scope_hint.go b/internal/registry/scope_hint.go index 1431ac210..5e7ecbf1c 100644 --- a/internal/registry/scope_hint.go +++ b/internal/registry/scope_hint.go @@ -44,22 +44,12 @@ func ExtractRequiredScopes(detail interface{}) []string { return scopes } -// SelectRecommendedScopeFromStrings is a string-typed convenience wrapper -// around SelectRecommendedScope. When no scope is recognized by the priority -// table, it falls back to the first input scope so callers always have -// something to surface to users. -func SelectRecommendedScopeFromStrings(scopes []string, identity string) string { - if len(scopes) == 0 { - return "" - } - ifaces := make([]interface{}, len(scopes)) - for i, s := range scopes { - ifaces[i] = s - } - if recommended := SelectRecommendedScope(ifaces, identity); recommended != "" { - return recommended - } - return scopes[0] +// SelectRecommendedScopeFromStrings returns the highest-priority (least-privilege) +// scope to surface to users, or "" for no scopes. Unknown scopes score +// DefaultScopeScore, so an all-unknown list yields the first entry. Priority is +// identity-independent; the parameter is kept for call-site clarity. +func SelectRecommendedScopeFromStrings(scopes []string, _ string) string { + return bestScope(scopes, LoadScopePriorities()) } // BuildConsoleScopeURL returns the developer-console "apply scope" URL for the diff --git a/internal/registry/scopes.go b/internal/registry/scopes.go index 22678b6fe..0a2b47f77 100644 --- a/internal/registry/scopes.go +++ b/internal/registry/scopes.go @@ -6,16 +6,63 @@ package registry import ( "sort" "strings" + + "github.com/larksuite/cli/internal/apicatalog" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/meta" ) -// IdentityToAccessToken maps the --identity flag value to the corresponding -// accessTokens value used in from_meta JSON files. Bot identity uses -// tenant_access_token, so "bot" maps to "tenant". -func IdentityToAccessToken(identity string) string { - if identity == "bot" { - return "tenant" +// methodsForProjects walks the runtime catalog once and returns the methods in +// the given projects that are reachable by the identity. Catalog navigation is +// owned by apicatalog; the collectors below only apply scope policy. +func methodsForProjects(projects []string, identity string) []apicatalog.MethodRef { + want := make(map[string]bool, len(projects)) + for _, p := range projects { + want[p] = true } - return identity + wantToken := meta.TokenForIdentity(identity) + supported := func(m meta.Method) bool { return m.SupportsToken(wantToken) } + // Walk only the requested services (in catalog name order) instead of every + // service's methods then discarding the rest. + var out []apicatalog.MethodRef + for _, svc := range RuntimeCatalog().Services() { + if want[svc.Name] { + out = append(out, apicatalog.ServiceMethods(svc, supported)...) + } + } + return out +} + +// bestScope returns the highest-priority scope from scopes (minimum privilege), +// or "" when scopes is empty. +func bestScope(scopes []string, priorities map[string]int) string { + best := "" + bestScore := -1 + for _, s := range scopes { + score := DefaultScopeScore + if v, ok := priorities[s]; ok { + score = v + } + if score > bestScore { + bestScore = score + best = s + } + } + return best +} + +// FilterForStrictMode returns a method filter enforcing the strict-mode forced +// identity, or nil when strict mode is inactive (no filtering). The +// token/identity vocabulary (meta.TokenForIdentity) and the "no accessTokens = +// permissive" predicate (meta.Method.SupportsToken) both live in meta, so this +// only composes them — schema completion/render and service commands never +// re-derive identity semantics. +func FilterForStrictMode(mode core.StrictMode) apicatalog.MethodFilter { + if !mode.IsActive() { + return nil + } + token := meta.TokenForIdentity(string(mode.ForcedIdentity())) + return func(m meta.Method) bool { return m.SupportsToken(token) } } // FilterScopes filters scopes by domain and permission level. @@ -76,71 +123,45 @@ func FilterScopes(allScopes []string, domains []string, permissions []string) [] return result } +var cachedAllScopes map[string][]string + +// CollectAllScopesFromMeta collects all unique scopes from from_meta/*.json +// for the given identity ("user" or "tenant"). Results are deduplicated and sorted. +func CollectAllScopesFromMeta(identity string) []string { + if cachedAllScopes == nil { + cachedAllScopes = make(map[string][]string) + } + if cached, ok := cachedAllScopes[identity]; ok { + return cached + } + + wantToken := meta.TokenForIdentity(identity) + supported := func(m meta.Method) bool { return m.SupportsToken(wantToken) } + scopeSet := make(map[string]bool) + for _, ref := range RuntimeCatalog().WalkMethods(supported) { + for _, s := range ref.Method.Scopes { + scopeSet[s] = true + } + } + + result := make([]string, 0, len(scopeSet)) + for s := range scopeSet { + result = append(result, s) + } + sort.Strings(result) + cachedAllScopes[identity] = result + return result +} + // CollectScopesForProjects collects the recommended scope for each API method // in the specified from_meta projects. For each method, only the scope with // the highest priority score is selected. func CollectScopesForProjects(projects []string, identity string) []string { priorities := LoadScopePriorities() scopeSet := make(map[string]bool) - for _, project := range projects { - spec := LoadFromMeta(project) - if spec == nil { - continue - } - resources, ok := spec["resources"].(map[string]interface{}) - if !ok { - continue - } - for _, resSpec := range resources { - resMap, ok := resSpec.(map[string]interface{}) - if !ok { - continue - } - methods, ok := resMap["methods"].(map[string]interface{}) - if !ok { - continue - } - for _, methodSpec := range methods { - methodMap, ok := methodSpec.(map[string]interface{}) - if !ok { - continue - } - if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { - supported := false - for _, t := range tokens { - if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { - supported = true - break - } - } - if !supported { - continue - } - } - scopes, ok := methodMap["scopes"].([]interface{}) - if !ok || len(scopes) == 0 { - continue - } - bestScope := "" - bestScore := -1 - for _, s := range scopes { - str, ok := s.(string) - if !ok { - continue - } - score := DefaultScopeScore - if v, exists := priorities[str]; exists { - score = v - } - if score > bestScore { - bestScore = score - bestScope = str - } - } - if bestScope != "" { - scopeSet[bestScope] = true - } - } + for _, ref := range methodsForProjects(projects, identity) { + if best := bestScope(ref.Method.Scopes, priorities); best != "" { + scopeSet[best] = true } } @@ -165,78 +186,25 @@ func CollectScopesWithSources(projects []string, identity string) ([]string, map scopeSet := make(map[string]bool) sources := make(map[string]*ScopeSource) - for _, project := range projects { - spec := LoadFromMeta(project) - if spec == nil { + for _, ref := range methodsForProjects(projects, identity) { + m := ref.Method + best := bestScope(m.Scopes, priorities) + if best == "" { continue } - resources, ok := spec["resources"].(map[string]interface{}) - if !ok { - continue + scopeSet[best] = true + if sources[best] == nil { + sources[best] = &ScopeSource{} } - for resName, resSpec := range resources { - resMap, ok := resSpec.(map[string]interface{}) - if !ok { - continue - } - methods, ok := resMap["methods"].(map[string]interface{}) - if !ok { - continue - } - for methodName, methodSpec := range methods { - methodMap, ok := methodSpec.(map[string]interface{}) - if !ok { - continue - } - if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { - supported := false - for _, t := range tokens { - if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { - supported = true - break - } - } - if !supported { - continue - } - } - scopes, ok := methodMap["scopes"].([]interface{}) - if !ok || len(scopes) == 0 { - continue - } - bestScope := "" - bestScore := -1 - for _, s := range scopes { - str, ok := s.(string) - if !ok { - continue - } - score := DefaultScopeScore - if v, exists := priorities[str]; exists { - score = v - } - if score > bestScore { - bestScore = score - bestScope = str - } - } - if bestScope != "" { - scopeSet[bestScope] = true - if sources[bestScope] == nil { - sources[bestScope] = &ScopeSource{} - } - methodID := GetStrFromMap(methodMap, "id") - if methodID == "" { - methodID = project + "." + resName + "." + methodName - } - httpMethod := GetStrFromMap(methodMap, "httpMethod") - if httpMethod == "" { - httpMethod = "?" - } - sources[bestScope].APIs = append(sources[bestScope].APIs, httpMethod+" "+methodID) - } - } + methodID := m.ID + if methodID == "" { + methodID = ref.ServiceName() + "." + ref.ResourceName() + "." + ref.MethodName() } + httpMethod := m.HTTPMethod + if httpMethod == "" { + httpMethod = "?" + } + sources[best].APIs = append(sources[best].APIs, httpMethod+" "+methodID) } // Sort API lists for stable output @@ -267,92 +235,27 @@ type CommandEntry struct { // - If the method has a "requiredScopes" field, all of those scopes are needed (conjunction). // - Otherwise, only the highest-priority scope from "scopes" is shown (minimum privilege). func CollectCommandScopes(projects []string, identity string) []CommandEntry { - priorities := LoadScopePriorities() var entries []CommandEntry - for _, project := range projects { - spec := LoadFromMeta(project) - if spec == nil { + for _, ref := range methodsForProjects(projects, identity) { + m := ref.Method + if len(m.Scopes) == 0 { continue } - resources, ok := spec["resources"].(map[string]interface{}) - if !ok { + + // Effective-scope policy (requiredScopes conjunction, else recommended) + // lives once in DeclaredScopesForMethod. + effectiveScopes := DeclaredScopesForMethod(m, identity) + if len(effectiveScopes) == 0 { continue } - for resName, resSpec := range resources { - resMap, ok := resSpec.(map[string]interface{}) - if !ok { - continue - } - methods, ok := resMap["methods"].(map[string]interface{}) - if !ok { - continue - } - for methodName, methodSpec := range methods { - methodMap, ok := methodSpec.(map[string]interface{}) - if !ok { - continue - } - if tokens, ok := methodMap["accessTokens"].([]interface{}); ok { - supported := false - for _, t := range tokens { - if ts, ok := t.(string); ok && ts == IdentityToAccessToken(identity) { - supported = true - break - } - } - if !supported { - continue - } - } - rawScopes, ok := methodMap["scopes"].([]interface{}) - if !ok || len(rawScopes) == 0 { - continue - } - // Check for requiredScopes (conjunction — all needed) - var effectiveScopes []string - if reqRaw, ok := methodMap["requiredScopes"].([]interface{}); ok && len(reqRaw) > 0 { - for _, s := range reqRaw { - if str, ok := s.(string); ok { - effectiveScopes = append(effectiveScopes, str) - } - } - } else { - // Pick the single best scope (minimum privilege) - bestScope := "" - bestScore := -1 - for _, s := range rawScopes { - str, ok := s.(string) - if !ok { - continue - } - score := DefaultScopeScore - if v, exists := priorities[str]; exists { - score = v - } - if score > bestScore { - bestScore = score - bestScope = str - } - } - if bestScope != "" { - effectiveScopes = []string{bestScope} - } - } - if len(effectiveScopes) == 0 { - continue - } - - httpMethod := GetStrFromMap(methodMap, "httpMethod") - entries = append(entries, CommandEntry{ - Command: resName + " " + methodName, - Type: "api", - Scopes: effectiveScopes, - HTTPMethod: httpMethod, - }) - } - } + entries = append(entries, CommandEntry{ + Command: ref.ResourceName() + " " + ref.MethodName(), + Type: "api", + Scopes: effectiveScopes, + HTTPMethod: m.HTTPMethod, + }) } sort.Slice(entries, func(i, j int) bool { diff --git a/internal/registry/service_descriptions.json b/internal/registry/service_descriptions.json index 1d7abd2bd..b6fef7caf 100644 --- a/internal/registry/service_descriptions.json +++ b/internal/registry/service_descriptions.json @@ -47,6 +47,10 @@ "en": { "title": "Minutes", "description": "Minutes content and metadata retrieval" }, "zh": { "title": "妙记", "description": "妙记信息获取、内容查询" } }, + "note": { + "en": { "title": "Note", "description": "Meeting note detail and unified transcript retrieval" }, + "zh": { "title": "会议纪要", "description": "会议纪要详情与 unified 逐字稿查询" } + }, "sheets": { "en": { "title": "Sheets", "description": "Spreadsheet operations" }, "zh": { "title": "电子表格", "description": "电子表格操作" } diff --git a/internal/schema/assembler.go b/internal/schema/assembler.go index 59f014805..92200119a 100644 --- a/internal/schema/assembler.go +++ b/internal/schema/assembler.go @@ -4,474 +4,47 @@ package schema import ( - "bytes" - "encoding/json" "sort" - "strconv" - "sync" - "github.com/larksuite/cli/internal/cmdutil" - "github.com/larksuite/cli/internal/registry" + "github.com/larksuite/cli/internal/apicatalog" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/meta" ) -// MethodKeyOrder records the natural meta_data.json key order for one method's -// parameters / requestBody / responseBody. Nested object key orders are stored -// under NestedKeys, keyed by dotted path from the method root -// (e.g. "responseBody.items.properties"). -type MethodKeyOrder struct { - Parameters []string - RequestBody []string - ResponseBody []string - NestedKeys map[string][]string -} - -var ( - keyOrderIndex map[string]*MethodKeyOrder // dottedPath -> order - keyOrderInitOnce sync.Once -) - -// lookupKeyOrder returns the key-order record for service.resourcePath.method, -// or nil if the method is not in the embedded data (e.g. remote-cached). -func lookupKeyOrder(service string, resourcePath []string, method string) *MethodKeyOrder { - keyOrderInitOnce.Do(buildKeyOrderIndex) - if keyOrderIndex == nil { - return nil - } - dotted := dottedPath(service, resourcePath, method) - return keyOrderIndex[dotted] -} - -func dottedPath(service string, resourcePath []string, method string) string { - var buf bytes.Buffer - buf.WriteString(service) - for _, r := range resourcePath { - buf.WriteByte('.') - buf.WriteString(r) - } - buf.WriteByte('.') - buf.WriteString(method) - return buf.String() -} - -// buildKeyOrderIndex parses the embedded meta_data.json bytes once at init, -// walking services -> resources -> methods -> {parameters,requestBody,responseBody} -// and recording each map's key insertion order via json.Decoder.Token(). -func buildKeyOrderIndex() { - raw := registry.EmbeddedMetaJSON() - if len(raw) == 0 { - return - } - keyOrderIndex = make(map[string]*MethodKeyOrder) - - dec := json.NewDecoder(bytes.NewReader(raw)) - // Top-level: { "services": [...], "version": "..." } - if !expectDelim(dec, '{') { - return - } - for dec.More() { - key, _ := readKey(dec) - if key != "services" { - skipValue(dec) - continue - } - if !expectDelim(dec, '[') { - return - } - for dec.More() { - parseService(dec) - } - // closing ] - _, _ = dec.Token() - } -} - -// parseService consumes one service object inside services[]. -// meta_data.json may emit "resources" before "name", so we first capture both -// raw fields, then walk resources with the resolved service name. -func parseService(dec *json.Decoder) { - if !expectDelim(dec, '{') { - return - } - var serviceName string - var resourcesRaw json.RawMessage - for dec.More() { - key, _ := readKey(dec) - switch key { - case "name": - tok, _ := dec.Token() - if s, ok := tok.(string); ok { - serviceName = s - } - case "resources": - if err := dec.Decode(&resourcesRaw); err != nil { - skipValue(dec) - } - default: - skipValue(dec) - } - } - _, _ = dec.Token() // closing } - if serviceName != "" && len(resourcesRaw) > 0 { - subDec := json.NewDecoder(bytes.NewReader(resourcesRaw)) - parseResources(subDec, serviceName, nil) - } -} - -// parseResources walks a resources map (resName -> resource object). -// resourcePath is the accumulated path of parent resources (for nested resources). -func parseResources(dec *json.Decoder, service string, resourcePath []string) { - if !expectDelim(dec, '{') { - return - } - for dec.More() { - resName, _ := readKey(dec) - parseResourceObj(dec, service, append(resourcePath, resName)) - } - _, _ = dec.Token() -} - -// parseResourceObj consumes one resource value: { methods: {...}, ... } and may -// recurse into nested resources via "resources" key if present. -func parseResourceObj(dec *json.Decoder, service string, resourcePath []string) { - if !expectDelim(dec, '{') { - return - } - for dec.More() { - key, _ := readKey(dec) - switch key { - case "methods": - parseMethods(dec, service, resourcePath) - case "resources": - parseResources(dec, service, resourcePath) - default: - skipValue(dec) - } - } - _, _ = dec.Token() -} - -// parseMethods consumes the methods map (methodName -> method object). -func parseMethods(dec *json.Decoder, service string, resourcePath []string) { - if !expectDelim(dec, '{') { - return - } - for dec.More() { - methodName, _ := readKey(dec) - mko := parseMethod(dec) - dotted := dottedPath(service, resourcePath, methodName) - keyOrderIndex[dotted] = mko - } - _, _ = dec.Token() -} - -// parseMethod consumes one method object and records key orders. -func parseMethod(dec *json.Decoder) *MethodKeyOrder { - mko := &MethodKeyOrder{NestedKeys: make(map[string][]string)} - if !expectDelim(dec, '{') { - return mko - } - for dec.More() { - key, _ := readKey(dec) - switch key { - case "parameters": - mko.Parameters = recordObjectKeysRecursive(dec, "parameters", mko.NestedKeys) - case "requestBody": - mko.RequestBody = recordObjectKeysRecursive(dec, "requestBody", mko.NestedKeys) - case "responseBody": - mko.ResponseBody = recordObjectKeysRecursive(dec, "responseBody", mko.NestedKeys) - default: - skipValue(dec) - } - } - _, _ = dec.Token() - return mko -} - -// recordObjectKeysRecursive consumes an object and records the top-level key -// order. It also recurses into each child's "properties" submap, recording -// nested orders under prefix.subpath in nestedKeys. Returns the top-level keys -// in order. -func recordObjectKeysRecursive(dec *json.Decoder, prefix string, nestedKeys map[string][]string) []string { - if !expectDelim(dec, '{') { - return nil - } - var order []string - for dec.More() { - key, _ := readKey(dec) - order = append(order, key) - // Each child value is itself an object; we want its nested "properties" order if present. - consumeFieldRecursive(dec, prefix+"."+key, nestedKeys) - } - _, _ = dec.Token() - if prefix != "" && len(order) > 0 { - nestedKeys[prefix] = order - } - return order -} - -// consumeFieldRecursive consumes a field object (e.g. one parameter spec) and, -// if it contains "properties": {...}, recursively records that submap's order. -func consumeFieldRecursive(dec *json.Decoder, path string, nestedKeys map[string][]string) { - tok, err := dec.Token() - if err != nil { - return - } - delim, ok := tok.(json.Delim) - if !ok || delim != '{' { - // Not an object — skip the rest of the value - skipValueAfterToken(dec, tok) - return - } - for dec.More() { - fieldKey, _ := readKey(dec) - if fieldKey == "properties" { - recordObjectKeysRecursive(dec, path+".properties", nestedKeys) - } else { - skipValue(dec) - } - } - _, _ = dec.Token() -} - -// --- json.Decoder helpers --- - -func expectDelim(dec *json.Decoder, want json.Delim) bool { - tok, err := dec.Token() - if err != nil { - return false - } - delim, ok := tok.(json.Delim) - return ok && delim == want -} - -func readKey(dec *json.Decoder) (string, error) { - tok, err := dec.Token() - if err != nil { - return "", err - } - s, _ := tok.(string) - return s, nil -} - -// skipValue consumes the next complete value (scalar, object, or array). -func skipValue(dec *json.Decoder) { - tok, err := dec.Token() - if err != nil { - return - } - skipValueAfterToken(dec, tok) -} - -func skipValueAfterToken(dec *json.Decoder, tok json.Token) { - delim, ok := tok.(json.Delim) - if !ok { - return - } - // We started inside a container of type `delim` ({ or [) and must eat - // tokens until that container closes, tracking nested containers of any - // kind. depth counts how many open containers we are currently inside. - _ = delim - depth := 1 - for depth > 0 { - t, err := dec.Token() - if err != nil { - return - } - if d, ok := t.(json.Delim); ok { - switch d { - case '{', '[': - depth++ - case '}', ']': - depth-- - } - } - } -} - -// coerceLiteral converts a meta_data literal (default / enum / example) to -// the JSON Schema type declared by the field (integer/number/boolean/string). -// meta_data stores every literal as a string, so without coercion an -// `integer` field would emit string literals and fail any standard validator. -// Already-typed values pass through unchanged. Returns (value, true) on -// success, or (nil, false) when the literal cannot be coerced (caller should -// drop it). -func coerceLiteral(fieldType string, raw interface{}) (interface{}, bool) { - s, isStr := raw.(string) - if !isStr { - // Already typed (e.g. meta_data emitted a JSON number/bool directly). - return raw, true - } - switch fieldType { - case "integer": - if v, err := strconv.ParseInt(s, 10, 64); err == nil { - return v, true - } - return nil, false - case "number": - if v, err := strconv.ParseFloat(s, 64); err == nil { - return v, true - } - return nil, false - case "boolean": - switch s { - case "true": - return true, true - case "false": - return false, true - } - return nil, false - default: // "string", "" (nested objects), or unknown - return s, true - } -} - -// sortEnum sorts an enum slice in-place using a comparator appropriate for -// the declared JSON Schema type, so integer enums end up [1, 2, 10] rather -// than the lexicographic [1, 10, 2]. -func sortEnum(fieldType string, vals []interface{}) { - sort.SliceStable(vals, func(i, j int) bool { - switch fieldType { - case "integer": - ai, _ := vals[i].(int64) - bi, _ := vals[j].(int64) - return ai < bi - case "number": - af, _ := vals[i].(float64) - bf, _ := vals[j].(float64) - return af < bf - case "boolean": - ab, _ := vals[i].(bool) - bb, _ := vals[j].(bool) - return !ab && bb // false < true - default: - as, _ := vals[i].(string) - bs, _ := vals[j].(string) - return as < bs - } - }) -} - -// convertProperty recursively converts one meta_data field map into a Property. -// nestedPath is the dotted lookup key into the current method's NestedKeys map -// (e.g. "responseBody.items.properties"). Empty path = top-level, no nested -// lookup needed. -func convertProperty(field map[string]interface{}, nestedPath string) Property { +// Convert renders a meta.Field as a JSON-Schema Property. meta owns the value +// normalization (canonical type, literal coercion, enum ordering); this adds +// only the JSON-Schema-specific shape: the "file" binary format, numeric +// bounds, nested object/array properties, and the array-items fallback. +func Convert(f meta.Field) Property { var p Property - rawType, _ := field["type"].(string) - switch rawType { - case "file": - p.Type = "string" + p.Type = f.CanonicalType() + if f.Type == "file" { p.Format = "binary" - case "list": - // meta_data uses non-standard "list" on a couple of fields; - // translate to JSON Schema "array" so validators accept it. - p.Type = "array" - default: - p.Type = rawType } + p.Description = f.Description + p.Default = f.CoercedDefault() + p.Example = f.CoercedExample() + p.Minimum = f.MinBound() + p.Maximum = f.MaxBound() + p.Enum, p.EnumDescriptions = enumSchema(f.EnumOptions()) - if s, ok := field["description"].(string); ok { - p.Description = s - } - if v, ok := field["default"]; ok { - // Coerce default literal to match the declared JSON Schema type so - // validators do not reject e.g. {type:"integer", default:"500"}. - // When coercion fails (e.g. default:"" on an integer field, which - // meta_data uses to mean "no default"), omit the field entirely - // instead of emitting a type-mismatched default — the result is a - // missing `default` key rather than a contract violation. - if coerced, ok := coerceLiteral(p.Type, v); ok { - p.Default = coerced - } - } - if v, ok := field["example"]; ok { - // meta_data stores examples as strings even when the field is integer/ - // boolean/number; coerce to the declared type so downstream validators - // accept the envelope. Drop on coerce failure (same policy as default). - if coerced, ok := coerceLiteral(p.Type, v); ok { - p.Example = coerced - } - } - - // min / max are stored as strings in meta_data; parse on best-effort. - if minStr, ok := field["min"].(string); ok && minStr != "" { - if v, err := strconv.ParseFloat(minStr, 64); err == nil { - p.Minimum = &v - } - } - if maxStr, ok := field["max"].(string); ok && maxStr != "" { - if v, err := strconv.ParseFloat(maxStr, 64); err == nil { - p.Maximum = &v - } - } - - // enum: prefer existing "enum" array; else extract from options[].value. - // Values are typed per p.Type so integer fields get integer enums, etc. - // (JSON Schema 2020-12 requires enum value types to match the declared - // type — meta_data stores everything as strings.) - if enumRaw, ok := field["enum"].([]interface{}); ok && len(enumRaw) > 0 { - for _, e := range enumRaw { - if v, ok := coerceLiteral(p.Type, e); ok { - p.Enum = append(p.Enum, v) - } - } - // Numeric/boolean enums get sorted (no inherent meaning in meta_data - // order); string enums keep meta_data order, which sometimes carries - // semantic priority (e.g. image_type ["message","avatar"]). - if p.Type != "string" && p.Type != "" { - sortEnum(p.Type, p.Enum) - } - } else if optsRaw, ok := field["options"].([]interface{}); ok && len(optsRaw) > 0 { - seen := make(map[string]bool) - for _, o := range optsRaw { - om, ok := o.(map[string]interface{}) - if !ok { - continue - } - raw, ok := om["value"].(string) - if !ok || seen[raw] { - continue - } - seen[raw] = true - if v, ok := coerceLiteral(p.Type, raw); ok { - p.Enum = append(p.Enum, v) - } - } - // Same policy as the `enum` branch: numeric/boolean enums get sorted - // (no semantic meaning in source order); string enums keep meta_data - // order, which may carry semantic priority. - if p.Type != "string" && p.Type != "" { - sortEnum(p.Type, p.Enum) - } - } - - // nested properties: recurse - if propsRaw, ok := field["properties"].(map[string]interface{}); ok && len(propsRaw) > 0 { - nested, nestedRequired := buildOrderedProps(propsRaw, nestedPath) + if children := f.Children(); len(children) > 0 { + props, required := propsOf(children), requiredOf(children) if p.Type == "array" { // meta_data quirk: array element schema is wrapped in "properties". - // Unfold into Items: { type: "object", properties: } - p.Items = &Property{ - Type: "object", - Properties: nested, - Required: nestedRequired, - } - // Property.Properties stays nil for arrays + p.Items = &Property{Type: "object", Properties: props, Required: required} } else { if p.Type == "" { p.Type = "object" // infer } - p.Properties = nested - p.Required = nestedRequired + p.Properties = props + p.Required = required } } - // array items fallback: emit `items: {}` (any schema) for every array that - // meta_data does not describe an element shape for — whether it arrived as - // "list" or natively as "array". Without this, typeless arrays (e.g. arrays - // of bare ID strings) violate the L1 lint rule and are not JSON Schema valid - // for consumers that require `items`. + // Every array needs an items schema to be valid for consumers that require + // one, even when meta_data describes no element shape. if p.Type == "array" && p.Items == nil { p.Items = &Property{} } @@ -479,396 +52,162 @@ func convertProperty(field map[string]interface{}, nestedPath string) Property { return p } -// buildOrderedProps converts a map[string]interface{} of field specs into an -// OrderedProps plus the alphabetized list of child keys marked `required:true` -// in meta_data. Callers attach that list to the enclosing object's `required`, -// so nested objects faithfully report their call contract (top-level required -// is handled separately by buildInputSchema). -func buildOrderedProps(raw map[string]interface{}, nestedPath string) (*OrderedProps, []string) { - op := &OrderedProps{Map: make(map[string]Property, len(raw))} +// enumSchema splits coerced enum options into the parallel enum / enumDescriptions +// arrays for the envelope. enumDescriptions is nil unless at least one value +// carries a description (so the bare-enum form stays values-only), keeping the +// two arrays index-aligned for AI consumers. +func enumSchema(opts []meta.EnumOption) (values []interface{}, descriptions []string) { + if len(opts) == 0 { + return nil, nil + } + values = make([]interface{}, len(opts)) + descs := make([]string, len(opts)) + hasDesc := false + for i, o := range opts { + values[i] = o.Value + descs[i] = o.Description + if o.Description != "" { + hasDesc = true + } + } + if hasDesc { + descriptions = descs + } + return values, descriptions +} +// propsOf renders fields as an ordered JSON-Schema property map. meta's field +// accessors return fields sorted by name, so the property order is alphabetical. +func propsOf(fields []meta.Field) *OrderedProps { + op := &OrderedProps{} + for _, f := range fields { + op.Set(f.Name, Convert(f)) + } + return op +} + +// requiredOf returns the alphabetized names of the required fields. +func requiredOf(fields []meta.Field) []string { var required []string - keys := orderedKeys(raw, nestedPath) - for _, k := range keys { - fieldRaw, _ := raw[k].(map[string]interface{}) - op.Order = append(op.Order, k) - op.Map[k] = convertProperty(fieldRaw, nestedPath+"."+k+".properties") - if req, _ := fieldRaw["required"].(bool); req { - required = append(required, k) + for _, f := range fields { + if f.Required { + required = append(required, f.Name) } } sort.Strings(required) - return op, required + return required } -// currentMethodOrder is the per-method key-order context used by orderedKeys. -// It is set inside AssembleEnvelope (under assembleMu) and reset on return. -var currentMethodOrder *MethodKeyOrder - -// parseAffordance lifts the affordance overlay from a method's raw meta_data.json -// entry into a typed *Affordance. Returns nil when the field is absent, malformed, -// or carries no populated subfields. -// -// Affordance is authored in larksuite-cli-registry's registry-config.yaml under -// overrides...affordance and flows through gen-registry.py's -// deep_merge into the embedded meta_data.json. -func parseAffordance(raw interface{}) *Affordance { - if raw == nil { - return nil - } - b, err := json.Marshal(raw) - if err != nil { - return nil - } - var a Affordance - if err := json.Unmarshal(b, &a); err != nil { - return nil - } - if len(a.UseWhen) == 0 && len(a.DoNotUseWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Examples) == 0 && len(a.Related) == 0 { - return nil - } - return &a -} - -// convertAccessTokens translates from_meta accessTokens (uses "tenant") into -// CLI --as form (uses "bot"). The result is deduped and sorted alphabetically. -// Unknown tokens are dropped. Returns an empty slice for nil/empty input. -func convertAccessTokens(raw []interface{}) []string { - seen := make(map[string]bool) - for _, t := range raw { - s, ok := t.(string) - if !ok { - continue - } - switch s { - case "tenant": - seen["bot"] = true - case "user": - seen["user"] = true - } - } - out := make([]string, 0, len(seen)) - for k := range seen { - out = append(out, k) - } - sort.Strings(out) - return out -} - -// buildMeta produces the _meta extension namespace. -func buildMeta(method map[string]interface{}) *Meta { - m := &Meta{ - EnvelopeVersion: "1.0", - RequiredScopes: []string{}, // never nil for stable JSON - } - - if scopesRaw, ok := method["scopes"].([]interface{}); ok { - for _, s := range scopesRaw { - if str, ok := s.(string); ok { - m.Scopes = append(m.Scopes, str) - } - } - } - if rsRaw, ok := method["requiredScopes"].([]interface{}); ok { - for _, s := range rsRaw { - if str, ok := s.(string); ok { - m.RequiredScopes = append(m.RequiredScopes, str) - } - } - } - - atRaw, _ := method["accessTokens"].([]interface{}) - m.AccessTokens = convertAccessTokens(atRaw) - - m.Danger, _ = method["danger"].(bool) - - if risk, _ := method["risk"].(string); risk != "" { - m.Risk = risk - } else { - m.Risk = cmdutil.RiskRead - } - - if docURL, _ := method["docUrl"].(string); docURL != "" { - m.DocURL = docURL - } - - m.Affordance = parseAffordance(method["affordance"]) - return m -} - -// buildInputSchema produces the inputSchema for one API method. -// -// Top-level shape: -// -// { type: object, -// required: [<"params" if any param required>, <"data" if any body required>], -// properties: { -// params: { type: object, required: [...], properties: { ...path/query fields } }, // only if method has parameters -// data: { type: object, required: [...], properties: { ...body fields } }, // only if method has requestBody -// yes: { type: boolean, default: false, ... } // only when risk == "high-risk-write" -// } } -// -// The params / data wrapping mirrors the CLI's actual flag layout: -// path+query → --params JSON, body → --data JSON, file → --file. AI consumers -// can pluck inputSchema.properties.params and pass it verbatim to --params. -// -// Caller must set currentMethodOrder for property-order preservation. -func buildInputSchema(method map[string]interface{}) *InputSchema { +// buildInputSchema produces the inputSchema sections — params (path+query → +// --params), data (non-file body → --data), file (file body → --file) — plus a +// `yes` confirmation gate for high-risk-write methods. +func buildInputSchema(m meta.Method) *InputSchema { is := &InputSchema{ Type: "object", Required: []string{}, // never nil — stable envelope shape - Properties: &OrderedProps{Map: make(map[string]Property)}, + Properties: &OrderedProps{}, } - // Build the "params" sub-object from method.parameters (path + query). - paramsRaw, _ := method["parameters"].(map[string]interface{}) - paramsProps := &OrderedProps{Map: make(map[string]Property)} - var paramsRequired []string - for _, k := range orderedKeys(paramsRaw, "parameters") { - field, _ := paramsRaw[k].(map[string]interface{}) - prop := convertProperty(field, "parameters."+k+".properties") - paramsProps.Order = append(paramsProps.Order, k) - paramsProps.Map[k] = prop - if req, _ := field["required"].(bool); req { - paramsRequired = append(paramsRequired, k) - } - } - if len(paramsProps.Order) > 0 { - sort.Strings(paramsRequired) - is.Properties.Order = append(is.Properties.Order, "params") - is.Properties.Map["params"] = Property{ - Type: "object", - Required: paramsRequired, - Properties: paramsProps, - } - if len(paramsRequired) > 0 { - is.Required = append(is.Required, "params") - } - } + addInputObject(is, "params", "", m.Params()) + addInputObject(is, "data", "", m.Data()) + addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file =.", m.Files()) - // Split method.requestBody into two buckets: - // - data: non-file body fields → corresponds to CLI --data JSON - // - file: type:file body fields → corresponds to CLI --file = - // File fields are kept *out* of `data` so the schema mirrors the actual - // CLI flag dispatch: --file owns one wire format (multipart upload), - // --data owns the rest (JSON body). - bodyRaw, _ := method["requestBody"].(map[string]interface{}) - dataProps := &OrderedProps{Map: make(map[string]Property)} - fileProps := &OrderedProps{Map: make(map[string]Property)} - var dataRequired []string - var fileRequired []string - for _, k := range orderedKeys(bodyRaw, "requestBody") { - field, _ := bodyRaw[k].(map[string]interface{}) - prop := convertProperty(field, "requestBody."+k+".properties") - isFile := false - if t, _ := field["type"].(string); t == "file" { - isFile = true - } - if isFile { - fileProps.Order = append(fileProps.Order, k) - fileProps.Map[k] = prop - if req, _ := field["required"].(bool); req { - fileRequired = append(fileRequired, k) - } - } else { - dataProps.Order = append(dataProps.Order, k) - dataProps.Map[k] = prop - if req, _ := field["required"].(bool); req { - dataRequired = append(dataRequired, k) - } - } - } - if len(dataProps.Order) > 0 { - sort.Strings(dataRequired) - is.Properties.Order = append(is.Properties.Order, "data") - is.Properties.Map["data"] = Property{ - Type: "object", - Required: dataRequired, - Properties: dataProps, - } - if len(dataRequired) > 0 { - is.Required = append(is.Required, "data") - } - } - if len(fileProps.Order) > 0 { - sort.Strings(fileRequired) - is.Properties.Order = append(is.Properties.Order, "file") - is.Properties.Map["file"] = Property{ - Type: "object", - Description: "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file =.", - Required: fileRequired, - Properties: fileProps, - } - if len(fileRequired) > 0 { - is.Required = append(is.Required, "file") - } - } - - // high-risk-write injects a top-level `yes` confirmation flag — sibling - // of params/data. It is a CLI gate (consumed by lark-cli, not sent to - // the backend), not an API field. - if risk, _ := method["risk"].(string); risk == cmdutil.RiskHighRiskWrite { - is.Properties.Order = append(is.Properties.Order, "yes") + if m.Risk == core.RiskHighRiskWrite { falseVal := false - is.Properties.Map["yes"] = Property{ + is.Properties.Set("yes", Property{ Type: "boolean", Default: falseVal, Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Not sent to the backend.", - } - // yes is intentionally NOT added to top-level Required; the gate is - // enforced semantically (yes==true) by the CLI, not structurally. + }) } - sort.Strings(is.Required) // alphabetical + sort.Strings(is.Required) return is } -// buildOutputSchema produces the outputSchema for one API method. -func buildOutputSchema(method map[string]interface{}) *OutputSchema { - os := &OutputSchema{ - Type: "object", - Properties: &OrderedProps{Map: make(map[string]Property)}, +// addInputObject adds one named sub-object section (params/data/file) to the +// input schema when it has fields: its Properties come from the fields, its +// Required lists the mandatory keys, and the section itself is required at top +// level when any field is required. Empty sections are skipped. +func addInputObject(is *InputSchema, name, description string, fields []meta.Field) { + if len(fields) == 0 { + return } - respRaw, _ := method["responseBody"].(map[string]interface{}) - for _, k := range orderedKeys(respRaw, "responseBody") { - field, _ := respRaw[k].(map[string]interface{}) - os.Properties.Order = append(os.Properties.Order, k) - os.Properties.Map[k] = convertProperty(field, "responseBody."+k+".properties") + req := requiredOf(fields) + is.Properties.Set(name, Property{ + Type: "object", + Description: description, + Required: req, + Properties: propsOf(fields), + }) + if len(req) > 0 { + is.Required = append(is.Required, name) } - return os } -// assembleMu serializes AssembleEnvelope calls so that the package-level -// currentMethodOrder pointer is safe for concurrent callers. -var assembleMu sync.Mutex +// buildOutputSchema produces the outputSchema from the response-body fields. +func buildOutputSchema(m meta.Method) *OutputSchema { + return &OutputSchema{Type: "object", Properties: propsOf(m.Response())} +} -// AssembleEnvelope is the main entry point: takes a service / resource path / -// method name plus its meta_data spec, and produces a fully assembled MCP -// envelope. Output is fully determined by inputs (same arguments → same -// envelope), but assembly briefly publishes the per-method key-order context -// through the package-level currentMethodOrder so orderedKeys can reach it -// without threading it through every helper. assembleMu serializes that -// publish, which is why concurrent callers are still safe — they queue -// rather than run in parallel. -// -// If parallelism becomes a bottleneck, replace currentMethodOrder with an -// assembler struct or pass *MethodKeyOrder explicitly down the call chain. -func AssembleEnvelope(serviceName string, resourcePath []string, methodName string, method map[string]interface{}) Envelope { - assembleMu.Lock() - defer assembleMu.Unlock() - currentMethodOrder = lookupKeyOrder(serviceName, resourcePath, methodName) - defer func() { currentMethodOrder = nil }() +// buildMeta produces the _meta extension namespace. +func buildMeta(m meta.Method) *Meta { + out := &Meta{ + EnvelopeVersion: "1.0", + RequiredScopes: []string{}, // never nil for stable JSON + Scopes: m.Scopes, + AccessTokens: m.Identities(), + Danger: m.Danger, + } + if a, ok := m.ParsedAffordance(); ok { + out.Affordance = &a + } + if len(m.RequiredScopes) > 0 { + out.RequiredScopes = m.RequiredScopes + } + if m.Risk != "" { + out.Risk = m.Risk + } else { + out.Risk = core.RiskRead + } + if m.DocURL != "" { + out.DocURL = m.DocURL + } + return out +} +// EnvelopeOf renders the MCP envelope for one method ref — the ref-based entry +// callers use, since apicatalog.MethodRef is the metadata navigation currency. +func EnvelopeOf(ref apicatalog.MethodRef) Envelope { + return assemble(ref.Service.Name, ref.ResourcePath, ref.Method) +} + +// Envelopes renders the given method refs into envelopes, sorted by name. The +// caller supplies the refs (from apicatalog navigation), so this package owns +// only rendering — never metadata source selection or traversal. +func Envelopes(refs []apicatalog.MethodRef) []Envelope { + var out []Envelope + for _, ref := range refs { + out = append(out, EnvelopeOf(ref)) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +// assemble builds the envelope from a method's navigation context. The method +// name comes from m.Name, injected by the typed accessors. +func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope { name := serviceName for _, r := range resourcePath { name += " " + r } - name += " " + methodName - - desc, _ := method["description"].(string) + name += " " + m.Name return Envelope{ Name: name, - Description: desc, - InputSchema: buildInputSchema(method), - OutputSchema: buildOutputSchema(method), - Meta: buildMeta(method), + Description: m.Description, + InputSchema: buildInputSchema(m), + OutputSchema: buildOutputSchema(m), + Meta: buildMeta(m), } } - -// MethodFilter is an optional predicate used by AssembleService and -// AssembleAll to filter methods (e.g. by access token for strict mode). -// Pass nil to include all methods. -type MethodFilter func(method map[string]interface{}) bool - -// AssembleService assembles all methods under one service into a sorted -// envelope slice (sorted by Envelope.Name ascending). -func AssembleService(serviceName string, spec map[string]interface{}, filter MethodFilter) []Envelope { - if spec == nil { - return nil - } - resources, _ := spec["resources"].(map[string]interface{}) - var out []Envelope - walkMethods(resources, nil, func(resourcePath []string, methodName string, method map[string]interface{}) { - if filter != nil && !filter(method) { - return - } - out = append(out, AssembleEnvelope(serviceName, resourcePath, methodName, method)) - }) - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out -} - -// AssembleAll assembles every embedded service into one big sorted slice. -// Uses embedded data only (bypasses remote overlay) so envelope output is -// deterministic across machines (CI vs dev vs different user brands). -func AssembleAll(filter MethodFilter) []Envelope { - var out []Envelope - for _, svc := range registry.EmbeddedServiceNames() { - spec := registry.EmbeddedSpec(svc) - out = append(out, AssembleService(svc, spec, filter)...) - } - sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) - return out -} - -// walkMethods recursively walks resources -> methods, calling visit for each -// terminal method. It supports nested resources via the optional "resources" -// key inside a resource value (matches meta_data.json structure). -func walkMethods(resources map[string]interface{}, parentPath []string, - visit func(resourcePath []string, methodName string, method map[string]interface{})) { - for resName, resRaw := range resources { - resMap, ok := resRaw.(map[string]interface{}) - if !ok { - continue - } - curPath := append(append([]string(nil), parentPath...), resName) - if methods, ok := resMap["methods"].(map[string]interface{}); ok { - for mName, mRaw := range methods { - if m, ok := mRaw.(map[string]interface{}); ok { - visit(curPath, mName, m) - } - } - } - if nested, ok := resMap["resources"].(map[string]interface{}); ok { - walkMethods(nested, curPath, visit) - } - } -} - -// orderedKeys returns the keys of raw in their meta_data natural order if -// the current per-method key-order context has them recorded; otherwise -// alphabetical fallback. -func orderedKeys(raw map[string]interface{}, nestedPath string) []string { - if currentMethodOrder != nil && nestedPath != "" { - if order, ok := currentMethodOrder.NestedKeys[nestedPath]; ok { - // Filter to keys that actually exist in raw (defensive) - out := make([]string, 0, len(order)) - seen := make(map[string]bool) - for _, k := range order { - if _, ok := raw[k]; ok { - out = append(out, k) - seen[k] = true - } - } - // Append any keys present in raw but missing from order (defensive), - // alphabetically for determinism. - var extra []string - for k := range raw { - if !seen[k] { - extra = append(extra, k) - } - } - sort.Strings(extra) - out = append(out, extra...) - return out - } - } - // Fallback: alphabetical - keys := make([]string, 0, len(raw)) - for k := range raw { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} diff --git a/internal/schema/assembler_test.go b/internal/schema/assembler_test.go index 6fa0e8bf2..c7bd2a0c4 100644 --- a/internal/schema/assembler_test.go +++ b/internal/schema/assembler_test.go @@ -10,6 +10,8 @@ import ( "strings" "testing" + "github.com/larksuite/cli/internal/apicatalog" + "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/registry" ) @@ -35,58 +37,6 @@ func TestMain(m *testing.M) { os.Exit(code) } -func TestKeyOrderIndex_ImReactionsList(t *testing.T) { - // We only assert key-set membership, not absolute order — the upstream - // meta_data API does not guarantee a stable JSON key sequence across - // fetches, so hard-coding the order makes CI flaky. Order preservation - // from input to output is tested separately in TestBuildInputSchema_*. - order := lookupKeyOrder("im", []string{"reactions"}, "list") - if order == nil { - t.Fatal("expected key order for im.reactions.list, got nil") - } - wantParams := map[string]bool{ - "message_id": true, "reaction_type": true, "page_token": true, - "page_size": true, "user_id_type": true, - } - if got, want := len(order.Parameters), len(wantParams); got != want { - t.Errorf("parameters count = %d, want %d (got %v)", got, want, order.Parameters) - } - for _, k := range order.Parameters { - if !wantParams[k] { - t.Errorf("unexpected parameter key %q", k) - } - } - // im.reactions.list 是 GET,没有 requestBody - if len(order.RequestBody) != 0 { - t.Errorf("expected empty RequestBody, got %v", order.RequestBody) - } -} - -func TestKeyOrderIndex_ImImagesCreate(t *testing.T) { - // Membership-only assertion; see comment on TestKeyOrderIndex_ImReactionsList. - order := lookupKeyOrder("im", []string{"images"}, "create") - if order == nil { - t.Fatal("expected key order for im.images.create, got nil") - } - wantBody := map[string]bool{"image_type": true, "image": true} - if got, want := len(order.RequestBody), len(wantBody); got != want { - t.Errorf("requestBody count = %d, want %d (got %v)", got, want, order.RequestBody) - } - for _, k := range order.RequestBody { - if !wantBody[k] { - t.Errorf("unexpected requestBody key %q", k) - } - } -} - -func TestKeyOrderIndex_UnknownPath(t *testing.T) { - // 远端缓存的命令(不在 embedded 内)查不到 key order,返回 nil 走字母序兜底 - order := lookupKeyOrder("nonexistent_service", []string{"foo"}, "bar") - if order != nil { - t.Errorf("expected nil for unknown path, got %+v", order) - } -} - func TestConvertProperty_BasicTypes(t *testing.T) { tests := []struct { name string @@ -288,9 +238,6 @@ func TestConvertProperty_DescriptionDefaultExample(t *testing.T) { func TestBuildInputSchema_ReactionsList(t *testing.T) { method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") - mko := lookupKeyOrder("im", []string{"reactions"}, "list") - currentMethodOrder = mko - defer func() { currentMethodOrder = nil }() is := buildInputSchema(method) @@ -313,16 +260,13 @@ func TestBuildInputSchema_ReactionsList(t *testing.T) { if !reflect.DeepEqual(params.Required, []string{"message_id"}) { t.Errorf("params.Required = %v, want [message_id]", params.Required) } - if !reflect.DeepEqual(params.Properties.Order, mko.Parameters) { - t.Errorf("params.properties order = %v, want (from key index) %v", - params.Properties.Order, mko.Parameters) + if want := []string{"message_id", "page_size", "page_token", "reaction_type", "user_id_type"}; !reflect.DeepEqual(params.Properties.Order, want) { + t.Errorf("params.properties order = %v, want %v (alphabetical)", params.Properties.Order, want) } } func TestBuildInputSchema_ImagesCreate_FileAndBody(t *testing.T) { method := loadMethodFromRegistry(t, "im", []string{"images"}, "create") - currentMethodOrder = lookupKeyOrder("im", []string{"images"}, "create") - defer func() { currentMethodOrder = nil }() is := buildInputSchema(method) @@ -382,10 +326,8 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) { }, }, } - currentMethodOrder = nil - defer func() { currentMethodOrder = nil }() - is := buildInputSchema(method) + is := buildInputSchema(meta.FromMap(method)) // yes lives at inputSchema.properties.yes (sibling of params/data) yes, ok := is.Properties.Map["yes"] @@ -413,9 +355,6 @@ func TestBuildInputSchema_HighRiskWriteInjectsYes(t *testing.T) { func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) { method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") - mko := lookupKeyOrder("im", []string{"reactions"}, "list") - currentMethodOrder = mko - defer func() { currentMethodOrder = nil }() is := buildInputSchema(method) if _, ok := is.Properties.Map["yes"]; ok { @@ -425,9 +364,6 @@ func TestBuildInputSchema_NoYesForReadRisk(t *testing.T) { func TestBuildOutputSchema_ReactionsList(t *testing.T) { method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") - mko := lookupKeyOrder("im", []string{"reactions"}, "list") - currentMethodOrder = mko - defer func() { currentMethodOrder = nil }() os := buildOutputSchema(method) @@ -450,31 +386,6 @@ func TestBuildOutputSchema_ReactionsList(t *testing.T) { } } -func TestConvertAccessTokens(t *testing.T) { - tests := []struct { - name string - input []interface{} - want []string - }{ - {"tenant only", []interface{}{"tenant"}, []string{"bot"}}, - {"user only", []interface{}{"user"}, []string{"user"}}, - {"tenant then user", []interface{}{"tenant", "user"}, []string{"bot", "user"}}, - {"user then tenant", []interface{}{"user", "tenant"}, []string{"bot", "user"}}, - {"deduped", []interface{}{"tenant", "tenant", "user"}, []string{"bot", "user"}}, - {"empty", []interface{}{}, []string{}}, - {"nil", nil, []string{}}, - {"unknown skipped", []interface{}{"user", "admin"}, []string{"user"}}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := convertAccessTokens(tt.input) - if !reflect.DeepEqual(got, tt.want) { - t.Errorf("got %v, want %v", got, tt.want) - } - }) - } -} - func TestBuildMeta_FullFields(t *testing.T) { // Synthesized method to avoid runtime variance from remote-cache overlay // (which strips `risk` from merged services). All other field semantics @@ -489,7 +400,7 @@ func TestBuildMeta_FullFields(t *testing.T) { "accessTokens": []interface{}{"tenant"}, "docUrl": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/image/create", } - m := buildMeta(method) + m := buildMeta(meta.FromMap(method)) if m.EnvelopeVersion != "1.0" { t.Errorf("EnvelopeVersion = %q", m.EnvelopeVersion) @@ -526,7 +437,7 @@ func TestBuildMeta_MissingRiskDefaultsToRead(t *testing.T) { "accessTokens": []interface{}{"user"}, // no risk field } - m := buildMeta(method) + m := buildMeta(meta.FromMap(method)) if m.Risk != "read" { t.Errorf("Risk = %q, want \"read\" (default for missing risk)", m.Risk) } @@ -540,58 +451,26 @@ func TestBuildMeta_RequiredScopesPresent(t *testing.T) { } } -func TestParseAffordance_NilOrEmpty(t *testing.T) { - cases := []struct { - name string - raw interface{} - }{ - {"nil", nil}, - {"empty object", map[string]interface{}{}}, - {"all-five-empty-arrays", map[string]interface{}{ - "use_when": []interface{}{}, - "do_not_use_when": []interface{}{}, - "prerequisites": []interface{}{}, - "examples": []interface{}{}, - "related": []interface{}{}, - }}, - {"malformed (string)", "not an object"}, - {"malformed (number)", 42}, - {"malformed (nested type mismatch)", map[string]interface{}{ - "examples": "should be a list, not a string", - }}, +func TestConvert_EnumDescriptions(t *testing.T) { + // options carrying descriptions -> enum + parallel enumDescriptions + withDesc := Convert(meta.Field{Type: "string", Options: []meta.Option{ + {Value: "open_id", Description: "A"}, + {Value: "user_id", Description: "B"}, + }}) + if !reflect.DeepEqual(withDesc.Enum, []interface{}{"open_id", "user_id"}) { + t.Errorf("Enum = %v", withDesc.Enum) } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := parseAffordance(c.raw); got != nil { - t.Errorf("parseAffordance(%v) = %+v, want nil", c.raw, got) - } - }) + if !reflect.DeepEqual(withDesc.EnumDescriptions, []string{"A", "B"}) { + t.Errorf("EnumDescriptions = %v, want [A B] aligned with enum", withDesc.EnumDescriptions) } -} -func TestParseAffordance_FullPopulated(t *testing.T) { - raw := map[string]interface{}{ - "use_when": []interface{}{"需要拿到当前用户的主日历 ID"}, - "do_not_use_when": []interface{}{"已知具体某一个非主日历的 calendar_id"}, - "prerequisites": []interface{}{"user 身份登录"}, - "examples": []interface{}{ - map[string]interface{}{"description": "获取主日历", "command": "lark-cli calendar calendars primary"}, - }, - "related": []interface{}{"calendars.list"}, + // bare enum form (no descriptions) -> enumDescriptions omitted (nil) + bare := Convert(meta.Field{Type: "string", Enum: []any{"x", "y"}}) + if !reflect.DeepEqual(bare.Enum, []interface{}{"x", "y"}) { + t.Errorf("bare Enum = %v", bare.Enum) } - a := parseAffordance(raw) - if a == nil { - t.Fatal("parseAffordance returned nil, want populated") - } - if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" { - t.Errorf("UseWhen = %v", a.UseWhen) - } - if len(a.Examples) != 1 || a.Examples[0].Description != "获取主日历" || - a.Examples[0].Command != "lark-cli calendar calendars primary" { - t.Errorf("Examples = %+v", a.Examples) - } - if len(a.Related) != 1 || a.Related[0] != "calendars.list" { - t.Errorf("Related = %v", a.Related) + if bare.EnumDescriptions != nil { + t.Errorf("bare enum must have nil EnumDescriptions, got %v", bare.EnumDescriptions) } } @@ -604,7 +483,7 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) { "use_when": []interface{}{"trigger"}, }, } - m := buildMeta(method) + m := buildMeta(meta.FromMap(method)) if m.Affordance == nil { t.Fatal("Affordance should be populated from method[\"affordance\"]") } @@ -620,7 +499,7 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) { "risk": "read", // no docUrl } - m := buildMeta(method) + m := buildMeta(meta.FromMap(method)) if m.DocURL != "" { t.Errorf("DocURL = %q, want empty (will be omitempty)", m.DocURL) } @@ -634,8 +513,7 @@ func TestBuildMeta_MissingDocURLOmitted(t *testing.T) { func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) { // 装配器对空 responseBody 应生成 properties = {} (不 nil) method := map[string]interface{}{} - currentMethodOrder = nil - os := buildOutputSchema(method) + os := buildOutputSchema(meta.FromMap(method)) if os.Type != "object" { t.Errorf("Type = %q, want \"object\"", os.Type) } @@ -647,9 +525,16 @@ func TestBuildOutputSchema_EmptyResponseBody(t *testing.T) { } } +// synthEnvelope renders an envelope for a synthetic (service, resourcePath, method) +// via the public ref entry, so these unit tests build the same MethodRef the +// command layer feeds Envelope. +func synthEnvelope(serviceName string, resourcePath []string, m meta.Method) Envelope { + return EnvelopeOf(apicatalog.MethodRef{Service: meta.Service{Name: serviceName}, ResourcePath: resourcePath, Method: m}) +} + func TestAssembleEnvelope_ReactionsList_FullStructure(t *testing.T) { method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") - env := AssembleEnvelope("im", []string{"reactions"}, "list", method) + env := synthEnvelope("im", []string{"reactions"}, method) if env.Name != "im reactions list" { t.Errorf("Name = %q, want \"im reactions list\"", env.Name) @@ -671,7 +556,7 @@ func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) { // overlay strips `bots` from the loaded method map on this environment; // the assertion is about name joining, not method specifics. method := loadMethodFromRegistry(t, "im", []string{"chat.members"}, "create") - env := AssembleEnvelope("im", []string{"chat.members"}, "create", method) + env := synthEnvelope("im", []string{"chat.members"}, method) // chat.members resourcePath stays as one element in the slice with a dot; // name should split it to "im chat.members create" — we keep the dot as-is // inside the resource segment to round-trip with completion logic. @@ -683,8 +568,8 @@ func TestAssembleEnvelope_NestedResource_NameJoinedWithSpaces(t *testing.T) { func TestAssembleEnvelope_JSONIsStable(t *testing.T) { // Assemble twice; JSON output must be byte-identical (determinism). method := loadMethodFromRegistry(t, "im", []string{"reactions"}, "list") - a := AssembleEnvelope("im", []string{"reactions"}, "list", method) - b := AssembleEnvelope("im", []string{"reactions"}, "list", method) + a := synthEnvelope("im", []string{"reactions"}, method) + b := synthEnvelope("im", []string{"reactions"}, method) ja, _ := json.MarshalIndent(a, "", " ") jb, _ := json.MarshalIndent(b, "", " ") if string(ja) != string(jb) { @@ -693,8 +578,8 @@ func TestAssembleEnvelope_JSONIsStable(t *testing.T) { } func TestAssembleService_Im(t *testing.T) { - spec := registry.LoadFromMeta("im") - envs := AssembleService("im", spec, nil) + svc, _ := registry.ServiceTyped("im") + envs := Envelopes(apicatalog.ServiceMethods(svc, nil)) if len(envs) == 0 { t.Fatal("expected non-empty envelopes for service im") } @@ -713,17 +598,16 @@ func TestAssembleService_Im(t *testing.T) { } func TestAssembleService_FilterByAccessToken(t *testing.T) { - spec := registry.LoadFromMeta("im") + svc, _ := registry.ServiceTyped("im") // Filter to bot-only (--as bot, which corresponds to "tenant") - envs := AssembleService("im", spec, func(method map[string]interface{}) bool { - tokens, _ := method["accessTokens"].([]interface{}) - for _, t := range tokens { - if s, _ := t.(string); s == "tenant" { + envs := Envelopes(apicatalog.ServiceMethods(svc, func(m meta.Method) bool { + for _, t := range m.AccessTokens { + if t == "tenant" { return true } } return false - }) + })) // Every envelope's _meta.access_tokens must contain "bot" for _, e := range envs { found := false @@ -740,11 +624,11 @@ func TestAssembleService_FilterByAccessToken(t *testing.T) { } func TestAssembleAll_AtLeast193(t *testing.T) { - envs := AssembleAll(nil) - // Envelope assembly is overlay-independent (Task 17b): AssembleAll walks the - // embedded meta_data.json directly, so the count is stable across machines. + envs := Envelopes(registry.EmbeddedCatalog().WalkMethods(nil)) + // Envelope assembly is overlay-independent: it walks the embedded + // meta_data.json directly, so the count is stable across machines. if len(envs) < 193 { - t.Errorf("AssembleAll returned %d envelopes, expected >= 193", len(envs)) + t.Errorf("envelope count = %d, expected >= 193", len(envs)) } // Spot check: im reactions list should be present found := false @@ -759,24 +643,32 @@ func TestAssembleAll_AtLeast193(t *testing.T) { } } -// loadMethodFromRegistry is a test helper that pulls one method's spec from the -// real embedded meta_data.json via the registry package. -func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) map[string]interface{} { +// loadMethodFromRegistry is a test helper that pulls one method from the real +// embedded meta_data.json via the registry's typed accessor, with Name set. +func loadMethodFromRegistry(t *testing.T, service string, resourcePath []string, methodName string) meta.Method { t.Helper() - spec := registry.LoadFromMeta(service) - if spec == nil { + svc, ok := registry.ServiceTyped(service) + if !ok { t.Fatalf("service %q not found in registry", service) } - resources, _ := spec["resources"].(map[string]interface{}) resKey := strings.Join(resourcePath, ".") - res, ok := resources[resKey].(map[string]interface{}) + res, ok := svc.Resources[resKey] if !ok { t.Fatalf("resource %q.%s not found", service, resKey) } - methods, _ := res["methods"].(map[string]interface{}) - m, ok := methods[methodName].(map[string]interface{}) + m, ok := res.Methods[methodName] if !ok { t.Fatalf("method %q.%s.%s not found", service, resKey, methodName) } + m.Name = methodName return m } + +// convertProperty is a test helper: it decodes a single field-spec map into a +// meta.Field and renders its Property (the conversion the assembler does). +func convertProperty(fieldMap map[string]interface{}, _ string) Property { + b, _ := json.Marshal(fieldMap) + var f meta.Field + _ = json.Unmarshal(b, &f) + return Convert(f) +} diff --git a/internal/schema/lint.go b/internal/schema/lint.go index 2af3baef1..7600527c5 100644 --- a/internal/schema/lint.go +++ b/internal/schema/lint.go @@ -7,7 +7,7 @@ import ( "errors" "fmt" - "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" ) var validJSONSchemaTypes = map[string]bool{ @@ -81,7 +81,7 @@ func lintEnvelope(env Envelope) []error { } // ---- L3: cross-field self-consistency ---- - dangerExpected := env.Meta.Risk == cmdutil.RiskWrite || env.Meta.Risk == cmdutil.RiskHighRiskWrite + dangerExpected := env.Meta.Risk == core.RiskWrite || env.Meta.Risk == core.RiskHighRiskWrite if env.Meta.Danger != dangerExpected { errs = append(errs, fmt.Errorf("L3: _meta.danger=%v inconsistent with risk=%q", env.Meta.Danger, env.Meta.Risk)) } @@ -92,7 +92,7 @@ func lintEnvelope(env Envelope) []error { if env.InputSchema != nil && env.InputSchema.Properties != nil { _, hasYes = env.InputSchema.Properties.Map["yes"] } - wantYes := env.Meta.Risk == cmdutil.RiskHighRiskWrite + wantYes := env.Meta.Risk == core.RiskHighRiskWrite if hasYes != wantYes { errs = append(errs, fmt.Errorf("L3: inputSchema `yes` property=%v inconsistent with risk=%q", hasYes, env.Meta.Risk)) } @@ -125,6 +125,9 @@ func walkForL2(props *OrderedProps, errs *[]error) { if p.Minimum != nil && p.Maximum != nil && *p.Minimum >= *p.Maximum { *errs = append(*errs, fmt.Errorf("L2: field %q minimum (%v) >= maximum (%v)", k, *p.Minimum, *p.Maximum)) } + if n := len(p.EnumDescriptions); n > 0 && n != len(p.Enum) { + *errs = append(*errs, fmt.Errorf("L2: field %q enumDescriptions length (%d) != enum length (%d)", k, n, len(p.Enum))) + } if len(p.Required) > 0 && p.Properties != nil { for _, r := range p.Required { if _, ok := p.Properties.Map[r]; !ok { diff --git a/internal/schema/lint_test.go b/internal/schema/lint_test.go index 265c4c775..14774f6de 100644 --- a/internal/schema/lint_test.go +++ b/internal/schema/lint_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/registry" ) @@ -147,6 +148,18 @@ func TestLintEnvelope_L2_TypeChecks(t *testing.T) { }, wantSub: "minimum", }, + { + name: "enumDescriptions length must match enum", + mutate: func(e *Envelope) { + e.InputSchema.Properties.Order = []string{"k"} + e.InputSchema.Properties.Map["k"] = Property{ + Type: "string", + Enum: []interface{}{"a", "b", "c"}, + EnumDescriptions: []string{"only one"}, // misaligned with 3 enum values + } + }, + wantSub: "enumDescriptions", + }, { // Regression guard: walkForL2 must recurse into the params/data // sub-objects introduced by the 4-bucket inputSchema, not only the @@ -334,9 +347,8 @@ func TestAllEnvelopesPass(t *testing.T) { knownEnvelopes := map[string]bool{} // Use embedded data only so the gate is deterministic across machines // (matches Task 17b: envelope assembly is overlay-independent). - for _, svc := range registry.EmbeddedServiceNames() { - spec := registry.EmbeddedSpec(svc) - envs := AssembleService(svc, spec, nil) + for _, svc := range registry.EmbeddedServicesTyped() { + envs := Envelopes(apicatalog.ServiceMethods(svc, nil)) for _, env := range envs { errs := lintEnvelope(env) if len(errs) == 0 { @@ -366,7 +378,7 @@ func TestAllEnvelopesPass(t *testing.T) { } // L4 coverage report (warn-only via t.Logf) - all := AssembleAll(nil) + all := Envelopes(registry.EmbeddedCatalog().WalkMethods(nil)) c := measureCoverage(all) for metric, rate := range c { baseline := coverageBaseline[metric] diff --git a/internal/schema/path.go b/internal/schema/path.go deleted file mode 100644 index a29b34136..000000000 --- a/internal/schema/path.go +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) 2026 Lark Technologies Pte. Ltd. -// SPDX-License-Identifier: MIT - -package schema - -import "strings" - -// ParsePath normalizes the positional arguments of `lark-cli schema` into a -// slice of path segments. It accepts two equivalent forms: -// -// lark-cli schema im.messages.reply -> single arg, split on "." -// lark-cli schema im messages reply -> multiple args, used as-is -// lark-cli schema "im chat.members bots" is NOT a supported form; quote -// arguments individually if your shell needs it. Nested resources keep their -// internal dots (e.g. "chat.members"). -// -// Returns nil for zero args (bare invocation). -func ParsePath(args []string) []string { - switch len(args) { - case 0: - return nil - case 1: - if strings.Contains(args[0], ".") { - return strings.Split(args[0], ".") - } - return []string{args[0]} - default: - return args - } -} diff --git a/internal/schema/types.go b/internal/schema/types.go index c8b1232c8..084ca4691 100644 --- a/internal/schema/types.go +++ b/internal/schema/types.go @@ -8,6 +8,8 @@ import ( "encoding/json" "fmt" "sort" + + "github.com/larksuite/cli/internal/meta" ) // Envelope is the MCP Tool spec contract for a single API method command. @@ -45,42 +47,32 @@ type Property struct { Type string `json:"type,omitempty"` Description string `json:"description,omitempty"` Enum []interface{} `json:"enum,omitempty"` - Default interface{} `json:"default,omitempty"` - Example interface{} `json:"example,omitempty"` - Minimum *float64 `json:"minimum,omitempty"` - Maximum *float64 `json:"maximum,omitempty"` - Format string `json:"format,omitempty"` - Required []string `json:"required,omitempty"` - Properties *OrderedProps `json:"properties,omitempty"` - Items *Property `json:"items,omitempty"` + // EnumDescriptions, when present, is parallel to Enum: the human meaning of + // each allowed value, in the same order. Omitted when no value carries a + // description. This is the widely-recognized JSON-Schema extension (VS Code, + // OpenAPI tooling) that lets an AI consumer learn what each enum value means + // without a second lookup. + EnumDescriptions []string `json:"enumDescriptions,omitempty"` + Default interface{} `json:"default,omitempty"` + Example interface{} `json:"example,omitempty"` + Minimum *float64 `json:"minimum,omitempty"` + Maximum *float64 `json:"maximum,omitempty"` + Format string `json:"format,omitempty"` + Required []string `json:"required,omitempty"` + Properties *OrderedProps `json:"properties,omitempty"` + Items *Property `json:"items,omitempty"` } // Meta is the Lark-specific extension namespace. type Meta struct { - EnvelopeVersion string `json:"envelope_version"` - Scopes []string `json:"scopes"` - RequiredScopes []string `json:"required_scopes"` - AccessTokens []string `json:"access_tokens"` - Danger bool `json:"danger"` - Risk string `json:"risk"` - DocURL string `json:"doc_url,omitempty"` - Affordance *Affordance `json:"affordance,omitempty"` -} - -// Affordance is the hand-written overlay (PR-1 only defines the type, no YAML loaded). -type Affordance struct { - UseWhen []string `json:"use_when,omitempty"` - DoNotUseWhen []string `json:"do_not_use_when,omitempty"` - Prerequisites []string `json:"prerequisites,omitempty"` - Examples []AffordanceCase `json:"examples,omitempty"` - Related []string `json:"related,omitempty"` -} - -// AffordanceCase is one example entry: a one-line description plus a -// ready-to-run lark-cli command string. -type AffordanceCase struct { - Description string `json:"description"` - Command string `json:"command"` + EnvelopeVersion string `json:"envelope_version"` + Scopes []string `json:"scopes"` + RequiredScopes []string `json:"required_scopes"` + AccessTokens []string `json:"access_tokens"` + Danger bool `json:"danger"` + Risk string `json:"risk"` + DocURL string `json:"doc_url,omitempty"` + Affordance *meta.Affordance `json:"affordance,omitempty"` } // OrderedProps is map[string]Property with preserved key order on MarshalJSON. @@ -91,6 +83,20 @@ type OrderedProps struct { Map map[string]Property } +// Set adds or replaces a property, recording first-seen keys in Order so JSON +// output preserves insertion order. Re-setting an existing key updates its +// value without reordering. Centralizing mutation here keeps Order and Map from +// drifting out of sync. +func (o *OrderedProps) Set(key string, p Property) { + if o.Map == nil { + o.Map = make(map[string]Property) + } + if _, exists := o.Map[key]; !exists { + o.Order = append(o.Order, key) + } + o.Map[key] = p +} + // MarshalJSON emits keys in Order, not alphabetical. If Order is empty but // Map has entries, fall back to alphabetical key order over Map so callers // that only populated Map (no explicit ordering) still see their fields. diff --git a/internal/schema/types_test.go b/internal/schema/types_test.go index ab1ae6c4e..ab69e4765 100644 --- a/internal/schema/types_test.go +++ b/internal/schema/types_test.go @@ -8,6 +8,21 @@ import ( "testing" ) +func TestOrderedProps_Set(t *testing.T) { + op := &OrderedProps{} + op.Set("b", Property{Type: "string"}) + op.Set("a", Property{Type: "integer"}) + op.Set("b", Property{Type: "boolean"}) // re-set: updates value, keeps position + + wantOrder := []string{"b", "a"} + if len(op.Order) != len(wantOrder) || op.Order[0] != "b" || op.Order[1] != "a" { + t.Errorf("Order = %v, want %v (insertion order, no duplicate on re-set)", op.Order, wantOrder) + } + if op.Map["b"].Type != "boolean" { + t.Errorf("re-set value = %q, want boolean", op.Map["b"].Type) + } +} + // OrderedProps 在测试里验证:MarshalJSON 按 Order 切片顺序输出 key,跳过 Go map 默认字母序。 func TestOrderedProps_MarshalJSON_PreservesOrder(t *testing.T) { op := &OrderedProps{ diff --git a/internal/transport/warn.go b/internal/transport/warn.go index cac050f72..2971b2be3 100644 --- a/internal/transport/warn.go +++ b/internal/transport/warn.go @@ -10,8 +10,6 @@ import ( "os" "strings" "sync" - - "github.com/larksuite/cli/internal/envvars" ) // Proxy environment constants control shared transport proxy behavior. @@ -79,16 +77,8 @@ func WarnIfProxied(w io.Writer) { // Shared), so its warning and disable instructions take precedence. // Emitting the env-proxy warning here would be misleading: it tells the // user to set LARK_CLI_NO_PROXY=1, which does NOT disable the plugin proxy. - if addr, caPath, enabled := proxyPluginStatus(); enabled { - fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin enabled: all requests (including credentials) are forced through %s. To disable, set %s=false or remove %s.\n", - redactProxyURL(addr), envvars.CliProxyEnable, Path()) - if strings.TrimSpace(caPath) != "" { - // A custom CA means upstream TLS can be intercepted/inspected by - // the proxy (MITM). Surface it so the operator is aware traffic - // (including Bearer tokens) is decryptable on this host. - fmt.Fprintf(w, "[lark-cli] [WARN] proxy plugin trusts a custom CA (%s); TLS to upstreams can be intercepted/inspected by this proxy.\n", - caPath) - } + if _, _, enabled := proxyPluginStatus(); enabled { + fmt.Fprintln(w, "[lark-cli] [WARN] proxy plugin enabled: all requests are forced through proxy.") return } if os.Getenv(EnvNoProxy) != "" { diff --git a/internal/transport/warn_test.go b/internal/transport/warn_test.go index 13708ca7e..a4e10b8da 100644 --- a/internal/transport/warn_test.go +++ b/internal/transport/warn_test.go @@ -8,8 +8,6 @@ import ( "strings" "sync" "testing" - - "github.com/larksuite/cli/internal/envvars" ) // TestDetectProxyEnv verifies proxy environment detection priority and empty-state behavior. @@ -120,9 +118,9 @@ func TestWarnIfProxied_OnlyOnce(t *testing.T) { } // TestWarnIfProxied_ProxyPluginEnabled verifies that when proxy plugin mode is -// enabled, the warning describes the plugin proxy and the correct disable method -// (LARKSUITE_CLI_PROXY_ENABLE=false) instead of the misleading LARK_CLI_NO_PROXY -// instruction — even when env proxy and LARK_CLI_NO_PROXY are also set. +// enabled, the warning is a single concise line that does not leak the proxy +// address or give the misleading LARK_CLI_NO_PROXY disable instruction — even +// when env proxy and LARK_CLI_NO_PROXY are also set. func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) @@ -140,51 +138,24 @@ func TestWarnIfProxied_ProxyPluginEnabled(t *testing.T) { WarnIfProxied(&buf) out := buf.String() - if !strings.Contains(out, "127.0.0.1:3128") { - t.Errorf("warning should mention the plugin proxy address, got: %s", out) + if !strings.Contains(out, "proxy plugin enabled") { + t.Errorf("warning should announce proxy plugin enabled, got: %s", out) } - if !strings.Contains(out, envvars.CliProxyEnable) { - t.Errorf("warning should mention %s as the disable method, got: %s", envvars.CliProxyEnable, out) + // Single line only — no address, CA, or disable hints. + if strings.Count(out, "\n") != 1 { + t.Errorf("warning must be a single line, got: %s", out) + } + if strings.Contains(out, "127.0.0.1:3128") || strings.Contains(out, "corp-proxy") { + t.Errorf("warning must not leak the proxy address, got: %s", out) } if strings.Contains(out, "Set "+EnvNoProxy+"=1") { t.Errorf("warning must NOT give the misleading %s disable instruction when plugin is enabled, got: %s", EnvNoProxy, out) } - // No custom CA configured -> no interception warning. - if strings.Contains(out, "custom CA") { - t.Errorf("warning should not mention a custom CA when none is configured, got: %s", out) - } -} - -// TestWarnIfProxied_ProxyPluginCustomCAWarns verifies that when a custom CA is -// trusted, the warning surfaces the TLS-interception capability. -func TestWarnIfProxied_ProxyPluginCustomCAWarns(t *testing.T) { - t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) - unsetProxyPluginEnv(t) - proxyWarningOnce = sync.Once{} - - old := proxyPluginStatus - proxyPluginStatus = func() (string, string, bool) { - return "http://127.0.0.1:3128", "/etc/lark/extra_ca.pem", true - } - t.Cleanup(func() { proxyPluginStatus = old }) - - var buf bytes.Buffer - WarnIfProxied(&buf) - out := buf.String() - - if !strings.Contains(out, "custom CA") { - t.Errorf("warning should mention the custom CA, got: %s", out) - } - if !strings.Contains(out, "/etc/lark/extra_ca.pem") { - t.Errorf("warning should include the CA path, got: %s", out) - } - if !strings.Contains(out, "intercept") { - t.Errorf("warning should mention TLS interception, got: %s", out) - } } // TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials verifies the plugin -// warning never leaks credentials embedded in the configured proxy address. +// warning never leaks credentials embedded in the configured proxy address — +// the simplified message omits the address entirely. func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) unsetProxyPluginEnv(t) @@ -204,9 +175,6 @@ func TestWarnIfProxied_ProxyPluginEnabledRedactsCredentials(t *testing.T) { if strings.Contains(out, "user:") { t.Errorf("plugin warning leaked username, got: %s", out) } - if !strings.Contains(out, "***@127.0.0.1:3128") { - t.Errorf("plugin warning should contain redacted proxy URL, got: %s", out) - } } // TestRedactProxyURL verifies redaction of proxy credentials across supported formats. diff --git a/internal/vfs/localfileio/path.go b/internal/vfs/localfileio/path.go index 7a4b52ab8..d02097373 100644 --- a/internal/vfs/localfileio/path.go +++ b/internal/vfs/localfileio/path.go @@ -60,12 +60,12 @@ func safePath(raw, flagName string) (string, error) { return "", err } - path := filepath.Clean(raw) - - if filepath.IsAbs(path) { + if isAbsolutePath(raw) { return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw) } + path := filepath.Clean(raw) + cwd, err := vfs.Getwd() if err != nil { return "", fmt.Errorf("cannot determine working directory: %w", err) @@ -114,6 +114,21 @@ func resolveNearestAncestor(path string) (string, error) { } } +func isAbsolutePath(path string) bool { + path = strings.TrimSpace(path) + if path == "" { + return false + } + if filepath.IsAbs(path) || strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\`) { + return true + } + if len(path) >= 3 && path[1] == ':' && (path[2] == '/' || path[2] == '\\') { + drive := path[0] + return ('A' <= drive && drive <= 'Z') || ('a' <= drive && drive <= 'z') + } + return false +} + func isUnderDir(child, parent string) bool { rel, err := filepath.Rel(parent, child) if err != nil { diff --git a/internal/vfs/localfileio/path_test.go b/internal/vfs/localfileio/path_test.go index 946d1be1c..3cfb8e740 100644 --- a/internal/vfs/localfileio/path_test.go +++ b/internal/vfs/localfileio/path_test.go @@ -34,6 +34,10 @@ func TestSafeOutputPath_RejectsPathTraversalAndDangerousInput(t *testing.T) { // ── GIVEN: absolute paths → THEN: rejected ── {"absolute path unix", "/etc/passwd", true}, {"absolute path root", "/tmp/evil", true}, + {"absolute path windows drive", `C:\Users\agent\secret.txt`, true}, + {"absolute path windows drive slash", "C:/Users/agent/secret.txt", true}, + {"absolute path windows rooted", `\Users\agent\secret.txt`, true}, + {"absolute path windows unc", `\\server\share\secret.txt`, true}, // ── GIVEN: control characters in path → THEN: rejected ── {"null byte", "file\x00.txt", true}, @@ -187,11 +191,23 @@ func TestSafeUploadPath_AllowsTempFileAbsolutePath(t *testing.T) { } func TestSafeUploadPath_RejectsNonTempAbsolutePath(t *testing.T) { - // GIVEN: an absolute path outside the temp directory - // WHEN / THEN: SafeUploadPath rejects it - _, err := SafeInputPath("/etc/passwd") - if err == nil { - t.Error("expected error for absolute non-temp path, got nil") + for _, tt := range []struct { + name string + input string + }{ + {"absolute path unix", "/etc/passwd"}, + {"absolute path windows drive", `C:\Users\agent\secret.txt`}, + {"absolute path windows drive slash", "C:/Users/agent/secret.txt"}, + {"absolute path windows rooted", `\Users\agent\secret.txt`}, + {"absolute path windows unc", `\\server\share\secret.txt`}, + } { + t.Run(tt.name, func(t *testing.T) { + // WHEN / THEN: SafeInputPath rejects absolute paths on every platform. + _, err := SafeInputPath(tt.input) + if err == nil { + t.Errorf("expected error for absolute path %q, got nil", tt.input) + } + }) } } diff --git a/lint/errscontract/rule_no_legacy_common_helper_call.go b/lint/errscontract/rule_no_legacy_common_helper_call.go index 7a6558200..c34fffc46 100644 --- a/lint/errscontract/rule_no_legacy_common_helper_call.go +++ b/lint/errscontract/rule_no_legacy_common_helper_call.go @@ -18,17 +18,25 @@ var migratedCommonHelperPaths = []string{ "cmd/event/", "events/", "internal/event/consume/", + "shortcuts/apps/", "shortcuts/base/", "shortcuts/calendar/", "shortcuts/contact/", + "shortcuts/doc/", "shortcuts/drive/", "shortcuts/event/", + "shortcuts/im/", "shortcuts/mail/", + "shortcuts/markdown/", "shortcuts/minutes/", + "shortcuts/note/", "shortcuts/okr/", + "shortcuts/sheets/", + "shortcuts/slides/", "shortcuts/task/", "shortcuts/vc/", "shortcuts/whiteboard/", + "shortcuts/wiki/", } const commonImportPath = "github.com/larksuite/cli/shortcuts/common" diff --git a/lint/errscontract/rule_no_legacy_envelope_literal.go b/lint/errscontract/rule_no_legacy_envelope_literal.go index bfaba9ff6..ffb87c554 100644 --- a/lint/errscontract/rule_no_legacy_envelope_literal.go +++ b/lint/errscontract/rule_no_legacy_envelope_literal.go @@ -19,18 +19,25 @@ var migratedEnvelopePaths = []string{ "cmd/event/", "events/", "internal/event/consume/", + "shortcuts/apps/", "shortcuts/base/", "shortcuts/calendar/", "shortcuts/contact/", + "shortcuts/doc/", "shortcuts/drive/", "shortcuts/event/", + "shortcuts/im/", "shortcuts/mail/", + "shortcuts/markdown/", "shortcuts/minutes/", + "shortcuts/note/", "shortcuts/okr/", + "shortcuts/sheets/", + "shortcuts/slides/", "shortcuts/task/", "shortcuts/vc/", "shortcuts/whiteboard/", - "shortcuts/im/", + "shortcuts/wiki/", } // legacyOutputImportPath is the import path of the package that declares the diff --git a/lint/errscontract/rules_test.go b/lint/errscontract/rules_test.go index 84535a7d7..309918721 100644 --- a/lint/errscontract/rules_test.go +++ b/lint/errscontract/rules_test.go @@ -620,6 +620,7 @@ func boom() error { func TestCheckNoLegacyEnvelopeLiteral_RejectsExitErrorLiteralOnMigratedShortcutPaths(t *testing.T) { for _, path := range []string{ + "shortcuts/markdown/markdown_fetch.go", "shortcuts/okr/okr_image_upload.go", "shortcuts/task/task_update.go", "shortcuts/whiteboard/whiteboard_update.go", @@ -950,11 +951,17 @@ func TestCheckNoLegacyCommonHelperCall_RejectsLegacyHelpersOnMigratedPath(t *tes "HandleApiResult", } paths := []string{ + "shortcuts/doc/docs_fetch_v2.go", "shortcuts/drive/drive_search.go", + "shortcuts/im/im_messages_send.go", "shortcuts/mail/mail_send.go", + "shortcuts/markdown/markdown_fetch.go", "shortcuts/okr/okr_progress_create.go", + "shortcuts/sheets/helpers.go", + "shortcuts/slides/slides_create.go", "shortcuts/task/task_update.go", "shortcuts/whiteboard/whiteboard_query.go", + "shortcuts/wiki/wiki_node_get.go", } for _, path := range paths { for _, helper := range helpers { @@ -982,6 +989,18 @@ common.` + helper + `() } } +func TestMigratedCommonHelperPaths_CoverMigratedEnvelopePaths(t *testing.T) { + commonPaths := make(map[string]struct{}, len(migratedCommonHelperPaths)) + for _, path := range migratedCommonHelperPaths { + commonPaths[path] = struct{}{} + } + for _, path := range migratedEnvelopePaths { + if _, ok := commonPaths[path]; !ok { + t.Fatalf("migratedEnvelopePaths contains %q but migratedCommonHelperPaths does not", path) + } + } +} + func TestCheckNoLegacyCommonHelperCall_RejectsDangerousCharsOnCalendarPath(t *testing.T) { src := `package calendar @@ -1003,6 +1022,91 @@ func boom() { } } +func TestCheckNoLegacyCommonHelperCall_CoversDocPathWithAliasAndFunctionValue(t *testing.T) { + src := `package migrated + +import c "github.com/larksuite/cli/shortcuts/common" + +func boom() { + f := c.FlagErrorf + _ = f + c.WrapInputStatError(nil) +} +` + v := CheckNoLegacyCommonHelperCall("shortcuts/doc/docs_fetch_v2.go", src) + if len(v) != 2 { + t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on doc path, got %d: %+v", len(v), v) + } +} + +func TestCheckNoLegacyCommonHelperCall_CoversSheetsPathWithAliasAndFunctionValue(t *testing.T) { + src := `package migrated + +import c "github.com/larksuite/cli/shortcuts/common" + +func boom() { + f := c.FlagErrorf + _ = f + c.WrapInputStatError(nil) +} +` + v := CheckNoLegacyCommonHelperCall("shortcuts/sheets/helpers.go", src) + if len(v) != 2 { + t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on sheets path, got %d: %+v", len(v), v) + } +} + +func TestCheckNoLegacyCommonHelperCall_CoversSlidesPathWithAliasAndFunctionValue(t *testing.T) { + src := `package migrated + +import c "github.com/larksuite/cli/shortcuts/common" + +func boom() { + f := c.FlagErrorf + _ = f + c.WrapInputStatError(nil) +} +` + v := CheckNoLegacyCommonHelperCall("shortcuts/slides/slides_create.go", src) + if len(v) != 2 { + t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on slides path, got %d: %+v", len(v), v) + } +} + +func TestCheckNoLegacyCommonHelperCall_CoversMarkdownPathWithAliasAndFunctionValue(t *testing.T) { + src := `package migrated + +import c "github.com/larksuite/cli/shortcuts/common" + +func boom() { + f := c.FlagErrorf + _ = f + c.WrapInputStatError(nil) +} +` + v := CheckNoLegacyCommonHelperCall("shortcuts/markdown/markdown_fetch.go", src) + if len(v) != 2 { + t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on markdown path, got %d: %+v", len(v), v) + } +} + +func TestCheckNoLegacyCommonHelperCall_CoversWikiPathWithAliasAndFunctionValue(t *testing.T) { + src := `package migrated + +import c "github.com/larksuite/cli/shortcuts/common" + +func boom() { + f := c.FlagErrorf + _ = f + c.WrapInputStatError(nil) +} +` + v := CheckNoLegacyCommonHelperCall("shortcuts/wiki/wiki_node_get.go", src) + if len(v) != 2 { + t.Fatalf("expected 2 violations for aliased/function-value legacy helpers on wiki path, got %d: %+v", len(v), v) + } +} + func TestCheckNoLegacyCommonHelperCall_AllowsNonMigratedPath(t *testing.T) { src := `package contact diff --git a/package.json b/package.json index bc366cae9..a8bf8b5db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@larksuite/cli", - "version": "1.0.49", + "version": "1.0.53", "description": "The official CLI for Lark/Feishu open platform", "bin": { "lark-cli": "scripts/run.js" @@ -15,7 +15,8 @@ ], "cpu": [ "x64", - "arm64" + "arm64", + "riscv64" ], "engines": { "node": ">=16" diff --git a/scripts/build-pkg-pr-new.sh b/scripts/build-pkg-pr-new.sh index 0a9ea4c3f..edd611769 100755 --- a/scripts/build-pkg-pr-new.sh +++ b/scripts/build-pkg-pr-new.sh @@ -33,6 +33,7 @@ build_target darwin arm64 build_target linux amd64 build_target darwin amd64 build_target linux arm64 +build_target linux riscv64 build_target windows amd64 build_target windows arm64 @@ -55,6 +56,7 @@ const platformMap = { const archMap = { x64: "amd64", arm64: "arm64", + riscv64: "riscv64", }; const platform = platformMap[process.platform]; diff --git a/scripts/install.js b/scripts/install.js index 1f967d268..88a8b74c4 100644 --- a/scripts/install.js +++ b/scripts/install.js @@ -30,6 +30,7 @@ const PLATFORM_MAP = { const ARCH_MAP = { x64: "amd64", arm64: "arm64", + riscv64: "riscv64", }; const platform = PLATFORM_MAP[process.platform]; diff --git a/shortcuts/apps/apps_access_scope_get.go b/shortcuts/apps/apps_access_scope_get.go index df1a8cfe2..9b90903ff 100644 --- a/shortcuts/apps/apps_access_scope_get.go +++ b/shortcuts/apps/apps_access_scope_get.go @@ -9,27 +9,29 @@ import ( "io" "strings" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) -// AppsAccessScopeGet reads the current access scope configuration of a Miaoda app. +// AppsAccessScopeGet reads the current access scope configuration of an app. // 响应原样透传服务端契约(字符串 scope 枚举 All/Tenant/Range + 拆分的 users/departments/chats 数组)。 var AppsAccessScopeGet = common.Shortcut{ Service: appsService, Command: "+access-scope-get", - Description: "Get Miaoda app access scope configuration", + Description: "Get 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}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("app-id")) == "" { - return output.ErrValidation("--app-id is required") + return appsValidationParamError("--app-id", "--app-id is required") } return nil }, @@ -37,14 +39,14 @@ var AppsAccessScopeGet = common.Shortcut{ appID := strings.TrimSpace(rctx.Str("app-id")) return common.NewDryRunAPI(). GET(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))). - Desc("Get Miaoda app access scope") + Desc("Get app access scope") }, 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 f9f474fae..9e0b8d5f3 100644 --- a/shortcuts/apps/apps_access_scope_set.go +++ b/shortcuts/apps/apps_access_scope_set.go @@ -10,7 +10,6 @@ import ( "io" "strings" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -25,11 +24,16 @@ var allowedAccessTargetTypes = map[string]bool{ var AppsAccessScopeSet = common.Shortcut{ Service: appsService, Command: "+access-scope-set", - Description: "Set Miaoda app access scope (specific / public / tenant)", + Description: "Set 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"}}, @@ -40,7 +44,7 @@ var AppsAccessScopeSet = common.Shortcut{ }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("app-id")) == "" { - return output.ErrValidation("--app-id is required") + return appsValidationParamError("--app-id", "--app-id is required") } return validateAccessScopeFlags(rctx) }, @@ -48,7 +52,7 @@ var AppsAccessScopeSet = common.Shortcut{ appID := strings.TrimSpace(rctx.Str("app-id")) dry := common.NewDryRunAPI(). PUT(fmt.Sprintf("%s/apps/%s/access-scope", apiBasePath, validate.EncodePathSegment(appID))). - Desc("Set Miaoda app access scope") + Desc("Set app access scope") body, bodyErr := buildAccessScopeBody(rctx) if bodyErr != nil { dry.Set("body_error", bodyErr.Error()) @@ -64,9 +68,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")) @@ -85,36 +89,42 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error { switch scope { case "specific": if targets == "" { - return output.ErrValidation("--targets is required when --scope=specific") + return appsValidationParamError("--targets", "--targets is required when --scope=specific") } if err := validateTargetsJSON(targets); err != nil { return err } if approver != "" && !applyEnabled { - return output.ErrValidation("--approver requires --apply-enabled") + return appsValidationParamError("--approver", "--approver requires --apply-enabled") } if requireLogin { - return output.ErrValidation("--require-login is not allowed when --scope=specific") + return appsValidationParamError("--require-login", "--require-login is not allowed when --scope=specific") } case "public": if targets != "" { - return output.ErrValidation("--targets is not allowed when --scope=public") + return appsValidationParamError("--targets", "--targets is not allowed when --scope=public") } if applyEnabled { - return output.ErrValidation("--apply-enabled is not allowed when --scope=public") + return appsValidationParamError("--apply-enabled", "--apply-enabled is not allowed when --scope=public") } if approver != "" { - return output.ErrValidation("--approver is not allowed when --scope=public") + return appsValidationParamError("--approver", "--approver is not allowed when --scope=public") } if !rctx.Cmd.Flags().Changed("require-login") { - return output.ErrValidation("--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)") + return appsValidationParamError("--require-login", "--require-login is required when --scope=public (pass true or false explicitly; do not rely on the default)") } case "tenant": if targets != "" || applyEnabled || approver != "" || requireLogin { - return output.ErrValidation("no extra flags allowed when --scope=tenant") + return appsValidationError("no extra flags allowed when --scope=tenant"). + WithParams( + appsInvalidParam("--targets", "not allowed when --scope=tenant"), + appsInvalidParam("--apply-enabled", "not allowed when --scope=tenant"), + appsInvalidParam("--approver", "not allowed when --scope=tenant"), + appsInvalidParam("--require-login", "not allowed when --scope=tenant"), + ) } default: - return output.ErrValidation("--scope must be specific / public / tenant") + return appsValidationParamError("--scope", "--scope must be specific / public / tenant") } return nil } @@ -122,18 +132,18 @@ func validateAccessScopeFlags(rctx *common.RuntimeContext) error { func validateTargetsJSON(targetsJSON string) error { var items []map[string]interface{} if err := json.Unmarshal([]byte(targetsJSON), &items); err != nil { - return output.ErrValidation("--targets is not valid JSON: %v", err) + return appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err) } if len(items) == 0 { - return output.ErrValidation("--targets must contain at least one entry; specific scope requires concrete user/department/chat ids") + return appsValidationParamError("--targets", "--targets must contain at least one entry; specific scope requires concrete user/department/chat ids") } for i, t := range items { typ, _ := t["type"].(string) if !allowedAccessTargetTypes[typ] { - return output.ErrValidation("--targets[%d].type %q must be one of: user / department / chat", i, typ) + return appsValidationParamError("--targets", "--targets[%d].type %q must be one of: user / department / chat", i, typ) } if id, _ := t["id"].(string); strings.TrimSpace(id) == "" { - return output.ErrValidation("--targets[%d].id is empty", i) + return appsValidationParamError("--targets", "--targets[%d].id is empty", i) } } return nil @@ -152,7 +162,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{}, scope := rctx.Str("scope") enum, ok := scopeStringToServerEnum[scope] if !ok { - return nil, output.ErrValidation("--scope must be specific / public / tenant, got %q", scope) + return nil, appsValidationParamError("--scope", "--scope must be specific / public / tenant, got %q", scope) } body := map[string]interface{}{"scope": enum} @@ -161,7 +171,7 @@ func buildAccessScopeBody(rctx *common.RuntimeContext) (map[string]interface{}, // 用户传统一格式 [{type:user|department|chat, id:...}],body 里拆 3 个并列数组发后端。 var targets []map[string]interface{} if err := json.Unmarshal([]byte(rctx.Str("targets")), &targets); err != nil { - return nil, output.ErrValidation("--targets is not valid JSON: %v", err) + return nil, appsValidationParamError("--targets", "--targets is not valid JSON: %v", err).WithCause(err) } users, departments, chats := splitAccessScopeTargets(targets) if len(users) > 0 { diff --git a/shortcuts/apps/apps_access_scope_set_test.go b/shortcuts/apps/apps_access_scope_set_test.go index f5eede00e..a6da187b0 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 000000000..90ae70013 --- /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 000000000..bcad45362 --- /dev/null +++ b/shortcuts/apps/apps_chat.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "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 appsValidationParamError("--app-id", "--app-id is required") + } + if strings.TrimSpace(rctx.Str("session-id")) == "" { + return appsValidationParamError("--session-id", "--session-id is required") + } + // Do not echo --message content in the error (spec §4 redaction). + if strings.TrimSpace(rctx.Str("message")) == "" { + return appsValidationParamError("--message", "--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 000000000..10a915672 --- /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 215b4ccd2..e7a91fab8 100644 --- a/shortcuts/apps/apps_create.go +++ b/shortcuts/apps/apps_create.go @@ -9,48 +9,46 @@ import ( "io" "strings" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) -// AppsCreate creates a new Miaoda app. +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 apps" + +// AppsCreate creates a new app. var AppsCreate = common.Shortcut{ Service: appsService, Command: "+create", - Description: "Create a new Miaoda app", + Description: "Create a new 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)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { 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 appsValidationParamError("--name", "--name is required") } return nil }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). POST(apiBasePath + "/apps"). - Desc("Create a Miaoda app"). + Desc("Create an app"). 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 +57,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 2cee3e581..ddc636714 100644 --- a/shortcuts/apps/apps_create_test.go +++ b/shortcuts/apps/apps_create_test.go @@ -12,6 +12,7 @@ import ( "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" @@ -22,6 +23,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()), @@ -46,6 +48,31 @@ func runAppsShortcut(t *testing.T, sc common.Shortcut, args []string, factory *c return parent.ExecuteContext(context.Background()) } +func requireAppsProblem(t *testing.T, err error, category errs.Category) *errs.Problem { + t.Helper() + if err == nil { + t.Fatalf("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Category != category { + t.Fatalf("error category = %q, want %q", p.Category, category) + } + return p +} + +func requireAppsValidationProblem(t *testing.T, err error) *errs.Problem { + t.Helper() + return requireAppsProblem(t, err, errs.CategoryValidation) +} + +func requireAppsAPIProblem(t *testing.T, err error) *errs.Problem { + t.Helper() + return requireAppsProblem(t, err, errs.CategoryAPI) +} + // +create 测试 func TestAppsCreate_Success(t *testing.T) { @@ -68,7 +95,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 +110,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 +135,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 +160,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 +171,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 +186,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 +221,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 000000000..5fe3034ed --- /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 an 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: "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 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 000000000..0b29bd452 --- /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 000000000..4405cccf9 --- /dev/null +++ b/shortcuts/apps/apps_db_execute.go @@ -0,0 +1,528 @@ +// 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 an 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 检测到哨兵 +// 后按 partial failure 上报(exit 非 0):stdout 输出 ok:false 数据,带 results / +// statement_index / error_code / error_message / rolled_back / note,避免 agent 误判 +// ok:true 假成功。CLI 永远 DBA 模式(transactional=false),失败前的语句已 auto-commit +// 落地,故 rolled_back=false(真机 boe 实证)。 +// +// 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 an 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: "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 appsValidationError("--sql and --file are mutually exclusive"). + WithParams( + appsInvalidParam("--sql", "mutually exclusive with --file"), + appsInvalidParam("--file", "mutually exclusive with --sql"), + ) + } + if file != "" { + data, err := cmdutil.ReadInputFile(rctx.FileIO(), file) + if err != nil { + return appsValidationParamError("--file", "--file: %v", err).WithCause(err) + } + // 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。 + rctx.Cmd.Flags().Set("sql", string(data)) + sql = strings.TrimSpace(string(data)) + } + if sql == "" { + return appsValidationError("one of --sql or --file is required (use --sql - to read stdin)"). + WithParams( + appsInvalidParam("--sql", "one of --sql or --file is required"), + appsInvalidParam("--file", "one of --sql or --file is required"), + ) + } + return 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 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。 + // 已落地的前序语句 + 失败语句构成 partial failure:逐条结果作为 ok:false 数据 + // 留在 stdout(机器可读)+ 非零退出信号,别让 agent 误判 ok:true 假成功。 + // pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope),仅返回退出信号。 + if errIdx, errStmt, failed := findErrorSentinel(stmts); failed { + if rctx.Format == "pretty" { + renderSQLPretty(rctx.IO().Out, stmts) + return output.PartialFailure(output.ExitAPI) + } + return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil) + } + + 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 +} + +// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据。 +// +// CLI 永远 DBA 模式(transactional=false),真机 boe 实证:失败语句之前的语句已逐条 auto-commit +// 落地,不存在外层事务回滚。因此 rolled_back=false、results 含全部逐条结果(ERROR 哨兵在 +// 失败位置),note 提示用户别整批重跑(否则会重复写入)。 +func sqlStatementFailurePayload(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) map[string]interface{} { + code, msg := parseErrorSentinel(common.GetString(errStmt, "data")) + stmtNo := errIdx + 1 // 1-based 给人看 + note := "no statements were applied; fix the SQL and re-run." + if errIdx > 0 { + note = fmt.Sprintf( + "statements 1-%d were already applied (DBA mode auto-commits each statement); fix statement %d and re-run only the remaining statements.", + errIdx, stmtNo) + } + return map[string]interface{}{ + "results": stmts, + "statement_index": errIdx, + "error_code": code, + "error_message": fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)), + "rolled_back": false, + "note": note, + } +} + +// 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 000000000..cb95d3f94 --- /dev/null +++ b/shortcuts/apps/apps_db_execute_test.go @@ -0,0 +1,850 @@ +// 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 钉死「多语句失败 → partial failure」: +// 逐条结果 + statement_index / error_code / rolled_back / note 作为 ok:false 数据落 stdout, +// 退出信号是 PartialFailureError(非零 exit)。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 partial-failure error; stdout:\n%s", stdout.String()) + } + // json 失败路径不得打成功 envelope。 + if strings.Contains(stdout.String(), `"ok": true`) { + t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String()) + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err) + } + if pfErr.Code != output.ExitAPI { + t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) + } + payload := decodePartialFailureData(t, stdout.String()) + if got := payload["statement_index"]; got != float64(1) { + t.Errorf("statement_index = %v, want 1", got) + } + if got := payload["error_code"]; got != float64(1300002) { + t.Errorf("error_code = %v, want 1300002", got) + } + msg, _ := payload["error_message"].(string) + if !strings.Contains(msg, "(at statement 2 of 2)") { + t.Errorf("error_message missing statement locator: %q", msg) + } + if got := payload["rolled_back"]; got != false { + t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got) + } + results, _ := payload["results"].([]interface{}) + if len(results) != 2 { + t.Errorf("results length = %d, want 2 (persisted statement + ERROR sentinel)", len(results)) + } + note, _ := payload["note"].(string) + if !strings.Contains(note, "already applied") { + t.Errorf("note should warn prior statements persisted, got %q", note) + } +} + +// decodePartialFailureData 解析 stdout 上 ok:false 的 partial-failure envelope,返回 data 块。 +func decodePartialFailureData(t *testing.T, stdoutStr string) map[string]interface{} { + t.Helper() + var envelope struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal([]byte(stdoutStr), &envelope); err != nil { + t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, stdoutStr) + } + if envelope.OK { + t.Fatalf("envelope.ok = true, want false on partial failure") + } + if envelope.Data == nil { + t.Fatalf("envelope.data missing; stdout:\n%s", stdoutStr) + } + return envelope.Data +} + +// TestAppsDBExecute_SingleErrorReturnsTypedError 单条语句失败(server 也返 code:0 + ERROR 哨兵) +// 同样走 partial failure:statement_index=0、note 说明无语句落地、message 标注 (at statement 1 of 1)。 +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 partial-failure error; stdout:\n%s", stdout.String()) + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err) + } + payload := decodePartialFailureData(t, stdout.String()) + msg, _ := payload["error_message"].(string) + if !strings.Contains(msg, "(at statement 1 of 1)") { + t.Errorf("error_message missing locator: %q", msg) + } + if got := payload["statement_index"]; got != float64(0) { + t.Errorf("statement_index = %v, want 0", got) + } + note, _ := payload["note"].(string) + if !strings.Contains(note, "no statements were applied") { + t.Errorf("note should say nothing was applied, got %q", note) + } +} + +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) + } + }) + } +} + +// TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly pins the pretty +// contract on a statement failure: stdout carries only the per-statement +// human summary (no JSON envelope stacked after it), and the command still +// exits non-zero via the partial-failure signal. +func TestAppsDBExecute_PrettyPartialFailureKeepsStdoutHumanOnly(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/sql_commands", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "result": `[{"sql_type":"ERROR","data":"{\"code\":\"k_dl_000002\",\"message\":\"syntax error\"}"}]`, + }, + }, + }) + err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--format", "pretty", "--as", "user"}, + factory, stdout) + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err) + } + out := stdout.String() + if !strings.Contains(out, "✗") { + t.Fatalf("pretty summary missing failure marker; stdout:\n%s", out) + } + if strings.Contains(out, `"ok"`) { + t.Fatalf("pretty stdout must not stack a JSON envelope after the summary; stdout:\n%s", out) + } +} diff --git a/shortcuts/apps/apps_db_table_get.go b/shortcuts/apps/apps_db_table_get.go new file mode 100644 index 000000000..af0e63f00 --- /dev/null +++ b/shortcuts/apps/apps_db_table_get.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "strings" + + "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: "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 appsValidationParamError("--table", "--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 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 000000000..bab56a848 --- /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 000000000..d905531ea --- /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 an 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 an 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: "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 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 000000000..0f3c54851 --- /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 000000000..e2242f9a1 --- /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 appsValidationParamError("--app-id", "--app-id is required") + } + _, envFile, err := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path"))) + if err != nil { + return appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(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 appsValidationParamError("--project-path", "--project-path: %v", err).WithCause(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 "", "", errs.NewInternalError(errs.SubtypeUnknown, "cannot determine working directory: %v", err).WithCause(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 appsValidationParamError("--project-path", "cannot inspect %s: %v", envFile, err).WithCause(err) + } + if info.Mode()&os.ModeSymlink != 0 { + return appsValidationParamError("--project-path", "target %s must be a regular file, not a symlink", envFile) + } + if !info.Mode().IsRegular() { + return appsValidationParamError("--project-path", "target %s must be a regular file", envFile) + } + 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.NewInternalError(errs.SubtypeInvalidResponse, "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.NewInternalError(errs.SubtypeInvalidResponse, "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 000000000..1e9b34247 --- /dev/null +++ b/shortcuts/apps/apps_env_pull_test.go @@ -0,0 +1,1106 @@ +// 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") + } +} + +// TestExtractEnvPullVars_MissingEnvVarsIsInternalInvalidResponse pins that a +// response without a usable env_vars field classifies as +// internal/invalid_response — a broken upstream payload, not a flag problem +// the agent should retry with different arguments. +func TestExtractEnvPullVars_MissingEnvVarsIsInternalInvalidResponse(t *testing.T) { + for name, data := range map[string]map[string]interface{}{ + "missing": {}, + "wrong type": {"env_vars": "not-an-object"}, + } { + t.Run(name, func(t *testing.T) { + _, _, _, err := extractEnvPullVars(data) + if err == nil { + t.Fatalf("expected error for %s env_vars", name) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype) + } + }) + } +} diff --git a/shortcuts/apps/apps_errors.go b/shortcuts/apps/apps_errors.go new file mode 100644 index 000000000..9d0653cbb --- /dev/null +++ b/shortcuts/apps/apps_errors.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "errors" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/client" +) + +func appsValidationError(format string, args ...any) *errs.ValidationError { + return errs.NewValidationError(errs.SubtypeInvalidArgument, format, args...) +} + +func appsValidationParamError(param, format string, args ...any) *errs.ValidationError { + return appsValidationError(format, args...).WithParam(param) +} + +func appsInvalidParam(name, reason string) errs.InvalidParam { + return errs.InvalidParam{Name: name, Reason: reason} +} + +func appsFailedPreconditionParamError(param, format string, args ...any) *errs.ValidationError { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...).WithParam(param) +} + +func appsFailedPreconditionError(format string, args ...any) *errs.ValidationError { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, format, args...) +} + +// appsStorageError classifies a local credential/state storage failure +// (read, write, delete, list) as internal/storage. +func appsStorageError(err error, format string, args ...any) *errs.InternalError { + return errs.NewInternalError(errs.SubtypeStorage, format, args...).WithCause(err) +} + +// appsExternalToolError classifies a runtime failure of an external tool the +// CLI shells out to (git, npx) as internal/external_tool. The tool output is +// carried in the message; recovery guidance goes in the hint. +func appsExternalToolError(err error, format string, args ...any) *errs.InternalError { + return errs.NewInternalError(errs.SubtypeExternalTool, format, args...).WithCause(err) +} + +// appsSubprocessEnvelopeError classifies a malformed or failed envelope from a +// lark-cli subprocess (+git-credential-init / +env-pull) as internal/invalid_response. +func appsSubprocessEnvelopeError(format string, args ...any) *errs.InternalError { + return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...) +} + +func appsInputPathError(err error) error { + if err == nil { + return nil + } + if errors.Is(err, fileio.ErrPathValidation) { + return appsValidationParamError("--path", "unsafe --path: %s", err).WithCause(err) + } + return appsValidationParamError("--path", "cannot read --path: %s", err).WithCause(err) +} + +func appsInputPathEntryError(path string, err error) error { + if err == nil { + return nil + } + if errors.Is(err, fileio.ErrPathValidation) { + return appsValidationParamError("--path", "unsafe --path entry %s: %s", path, err).WithCause(err) + } + return appsValidationParamError("--path", "cannot read --path entry %s: %s", path, err).WithCause(err) +} + +func appsFileIOError(err error, format string, args ...any) *errs.InternalError { + return errs.NewInternalError(errs.SubtypeFileIO, format, args...).WithCause(err) +} + +// enrichHTMLPublishAPIError adapts a typed failure from the HTML publish +// endpoint: refines endpoint-scoped business codes, prefixes the message with +// command context, and attaches endpoint-specific recovery hints. A +// still-untyped error is lifted at the SDK boundary instead. +func enrichHTMLPublishAPIError(err error) error { + if err == nil { + return nil + } + p, ok := errs.ProblemOf(err) + if !ok { + return client.WrapDoAPIError(err) + } + // The HTML publish business codes (90001/90002) are scoped to this + // endpoint, not service-global, so their subtype classification lives + // here instead of the global errclass code table. Only an + // otherwise-unclassified API error is refined; a stronger upstream + // classification is never overridden. + if p.Category == errs.CategoryAPI && p.Subtype == errs.SubtypeUnknown && p.Code == errCodeAppNotFound { + p.Subtype = errs.SubtypeNotFound + } + if p.Message != "" { + p.Message = "html-publish failed: " + p.Message + } + if hint := buildHTMLPublishFailureHint(p.Code); hint != "" { + p.Hint = hint + } + return err +} diff --git a/shortcuts/apps/apps_errors_test.go b/shortcuts/apps/apps_errors_test.go new file mode 100644 index 000000000..ccab1d7cb --- /dev/null +++ b/shortcuts/apps/apps_errors_test.go @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" +) + +func TestAppsInputPathError_ClassifiesPathValidation(t *testing.T) { + cause := errors.New("path escapes working directory") + err := appsInputPathError(&fileio.PathValidationError{Err: cause}) + + problem := requireAppsValidationProblem(t, err) + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidArgument) + } + if !strings.Contains(problem.Message, "unsafe --path") { + t.Fatalf("message = %q, want unsafe --path context", problem.Message) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) || validationErr.Param != "--path" { + t.Fatalf("param = %q, want --path", validationErr.Param) + } + if !errors.Is(err, fileio.ErrPathValidation) || !errors.Is(err, cause) { + t.Fatalf("path validation cause chain not preserved: %v", err) + } +} + +func TestAppsInputPathEntryError_ClassifiesReadFailure(t *testing.T) { + cause := errors.New("permission denied") + err := appsInputPathEntryError("dist/index.html", cause) + + problem := requireAppsValidationProblem(t, err) + if !strings.Contains(problem.Message, "cannot read --path entry dist/index.html") { + t.Fatalf("message = %q, want entry read context", problem.Message) + } + if !errors.Is(err, cause) { + t.Fatalf("cause chain not preserved: %v", err) + } +} + +func TestAppsFileIOError_ClassifiesInternalFileIO(t *testing.T) { + cause := errors.New("archive writer failed") + err := appsFileIOError(cause, "pack failed: %v", cause) + + problem := requireAppsProblem(t, err, errs.CategoryInternal) + if problem.Subtype != errs.SubtypeFileIO { + t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeFileIO) + } + if !errors.Is(err, cause) { + t.Fatalf("cause chain not preserved: %v", err) + } +} + +func TestEnrichHTMLPublishAPIError_LiftsUntypedBoundaryError(t *testing.T) { + err := enrichHTMLPublishAPIError(errors.New("connection reset by peer")) + + problem := requireAppsProblem(t, err, errs.CategoryNetwork) + if problem.Subtype != errs.SubtypeNetworkTransport { + t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNetworkTransport) + } +} + +func TestEnrichHTMLPublishAPIError_PreservesClassificationAndAddsHint(t *testing.T) { + err := errs.NewAPIError(errs.SubtypeUnknown, "build failed"). + WithCode(errCodeBuildFailed). + WithLogID("logid-build-failed") + + got := enrichHTMLPublishAPIError(err) + if got != err { + t.Fatalf("typed error should be enriched in place") + } + problem := requireAppsAPIProblem(t, got) + if problem.Subtype != errs.SubtypeUnknown { + t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeUnknown) + } + if problem.Code != errCodeBuildFailed { + t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed) + } + if problem.LogID != "logid-build-failed" { + t.Fatalf("log_id = %q, want preserved", problem.LogID) + } + if !strings.Contains(problem.Message, "html-publish failed") { + t.Fatalf("message = %q, want html-publish context", problem.Message) + } + if problem.Hint == "" { + t.Fatalf("expected known-code recovery hint") + } +} + +func TestEnrichHTMLPublishAPIError_ClassifiesAppNotFoundLocally(t *testing.T) { + err := errs.NewAPIError(errs.SubtypeUnknown, "app not found").WithCode(errCodeAppNotFound) + + problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err)) + if problem.Subtype != errs.SubtypeNotFound { + t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound) + } +} + +func TestEnrichHTMLPublishAPIError_KeepsStrongerClassification(t *testing.T) { + err := errs.NewAPIError(errs.SubtypeRateLimit, "throttled").WithCode(errCodeAppNotFound) + + problem := requireAppsAPIProblem(t, enrichHTMLPublishAPIError(err)) + if problem.Subtype != errs.SubtypeRateLimit { + t.Fatalf("subtype = %q, want %q unchanged", problem.Subtype, errs.SubtypeRateLimit) + } +} diff --git a/shortcuts/apps/apps_examples_test.go b/shortcuts/apps/apps_examples_test.go new file mode 100644 index 000000000..5b977d0ff --- /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 000000000..a06f98038 --- /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 000000000..2fed3cd68 --- /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 000000000..6c959f497 --- /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 64cc5ae79..952e489cc 100644 --- a/shortcuts/apps/apps_html_publish.go +++ b/shortcuts/apps/apps_html_publish.go @@ -10,7 +10,7 @@ import ( "strings" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -19,23 +19,27 @@ import ( var AppsHTMLPublish = common.Shortcut{ Service: appsService, Command: "+html-publish", - Description: "Publish HTML to a Miaoda app (single multipart POST returns the access URL)", + Description: "Publish HTML to an 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: "app-id", Desc: "app ID", Required: true}, {Name: "path", Desc: "path to HTML file or directory", Required: true}, {Name: "allow-sensitive", Type: "bool", Desc: "skip the credential-file scan (allow .env / .npmrc / .aws/credentials / etc. in the publish payload)"}, }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("app-id")) == "" { - return output.ErrValidation("--app-id is required") + return appsValidationParamError("--app-id", "--app-id is required") } path := strings.TrimSpace(rctx.Str("path")) if path == "" { - return output.ErrValidation("--path is required") + return appsValidationParamError("--path", "--path is required") } // Block well-known credential files in the publish payload unless the // caller explicitly opts in. Lives in Validate (not DryRun) so that @@ -146,9 +150,9 @@ func sensitiveCandidatesError(hits []string) error { sample = strings.Join(hits[:maxSensitiveListInError], ", ") + fmt.Sprintf(" (and %d more)", len(hits)-maxSensitiveListInError) } - return output.ErrWithHint(output.ExitValidation, "validation", - fmt.Sprintf("--path contains %d credential file(s) that should not be published: %s", len(hits), sample), - "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)") + return appsValidationParamError("--path", + "--path contains %d credential file(s) that should not be published: %s", len(hits), sample). + WithHint("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)") } // maxHTMLPublishTarballBytes 是 client 端 tar.gz 包体上限,对齐 OAPI 设计 20MB 约束。 @@ -174,15 +178,14 @@ func ensureIndexHTML(candidates []htmlPublishCandidate) error { return nil } } - return output.ErrWithHint(output.ExitAPI, "validation", - "--path 中缺少 index.html", - "妙搭以 index.html 作为应用入口;目录形态把首页放在根目录命名 index.html,单文件形态把文件命名为 index.html") + return appsFailedPreconditionParamError("--path", "--path is missing index.html"). + WithHint("index.html is the app entrypoint; for a directory put index.html at the root, or pass a single file named index.html") } -func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) { +func runHTMLPublish(ctx context.Context, fio fileio.FileIO, publisher appsHTMLPublishClient, spec appsHTMLPublishSpec) (map[string]interface{}, error) { candidates, err := walkHTMLPublishCandidates(fio, spec.Path) if err != nil { - return nil, output.Errorf(output.ExitAPI, "io", "scan --path %s: %v", spec.Path, err) + return nil, err } if err := ensureIndexHTML(candidates); err != nil { return nil, err @@ -192,24 +195,24 @@ func runHTMLPublish(ctx context.Context, fio fileio.FileIO, client appsHTMLPubli rawTotal += c.Size } if rawTotal > maxHTMLPublishRawBytes { - return nil, output.ErrWithHint(output.ExitAPI, "validation", - fmt.Sprintf("--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes), - "在 tar+gzip 进入内存前拦截,避免 OOM;精简 --path 内容或选择更小的子目录") + return nil, appsValidationParamError("--path", + "--path total raw bytes %d exceeds %d bytes limit (uncompressed pre-pack cap)", rawTotal, maxHTMLPublishRawBytes). + WithHint("reduce --path contents or choose a smaller subdirectory before packaging") } tarball, err := buildHTMLPublishTarball(fio, candidates) if err != nil { - return nil, output.Errorf(output.ExitAPI, "io", "pack: %v", err) + return nil, err } if tarball.Size > maxHTMLPublishTarballBytes { - return nil, output.ErrWithHint(output.ExitAPI, "validation", - fmt.Sprintf("packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes), - "请精简 --path 目录(去掉无关大文件 / 压缩资源)后重试;本期接口上限 20MB") + return nil, appsValidationParamError("--path", + "packed tar.gz size %d bytes exceeds %d bytes limit", tarball.Size, maxHTMLPublishTarballBytes). + WithHint("reduce --path contents, remove unrelated large files, then retry") } - resp, err := client.HTMLPublish(ctx, spec.AppID, tarball) + resp, err := publisher.HTMLPublish(ctx, spec.AppID, tarball) if err != nil { - return nil, err + return nil, client.WrapDoAPIError(err) } out := map[string]interface{}{} diff --git a/shortcuts/apps/apps_html_publish_test.go b/shortcuts/apps/apps_html_publish_test.go index 1d8e942fe..788e1c067 100644 --- a/shortcuts/apps/apps_html_publish_test.go +++ b/shortcuts/apps/apps_html_publish_test.go @@ -10,8 +10,6 @@ import ( "path/filepath" "strings" "testing" - - "github.com/larksuite/cli/internal/output" ) type fakeAppsHTMLPublishClient struct { @@ -105,17 +103,11 @@ func TestRunHTMLPublish_DirRequiresIndexHTML(t *testing.T) { if err == nil { t.Fatalf("expected error for missing index.html") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError with detail, got %v", err) + problem := requireAppsValidationProblem(t, err) + if !strings.Contains(problem.Message, "index.html") { + t.Fatalf("message missing 'index.html': %v", problem.Message) } - if exitErr.Detail.Type != "validation" { - t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type) - } - if !strings.Contains(exitErr.Detail.Message, "index.html") { - t.Fatalf("message missing 'index.html': %v", exitErr.Detail.Message) - } - if exitErr.Detail.Hint == "" { + if problem.Hint == "" { t.Fatalf("expected non-empty hint") } if len(fake.calls) != 0 { @@ -153,10 +145,7 @@ func TestRunHTMLPublish_SingleFileRejectedIfNotNamedIndex(t *testing.T) { if err == nil { t.Fatalf("single-file path 'foo.html' should be rejected (not named index.html)") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil || exitErr.Detail.Type != "validation" { - t.Fatalf("expected ExitError type=validation, got %v", err) - } + requireAppsValidationProblem(t, err) if len(fake.calls) != 0 { t.Fatalf("client must not be called when index.html missing") } @@ -199,17 +188,11 @@ func TestRunHTMLPublish_RejectsOversizeTarball(t *testing.T) { if err == nil { t.Fatalf("expected oversize error") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError with detail, got %v", err) + problem := requireAppsValidationProblem(t, err) + if !strings.Contains(problem.Message, "exceeds") { + t.Fatalf("message missing 'exceeds': %v", problem.Message) } - if exitErr.Detail.Type != "validation" { - t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type) - } - if !strings.Contains(exitErr.Detail.Message, "exceeds") { - t.Fatalf("message missing 'exceeds': %v", exitErr.Detail.Message) - } - if exitErr.Detail.Hint == "" { + if problem.Hint == "" { t.Fatalf("expected non-empty hint") } if len(fake.calls) != 0 { @@ -337,18 +320,12 @@ func TestAppsHTMLPublish_SensitiveBlocksValidate(t *testing.T) { if err == nil { t.Fatalf("dry-run with sensitive file should fail") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError with detail, got %v", err) + problem := requireAppsValidationProblem(t, err) + if !strings.Contains(problem.Message, ".env") { + t.Fatalf("error message should list the offending file, got %q", problem.Message) } - if exitErr.Detail.Type != "validation" { - t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type) - } - if !strings.Contains(exitErr.Detail.Message, ".env") { - t.Fatalf("error message should list the offending file, got %q", exitErr.Detail.Message) - } - if !strings.Contains(exitErr.Detail.Hint, "--allow-sensitive") { - t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", exitErr.Detail.Hint) + if !strings.Contains(problem.Hint, "--allow-sensitive") { + t.Fatalf("error hint should mention --allow-sensitive escape hatch, got %q", problem.Hint) } } @@ -438,15 +415,9 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialParentDir(t *testing if err == nil { t.Fatalf("expected rejection when --path is %s/ (would leak %s), got success", tc.parent, tc.fileName) } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError with detail, got %v", err) - } - if exitErr.Detail.Type != "validation" { - t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type) - } - if !strings.Contains(exitErr.Detail.Message, tc.wantSubstr) { - t.Fatalf("error message should name the leaked file, got %q", exitErr.Detail.Message) + problem := requireAppsValidationProblem(t, err) + if !strings.Contains(problem.Message, tc.wantSubstr) { + t.Fatalf("error message should name the leaked file, got %q", problem.Message) } }) } @@ -480,15 +451,9 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testin if err == nil { t.Fatalf("expected rejection when --path points directly at .aws/credentials, got success") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError with detail, got %v", err) - } - if exitErr.Detail.Type != "validation" { - t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type) - } - if !strings.Contains(exitErr.Detail.Message, "credentials") { - t.Fatalf("error message should name the leaked file, got %q", exitErr.Detail.Message) + problem := requireAppsValidationProblem(t, err) + if !strings.Contains(problem.Message, "credentials") { + t.Fatalf("error message should name the leaked file, got %q", problem.Message) } } @@ -498,11 +463,7 @@ func TestAppsHTMLPublish_SensitiveBlocksWhenPathIsCredentialFileItself(t *testin func TestSensitiveCandidatesError_Truncation(t *testing.T) { hits := []string{"a.env", "b.env", "c.env", "d.env", "e.env", "f.env", "g.env"} err := sensitiveCandidatesError(hits) - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError with detail, got %v", err) - } - msg := exitErr.Detail.Message + msg := requireAppsValidationProblem(t, err).Message if !strings.Contains(msg, "7 credential file(s)") { t.Fatalf("message should report the full count, got %q", msg) } @@ -534,15 +495,9 @@ func TestRunHTMLPublish_RejectsOversizeRawCandidates(t *testing.T) { if err == nil { t.Fatalf("expected raw-size cap to fire") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError with detail, got %v", err) - } - if exitErr.Detail.Type != "validation" { - t.Fatalf("error.type = %q, want validation", exitErr.Detail.Type) - } - if !strings.Contains(exitErr.Detail.Message, "raw") || !strings.Contains(exitErr.Detail.Message, "bytes") { - t.Fatalf("expected message to explain raw-byte cap, got %q", exitErr.Detail.Message) + problem := requireAppsValidationProblem(t, err) + if !strings.Contains(problem.Message, "raw") || !strings.Contains(problem.Message, "bytes") { + t.Fatalf("expected message to explain raw-byte cap, got %q", problem.Message) } if len(fake.calls) != 0 { t.Fatalf("client must not be called when raw cap hit") diff --git a/shortcuts/apps/apps_init.go b/shortcuts/apps/apps_init.go new file mode 100644 index 000000000..b51e39010 --- /dev/null +++ b/shortcuts/apps/apps_init.go @@ -0,0 +1,676 @@ +// 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/errs" + "github.com/larksuite/cli/internal/charcheck" + "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 app config" + commitMsgUpgrade = "chore: initialize 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 an app's code and local development environment. +var AppsInit = common.Shortcut{ + Service: appsService, + Command: "+init", + Description: "Initialize an 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 (typed validation error -> exit 2). + {Name: "app-id", Desc: "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 appsValidationParamError("--app-id", "--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 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 "", appsValidationParamError("--dir", "--dir must not contain control characters") + } + if err := charcheck.RejectControlChars(raw, "--dir"); err != nil { + return "", appsValidationParamError("--dir", "%v", err).WithCause(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 "", appsValidationParamError("--dir", "--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 appsValidationParamError("--dir", "--dir cannot be read: %v", err) + } + if info.Mode()&os.ModeSymlink != 0 { + return appsValidationParamError("--dir", "--dir must not be a symlink: %q", dir) + } + if !info.IsDir() { + return appsValidationParamError("--dir", "--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 appsValidationParamError("--dir", "--dir cannot be read: %v", err) + } + if len(entries) > 0 { + return appsValidationParamError("--dir", "target directory %q already exists and is not empty", dir) + } + return nil +} + +// isAlreadyInitialized reports whether dir is an already-initialized 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 appsFileIOError(err, "read %s failed: %v", metaRelPath, err) + } + var m map[string]interface{} + if err := json.Unmarshal(b, &m); err != nil { + return appsFileIOError(err, "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 appsFileIOError(err, "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 appsFileIOError(err, "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, appsExternalToolError(err, "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 "", appsExternalToolError(err, "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 "", appsExternalToolError(err, "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 "", appsExternalToolError(err, "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 "", appsSubprocessEnvelopeError("could not parse +git-credential-init output as JSON: %v", err) + } + if !env.OK { + return "", appsSubprocessEnvelopeError("+git-credential-init reported failure") + } + if strings.TrimSpace(env.Data.RepositoryURL) == "" { + return "", appsSubprocessEnvelopeError("+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 "", appsSubprocessEnvelopeError("could not parse +env-pull output as JSON: %v", err) + } + if !env.OK { + return "", appsSubprocessEnvelopeError("+env-pull reported failure") + } + if strings.TrimSpace(env.Data.EnvFile) == "" { + return "", appsSubprocessEnvelopeError("+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 + } + // The URL comes from the +git-credential-init subprocess response, not user + // input, so a non-http(s) scheme is a broken upstream contract. + return appsSubprocessEnvelopeError( + "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 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 appsFailedPreconditionError("git executable not found on PATH"). + WithHint("install git and ensure it is on your PATH") + } + if _, err := exec.LookPath("npx"); err != nil { + return appsFailedPreconditionError("npx executable not found on PATH"). + WithHint("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 appsExternalToolError(err, "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 appsExternalToolError(err, "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 "", errs.NewInternalError(errs.SubtypeUnknown, "cannot locate lark-cli executable: %v", err).WithCause(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 "", appsExternalToolError(err, "apps +git-credential-init failed: %s", gitErr(stderr, err)). + WithHint("ensure apps +git-credential-init is available and you are logged in"). + WithCause(err) + } + 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 app 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, appsExternalToolError(runErr, "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( + appsExternalToolError(e, "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 appsExternalToolError(e, "git add failed: %s", gitErr(se, e)) + } + if _, se, e := initRunner.Run(ctx, dir, "git", "commit", "--no-verify", "-m", message); e != nil { + return appsExternalToolError(e, "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 "app 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 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 000000000..1137a1de9 --- /dev/null +++ b/shortcuts/apps/apps_init_test.go @@ -0,0 +1,1494 @@ +// 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/errs" + "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 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 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 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) + } +} + +// TestRunScaffold_SubprocessFailureIsExternalTool pins the typed +// classification of an external-tool failure: a failing git subprocess +// surfaces as internal/external_tool with the cause preserved. +func TestRunScaffold_SubprocessFailureIsExternalTool(t *testing.T) { + cause := errors.New("exit status 128") + f := &fakeCommandRunner{results: map[string]fakeCallResult{ + "git ls-files": {stderr: "fatal: not a git repository", err: cause}, + }} + withFakeRunner(t, f) + _, err := runScaffold(context.Background(), t.TempDir(), "app_x", "nestjs-react-fullstack") + if err == nil { + t.Fatalf("expected error from failing git subprocess") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeExternalTool { + t.Fatalf("classification = %s/%s, want internal/external_tool", p.Category, p.Subtype) + } + if !errors.Is(err, cause) { + t.Fatalf("cause chain not preserved: %v", err) + } +} diff --git a/shortcuts/apps/apps_jq_tips_test.go b/shortcuts/apps/apps_jq_tips_test.go new file mode 100644 index 000000000..5c0c8db0f --- /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 edfcca54c..d42ab6f04 100644 --- a/shortcuts/apps/apps_list.go +++ b/shortcuts/apps/apps_list.go @@ -12,45 +12,76 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) -// AppsList lists Miaoda apps owned by the calling user (cursor pagination). +// AppsList lists 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 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"}, }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). GET(apiBasePath + "/apps"). - Desc("List Miaoda apps"). + Desc("List apps"). 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 3b34ec21e..cdac719d6 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 000000000..694a82d1a --- /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 000000000..d54cdf395 --- /dev/null +++ b/shortcuts/apps/apps_release_create.go @@ -0,0 +1,75 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsReleaseCreate creates a release for an app. +var AppsReleaseCreate = common.Shortcut{ + Service: appsService, + Command: "+release-create", + Description: "Create a release for an 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: "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 appsValidationParamError("--app-id", "--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 000000000..cf6060091 --- /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 000000000..133765f78 --- /dev/null +++ b/shortcuts/apps/apps_release_get.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// 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: "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 appsValidationParamError("--app-id", "--app-id is required") + } + if strings.TrimSpace(rctx.Str("release-id")) == "" { + return appsValidationParamError("--release-id", "--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 + if el, ok := data["error_logs"]; ok { + out["error_logs"] = el + } + } + 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 000000000..9cd46cb38 --- /dev/null +++ b/shortcuts/apps/apps_release_get_test.go @@ -0,0 +1,367 @@ +// 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 TestAppsReleaseGetJSONErrorLogsPassthrough(t *testing.T) { + rctx, stdoutBuf, reg := newStatusRuntimeContext(t, "app_x", "6") + 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) + } + 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()) + } + logs, ok := env.Data["error_logs"].([]interface{}) + if !ok || len(logs) != 1 { + t.Fatalf("JSON must passthrough data.error_logs, got: %v", env.Data["error_logs"]) + } + first, _ := logs[0].(map[string]interface{}) + if first["step"] != "build" || first["error_log"] != "compile error" { + t.Errorf("error_logs content mismatch: %v", logs[0]) + } + // flattened release fields must still be present alongside error_logs + if env.Data["release_id"] != "6" || env.Data["status"] != "failed" { + t.Errorf("flattened release fields missing: %v", env.Data) + } +} + +func TestAppsReleaseGetJSONNoErrorLogsKeyWhenAbsent(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", + }}}, + }) + if err := AppsReleaseGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + var env struct { + 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 _, present := env.Data["error_logs"]; present { + t.Errorf("error_logs key must be absent when API omits it, got: %v", env.Data) + } +} + +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 000000000..df9468071 --- /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 an app's release history (most recent first). +var AppsReleaseList = common.Shortcut{ + Service: appsService, + Command: "+release-list", + Description: "List an 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: "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 appsValidationParamError("--app-id", "--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 000000000..d8c7c56f7 --- /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 000000000..3d97435c4 --- /dev/null +++ b/shortcuts/apps/apps_session_create.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsSessionCreate creates a new session under an existing app. +var AppsSessionCreate = common.Shortcut{ + Service: appsService, + Command: "+session-create", + Description: "Create a session under an 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 appsValidationParamError("--app-id", "--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 an 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 000000000..09a157c6e --- /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 000000000..b8c9bc8f1 --- /dev/null +++ b/shortcuts/apps/apps_session_get.go @@ -0,0 +1,72 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// 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 appsValidationParamError("--app-id", "--app-id is required") + } + if strings.TrimSpace(rctx.Str("session-id")) == "" { + return appsValidationParamError("--session-id", "--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 000000000..c5b528cd4 --- /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 000000000..9e2eac7b9 --- /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 an app (cursor pagination, single page). +var AppsSessionList = common.Shortcut{ + Service: appsService, + Command: "+session-list", + Description: "List sessions under an 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 appsValidationParamError("--app-id", "--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 an 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 000000000..41b49cbe3 --- /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 000000000..028480b60 --- /dev/null +++ b/shortcuts/apps/apps_session_stop.go @@ -0,0 +1,79 @@ +// 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 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 appsValidationParamError("--app-id", "--app-id is required") + } + if strings.TrimSpace(rctx.Str("session-id")) == "" { + return appsValidationParamError("--session-id", "--session-id is required") + } + if strings.TrimSpace(rctx.Str("turn-id")) == "" { + return appsValidationParamError("--turn-id", "--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 000000000..7887b5207 --- /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 000000000..b0a4c9852 --- /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 16ea4f364..3e926e977 100644 --- a/shortcuts/apps/apps_update.go +++ b/shortcuts/apps/apps_update.go @@ -9,20 +9,23 @@ import ( "io" "strings" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) -// AppsUpdate partially updates a Miaoda app's name / description. +// AppsUpdate partially updates an app's name / description. var AppsUpdate = common.Shortcut{ Service: appsService, Command: "+update", - Description: "Partially update a Miaoda app (only provided fields are sent)", + Description: "Partially update an 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"}, @@ -30,11 +33,15 @@ var AppsUpdate = common.Shortcut{ }, Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if strings.TrimSpace(rctx.Str("app-id")) == "" { - return output.ErrValidation("--app-id is required") + return appsValidationParamError("--app-id", "--app-id is required") } body := buildAppsUpdateBody(rctx) if len(body) == 0 { - return output.ErrValidation("provide at least one of --name or --description") + return appsValidationError("provide at least one of --name or --description"). + WithParams( + appsInvalidParam("--name", "provide at least one of --name or --description"), + appsInvalidParam("--description", "provide at least one of --name or --description"), + ) } return nil }, @@ -42,15 +49,15 @@ var AppsUpdate = common.Shortcut{ appID := strings.TrimSpace(rctx.Str("app-id")) return common.NewDryRunAPI(). PATCH(fmt.Sprintf("%s/apps/%s", apiBasePath, validate.EncodePathSegment(appID))). - Desc("Update a Miaoda app"). + Desc("Update an app"). Body(buildAppsUpdateBody(rctx)) }, 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 f187e2731..01276c3fb 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 000000000..1f24c2333 --- /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 000000000..81b4cadd7 --- /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 7616e14d0..5a8b59b9d 100644 --- a/shortcuts/apps/common.go +++ b/shortcuts/apps/common.go @@ -3,8 +3,39 @@ package apps +import ( + "strings" + + "github.com/larksuite/cli/errs" +) + // appsService 是 CLI 命令的 service 前缀(lark-cli apps ...)。 const appsService = "apps" -// apiBasePath is the registered OAPI prefix for the Miaoda apps domain. +// apiBasePath is the registered OAPI prefix for the 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 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 typed failure, +// preserving its original classification (subtype/code/log_id). 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 and +// untyped errors pass through unchanged. +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 + } + return err +} diff --git a/shortcuts/apps/common_test.go b/shortcuts/apps/common_test.go new file mode 100644 index 000000000..8060d1dac --- /dev/null +++ b/shortcuts/apps/common_test.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "errors" + "testing" + + "github.com/larksuite/cli/errs" +) + +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, classification preserved", func(t *testing.T) { + in := errs.NewAPIError(errs.SubtypeNotFound, "boom").WithCode(404) + out := withAppsHint(in, "run +release-list") + p, ok := errs.ProblemOf(out) + if !ok { + t.Fatalf("returned error is not typed: %T", out) + } + if p.Hint != "run +release-list" { + t.Errorf("Hint = %q, want %q", p.Hint, "run +release-list") + } + if p.Subtype != errs.SubtypeNotFound || p.Code != 404 || p.Message != "boom" { + t.Errorf("subtype/code/message mutated: subtype=%q code=%d msg=%q", p.Subtype, p.Code, p.Message) + } + }) + + t.Run("existing hint is preserved, not clobbered", func(t *testing.T) { + in := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint("original hint") + out := withAppsHint(in, "new hint") + p, _ := errs.ProblemOf(out) + if p.Hint != "original hint" { + t.Errorf("Hint = %q, want preserved %q", p.Hint, "original hint") + } + }) + + t.Run("blank-whitespace hint is treated as empty and filled", func(t *testing.T) { + in := errs.NewAPIError(errs.SubtypeUnknown, "boom").WithHint(" ") + out := withAppsHint(in, "filled hint") + p, _ := errs.ProblemOf(out) + if p.Hint != "filled hint" { + t.Errorf("Hint = %q, want %q", p.Hint, "filled hint") + } + }) + + t.Run("untyped error 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 000000000..9223e46e5 --- /dev/null +++ b/shortcuts/apps/db_common.go @@ -0,0 +1,42 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "fmt" + "strings" + + "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 "", appsValidationParamError("--app-id", "--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 000000000..843580f9e --- /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 000000000..4d74a0b81 --- /dev/null +++ b/shortcuts/apps/git_credential.go @@ -0,0 +1,576 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "path/filepath" + "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/client" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/errclass" + "github.com/larksuite/cli/internal/keychain" + "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 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 an 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: "app ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return appsValidationParamError("--app-id", "--app-id is required") + } + if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil { + return appsValidationParamError("--app-id", "%v", err).WithCause(err) + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(gitCredentialIssuePath). + Desc("Issue an app Git repository PAT"). + Set("mode", "api-plus-local-setup"). + Set("action", "initialize_local_git_credential"). + Set("app_id", appID). + Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)). + Set("local_effects", []string{ + "save the issued PAT in the local system credential store", + "write app-scoped git credential metadata", + "configure a URL-scoped Git credential helper in global git config when possible", + }). + 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 app 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 an 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: "app ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return appsValidationParamError("--app-id", "--app-id is required") + } + if err := validate.ResourceName(strings.TrimSpace(rctx.Str("app-id")), "--app-id"); err != nil { + return appsValidationParamError("--app-id", "%v", err).WithCause(err) + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + return common.NewDryRunAPI(). + Desc("Preview local Git credential cleanup (no API call; would clean up local-only state)."). + Set("mode", "local-cleanup-only"). + Set("action", "remove_local_git_credential"). + Set("app_id", appID). + Set("metadata_file", appKeyPath(appID, gitcred.MetadataFilename)). + Set("effects", []string{ + "read app-scoped git credential metadata", + "remove the saved PAT from the local system credential store", + "remove the app-scoped Git helper from global git config when present", + "delete the local metadata record after cleanup succeeds", + }) + }, + 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 app 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 app repositories", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +git-credential-list", + }, + Scopes: []string{}, + AuthTypes: []string{"user"}, + HasFormat: true, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Desc("Preview local Git credential listing (no API call, read-only local state)."). + Set("mode", "local-read-only"). + Set("action", "list_local_git_credentials"). + Set("storage_root", filepath.Join(core.GetConfigDir(), storageRoot)). + Set("reads", []string{ + "scan app-scoped git credential metadata under the CLI config directory", + "derive per-app repository URLs and local credential status from local metadata", + }) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + records, err := listGitCredentialRecords(rctx.Factory.Keychain, time.Now) + if err != nil { + return gitCredentialLocalError("List local app 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 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", "", "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, i.rctx.APIClassifyContext()) + 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, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in"). + WithHint("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, errclass.ClassifyContext{ + Brand: string(cfg.Brand), + AppID: cfg.AppID, + Identity: string(core.AsUser), + }) + 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 + } + // Typed errors pass through unchanged; everything the apps domain and the + // shared runtime produce is typed, so there is no legacy envelope to forward. + if _, ok := errs.UnwrapTypedError(err); ok { + 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, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing gitURL") + } + if issued.PAT == "" { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing token") + } + return issued, nil +} + +// parseIssueCredentialData turns the git-credential issue response into the +// credential data map. A standard Lark envelope (top-level "code") and any +// HTTP error status route through the shared response classifier, so generic +// codes (missing scope, app not authorized) and 5xx statuses keep their +// canonical category/subtype/retryable classification. The endpoint's +// non-standard success shapes — direct git info or a BaseResp wrapper — are +// handled locally. +func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) { + if err != nil { + return nil, client.WrapDoAPIError(err) + } + detail := logIDDetail(resp) + if resp == nil || len(resp.RawBody) == 0 { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, + "Issue app Git credential: empty response body") + } + var result map[string]any + jsonErr := json.Unmarshal(resp.RawBody, &result) + _, hasCode := result["code"] + if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest { + data, cerr := common.ClassifyAPIResponseWith(resp, cc) + if cerr != nil { + return nil, withAppsHint(cerr, gitCredentialIssueHint) + } + if data != nil { + result = data + } + // data == nil: a code==0 envelope whose fields sit beside "code" instead + // of under "data" — keep the locally-unmarshalled top-level object. + } 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" + } + baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue app Git credential: %s", message).WithCode(int(code)) + if logID != "" { + baseErr = baseErr.WithLogID(logID) + } + return baseErr + } + 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 json.Number: + n, _ := v.Int64() + return n + 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 000000000..d76355c4b --- /dev/null +++ b/shortcuts/apps/git_credential_storage.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "errors" + "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, appsStorageError(err, "apps storage: read root: %v", 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 000000000..4552ae5e1 --- /dev/null +++ b/shortcuts/apps/git_credential_test.go @@ -0,0 +1,1184 @@ +// 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/errclass" + "github.com/larksuite/cli/internal/httpmock" + "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"` + Mode string `json:"mode"` + Action string `json:"action"` + AppID string `json:"app_id"` + MetadataFile string `json:"metadata_file"` + LocalEffects []string `json:"local_effects"` + } + 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) + } + if payload.Mode != "api-plus-local-setup" { + t.Fatalf("mode = %q", payload.Mode) + } + if payload.Action != "initialize_local_git_credential" { + t.Fatalf("action = %q", payload.Action) + } + if payload.AppID != "app_xxx" { + t.Fatalf("app_id = %q", payload.AppID) + } + if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) { + t.Fatalf("metadata_file = %q", payload.MetadataFile) + } + assertStringSliceEqual(t, payload.LocalEffects, []string{ + "save the issued PAT in the local system credential store", + "write app-scoped git credential metadata", + "configure a URL-scoped Git credential helper in global git config when possible", + }) +} + +func TestAppsGitCredentialListDryRunDescribesLocalReads(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsGitCredentialList, + []string{"+git-credential-list", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var payload struct { + Description string `json:"description"` + API []interface{} `json:"api"` + Mode string `json:"mode"` + Action string `json:"action"` + StorageRoot string `json:"storage_root"` + Reads []string `json:"reads"` + } + if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if payload.Description != "Preview local Git credential listing (no API call, read-only local state)." { + t.Fatalf("description = %q", payload.Description) + } + if len(payload.API) != 0 { + t.Fatalf("api len = %d, want 0", len(payload.API)) + } + if payload.Mode != "local-read-only" { + t.Fatalf("mode = %q", payload.Mode) + } + if payload.Action != "list_local_git_credentials" { + t.Fatalf("action = %q", payload.Action) + } + if !strings.HasSuffix(payload.StorageRoot, filepath.Join("spark")) { + t.Fatalf("storage_root = %q", payload.StorageRoot) + } + assertStringSliceEqual(t, payload.Reads, []string{ + "scan app-scoped git credential metadata under the CLI config directory", + "derive per-app repository URLs and local credential status from local metadata", + }) +} + +func TestAppsGitCredentialRemoveDryRunDescribesLocalCleanup(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsGitCredentialRemove, + []string{"+git-credential-remove", "--app-id", "app_xxx", "--dry-run", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var payload struct { + Description string `json:"description"` + API []interface{} `json:"api"` + Mode string `json:"mode"` + Action string `json:"action"` + AppID string `json:"app_id"` + MetadataFile string `json:"metadata_file"` + Effects []string `json:"effects"` + } + if err := json.Unmarshal([]byte(stdout.String()), &payload); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if payload.Description != "Preview local Git credential cleanup (no API call; would clean up local-only state)." { + t.Fatalf("description = %q", payload.Description) + } + if len(payload.API) != 0 { + t.Fatalf("api len = %d, want 0", len(payload.API)) + } + if payload.Mode != "local-cleanup-only" { + t.Fatalf("mode = %q", payload.Mode) + } + if payload.Action != "remove_local_git_credential" { + t.Fatalf("action = %q", payload.Action) + } + if payload.AppID != "app_xxx" { + t.Fatalf("app_id = %q", payload.AppID) + } + if !strings.HasSuffix(payload.MetadataFile, filepath.Join("spark", "app_xxx", "git.json")) { + t.Fatalf("metadata_file = %q", payload.MetadataFile) + } + assertStringSliceEqual(t, payload.Effects, []string{ + "read app-scoped git credential metadata", + "remove the saved PAT from the local system credential store", + "remove the app-scoped Git helper from global git config when present", + "delete the local metadata record after cleanup succeeds", + }) +} + +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, errclass.ClassifyContext{}) + 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 assertStringSliceEqual(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("slice len = %d, want %d; got %#v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("slice[%d] = %q, want %q; got %#v", i, got[i], want[i], got) + } + } +} + +func TestGitCredentialLocalErrorWrapsOnlyPlainErrors(t *testing.T) { + plain := errors.New("git config failed") + wrapped := gitCredentialLocalError("List local app 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) + } + + validationErr := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad app") + if got := gitCredentialLocalError("action", validationErr); got != error(validationErr) { + t.Fatalf("typed validation 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"), errclass.ClassifyContext{}); err == nil { + t.Fatal("parseIssueCredentialData transport error returned nil") + } + if _, err := parseIssueCredentialData(nil, nil, errclass.ClassifyContext{}); err == nil { + t.Fatal("parseIssueCredentialData nil response returned nil") + } + if _, err := parseIssueCredentialData(&larkcore.ApiResp{StatusCode: http.StatusOK, RawBody: []byte("{bad json")}, nil, errclass.ClassifyContext{}); 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, errclass.ClassifyContext{}); 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, errclass.ClassifyContext{}); 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, errclass.ClassifyContext{}); 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, errclass.ClassifyContext{}) + 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, errclass.ClassifyContext{}) + 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, errclass.ClassifyContext{}); 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, errclass.ClassifyContext{}); 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, errclass.ClassifyContext{}) + 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, errclass.ClassifyContext{}) + 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 shared classifier's responsibility; +// apps adds only a static hint. There is no msg redaction in this path, 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, errclass.ClassifyContext{}) + 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 survives into the message. The business-code + // path passes it through verbatim; the HTTP-status path reports + // "HTTP : " via the shared classifier, so assert + // containment rather than equality. + if !strings.Contains(p.Message, serverMsg) { + t.Fatalf("Message = %q, want it to contain server msg %q", 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")) +} + +// TestParseIssueCredentialData_SharedClassifierCoverage pins the canonical +// classifications the shared classifier provides on the credential-issue +// path: a generic missing-scope code becomes a typed permission error with +// the missing scopes extracted, and an HTTP 503 becomes a retryable +// network/server_error — neither collapses to api/unknown. +func TestParseIssueCredentialData_SharedClassifierCoverage(t *testing.T) { + header := http.Header{"X-Tt-Logid": []string{"log_x"}} + + t.Run("missing scope classifies as authorization with scopes", func(t *testing.T) { + body := `{"code":99991676,"msg":"token scope insufficient","error":{"permission_violations":[{"subject":"spark:app:read"}]}}` + _, err := parseIssueCredentialData(&larkcore.ApiResp{ + StatusCode: http.StatusOK, RawBody: []byte(body), Header: header, + }, nil, errclass.ClassifyContext{}) + var permErr *errs.PermissionError + if !errors.As(err, &permErr) { + t.Fatalf("want *errs.PermissionError, got %T: %v", err, err) + } + if permErr.Subtype != errs.SubtypeTokenScopeInsufficient { + t.Fatalf("subtype = %q, want %q", permErr.Subtype, errs.SubtypeTokenScopeInsufficient) + } + if len(permErr.MissingScopes) != 1 || permErr.MissingScopes[0] != "spark:app:read" { + t.Fatalf("MissingScopes = %v, want [spark:app:read]", permErr.MissingScopes) + } + }) + + t.Run("http 503 classifies as retryable network server_error", func(t *testing.T) { + _, err := parseIssueCredentialData(&larkcore.ApiResp{ + StatusCode: http.StatusServiceUnavailable, RawBody: []byte(`{"msg":"upstream busy"}`), Header: header, + }, nil, errclass.ClassifyContext{}) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want typed problem, got %T: %v", err, err) + } + if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkServer { + t.Fatalf("classification = %s/%s, want network/server_error", p.Category, p.Subtype) + } + if !p.Retryable { + t.Fatalf("retryable = false, want true for 5xx") + } + }) +} diff --git a/shortcuts/apps/gitcred/gitconfig.go b/shortcuts/apps/gitcred/gitconfig.go new file mode 100644 index 000000000..28b183813 --- /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" + "os/exec" + "strings" + + "github.com/larksuite/cli/errs" + "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 errs.NewValidationError(errs.SubtypeFailedPrecondition, "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, errs.NewInternalError(errs.SubtypeExternalTool, "git config get %s failed: %v", key, err).WithCause(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 errs.NewInternalError(errs.SubtypeExternalTool, "git config unset %s failed: %v", key, err).WithCause(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 000000000..7085a6829 --- /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 000000000..2533efe63 --- /dev/null +++ b/shortcuts/apps/gitcred/helper.go @@ -0,0 +1,476 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package gitcred + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id") + } + if err := validate.ResourceName(appID, "--app-id"); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err) + } + if profile.UserOpenID == "" { + return nil, errs.NewAuthenticationError(errs.SubtypeTokenMissing, "not logged in").WithHint("run `lark-cli auth login --scope \"spark:app:read\"`") + } + unlockApp, err := lockApp(appID) + if err != nil { + return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(err) + } + defer unlockApp() + if m.Issuer == nil { + return nil, errs.NewInternalError(errs.SubtypeUnknown, "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, errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id") + } + if err := validate.ResourceName(appID, "--app-id"); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%v", err).WithParam("--app-id").WithCause(err) + } + unlockApp, err := lockApp(appID) + if err != nil { + return nil, errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", appID, err).WithCause(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 errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(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, errs.NewValidationError(errs.SubtypeFailedPrecondition, "current login does not match initialized credential"). + WithHint(fmt.Sprintf("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{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "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 errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: empty credential") + } + if issued.AppID != "" && issued.AppID != appID { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response app_id %q does not match requested app_id %q", issued.AppID, appID) + } + if normalizedURL == "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing gitURL") + } + if strings.TrimSpace(issued.PAT) == "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app Git credential: response missing token") + } + if issued.ExpiresAt <= now { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "Issue app 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 000000000..21e84d4ba --- /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 000000000..3cc0ae460 --- /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" + "path/filepath" + "regexp" + "sync" + "time" + + "github.com/larksuite/cli/errs" + "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, errs.NewInternalError(errs.SubtypeStorage, "create Git credential lock dir: %v", err).WithCause(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 000000000..6b1988a9b --- /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 000000000..b078b8ba8 --- /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 +// 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 000000000..bb1447992 --- /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/errs" +) + +func NormalizeGitHTTPURL(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git_http_url is empty") + } + u, err := url.Parse(raw) + if err != nil { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid git_http_url %q: %s", raw, err).WithCause(err) + } + return normalizeParsedURL(u) +} + +func NormalizeCredentialInput(input CredentialInput) (string, error) { + protocol := strings.TrimSpace(input.Protocol) + host := strings.TrimSpace(input.Host) + if protocol == "" || host == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "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 "", errs.NewValidationError(errs.SubtypeInvalidArgument, "git credential only supports http/https URLs") + } + host := normalizeHost(scheme, u.Host) + if host == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "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.go b/shortcuts/apps/html_publish_client.go index c1b05b93b..1efd627a6 100644 --- a/shortcuts/apps/html_publish_client.go +++ b/shortcuts/apps/html_publish_client.go @@ -6,13 +6,13 @@ package apps import ( "bytes" "context" - "encoding/json" "fmt" "net/http" larkcore "github.com/larksuite/oapi-sdk-go/v3/core" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/client" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -39,31 +39,21 @@ func (api appsHTMLPublishAPI) HTMLPublish(ctx context.Context, appID string, tar Body: fd, }, larkcore.WithFileUpload()) if err != nil { - return nil, err + return nil, client.WrapDoAPIError(err) } - return parseHTMLPublishResponse(apiResp.RawBody) + data, err := api.runtime.ClassifyAPIResponse(apiResp) + if err != nil { + return nil, enrichHTMLPublishAPIError(err) + } + url, _ := data["url"].(string) + if url == "" { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, + "html-publish response is missing the published app url") + } + return &htmlPublishResponse{URL: url}, nil } -func parseHTMLPublishResponse(raw []byte) (*htmlPublishResponse, error) { - var envelope struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - URL string `json:"url"` - } `json:"data"` - } - if err := json.Unmarshal(raw, &envelope); err != nil { - return nil, fmt.Errorf("decode html-publish response: %w", err) - } - if envelope.Code != 0 { - return nil, output.ErrWithHint(output.ExitAPI, "api_error", - fmt.Sprintf("html-publish failed (code=%d): %s", envelope.Code, envelope.Msg), - buildHTMLPublishFailureHint(envelope.Code)) - } - return &htmlPublishResponse{URL: envelope.Data.URL}, nil -} - -// OAPI business error codes returned by the Miaoda +// OAPI business error codes returned by the // /apps/{id}/upload_and_release_html_code endpoint. Owned by the backend // service; update when new codes are documented in the OAPI spec. const ( @@ -74,9 +64,9 @@ const ( func buildHTMLPublishFailureHint(code int) string { switch code { case errCodeBuildFailed: - return "构建失败:用 `lark-cli apps +html-publish --app-id --path --dry-run` 检查打包文件清单" + return "server-side build failed: run `lark-cli apps +html-publish --app-id --path --dry-run` to inspect the packaged file list" case errCodeAppNotFound: - return "应用不存在或无权访问;请用户确认 app_id(从妙搭应用链接 https://miaoda.feishu.cn/app/app_xxx 的 /app/ 后面提取,或直接给 app_xxx 字符串)" + return "the app does not exist or the caller has no access; ask the user to confirm the app_id (extract it from the app URL https://miaoda.feishu.cn/app/app_xxx after /app/, or take the app_xxx string directly)" default: return "" } diff --git a/shortcuts/apps/html_publish_client_test.go b/shortcuts/apps/html_publish_client_test.go index ca0a5949c..998b46220 100644 --- a/shortcuts/apps/html_publish_client_test.go +++ b/shortcuts/apps/html_publish_client_test.go @@ -6,21 +6,21 @@ package apps import ( "bytes" "context" - "errors" "mime" "mime/multipart" "strings" "testing" + "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/common" ) func newAppsClientRuntime(t *testing.T) (*common.RuntimeContext, *httpmock.Registry) { t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) cfg := &core.CliConfig{ AppID: "test-app-" + strings.ToLower(t.Name()), AppSecret: "test-secret", @@ -94,15 +94,57 @@ func TestAppsHTMLPublishAPI_BusinessErrorHasHint(t *testing.T) { if err == nil { t.Fatalf("expected error") } - var exitErr *output.ExitError - if !errors.As(err, &exitErr) || exitErr.Detail == nil { - t.Fatalf("expected ExitError with detail, got %v", err) + problem := requireAppsAPIProblem(t, err) + if problem.Code != errCodeBuildFailed { + t.Fatalf("code = %d, want %d", problem.Code, errCodeBuildFailed) } - if exitErr.Detail.Hint == "" { + if problem.Hint == "" { t.Fatalf("expected non-empty hint on code 90001") } - if !strings.Contains(exitErr.Detail.Message, "build failed") { - t.Fatalf("missing failure message: %v", exitErr.Detail.Message) + if !strings.Contains(problem.Message, "build failed") { + t.Fatalf("missing failure message: %v", problem.Message) + } +} + +func TestAppsHTMLPublishAPI_AppNotFoundClassified(t *testing.T) { + rctx, reg := newAppsClientRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_missing/upload_and_release_html_code", + Body: map[string]interface{}{ + "code": errCodeAppNotFound, + "msg": "app not found", + }, + }) + + api := appsHTMLPublishAPI{runtime: rctx} + _, err := api.HTMLPublish(context.Background(), "app_missing", &htmlPublishTarball{Body: []byte("fake")}) + problem := requireAppsAPIProblem(t, err) + if problem.Subtype != errs.SubtypeNotFound { + t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeNotFound) + } + if problem.Hint == "" { + t.Fatalf("expected app-not-found recovery hint") + } +} + +func TestAppsHTMLPublishAPI_MissingURLIsInvalidResponse(t *testing.T) { + rctx, reg := newAppsClientRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{}, + }, + }) + + api := appsHTMLPublishAPI{runtime: rctx} + _, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")}) + problem := requireAppsProblem(t, err, errs.CategoryInternal) + if problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse) } } @@ -137,3 +179,19 @@ func TestBuildHTMLPublishFailureHint_NotFoundHintNoLongerMentionsList(t *testing t.Fatalf("hint should reference app_id, got: %q", hint) } } + +func TestAppsHTMLPublishAPI_MalformedResponseIsInvalidResponse(t *testing.T) { + rctx, reg := newAppsClientRuntime(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/upload_and_release_html_code", + RawBody: []byte("{not json"), + }) + + api := appsHTMLPublishAPI{runtime: rctx} + _, err := api.HTMLPublish(context.Background(), "app_x", &htmlPublishTarball{Body: []byte("fake")}) + problem := requireAppsProblem(t, err, errs.CategoryInternal) + if problem.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeInvalidResponse) + } +} diff --git a/shortcuts/apps/html_publish_tarball.go b/shortcuts/apps/html_publish_tarball.go index b49d4eb77..b88909a9a 100644 --- a/shortcuts/apps/html_publish_tarball.go +++ b/shortcuts/apps/html_publish_tarball.go @@ -9,10 +9,9 @@ import ( "compress/gzip" "crypto/sha256" "encoding/hex" - "errors" - "fmt" "io" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" ) @@ -26,7 +25,7 @@ type htmlPublishTarball struct { func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidate) (*htmlPublishTarball, error) { if len(candidates) == 0 { - return nil, errors.New("no files to pack") + return nil, appsValidationParamError("--path", "no files to pack") } var buf bytes.Buffer @@ -45,10 +44,10 @@ func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidat if err := tw.Close(); err != nil { _ = gz.Close() - return nil, fmt.Errorf("tar close: %w", err) + return nil, appsFileIOError(err, "tar close: %v", err) } if err := gz.Close(); err != nil { - return nil, fmt.Errorf("gzip close: %w", err) + return nil, appsFileIOError(err, "gzip close: %v", err) } return &htmlPublishTarball{ @@ -60,12 +59,12 @@ func buildHTMLPublishTarball(fio fileio.FileIO, candidates []htmlPublishCandidat func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCandidate) error { if isUnsafeRelPath(c.RelPath) { - return fmt.Errorf("invalid tar entry name %q", c.RelPath) + return errs.NewInternalError(errs.SubtypeUnknown, "invalid tar entry name %q", c.RelPath) } src, err := fio.Open(c.AbsPath) if err != nil { - return fmt.Errorf("open %s: %w", c.AbsPath, err) + return appsInputPathEntryError(c.AbsPath, err) } defer src.Close() @@ -76,10 +75,10 @@ func writeHTMLPublishTarEntry(fio fileio.FileIO, tw *tar.Writer, c htmlPublishCa Typeflag: tar.TypeReg, } if err := tw.WriteHeader(hdr); err != nil { - return fmt.Errorf("write header %s: %w", c.RelPath, err) + return appsFileIOError(err, "write header %s: %v", c.RelPath, err) } if _, err := io.Copy(tw, src); err != nil { - return fmt.Errorf("copy %s: %w", c.RelPath, err) + return appsFileIOError(err, "copy %s: %v", c.RelPath, err) } return nil } diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index 795fc0ec0..d5a226585 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 04fc8a044..689bc9246 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 000000000..fea308a20 --- /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" + "net/url" + "os" + "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 // 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 appsStorageError(err, "apps storage: %v", err) + } + if name == "." { + return errs.NewInternalError(errs.SubtypeStorage, "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, appsStorageError(err, "apps storage: read: %v", 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 appsStorageError(err, "apps storage: create dir: %v", err) + } + if err := validate.AtomicWrite(appKeyPath(appID, key), data, 0600); err != nil { + return appsStorageError(err, "apps storage: write: %v", 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 appsStorageError(err, "apps storage: delete: %v", 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, appsStorageError(err, "apps storage: read dir: %v", 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 000000000..1546e5ab8 --- /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/apps/walk_html_publish_candidates.go b/shortcuts/apps/walk_html_publish_candidates.go index a9ef79412..4c762c5db 100644 --- a/shortcuts/apps/walk_html_publish_candidates.go +++ b/shortcuts/apps/walk_html_publish_candidates.go @@ -4,11 +4,11 @@ package apps import ( - "fmt" "io/fs" "path/filepath" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" ) @@ -40,7 +40,7 @@ func isUnsafeRelPath(rel string) bool { func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublishCandidate, error) { stat, err := fio.Stat(rootPath) if err != nil { - return nil, fmt.Errorf("stat %s: %w", rootPath, err) + return nil, appsInputPathError(err) } if !stat.IsDir() { return []htmlPublishCandidate{{ @@ -54,14 +54,23 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis //nolint:forbidigo // fileio has no WalkDir; rootPath is already validated above via fio.Stat -> SafeInputPath. err = filepath.WalkDir(rootPath, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { - return walkErr + return appsInputPathEntryError(path, walkErr) + } + // Skip a stray git repo: a directory named .git skips the whole subtree, + // and a .git file (the gitdir pointer used by submodules/worktrees) is + // skipped too. + if d.Name() == ".git" { + if d.IsDir() { + return filepath.SkipDir + } + return nil } if d.IsDir() { return nil } info, err := d.Info() if err != nil { - return err + return appsInputPathEntryError(path, err) } // 只接受 regular file —— symlink / device / pipe / socket 都跳过。 // symlink 不跟随是设计决策(避免 loop + out-of-root 引用),且 fio.Open 也会拒非 regular。 @@ -70,7 +79,7 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis } rel, err := filepath.Rel(rootPath, path) if err != nil { - return err + return appsFileIOError(err, "resolve relative path for %s: %v", path, err) } relSlash := filepath.ToSlash(rel) // Defense in depth: WalkDir + Rel inside rootPath should never yield a @@ -78,7 +87,7 @@ func walkHTMLPublishCandidates(fio fileio.FileIO, rootPath string) ([]htmlPublis // filesystem layout shouldn't be able to inject one into RelPath. // Mirrors the same guard at tar entry write time. if isUnsafeRelPath(relSlash) { - return fmt.Errorf("walker produced unsafe relative path %q for %s", relSlash, path) + return errs.NewInternalError(errs.SubtypeUnknown, "walker produced unsafe relative path %q for %s", relSlash, path) } out = append(out, htmlPublishCandidate{ RelPath: relSlash, diff --git a/shortcuts/apps/walk_html_publish_candidates_test.go b/shortcuts/apps/walk_html_publish_candidates_test.go index 7f881cbcf..456519b05 100644 --- a/shortcuts/apps/walk_html_publish_candidates_test.go +++ b/shortcuts/apps/walk_html_publish_candidates_test.go @@ -113,6 +113,102 @@ func TestIsUnsafeRelPath(t *testing.T) { } } +func TestWalkHTMLPublishCandidates_ExcludesGitDir(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "index.html": "", + ".git/config": "[core]\n", + ".git/HEAD": "ref: refs/heads/main\n", + ".git/objects/ab/cdef123": "binary", + ".github/workflows/ci.yml": "on: push\n", + ".gitignore": "node_modules\n", + } + for rel, content := range files { + full := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + } + + got, err := walkHTMLPublishCandidates(newTestFIO(), dir) + if err != nil { + t.Fatalf("err=%v", err) + } + rels := make(map[string]bool) + for _, c := range got { + rels[c.RelPath] = true + } + // .git 子树整体排除 + for _, banned := range []string{".git/config", ".git/HEAD", ".git/objects/ab/cdef123"} { + if rels[banned] { + t.Errorf("%q should be excluded from candidates", banned) + } + } + // 相邻名不受影响 + for _, kept := range []string{"index.html", ".github/workflows/ci.yml", ".gitignore"} { + if !rels[kept] { + t.Errorf("%q should be kept in candidates, got %+v", kept, got) + } + } +} + +func TestWalkHTMLPublishCandidates_ExcludesNestedGitDir(t *testing.T) { + dir := t.TempDir() + files := map[string]string{ + "index.html": "", + "sub/.git/config": "[core]\n", + "sub/page.html": "", + } + for rel, content := range files { + full := filepath.Join(dir, rel) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(full, []byte(content), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + } + + got, err := walkHTMLPublishCandidates(newTestFIO(), dir) + if err != nil { + t.Fatalf("err=%v", err) + } + rels := make(map[string]bool) + for _, c := range got { + rels[c.RelPath] = true + } + if rels["sub/.git/config"] { + t.Errorf("nested sub/.git/config should be excluded, got %+v", got) + } + if !rels["sub/page.html"] || !rels["index.html"] { + t.Errorf("non-git files should be kept, got %+v", got) + } +} + +func TestWalkHTMLPublishCandidates_ExcludesGitFile(t *testing.T) { + // submodule / worktree 场景:.git 是个 gitdir 指针文件,不是目录。 + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(""), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: /elsewhere\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + got, err := walkHTMLPublishCandidates(newTestFIO(), dir) + if err != nil { + t.Fatalf("err=%v", err) + } + for _, c := range got { + if c.RelPath == ".git" { + t.Errorf(".git pointer file should be excluded, got %+v", got) + } + } +} + func TestWalkHTMLPublishCandidates_SymlinkSkipped(t *testing.T) { // Walker 只接受 regular file —— symlink 跳过(避免 loop + out-of-root 引用, // 且 fio.Open 对 symlink 行为不一致)。real.html 仍然被收,link.html 不在结果里。 diff --git a/shortcuts/base/base_create.go b/shortcuts/base/base_create.go index 47d5e27ed..d1108d632 100644 --- a/shortcuts/base/base_create.go +++ b/shortcuts/base/base_create.go @@ -14,18 +14,39 @@ var BaseBaseCreate = common.Shortcut{ Command: "+base-create", Description: "Create a new base resource", Risk: "write", - UserScopes: []string{"base:app:create"}, - BotScopes: []string{"base:app:create", "docs:permission.member:create"}, - AuthTypes: authTypes(), + UserScopes: []string{ + "base:app:create", + "base:table:read", + "base:table:create", + "base:table:update", + "base:table:delete", + }, + BotScopes: []string{ + "base:app:create", + "base:table:read", + "base:table:create", + "base:table:update", + "base:table:delete", + "docs:permission.member:create", + }, + AuthTypes: authTypes(), Flags: []common.Flag{ {Name: "name", Desc: "base name", Required: true}, {Name: "folder-token", Desc: "folder token for destination"}, {Name: "time-zone", Desc: "time zone, e.g. Asia/Shanghai"}, + {Name: "fields", Desc: `field JSON array for the first table schema; use with --table-name, e.g. [{"name":"Title","type":"text"},{"name":"Status","type":"select","options":[{"name":"Todo"},{"name":"Done"}]}]`}, + {Name: "table-name", Desc: "first table name for the custom first table schema; use with --fields"}, }, Tips: []string{ `Example: lark-cli base +base-create --name "Project Tracker" --time-zone Asia/Shanghai`, + `Strongly recommended initial table schema: lark-cli base +base-create --name "Project Tracker" --table-name "Tasks" --fields '[{"name":"Title","type":"text"},{"name":"Status","type":"select","options":[{"name":"Todo"},{"name":"Done"}]}]'`, + "Before using --fields, read lark-base-field-json.md or rely on the same field JSON shape used by +field-create; do not invent field properties.", + "If --table-name and --fields are both omitted, Base creates one initial table with the platform default schema.", "If created as bot, output may include permission_grant; report it so the user knows whether they can open the new Base.", }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateBaseCreate(runtime) + }, DryRun: dryRunBaseCreate, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { return executeBaseCreate(runtime) diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index 32993008d..8f34fb119 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -28,6 +28,14 @@ func TestDryRunTableOps(t *testing.T) { rt := newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_1", "name": "Orders"}, nil, nil) assertDryRunContains(t, dryRunTableGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1") assertDryRunContains(t, dryRunTableCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables") + + tableCreateWithFieldsRT := newBaseTestRuntime( + map[string]string{"base-token": "app_x", "name": "Orders", "fields": `[{"name":"Title","type":"text"}]`}, + nil, + nil, + ) + assertDryRunContains(t, dryRunTableCreate(ctx, tableCreateWithFieldsRT), "POST /open-apis/base/v3/bases/app_x/tables", `"fields":[{"name":"Title","type":"text"}]`) + assertDryRunContains(t, dryRunTableUpdate(ctx, rt), "PATCH /open-apis/base/v3/bases/app_x/tables/tbl_1") assertDryRunContains(t, dryRunTableDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1") } @@ -264,6 +272,51 @@ func TestDryRunBaseOps(t *testing.T) { nil, ) assertDryRunContains(t, dryRunBaseCreate(ctx, createRT), "POST /open-apis/base/v3/bases") + + createWithFieldsRT := newBaseTestRuntime( + map[string]string{"name": "New Base", "table-name": "Tasks", "fields": `[{"name":"Title","type":"text"},{"name":"Status","type":"text"}]`}, + nil, + nil, + ) + assertDryRunContains( + t, + dryRunBaseCreate(ctx, createWithFieldsRT), + "POST /open-apis/base/v3/bases", + "GET /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables?limit=100&offset=0", + "POST /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", + `"name":"Tasks"`, + `"fields":[{"name":"Title","type":"text"},{"name":"Status","type":"text"}]`, + "DELETE /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", + ) + + createWithFieldsDefaultNameRT := newBaseTestRuntime( + map[string]string{"name": "New Base", "fields": `[{"name":"Title","type":"text"}]`}, + nil, + nil, + ) + assertDryRunContains( + t, + dryRunBaseCreate(ctx, createWithFieldsDefaultNameRT), + "POST /open-apis/base/v3/bases", + "POST /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables", + `"name":"Table 1"`, + `"fields":[{"name":"Title","type":"text"}]`, + "DELETE /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", + ) + + createWithTableNameRT := newBaseTestRuntime( + map[string]string{"name": "New Base", "table-name": "Tasks"}, + nil, + nil, + ) + assertDryRunContains( + t, + dryRunBaseCreate(ctx, createWithTableNameRT), + "POST /open-apis/base/v3/bases", + "GET /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables?limit=100&offset=0", + "PATCH /open-apis/base/v3/bases/%3Ccreated_base_token%3E/tables/%3Cdefault_table_id%3E", + `"name":"Tasks"`, + ) } func TestDryRunDashboardOps(t *testing.T) { diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 22626be2a..79bd12ac4 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -132,6 +132,221 @@ func TestBaseWorkspaceExecuteCreate(t *testing.T) { } } +func TestBaseWorkspaceExecuteCreateWithFields(t *testing.T) { + oldDelay := baseCreateDefaultTableDeleteDelay + baseCreateDefaultTableDeleteDelay = 0 + t.Cleanup(func() { baseCreateDefaultTableDeleteDelay = oldDelay }) + + factory, stdout, reg := newExecuteFactory(t) + stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_default", "name": "Table 1"}, + }}, + }, + }) + createTableStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_custom", "name": "Tasks", "fields": []interface{}{ + map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"}, + map[string]interface{}{"id": "fld_status", "name": "Status", "type": "text"}, + }}, + }, + } + reg.Register(createTableStub) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_default", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + + err := runShortcut( + t, + BaseBaseCreate, + []string{"+base-create", "--name", "Demo Base", "--table-name", "Tasks", "--fields", `[{"name":"Title","type":"text"},{"name":"Status","type":"text"}]`}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["created"] != true || data["default_table_deleted"] != true || data["deleted_default_table_id"] != "tbl_default" { + t.Fatalf("unexpected create output: %#v", data) + } + table, _ := data["table"].(map[string]interface{}) + if got := common.GetString(table, "id"); got != "tbl_custom" { + t.Fatalf("table.id = %q, want tbl_custom", got) + } + fields, _ := data["fields"].([]interface{}) + if len(fields) != 2 { + t.Fatalf("fields len = %d, want 2; output=%#v", len(fields), data["fields"]) + } + if strings.Contains(stderr.String(), baseCreateHint) { + t.Fatalf("stderr should not contain default-table cleanup hint when --fields handled cleanup: %q", stderr.String()) + } + + if body := decodeCapturedJSONBody(t, createTableStub); body["name"] != "Tasks" { + t.Fatalf("create table body = %#v", body) + } + body := decodeCapturedJSONBody(t, createTableStub) + fieldsBody, _ := body["fields"].([]interface{}) + if len(fieldsBody) != 2 { + t.Fatalf("create table fields body = %#v", body["fields"]) + } +} + +func TestBaseWorkspaceExecuteCreateWithFieldsDefaultTableName(t *testing.T) { + oldDelay := baseCreateDefaultTableDeleteDelay + baseCreateDefaultTableDeleteDelay = 0 + t.Cleanup(func() { baseCreateDefaultTableDeleteDelay = oldDelay }) + + factory, stdout, reg := newExecuteFactory(t) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_default", "name": "Table 1"}, + }}, + }, + }) + createTableStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_custom", "name": "Table 1", "fields": []interface{}{ + map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"}, + }}, + }, + } + reg.Register(createTableStub) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_default", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{}}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + + err := runShortcut( + t, + BaseBaseCreate, + []string{"+base-create", "--name", "Demo Base", "--fields", `[{"name":"Title","type":"text"}]`}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("err=%v", err) + } + body := decodeCapturedJSONBody(t, createTableStub) + if body["name"] != "Table 1" { + t.Fatalf("create table body = %#v, want name Table 1", body) + } +} + +func TestBaseWorkspaceExecuteCreateWithTableNameOnly(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer) + + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"app_token": "app_x", "name": "Demo Base"}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"tables": []interface{}{ + map[string]interface{}{"id": "tbl_default", "name": "Table 1"}, + }}, + }, + }) + renameStub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_default", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "tbl_default", "name": "Tasks"}, + }, + } + reg.Register(renameStub) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/permissions/app_x/members?need_notification=false&type=bitable", + Body: map[string]interface{}{"code": 0, "msg": "ok"}, + }) + + err := runShortcut( + t, + BaseBaseCreate, + []string{"+base-create", "--name", "Demo Base", "--table-name", "Tasks"}, + factory, + stdout, + ) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["created"] != true || data["default_table_renamed"] != true || data["renamed_default_table_id"] != "tbl_default" { + t.Fatalf("unexpected create output: %#v", data) + } + if data["default_table_deleted"] == true { + t.Fatalf("table-name-only should not delete the default table: %#v", data) + } + table, _ := data["table"].(map[string]interface{}) + if got := common.GetString(table, "name"); got != "Tasks" { + t.Fatalf("table.name = %q, want Tasks", got) + } + if strings.Contains(stderr.String(), baseCreateHint) { + t.Fatalf("stderr should not contain default schema hint when --table-name handled rename: %q", stderr.String()) + } + body := decodeCapturedJSONBody(t, renameStub) + if body["name"] != "Tasks" { + t.Fatalf("rename table body = %#v", body) + } +} + func TestBaseWorkspaceExecuteGetAndCopy(t *testing.T) { t.Run("get", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) @@ -692,30 +907,21 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) { func TestBaseTableExecuteCreate(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) - reg.Register(&httpmock.Stub{ + createTableStub := &httpmock.Stub{ Method: "POST", URL: "/open-apis/base/v3/bases/app_x/tables", Body: map[string]interface{}{ "code": 0, - "data": map[string]interface{}{"id": "tbl_new", "name": "Orders"}, + "data": map[string]interface{}{ + "id": "tbl_new", + "name": "Orders", + "fields": []interface{}{ + map[string]interface{}{"id": "fld_primary", "name": "OrderNo", "type": "text"}, + }, + }, }, - }) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/fields", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"fields": []interface{}{map[string]interface{}{"id": "fld_primary", "name": "Primary"}}}, - }, - }) - reg.Register(&httpmock.Stub{ - Method: "PUT", - URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/fields/fld_primary", - Body: map[string]interface{}{ - "code": 0, - "data": map[string]interface{}{"id": "fld_primary", "name": "OrderNo", "type": "text"}, - }, - }) + } + reg.Register(createTableStub) reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/base/v3/bases/app_x/tables/tbl_new/views", @@ -731,6 +937,11 @@ func TestBaseTableExecuteCreate(t *testing.T) { if got := stdout.String(); !strings.Contains(got, `"table"`) || !strings.Contains(got, `"vew_main"`) { t.Fatalf("stdout=%s", got) } + body := decodeCapturedJSONBody(t, createTableStub) + fieldsBody, _ := body["fields"].([]interface{}) + if body["name"] != "Orders" || len(fieldsBody) != 1 { + t.Fatalf("create table body = %#v", body) + } } func TestBaseTableExecuteUpdate(t *testing.T) { diff --git a/shortcuts/base/base_ops.go b/shortcuts/base/base_ops.go index dc0bba5a7..68bbf795b 100644 --- a/shortcuts/base/base_ops.go +++ b/shortcuts/base/base_ops.go @@ -7,11 +7,15 @@ import ( "context" "fmt" "strings" + "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) -const baseCreateHint = "Tip: New bases include a default empty table with 5-10 blank records. After finishing table/field setup on this base, ask whether to delete that default table. If yes, run +table-list first, then delete the default table." +const baseCreateHint = "Tip: Base created the platform default first-table schema. To configure the initial table schema during +base-create, pass both --table-name and --fields." + +var baseCreateDefaultTableDeleteDelay = time.Second func dryRunBaseGet(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { return common.NewDryRunAPI(). @@ -31,15 +35,43 @@ func dryRunBaseCopy(_ context.Context, runtime *common.RuntimeContext) *common.D } func dryRunBaseCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - d := common.NewDryRunAPI(). - POST("/open-apis/base/v3/bases"). - Body(buildBaseCreateBody(runtime)) + d := common.NewDryRunAPI() if runtime.IsBot() { d.Desc("After Base creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new Base.") } + d. + POST("/open-apis/base/v3/bases"). + Body(buildBaseCreateBody(runtime)) + hasFields := strings.TrimSpace(runtime.Str("fields")) != "" + hasTableName := strings.TrimSpace(runtime.Str("table-name")) != "" + if hasFields { + d.GET("/open-apis/base/v3/bases/:created_base_token/tables"). + Params(map[string]interface{}{"offset": 0, "limit": 100}). + Desc("If the create response does not include the default table ID, the CLI lists tables to find it."). + Set("created_base_token", "") + d.POST("/open-apis/base/v3/bases/:created_base_token/tables"). + Body(dryRunTableCreateBody(runtime, baseCreateFirstTableName(runtime))). + Desc("Create a replacement first table with --fields in the create-table body.") + d.DELETE("/open-apis/base/v3/bases/:created_base_token/tables/:default_table_id"). + Desc("After a 1s wait, delete the default table created with the Base."). + Set("default_table_id", "") + } else if hasTableName { + d.GET("/open-apis/base/v3/bases/:created_base_token/tables"). + Params(map[string]interface{}{"offset": 0, "limit": 100}). + Desc("If the create response does not include the default table ID, the CLI lists tables to find it."). + Set("created_base_token", "") + d.PATCH("/open-apis/base/v3/bases/:created_base_token/tables/:default_table_id"). + Body(map[string]interface{}{"name": baseCreateFirstTableName(runtime)}). + Desc("Rename the default first table when only --table-name is provided."). + Set("default_table_id", "") + } return d } +func validateBaseCreate(runtime *common.RuntimeContext) error { + return nil +} + func executeBaseGet(runtime *common.RuntimeContext) error { data, err := baseV3Call(runtime, "GET", baseV3Path("bases", runtime.Str("base-token")), nil, nil) if err != nil { @@ -66,9 +98,28 @@ func executeBaseCreate(runtime *common.RuntimeContext) error { return err } out := map[string]interface{}{"base": data, "created": true} + if strings.TrimSpace(runtime.Str("fields")) != "" { + customTable, createdFields, defaultTableID, err := replaceBaseDefaultTable(runtime, data) + if err != nil { + return err + } + out["table"] = customTable + out["fields"] = createdFields + out["default_table_deleted"] = true + out["deleted_default_table_id"] = defaultTableID + } else if strings.TrimSpace(runtime.Str("table-name")) != "" { + renamedTable, defaultTableID, err := renameBaseDefaultTable(runtime, data) + if err != nil { + return err + } + out["table"] = renamedTable + out["default_table_renamed"] = true + out["renamed_default_table_id"] = defaultTableID + } else { + fmt.Fprintln(runtime.IO().ErrOut, baseCreateHint) + } augmentBasePermissionGrant(runtime, out, data) runtime.Out(out, nil) - fmt.Fprintln(runtime.IO().ErrOut, baseCreateHint) return nil } @@ -114,3 +165,106 @@ func extractBasePermissionToken(base map[string]interface{}) string { } return "" } + +func replaceBaseDefaultTable(runtime *common.RuntimeContext, base map[string]interface{}) (map[string]interface{}, []interface{}, string, error) { + baseToken := extractBasePermissionToken(base) + if baseToken == "" { + return nil, nil, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "+base-create --fields could not find base_token/app_token in create response") + } + defaultTableID, err := findCreatedBaseDefaultTableID(runtime, baseToken, base, "--fields") + if err != nil { + return nil, nil, "", err + } + tableBody, err := buildTableCreateBody(runtime, newParseCtx(runtime), baseCreateFirstTableName(runtime)) + if err != nil { + return nil, nil, "", err + } + customTable, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables"), nil, tableBody) + if err != nil { + return nil, nil, "", err + } + customTableID := tableID(customTable) + if customTableID == "" { + return nil, nil, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "+base-create --fields could not find table_id/id in replacement table create response") + } + createdFields := []interface{}{} + if fields, ok := customTable["fields"].([]interface{}); ok { + createdFields = fields + } + time.Sleep(baseCreateDefaultTableDeleteDelay) + if _, err := baseV3Call(runtime, "DELETE", baseV3Path("bases", baseToken, "tables", defaultTableID), nil, nil); err != nil { + return nil, nil, "", err + } + return customTable, createdFields, defaultTableID, nil +} + +func renameBaseDefaultTable(runtime *common.RuntimeContext, base map[string]interface{}) (map[string]interface{}, string, error) { + baseToken := extractBasePermissionToken(base) + if baseToken == "" { + return nil, "", errs.NewInternalError(errs.SubtypeInvalidResponse, "+base-create --table-name could not find base_token/app_token in create response") + } + defaultTableID, err := findCreatedBaseDefaultTableID(runtime, baseToken, base, "--table-name") + if err != nil { + return nil, "", err + } + renamedTable, err := baseV3Call( + runtime, + "PATCH", + baseV3Path("bases", baseToken, "tables", defaultTableID), + nil, + map[string]interface{}{"name": baseCreateFirstTableName(runtime)}, + ) + if err != nil { + return nil, "", err + } + return renamedTable, defaultTableID, nil +} + +func baseCreateFirstTableName(runtime *common.RuntimeContext) string { + if name := strings.TrimSpace(runtime.Str("table-name")); name != "" { + return name + } + return "Table 1" +} + +func findCreatedBaseDefaultTableID(runtime *common.RuntimeContext, baseToken string, base map[string]interface{}, flag string) (string, error) { + if tableIDValue := tableIDFromCreateBaseResponse(base); tableIDValue != "" { + return tableIDValue, nil + } + tables, _, err := listAllTables(runtime, baseToken, 0, 100) + if err != nil { + return "", err + } + if len(tables) == 0 { + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "+base-create "+flag+" could not find the default table created with the Base") + } + if id := tableID(tables[0]); id != "" { + return id, nil + } + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "+base-create "+flag+" could not find table_id/id in default table list response") +} + +func tableIDFromCreateBaseResponse(base map[string]interface{}) string { + for _, key := range []string{"table_id", "default_table_id"} { + if id := strings.TrimSpace(common.GetString(base, key)); id != "" { + return id + } + } + for _, key := range []string{"table", "default_table"} { + if table, ok := base[key].(map[string]interface{}); ok { + if id := tableID(table); id != "" { + return id + } + } + } + for _, key := range []string{"tables", "default_tables"} { + if tables, ok := base[key].([]interface{}); ok && len(tables) > 0 { + if table, ok := tables[0].(map[string]interface{}); ok { + if id := tableID(table); id != "" { + return id + } + } + } + } + return "" +} diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index e304d4f18..05f51da3b 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -576,6 +576,14 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) { shortcut common.Shortcut wantHelp []string }{ + { + name: "base create fields", + shortcut: BaseBaseCreate, + wantHelp: []string{ + `field JSON array for the first table schema; use with --table-name`, + `first table name for the custom first table schema; use with --fields`, + }, + }, { name: "table create fields", shortcut: BaseTableCreate, @@ -986,6 +994,50 @@ func TestBaseTableValidate(t *testing.T) { } } +func TestBaseCreateValidate(t *testing.T) { + ctx := context.Background() + if err := BaseBaseCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"name": "Demo", "table-name": "Tasks"}, nil, nil)); err != nil { + t.Fatalf("table-name-only should be valid, err=%v", err) + } + if err := BaseBaseCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"name": "Demo", "table-name": "Tasks", "fields": `[{"name":"Title","type":"text"}]`}, nil, nil)); err != nil { + t.Fatalf("create validate err=%v", err) + } +} + +func TestBaseCreateTipsGuideFieldSchema(t *testing.T) { + parent := &cobra.Command{Use: "base"} + BaseBaseCreate.Mount(parent, &cmdutil.Factory{}) + cmd := parent.Commands()[0] + + tips := strings.Join(cmdutil.GetTips(cmd), "\n") + for _, want := range []string{ + "Before using --fields, read lark-base-field-json.md", + "do not invent field properties", + } { + if !strings.Contains(tips, want) { + t.Fatalf("tips missing %q:\n%s", want, tips) + } + } +} + +func TestBaseCreateScopesCoverFollowUpTableOperations(t *testing.T) { + requiredUserScopes := []string{ + "base:app:create", + "base:table:read", + "base:table:create", + "base:table:update", + "base:table:delete", + } + if !reflect.DeepEqual(BaseBaseCreate.UserScopes, requiredUserScopes) { + t.Fatalf("UserScopes=%v want=%v", BaseBaseCreate.UserScopes, requiredUserScopes) + } + + requiredBotScopes := append(append([]string{}, requiredUserScopes...), "docs:permission.member:create") + if !reflect.DeepEqual(BaseBaseCreate.BotScopes, requiredBotScopes) { + t.Fatalf("BotScopes=%v want=%v", BaseBaseCreate.BotScopes, requiredBotScopes) + } +} + func TestBaseRecordValidate(t *testing.T) { ctx := context.Background() if BaseRecordList.Validate == nil { diff --git a/shortcuts/base/table_create.go b/shortcuts/base/table_create.go index b49c0a4c0..2175080e1 100644 --- a/shortcuts/base/table_create.go +++ b/shortcuts/base/table_create.go @@ -24,7 +24,7 @@ var BaseTableCreate = common.Shortcut{ }, Tips: []string{ "Before using --fields, read lark-base-field-json.md or rely on the same field JSON shape used by +field-create; do not invent field properties.", - "The first --fields item replaces the default field.", + "The first --fields item becomes the primary field.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { return validateTableCreate(runtime) diff --git a/shortcuts/base/table_ops.go b/shortcuts/base/table_ops.go index 909709122..5d986eab6 100644 --- a/shortcuts/base/table_ops.go +++ b/shortcuts/base/table_ops.go @@ -5,6 +5,7 @@ package base import ( "context" + "strings" "github.com/larksuite/cli/shortcuts/common" ) @@ -29,10 +30,12 @@ func dryRunTableGet(_ context.Context, runtime *common.RuntimeContext) *common.D } func dryRunTableCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - return common.NewDryRunAPI(). + body := dryRunTableCreateBody(runtime, runtime.Str("name")) + d := common.NewDryRunAPI(). POST("/open-apis/base/v3/bases/:base_token/tables"). - Body(map[string]interface{}{"name": runtime.Str("name")}). + Body(body). Set("base_token", runtime.Str("base-token")) + return d } func dryRunTableUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -96,43 +99,21 @@ func executeTableGet(runtime *common.RuntimeContext) error { func executeTableCreate(runtime *common.RuntimeContext) error { baseToken := runtime.Str("base-token") - created, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables"), nil, map[string]interface{}{"name": runtime.Str("name")}) + pc := newParseCtx(runtime) + body, err := buildTableCreateBody(runtime, pc, runtime.Str("name")) + if err != nil { + return err + } + created, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables"), nil, body) if err != nil { return err } result := map[string]interface{}{"table": created} tableIDValue := tableID(created) - pc := newParseCtx(runtime) if tableIDValue != "" && runtime.Str("fields") != "" { - fieldItems, err := parseJSONArray(pc, runtime.Str("fields"), "fields") - if err != nil { - return err + if fields, ok := created["fields"]; ok { + result["fields"] = fields } - defaultFields, err := listEveryField(runtime, baseToken, tableIDValue) - if err != nil { - return err - } - createdFields := []interface{}{} - for idx, item := range fieldItems { - body, ok := item.(map[string]interface{}) - if !ok { - return baseValidationErrorf("--fields item %d must be an object", idx+1) - } - if idx == 0 && len(defaultFields) > 0 { - fieldData, err := baseV3Call(runtime, "PUT", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldID(defaultFields[0])), nil, body) - if err != nil { - return err - } - createdFields = append(createdFields, fieldData) - continue - } - fieldData, err := baseV3Call(runtime, "POST", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields"), nil, body) - if err != nil { - return err - } - createdFields = append(createdFields, fieldData) - } - result["fields"] = createdFields } if tableIDValue != "" && runtime.Str("view") != "" { viewItems, err := parseObjectList(pc, runtime.Str("view"), "view") @@ -153,6 +134,40 @@ func executeTableCreate(runtime *common.RuntimeContext) error { return nil } +func buildTableCreateBody(runtime *common.RuntimeContext, pc *parseCtx, tableName string) (map[string]interface{}, error) { + body := map[string]interface{}{"name": tableName} + if strings.TrimSpace(runtime.Str("fields")) == "" { + return body, nil + } + fieldItems, err := parseJSONArray(pc, runtime.Str("fields"), "fields") + if err != nil { + return nil, err + } + for idx, item := range fieldItems { + if _, ok := item.(map[string]interface{}); !ok { + return nil, baseValidationErrorf("--fields item %d must be an object", idx+1) + } + } + if len(fieldItems) > 0 { + body["fields"] = fieldItems + } + return body, nil +} + +func dryRunTableCreateBody(runtime *common.RuntimeContext, tableName string) map[string]interface{} { + body := map[string]interface{}{"name": tableName} + if strings.TrimSpace(runtime.Str("fields")) == "" { + return body + } + fieldItems, err := parseJSONArray(newParseCtx(runtime), runtime.Str("fields"), "fields") + if err != nil { + body["fields"] = "" + return body + } + body["fields"] = fieldItems + return body +} + func listEveryField(runtime *common.RuntimeContext, baseToken, tableID string) ([]map[string]interface{}, error) { const pageLimit = 100 offset := 0 diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 59be2040f..d8be762cd 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -298,6 +298,14 @@ func (ctx *RuntimeContext) CallAPITyped(method, url string, params map[string]in // carry fields a caller needs on failure (e.g. the file_token an overwrite // returned, for token-stability handling). func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[string]interface{}, error) { + return ClassifyAPIResponseWith(resp, ctx.APIClassifyContext()) +} + +// ClassifyAPIResponseWith is the RuntimeContext-free form of +// ClassifyAPIResponse for callers that drive the request outside a running +// shortcut (e.g. a cobra command holding only a factory) and supply their own +// classification context. +func ClassifyAPIResponseWith(resp *larkcore.ApiResp, cc errclass.ClassifyContext) (map[string]interface{}, error) { logID, _ := logIDFromHeader(resp)["log_id"].(string) result, parseErr := client.ParseJSONResponse(resp) @@ -321,7 +329,7 @@ func (ctx *RuntimeContext) ClassifyAPIResponse(resp *larkcore.ApiResp) (map[stri } } out, _ := resultMap["data"].(map[string]interface{}) - if apiErr := errclass.BuildAPIError(resultMap, ctx.APIClassifyContext()); apiErr != nil { + if apiErr := errclass.BuildAPIError(resultMap, cc); apiErr != nil { return out, apiErr } if resp.StatusCode >= 400 { @@ -676,30 +684,10 @@ func WrapInputStatErrorTyped(err error, readMsg ...string) error { WithCause(err) } -// WrapSaveErrorByCategory maps a FileIO.Save error to structured output errors, -// using standardized messages and the given error category (e.g. "api_error", "io"). -// Path validation errors always use ErrValidation (exit code 2). -// -// Deprecated: use WrapSaveErrorTyped for typed error envelopes. -func WrapSaveErrorByCategory(err error, category string) error { - if err == nil { - return nil - } - var me *fileio.MkdirError - switch { - case errors.Is(err, fileio.ErrPathValidation): - return output.ErrValidation("unsafe output path: %s", err) - case errors.As(err, &me): - return output.Errorf(output.ExitInternal, category, "cannot create parent directory: %s", err) - default: - return output.Errorf(output.ExitInternal, category, "cannot create file: %s", err) - } -} - // WrapSaveErrorTyped maps a FileIO.Save error to typed validation/internal errors. -// Unlike WrapSaveErrorByCategory, non-path failures always emit the canonical -// "internal" wire type: call sites migrating from a custom category -// (e.g. "io", "api_error") change their envelope's type field. +// Non-path failures always emit the canonical "internal" wire type; call sites +// migrating from a custom category (e.g. "io", "api_error") change their +// envelope's type field. func WrapSaveErrorTyped(err error) error { if err == nil { return nil diff --git a/shortcuts/doc/clipboard.go b/shortcuts/doc/clipboard.go index cb9f2c225..c7b8ed877 100644 --- a/shortcuts/doc/clipboard.go +++ b/shortcuts/doc/clipboard.go @@ -11,6 +11,8 @@ import ( "regexp" "runtime" "strings" + + "github.com/larksuite/cli/errs" ) // readClipboardImageBytes reads the current clipboard image and returns the @@ -35,13 +37,13 @@ func readClipboardImageBytes() ([]byte, error) { case "linux": data, err = readClipboardLinux() default: - return nil, fmt.Errorf("clipboard image upload is not supported on %s", runtime.GOOS) + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image upload is not supported on %s", runtime.GOOS) } if err != nil { return nil, err } if len(data) == 0 { - return nil, fmt.Errorf("clipboard contains no image data") + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data") } return data, nil } @@ -91,9 +93,9 @@ func readClipboardDarwin() ([]byte, error) { } if stderrText != "" { - return nil, fmt.Errorf("clipboard contains no image data (osascript: %s)", stderrText) + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (osascript: %s)", stderrText) } - return nil, fmt.Errorf("clipboard contains no image data") + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data") } // runOsascript invokes osascript with a single AppleScript expression and @@ -188,14 +190,14 @@ func decodeOsascriptData(s string) ([]byte, error) { // decodeHex decodes an uppercase hex string (as produced by osascript) to bytes. func decodeHex(h string) ([]byte, error) { if len(h)%2 != 0 { - return nil, fmt.Errorf("odd hex length") + return nil, fmt.Errorf("odd hex length") //nolint:forbidigo // intermediate decode helper; result discarded by caller on error } b := make([]byte, len(h)/2) for i := 0; i < len(h); i += 2 { hi := hexVal(h[i]) lo := hexVal(h[i+1]) if hi < 0 || lo < 0 { - return nil, fmt.Errorf("invalid hex char at %d", i) + return nil, fmt.Errorf("invalid hex char at %d", i) //nolint:forbidigo // intermediate decode helper; result discarded by caller on error } b[i/2] = byte(hi<<4 | lo) } @@ -237,12 +239,12 @@ $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) if msg == "" { msg = err.Error() } - return nil, fmt.Errorf("clipboard read failed (%s)", msg) + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard read failed (%s)", msg).WithCause(err) } b64 := strings.TrimSpace(string(out)) data, decErr := base64.StdEncoding.DecodeString(b64) if decErr != nil { - return nil, fmt.Errorf("clipboard image decode failed: %w", decErr) + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image decode failed: %s", decErr).WithCause(decErr) } return data, nil } @@ -325,15 +327,15 @@ func readClipboardLinux() ([]byte, error) { foundTool = true out, err := exec.Command(t.name, t.args...).Output() if err != nil { - lastErr = fmt.Errorf("clipboard image read failed via %s: %w", t.name, err) + lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard image read failed via %s: %s", t.name, err).WithCause(err) continue } if len(out) == 0 { - lastErr = fmt.Errorf("clipboard contains no image data (%s returned empty output)", t.name) + lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no image data (%s returned empty output)", t.name) continue } if t.validatePNG && !hasPNGMagic(out) { - lastErr = fmt.Errorf("clipboard contains no PNG image data (%s output is not a PNG)", t.name) + lastErr = errs.NewValidationError(errs.SubtypeFailedPrecondition, "clipboard contains no PNG image data (%s output is not a PNG)", t.name) continue } return out, nil @@ -342,8 +344,8 @@ func readClipboardLinux() ([]byte, error) { if foundTool && lastErr != nil { return nil, lastErr } - return nil, fmt.Errorf( - "clipboard image read failed: no supported tool found. " + - "Install one of xclip, wl-clipboard, or xsel via your distro's package manager " + + return nil, errs.NewValidationError(errs.SubtypeFailedPrecondition, + "clipboard image read failed: no supported tool found. "+ + "Install one of xclip, wl-clipboard, or xsel via your distro's package manager "+ "(apt, dnf, pacman, apk, brew, etc.).") } diff --git a/shortcuts/doc/doc_errors.go b/shortcuts/doc/doc_errors.go new file mode 100644 index 000000000..770351a84 --- /dev/null +++ b/shortcuts/doc/doc_errors.go @@ -0,0 +1,34 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "errors" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// wrapDocNetworkErr returns err unchanged when it is already a typed errs.* +// error (preserving its subtype / code / log_id from the runtime boundary), +// and only wraps a raw, unclassified error as a transport-level network error. +func wrapDocNetworkErr(err error, format string, args ...any) error { + if _, ok := errs.ProblemOf(err); ok { + return err + } + return errs.NewNetworkError(errs.SubtypeNetworkTransport, format, args...).WithCause(err) +} + +// wrapDocInputFileErr wraps a --file Stat/read failure via the shared typed +// helper (which sets the cause) and tags it with the --file param so agents +// learn which flag to fix. The common helper is flag-agnostic, so the param is +// attached here at the Doc call site rather than mutating shared behavior. +func wrapDocInputFileErr(err error, readMsg string) error { + wrapped := common.WrapInputStatErrorTyped(err, readMsg) + var ve *errs.ValidationError + if errors.As(wrapped, &ve) { + ve.Param = "--file" + } + return wrapped +} diff --git a/shortcuts/doc/doc_errors_test.go b/shortcuts/doc/doc_errors_test.go new file mode 100644 index 000000000..56871a87f --- /dev/null +++ b/shortcuts/doc/doc_errors_test.go @@ -0,0 +1,420 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "errors" + "slices" + "strconv" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// testDocxToken is a bare docx token that parseDocumentRef accepts, letting the +// validation tests reach the flag checks that run after --doc is resolved. +const testDocxToken = "doxcnDocErrorsTestToken" + +// docValidateRuntime builds a RuntimeContext carrying only the flags a Doc +// Validate function reads. String values are applied (and marked Changed) only +// when non-empty; int values are always applied so Changed() reports true, +// mirroring how cobra records an explicitly supplied numeric flag. +func docValidateRuntime(t *testing.T, str map[string]string, bools map[string]bool, ints map[string]int) *common.RuntimeContext { + t.Helper() + cmd := &cobra.Command{Use: "docs"} + fs := cmd.Flags() + for name, val := range str { + fs.String(name, "", "") + if val != "" { + if err := fs.Set(name, val); err != nil { + t.Fatalf("set --%s=%q: %v", name, val, err) + } + } + } + for name, val := range bools { + fs.Bool(name, false, "") + if val { + if err := fs.Set(name, "true"); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + } + for name, val := range ints { + fs.Int(name, 0, "") + if err := fs.Set(name, strconv.Itoa(val)); err != nil { + t.Fatalf("set --%s=%d: %v", name, val, err) + } + } + return common.TestNewRuntimeContext(cmd, nil) +} + +// assertValidationContract pins the typed envelope every migrated Doc +// validation fault must emit: a *errs.ValidationError in CategoryValidation +// with the expected Subtype, the single offending flag in Param, and every +// involved flag in Params. Single-flag faults set Param and leave Params empty; +// multi-flag faults (mutual exclusion, "one of A or B") leave Param empty and +// enumerate each flag in Params so agents resolve them without parsing the text. +func assertValidationContract(t *testing.T, err error, wantSubtype errs.Subtype, wantParam string, wantParams ...string) { + t.Helper() + if err == nil { + t.Fatal("expected validation error, got nil") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error type = %T, want *errs.ValidationError (%v)", err, err) + } + if ve.Category != errs.CategoryValidation { + t.Errorf("category = %q, want %q", ve.Category, errs.CategoryValidation) + } + if ve.Subtype != wantSubtype { + t.Errorf("subtype = %q, want %q", ve.Subtype, wantSubtype) + } + if ve.Param != wantParam { + t.Errorf("param = %q, want %q", ve.Param, wantParam) + } + gotParams := make([]string, len(ve.Params)) + for i, p := range ve.Params { + gotParams[i] = p.Name + } + if !slices.Equal(gotParams, wantParams) { + t.Errorf("params = %v, want %v", gotParams, wantParams) + } +} + +func TestDocMediaInsertValidateContract(t *testing.T) { + cases := []struct { + name string + str map[string]string + bools map[string]bool + ints map[string]int + wantParam string + wantParams []string + }{ + { + name: "neither file nor clipboard", + str: map[string]string{"doc": testDocxToken}, + wantParam: "", // one-of-two flags: enumerated in Params + wantParams: []string{"--file", "--from-clipboard"}, + }, + { + name: "file and clipboard together", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png"}, + bools: map[string]bool{"from-clipboard": true}, + wantParam: "", // mutual exclusion: enumerated in Params + wantParams: []string{"--file", "--from-clipboard"}, + }, + { + name: "non-docx document", + str: map[string]string{"doc": "https://example.larksuite.com/doc/xxxxxx", "file": "dummy.png"}, + wantParam: "--doc", + }, + { + name: "blank selection", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "selection-with-ellipsis": " "}, + wantParam: "--selection-with-ellipsis", + }, + { + name: "before without selection", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png"}, + bools: map[string]bool{"before": true}, + wantParam: "--before", + }, + { + name: "invalid file-view", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "file-view": "bogus"}, + wantParam: "--file-view", + }, + { + name: "file-view without type file", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "file-view": "card", "type": "image"}, + wantParam: "--file-view", + }, + { + name: "dimensions with non-image type", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "file"}, + ints: map[string]int{"width": 100}, + wantParam: "", // only --width was set here, so only it is enumerated + wantParams: []string{"--width"}, + }, + { + name: "non-positive width", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"}, + ints: map[string]int{"width": 0}, + wantParam: "--width", + }, + { + name: "non-positive height", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"}, + ints: map[string]int{"height": 0}, + wantParam: "--height", + }, + { + name: "width over maximum", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"}, + ints: map[string]int{"width": 10001}, + wantParam: "--width", + }, + { + name: "height over maximum", + str: map[string]string{"doc": testDocxToken, "file": "dummy.png", "type": "image"}, + ints: map[string]int{"height": 10001}, + wantParam: "--height", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rt := docValidateRuntime(t, tc.str, tc.bools, tc.ints) + err := DocMediaInsert.Validate(context.Background(), rt) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...) + }) + } +} + +func TestValidateCreateV2Contract(t *testing.T) { + cases := []struct { + name string + str map[string]string + wantParam string + wantParams []string + }{ + { + name: "content required", + str: map[string]string{}, + wantParam: "--content", + }, + { + name: "parent token and position mutually exclusive", + str: map[string]string{"content": "", "parent-token": "fldcnX", "parent-position": "my_library"}, + wantParam: "", // mutual exclusion: enumerated in Params + wantParams: []string{"--parent-token", "--parent-position"}, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rt := docValidateRuntime(t, tc.str, nil, nil) + err := validateCreateV2(context.Background(), rt) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...) + }) + } +} + +func TestValidateFetchV2Contract(t *testing.T) { + cases := []struct { + name string + str map[string]string + ints map[string]int + wantParam string + wantParams []string + }{ + { + name: "range mode without block ids", + str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "range"}, + wantParam: "", // either --start-block-id or --end-block-id: enumerated in Params + wantParams: []string{"--start-block-id", "--end-block-id"}, + }, + { + name: "keyword mode without keyword", + str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "keyword"}, + wantParam: "--keyword", + }, + { + name: "section mode without start block id", + str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "section"}, + wantParam: "--start-block-id", + }, + { + name: "negative context-before", + str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "outline"}, + ints: map[string]int{"context-before": -1}, + wantParam: "--context-before", + }, + { + name: "unknown scope", + str: map[string]string{"doc": testDocxToken, "detail": "simple", "scope": "bogus"}, + wantParam: "--scope", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rt := docValidateRuntime(t, tc.str, nil, tc.ints) + err := validateFetchV2(context.Background(), rt) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam, tc.wantParams...) + }) + } +} + +// TestBuildDocsSearchRequestPreservesParseCause pins the --filter parse faults: +// the typed envelope carries Param --filter and chains the original parse error +// so errors.Is/Unwrap traversal keeps the underlying JSON/time-parse detail. +func TestBuildDocsSearchRequestPreservesParseCause(t *testing.T) { + cases := []struct { + name string + filter string + }{ + {"invalid filter json", "{not json"}, + {"invalid open_time start", `{"open_time":{"start":"not-a-time"}}`}, + {"invalid open_time end", `{"open_time":{"end":"not-a-time"}}`}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, err := buildDocsSearchRequest("q", tc.filter, "", "15") + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error type = %T, want *errs.ValidationError (%v)", err, err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument) + } + if ve.Param != "--filter" { + t.Errorf("param = %q, want %q", ve.Param, "--filter") + } + if errors.Unwrap(ve) == nil { + t.Error("parse error not chained: errors.Unwrap == nil") + } + }) + } +} + +// TestWrapDocNetworkErr pins wrapDocNetworkErr's contract: a typed error passes +// through untouched, while a raw error becomes a transport-level NetworkError +// that still chains the original cause for errors.Is/Unwrap. +func TestWrapDocNetworkErr(t *testing.T) { + t.Run("typed error passes through unchanged", func(t *testing.T) { + typed := errs.NewValidationError(errs.SubtypeInvalidArgument, "bad input") + got := wrapDocNetworkErr(typed, "fetch failed") + if got != error(typed) { + t.Fatalf("typed error must pass through unchanged, got %T", got) + } + }) + t.Run("raw error becomes transport network error", func(t *testing.T) { + raw := errors.New("dial tcp: i/o timeout") + got := wrapDocNetworkErr(raw, "fetch failed: %s", "docx") + var ne *errs.NetworkError + if !errors.As(got, &ne) { + t.Fatalf("raw error must become *errs.NetworkError, got %T", got) + } + if ne.Subtype != errs.SubtypeNetworkTransport { + t.Errorf("subtype = %q, want %q", ne.Subtype, errs.SubtypeNetworkTransport) + } + if !errors.Is(got, raw) { + t.Error("cause not chained: errors.Is(got, raw) == false") + } + }) +} + +// TestWrapDocInputFileErr pins that a --file stat/read failure becomes a typed +// validation error tagged with the --file param and the cause preserved, so an +// agent knows which flag to fix even though the shared helper is flag-agnostic. +func TestWrapDocInputFileErr(t *testing.T) { + raw := errors.New("no such file or directory") + got := wrapDocInputFileErr(raw, "file not found") + var ve *errs.ValidationError + if !errors.As(got, &ve) { + t.Fatalf("error type = %T, want *errs.ValidationError (%v)", got, got) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument) + } + if ve.Param != "--file" { + t.Errorf("param = %q, want %q", ve.Param, "--file") + } + if !errors.Is(got, raw) { + t.Error("cause not chained: errors.Is(got, raw) == false") + } +} + +func TestValidateUpdateV2Contract(t *testing.T) { + cases := []struct { + name string + str map[string]string + wantParam string + }{ + { + name: "command required", + str: map[string]string{"doc": testDocxToken}, + wantParam: "--command", + }, + { + name: "invalid command", + str: map[string]string{"doc": testDocxToken, "command": "bogus"}, + wantParam: "--command", + }, + { + name: "str_replace without pattern", + str: map[string]string{"doc": testDocxToken, "command": "str_replace"}, + wantParam: "--pattern", + }, + { + name: "block_delete without block id", + str: map[string]string{"doc": testDocxToken, "command": "block_delete"}, + wantParam: "--block-id", + }, + { + name: "block_insert_after without block id", + str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"}, + wantParam: "--block-id", + }, + { + name: "block_insert_after without content", + str: map[string]string{"doc": testDocxToken, "command": "block_insert_after", "block-id": "blkX"}, + wantParam: "--content", + }, + { + name: "block_copy_insert_after without block id", + str: map[string]string{"doc": testDocxToken, "command": "block_copy_insert_after"}, + wantParam: "--block-id", + }, + { + name: "block_copy_insert_after without src block ids", + str: map[string]string{"doc": testDocxToken, "command": "block_copy_insert_after", "block-id": "blkX"}, + wantParam: "--src-block-ids", + }, + { + name: "block_move_after without block id", + str: map[string]string{"doc": testDocxToken, "command": "block_move_after"}, + wantParam: "--block-id", + }, + { + name: "block_move_after without src block ids", + str: map[string]string{"doc": testDocxToken, "command": "block_move_after", "block-id": "blkX"}, + wantParam: "--src-block-ids", + }, + { + name: "block_move_after rejects content", + str: map[string]string{"doc": testDocxToken, "command": "block_move_after", "block-id": "blkX", "src-block-ids": "blkY", "content": "x"}, + wantParam: "--content", + }, + { + name: "block_replace without block id", + str: map[string]string{"doc": testDocxToken, "command": "block_replace"}, + wantParam: "--block-id", + }, + { + name: "block_replace without content", + str: map[string]string{"doc": testDocxToken, "command": "block_replace", "block-id": "blkX"}, + wantParam: "--content", + }, + { + name: "overwrite without content", + str: map[string]string{"doc": testDocxToken, "command": "overwrite"}, + wantParam: "--content", + }, + { + name: "append without content", + str: map[string]string{"doc": testDocxToken, "command": "append"}, + wantParam: "--content", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + rt := docValidateRuntime(t, tc.str, nil, nil) + err := validateUpdateV2(context.Background(), rt) + assertValidationContract(t, err, errs.SubtypeInvalidArgument, tc.wantParam) + }) + } +} diff --git a/shortcuts/doc/doc_media_download.go b/shortcuts/doc/doc_media_download.go index 79a162457..1d28481f1 100644 --- a/shortcuts/doc/doc_media_download.go +++ b/shortcuts/doc/doc_media_download.go @@ -10,8 +10,8 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -51,10 +51,10 @@ var DocMediaDownload = common.Shortcut{ overwrite := runtime.Bool("overwrite") if err := validate.ResourceName(token, "--token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token") } if _, err := runtime.ResolveSavePath(outputPath); err != nil { - return output.ErrValidation("unsafe output path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) } fmt.Fprintf(runtime.IO().ErrOut, "Downloading: %s %s\n", mediaType, common.MaskToken(token)) @@ -73,7 +73,7 @@ var DocMediaDownload = common.Shortcut{ ApiPath: apiPath, }) if err != nil { - return output.ErrNetwork("download failed: %v", err) + return wrapDocNetworkErr(err, "download failed: %v", err) } defer resp.Body.Close() @@ -86,14 +86,14 @@ var DocMediaDownload = common.Shortcut{ // Validate final path after extension append if finalPath != outputPath { if _, err := runtime.ResolveSavePath(finalPath); err != nil { - return output.ErrValidation("unsafe output path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) } } // Overwrite check on final path (after extension detection) if !overwrite { if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil { - return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath) + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output") } } @@ -102,7 +102,7 @@ var DocMediaDownload = common.Shortcut{ ContentLength: resp.ContentLength, }, resp.Body) if err != nil { - return common.WrapSaveErrorByCategory(err, "io") + return common.WrapSaveErrorTyped(err) } savedPath, _ := runtime.ResolveSavePath(finalPath) diff --git a/shortcuts/doc/doc_media_insert.go b/shortcuts/doc/doc_media_insert.go index 5c31495a5..495f5067c 100644 --- a/shortcuts/doc/doc_media_insert.go +++ b/shortcuts/doc/doc_media_insert.go @@ -15,8 +15,8 @@ import ( "path/filepath" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -67,10 +67,16 @@ var DocMediaInsert = common.Shortcut{ filePath := runtime.Str("file") fromClipboard := runtime.Bool("from-clipboard") if filePath == "" && !fromClipboard { - return common.FlagErrorf("one of --file or --from-clipboard is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --file or --from-clipboard is required").WithParams( + errs.InvalidParam{Name: "--file", Reason: "provide either --file or --from-clipboard"}, + errs.InvalidParam{Name: "--from-clipboard", Reason: "provide either --file or --from-clipboard"}, + ) } if filePath != "" && fromClipboard { - return common.FlagErrorf("--file and --from-clipboard are mutually exclusive") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file and --from-clipboard are mutually exclusive").WithParams( + errs.InvalidParam{Name: "--file", Reason: "mutually exclusive with --from-clipboard"}, + errs.InvalidParam{Name: "--from-clipboard", Reason: "mutually exclusive with --file"}, + ) } docRef, err := parseDocumentRef(runtime.Str("doc")) @@ -78,7 +84,7 @@ var DocMediaInsert = common.Shortcut{ return err } if docRef.Kind == "doc" { - return output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc") } rawSelection := runtime.Str("selection-with-ellipsis") trimmedSelection := strings.TrimSpace(rawSelection) @@ -87,36 +93,43 @@ var DocMediaInsert = common.Shortcut{ // trim-to-empty would make +media-insert fall back to append-mode and // write at the wrong location. if rawSelection != "" && trimmedSelection == "" { - return output.ErrValidation("--selection-with-ellipsis must not be blank or whitespace-only") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--selection-with-ellipsis must not be blank or whitespace-only").WithParam("--selection-with-ellipsis") } if runtime.Bool("before") && trimmedSelection == "" { - return output.ErrValidation("--before requires --selection-with-ellipsis") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--before requires --selection-with-ellipsis").WithParam("--before") } if view := runtime.Str("file-view"); view != "" { if _, ok := fileViewMap[view]; !ok { - return output.ErrValidation("invalid --file-view value %q, expected one of: card | preview | inline", view) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --file-view value %q, expected one of: card | preview | inline", view).WithParam("--file-view") } if runtime.Str("type") != "file" { - return output.ErrValidation("--file-view only applies when --type=file") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file-view only applies when --type=file").WithParam("--file-view") } } widthChanged := runtime.Changed("width") heightChanged := runtime.Changed("height") if (widthChanged || heightChanged) && runtime.Str("type") != "image" { - return output.ErrValidation("--width/--height only apply when --type=image") + var params []errs.InvalidParam + if widthChanged { + params = append(params, errs.InvalidParam{Name: "--width", Reason: "only applies when --type=image"}) + } + if heightChanged { + params = append(params, errs.InvalidParam{Name: "--height", Reason: "only applies when --type=image"}) + } + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width/--height only apply when --type=image").WithParams(params...) } if widthChanged && runtime.Int("width") <= 0 { - return output.ErrValidation("--width must be a positive integer") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must be a positive integer").WithParam("--width") } if heightChanged && runtime.Int("height") <= 0 { - return output.ErrValidation("--height must be a positive integer") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must be a positive integer").WithParam("--height") } const maxDimension = 10000 if widthChanged && runtime.Int("width") > maxDimension { - return output.ErrValidation("--width must not exceed %d pixels", maxDimension) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--width must not exceed %d pixels", maxDimension).WithParam("--width") } if heightChanged && runtime.Int("height") > maxDimension { - return output.ErrValidation("--height must not exceed %d pixels", maxDimension) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--height must not exceed %d pixels", maxDimension).WithParam("--height") } return nil }, @@ -269,10 +282,10 @@ var DocMediaInsert = common.Shortcut{ } else { stat, err := runtime.FileIO().Stat(filePath) if err != nil { - return common.WrapInputStatError(err, "file not found") + return wrapDocInputFileErr(err, "file not found") } if !stat.Mode().IsRegular() { - return output.ErrValidation("file must be a regular file: %s", filePath) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file") } fileSize = stat.Size() fileName = filepath.Base(filePath) @@ -284,7 +297,7 @@ var DocMediaInsert = common.Shortcut{ } // Step 1: Get document root block to find where to insert - rootData, err := runtime.CallAPI("GET", + rootData, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentID), validate.EncodePathSegment(documentID)), nil, nil) if err != nil { @@ -318,7 +331,7 @@ var DocMediaInsert = common.Shortcut{ // Step 2: Create an empty block at the target position fmt.Fprintf(runtime.IO().ErrOut, "Creating block at index %d\n", insertIndex) - createData, err := runtime.CallAPI("POST", + createData, err := runtime.CallAPITyped("POST", fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)), nil, buildCreateBlockData(mediaType, insertIndex, fileViewType)) if err != nil { @@ -328,7 +341,7 @@ var DocMediaInsert = common.Shortcut{ blockId, uploadParentNode, replaceBlockID := extractCreatedBlockTargets(createData, mediaType) if blockId == "" { - return output.Errorf(output.ExitAPI, "api_error", "failed to create block: no block_id returned") + return errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to create block: no block_id returned") } fmt.Fprintf(runtime.IO().ErrOut, "Block created: %s\n", blockId) @@ -340,7 +353,7 @@ var DocMediaInsert = common.Shortcut{ // later steps should try to remove it instead of leaving an empty artifact. rollback := func() error { fmt.Fprintf(runtime.IO().ErrOut, "Rolling back: deleting block %s\n", blockId) - _, err := runtime.CallAPI("DELETE", + _, err := runtime.CallAPITyped("DELETE", fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s/children/batch_delete", validate.EncodePathSegment(documentID), validate.EncodePathSegment(parentBlockID)), nil, buildDeleteBlockData(insertIndex)) return err @@ -379,15 +392,21 @@ var DocMediaInsert = common.Shortcut{ } else { f, openErr := runtime.FileIO().Open(filePath) if openErr != nil { - return withRollbackWarning(output.ErrValidation( - "unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName)) + return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument, + "unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName).WithCause(openErr).WithParams( + errs.InvalidParam{Name: "--width", Reason: "provide explicitly; source image dimensions could not be detected"}, + errs.InvalidParam{Name: "--height", Reason: "provide explicitly; source image dimensions could not be detected"}, + )) } nativeW, nativeH, dimErr = detectImageDimensions(f) f.Close() } if dimErr != nil { - return withRollbackWarning(output.ErrValidation( - "unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName)) + return withRollbackWarning(errs.NewValidationError(errs.SubtypeInvalidArgument, + "unable to detect image dimensions from %s for aspect-ratio calculation; provide both --width and --height", fileName).WithCause(dimErr).WithParams( + errs.InvalidParam{Name: "--width", Reason: "provide explicitly; source image dimensions could not be detected"}, + errs.InvalidParam{Name: "--height", Reason: "provide explicitly; source image dimensions could not be detected"}, + )) } dims := computeMissingDimension(userWidth, userHeight, nativeW, nativeH) finalWidth = dims.width @@ -417,7 +436,7 @@ var DocMediaInsert = common.Shortcut{ // Step 4: Bind file token to block via batch_update fmt.Fprintf(runtime.IO().ErrOut, "Binding uploaded media to block %s\n", replaceBlockID) - if _, err := runtime.CallAPI("PATCH", + if _, err := runtime.CallAPITyped("PATCH", fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/batch_update", validate.EncodePathSegment(documentID)), nil, buildBatchUpdateData(replaceBlockID, mediaType, fileToken, alignStr, caption, finalWidth, finalHeight)); err != nil { return withRollbackWarning(err) @@ -512,10 +531,10 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string case "docx": return docRef.Token, nil case "doc": - return "", output.ErrValidation("docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx").WithParam("--doc") case "wiki": fmt.Fprintf(runtime.IO().ErrOut, "Resolving wiki node: %s\n", common.MaskToken(docRef.Token)) - data, err := runtime.CallAPI( + data, err := runtime.CallAPITyped( "GET", "/open-apis/wiki/v2/spaces/get_node", map[string]interface{}{"token": docRef.Token}, @@ -529,16 +548,16 @@ func resolveDocxDocumentID(runtime *common.RuntimeContext, input string) (string objType := common.GetString(node, "obj_type") objToken := common.GetString(node, "obj_token") if objType == "" || objToken == "" { - return "", output.Errorf(output.ExitAPI, "api_error", "wiki get_node returned incomplete node data") + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki get_node returned incomplete node data") } if objType != "docx" { - return "", output.ErrValidation("wiki resolved to %q, but docs +media-insert only supports docx documents", objType) + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "wiki resolved to %q, but docs +media-insert only supports docx documents", objType).WithParam("--doc") } fmt.Fprintf(runtime.IO().ErrOut, "Resolved wiki to docx: %s\n", common.MaskToken(objToken)) return objToken, nil default: - return "", output.ErrValidation("docs +media-insert only supports docx documents") + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "docs +media-insert only supports docx documents").WithParam("--doc") } } @@ -622,7 +641,7 @@ func buildBatchUpdateData(blockID, mediaType, fileToken, alignStr, caption strin func extractAppendTarget(rootData map[string]interface{}, fallbackBlockID string) (parentBlockID string, insertIndex int, children []interface{}, err error) { block, _ := rootData["block"].(map[string]interface{}) if len(block) == 0 { - return "", 0, nil, output.Errorf(output.ExitAPI, "api_error", "failed to query document root block") + return "", 0, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "failed to query document root block") } parentBlockID = fallbackBlockID @@ -653,12 +672,10 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select matches := common.GetSlice(result, "matches") if len(matches) == 0 { - return 0, output.ErrWithHint( - output.ExitValidation, - "no_match", - fmt.Sprintf("locate-doc did not find any block matching selection (%s)", redactSelection(selection)), - "check spelling or use 'start...end' syntax to narrow the selection", - ) + return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, + "locate-doc did not find any block matching selection (%s)", redactSelection(selection)). + WithParam("--selection-with-ellipsis"). + WithHint("check spelling or use 'start...end' syntax to narrow the selection") } if len(matches) > 1 { // Silently picking the first match surprises users whose selection appears @@ -682,7 +699,7 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select } } if anchorBlockID == "" { - return 0, output.Errorf(output.ExitAPI, "api_error", "locate-doc response missing anchor_block_id") + return 0, errs.NewInternalError(errs.SubtypeInvalidResponse, "locate-doc response missing anchor_block_id") } parentBlockID := common.GetString(matchMap, "parent_block_id") @@ -740,7 +757,7 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select nextParent = "" // clear hint after first use if parent == "" || parent == cur { // Need to fetch this block to find its parent. - data, err := runtime.CallAPI("GET", + data, err := runtime.CallAPITyped("GET", fmt.Sprintf("/open-apis/docx/v1/documents/%s/blocks/%s", validate.EncodePathSegment(documentID), validate.EncodePathSegment(cur)), nil, nil) @@ -757,12 +774,10 @@ func locateInsertIndex(runtime *common.RuntimeContext, documentID string, select walkDepth++ } - return 0, output.ErrWithHint( - output.ExitValidation, - "block_not_reachable", - fmt.Sprintf("block matching selection (%s) is not reachable from document root", redactSelection(selection)), - "try a top-level heading or paragraph as the selection", - ) + return 0, errs.NewValidationError(errs.SubtypeInvalidArgument, + "block matching selection (%s) is not reachable from document root", redactSelection(selection)). + WithParam("--selection-with-ellipsis"). + WithHint("try a top-level heading or paragraph as the selection") } func extractCreatedBlockTargets(createData map[string]interface{}, mediaType string) (blockID, uploadParentNode, replaceBlockID string) { diff --git a/shortcuts/doc/doc_media_preview.go b/shortcuts/doc/doc_media_preview.go index 5b0a7be2b..6bc88d8e4 100644 --- a/shortcuts/doc/doc_media_preview.go +++ b/shortcuts/doc/doc_media_preview.go @@ -10,8 +10,8 @@ import ( larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/shortcuts/common" ) @@ -45,11 +45,11 @@ var DocMediaPreview = common.Shortcut{ overwrite := runtime.Bool("overwrite") if err := validate.ResourceName(token, "--token"); err != nil { - return output.ErrValidation("%s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token") } // Early path validation before API call (final validation after auto-extension below) if _, err := runtime.ResolveSavePath(outputPath); err != nil { - return output.ErrValidation("unsafe output path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) } fmt.Fprintf(runtime.IO().ErrOut, "Previewing: media %s\n", common.MaskToken(token)) @@ -65,7 +65,7 @@ var DocMediaPreview = common.Shortcut{ }, }) if err != nil { - return output.ErrNetwork("preview failed: %v", err) + return wrapDocNetworkErr(err, "preview failed: %v", err) } defer resp.Body.Close() @@ -74,14 +74,14 @@ var DocMediaPreview = common.Shortcut{ // Validate final path after extension append if finalPath != outputPath { if _, err := runtime.ResolveSavePath(finalPath); err != nil { - return output.ErrValidation("unsafe output path: %s", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsafe output path: %s", err).WithParam("--output").WithCause(err) } } // Overwrite check on final path (after extension detection) if !overwrite { if _, statErr := runtime.FileIO().Stat(finalPath); statErr == nil { - return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", finalPath) + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "output file already exists: %s (use --overwrite to replace)", finalPath).WithParam("--output") } } @@ -90,7 +90,7 @@ var DocMediaPreview = common.Shortcut{ ContentLength: resp.ContentLength, }, resp.Body) if err != nil { - return common.WrapSaveErrorByCategory(err, "io") + return common.WrapSaveErrorTyped(err) } savedPath, _ := runtime.ResolveSavePath(finalPath) diff --git a/shortcuts/doc/doc_media_upload.go b/shortcuts/doc/doc_media_upload.go index e7168c623..449dbb40b 100644 --- a/shortcuts/doc/doc_media_upload.go +++ b/shortcuts/doc/doc_media_upload.go @@ -9,8 +9,8 @@ import ( "io" "path/filepath" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" - "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -84,10 +84,10 @@ var DocMediaUpload = common.Shortcut{ // Validate file stat, err := runtime.FileIO().Stat(filePath) if err != nil { - return common.WrapInputStatError(err, "file not found") + return wrapDocInputFileErr(err, "file not found") } if !stat.Mode().IsRegular() { - return output.ErrValidation("file must be a regular file: %s", filePath) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file must be a regular file: %s", filePath).WithParam("--file") } fileName := filepath.Base(filePath) diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index 70d07ddcb..6f0e1471c 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -7,6 +7,7 @@ import ( "context" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -25,10 +26,13 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error { return err } if runtime.Str("content") == "" { - return common.FlagErrorf("--content is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content") } if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" { - return common.FlagErrorf("--parent-token and --parent-position are mutually exclusive") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams( + errs.InvalidParam{Name: "--parent-token", Reason: "mutually exclusive with --parent-position"}, + errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"}, + ) } return nil } diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index 305847a2a..0ab446c33 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -37,7 +38,7 @@ func validateFetchV2(_ context.Context, runtime *common.RuntimeContext) error { return err } if _, err := parseDocumentRef(runtime.Str("doc")); err != nil { - return common.FlagErrorf("invalid --doc: %v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc") } if err := validateFetchDetail(runtime); err != nil { return err @@ -153,7 +154,7 @@ func validateFetchDetail(runtime *common.RuntimeContext) error { return nil } if detail == "with-ids" || detail == "full" { - return common.FlagErrorf("--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--detail %s is only supported with --doc-format xml; %s output has no block ids, use --detail simple or switch to --doc-format xml", detail, format).WithParam("--detail") } return nil } @@ -166,13 +167,13 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error { } if v := runtime.Int("context-before"); v < 0 { - return common.FlagErrorf("--context-before must be >= 0, got %d", v) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--context-before must be >= 0, got %d", v).WithParam("--context-before") } if v := runtime.Int("context-after"); v < 0 { - return common.FlagErrorf("--context-after must be >= 0, got %d", v) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--context-after must be >= 0, got %d", v).WithParam("--context-after") } if v := runtime.Int("max-depth"); v < -1 { - return common.FlagErrorf("--max-depth must be >= -1, got %d", v) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--max-depth must be >= -1, got %d", v).WithParam("--max-depth") } switch mode { @@ -181,20 +182,23 @@ func validateReadModeFlags(runtime *common.RuntimeContext) error { case "range": if strings.TrimSpace(runtime.Str("start-block-id")) == "" && strings.TrimSpace(runtime.Str("end-block-id")) == "" { - return common.FlagErrorf("range mode requires --start-block-id or --end-block-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "range mode requires --start-block-id or --end-block-id").WithParams( + errs.InvalidParam{Name: "--start-block-id", Reason: "provide --start-block-id or --end-block-id for range mode"}, + errs.InvalidParam{Name: "--end-block-id", Reason: "provide --start-block-id or --end-block-id for range mode"}, + ) } return nil case "keyword": if strings.TrimSpace(runtime.Str("keyword")) == "" { - return common.FlagErrorf("keyword mode requires --keyword") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "keyword mode requires --keyword").WithParam("--keyword") } return nil case "section": if strings.TrimSpace(runtime.Str("start-block-id")) == "" { - return common.FlagErrorf("section mode requires --start-block-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "section mode requires --start-block-id").WithParam("--start-block-id") } return nil default: - return common.FlagErrorf("invalid --scope %q", mode) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --scope %q", mode).WithParam("--scope") } } diff --git a/shortcuts/doc/docs_search.go b/shortcuts/doc/docs_search.go index 73dfca4bf..80ebf9680 100644 --- a/shortcuts/doc/docs_search.go +++ b/shortcuts/doc/docs_search.go @@ -14,6 +14,7 @@ import ( "strings" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/shortcuts/common" ) @@ -58,7 +59,7 @@ var DocsSearch = common.Shortcut{ return err } - data, err := runtime.CallAPI("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData) + data, err := runtime.CallAPITyped("POST", "/open-apis/search/v2/doc_wiki/search", nil, requestData) if err != nil { return err } @@ -159,7 +160,7 @@ func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (ma var filter map[string]interface{} if err := json.Unmarshal([]byte(filterStr), &filter); err != nil { - return nil, output.ErrValidation("--filter is not valid JSON") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--filter is not valid JSON").WithParam("--filter").WithCause(err) } if err := convertTimeRangeInFilter(filter, "open_time"); err != nil { return nil, err @@ -172,7 +173,7 @@ func buildDocsSearchRequest(query, filterStr, pageToken, pageSizeStr string) (ma hasSpaceIDs := hasNonEmptyFilterArray(filter, "space_ids") if hasFolderTokens && hasSpaceIDs { - return nil, output.ErrValidation("--filter cannot contain both folder_tokens and space_ids; doc and wiki scoped search cannot be combined") + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--filter cannot contain both folder_tokens and space_ids; doc and wiki scoped search cannot be combined").WithParam("--filter") } docFilter := cloneFilterMap(filter) @@ -225,14 +226,14 @@ func convertTimeRangeInFilter(filter map[string]interface{}, key string) error { if start, ok := rangeMap["start"].(string); ok && start != "" { startTime, err := toUnixSeconds(start) if err != nil { - return output.ErrValidation("invalid %s.start %q: %s", key, start, err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.start %q: %s", key, start, err).WithParam("--filter").WithCause(err) } result["start"] = startTime } if end, ok := rangeMap["end"].(string); ok && end != "" { endTime, err := toUnixSeconds(end) if err != nil { - return output.ErrValidation("invalid %s.end %q: %s", key, end, err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid %s.end %q: %s", key, end, err).WithParam("--filter").WithCause(err) } result["end"] = endTime } @@ -256,7 +257,7 @@ func toUnixSeconds(input string) (int64, error) { if n, err := strconv.ParseInt(input, 10, 64); err == nil { return n, nil } - return 0, fmt.Errorf("expected RFC3339, YYYY-MM-DD[ HH:MM:SS], or unix seconds") + return 0, fmt.Errorf("expected RFC3339, YYYY-MM-DD[ HH:MM:SS], or unix seconds") //nolint:forbidigo // intermediate parse helper; caller wraps into typed ValidationError } func unixTimestampToISO8601(v interface{}) string { diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index fcc7beb17..cc75d44ed 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -7,6 +7,7 @@ import ( "context" "fmt" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -43,14 +44,14 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { return err } if _, err := parseDocumentRef(runtime.Str("doc")); err != nil { - return common.FlagErrorf("invalid --doc: %v", err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --doc: %v", err).WithParam("--doc") } cmd := runtime.Str("command") if cmd == "" { - return common.FlagErrorf("--command is required") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command is required").WithParam("--command") } if !validCommandsV2[cmd] { - return common.FlagErrorf("invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --command %q, valid: str_replace | block_delete | block_insert_after | block_copy_insert_after | block_replace | block_move_after | overwrite | append", cmd).WithParam("--command") } content := runtime.Str("content") pattern := runtime.Str("pattern") @@ -60,50 +61,50 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { switch cmd { case "str_replace": if pattern == "" { - return common.FlagErrorf("--command str_replace requires --pattern") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern") } case "block_delete": if blockID == "" { - return common.FlagErrorf("--command block_delete requires --block-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id") } case "block_insert_after": if blockID == "" { - return common.FlagErrorf("--command block_insert_after requires --block-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id") } if content == "" { - return common.FlagErrorf("--command block_insert_after requires --content") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --content").WithParam("--content") } case "block_copy_insert_after": if blockID == "" { - return common.FlagErrorf("--command block_copy_insert_after requires --block-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_copy_insert_after requires --block-id").WithParam("--block-id") } if srcBlockIDs == "" { - return common.FlagErrorf("--command block_copy_insert_after requires --src-block-ids") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_copy_insert_after requires --src-block-ids").WithParam("--src-block-ids") } case "block_move_after": if blockID == "" { - return common.FlagErrorf("--command block_move_after requires --block-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_move_after requires --block-id").WithParam("--block-id") } if srcBlockIDs == "" { - return common.FlagErrorf("--command block_move_after requires --src-block-ids") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_move_after requires --src-block-ids").WithParam("--src-block-ids") } if content != "" { - return common.FlagErrorf("--command block_move_after does not accept --content; use --src-block-ids") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_move_after does not accept --content; use --src-block-ids").WithParam("--content") } case "block_replace": if blockID == "" { - return common.FlagErrorf("--command block_replace requires --block-id") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_replace requires --block-id").WithParam("--block-id") } if content == "" { - return common.FlagErrorf("--command block_replace requires --content") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_replace requires --content").WithParam("--content") } case "overwrite": if content == "" { - return common.FlagErrorf("--command overwrite requires --content") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command overwrite requires --content").WithParam("--content") } case "append": if content == "" { - return common.FlagErrorf("--command append requires --content") + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content") } } return nil diff --git a/shortcuts/doc/helpers.go b/shortcuts/doc/helpers.go index c3446d3b8..4305e9ba5 100644 --- a/shortcuts/doc/helpers.go +++ b/shortcuts/doc/helpers.go @@ -8,7 +8,7 @@ import ( "encoding/json" "strings" - "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -24,7 +24,7 @@ type documentRef struct { func parseDocumentRef(input string) (documentRef, error) { raw := strings.TrimSpace(input) if raw == "" { - return documentRef{}, output.ErrValidation("--doc cannot be empty") + return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--doc cannot be empty").WithParam("--doc") } if token, ok := extractDocumentToken(raw, "/wiki/"); ok { @@ -37,10 +37,10 @@ func parseDocumentRef(input string) (documentRef, error) { return documentRef{Kind: "doc", Token: token}, nil } if strings.Contains(raw, "://") { - return documentRef{}, output.ErrValidation("unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw) + return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx URL/token or a wiki URL that resolves to docx", raw).WithParam("--doc") } if strings.ContainsAny(raw, "/?#") { - return documentRef{}, output.ErrValidation("unsupported --doc input %q: use a docx token or a wiki URL", raw) + return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported --doc input %q: use a docx token or a wiki URL", raw).WithParam("--doc") } return documentRef{Kind: "docx", Token: raw}, nil @@ -64,10 +64,10 @@ func extractDocumentToken(raw, marker string) (string, bool) { // doDocAPI executes an OpenAPI request against the docs_ai endpoints and returns // the parsed "data" field from the standard Lark response envelope {code, msg, data}. -// Uses the log-id-aware variant so the x-tt-logid header is surfaced in both the -// success payload and error details — doc v2 callers rely on it for support escalations. +// CallAPITyped lifts the x-tt-logid response header onto the typed error so log_id +// surfaces for support escalations even when the body omits it. func doDocAPI(runtime *common.RuntimeContext, method, apiPath string, body interface{}) (map[string]interface{}, error) { - return runtime.DoAPIJSONWithLogID(method, apiPath, nil, body) + return runtime.CallAPITyped(method, apiPath, nil, body) } func docsSceneFromContext(ctx context.Context) string { @@ -87,7 +87,7 @@ func injectDocsScene(runtime *common.RuntimeContext, body map[string]interface{} func buildDriveRouteExtra(docID string) (string, error) { extra, err := json.Marshal(map[string]string{"drive_route_token": docID}) if err != nil { - return "", output.Errorf(output.ExitInternal, "internal_error", "failed to marshal upload extra data: %v", err) + return "", errs.NewInternalError(errs.SubtypeUnknown, "failed to marshal upload extra data: %v", err).WithCause(err) } return string(extra), nil } diff --git a/shortcuts/doc/v2_only.go b/shortcuts/doc/v2_only.go index e74857806..1d0515166 100644 --- a/shortcuts/doc/v2_only.go +++ b/shortcuts/doc/v2_only.go @@ -6,6 +6,7 @@ package doc import ( "strings" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" ) @@ -65,7 +66,7 @@ func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyF switch apiVersion := strings.TrimSpace(runtime.Str("api-version")); apiVersion { case "", "v1", "v2": default: - return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API") + return docsV2OnlyError(shortcut, "--api-version is deprecated and only accepts v1 or v2; both values execute the v2 API", "--api-version") } var used []string @@ -87,11 +88,12 @@ func validateDocsV2Only(runtime *common.RuntimeContext, shortcut string, legacyF if len(replacements) > 0 { detail += "; " + strings.Join(replacements, "; ") } - return docsV2OnlyError(shortcut, detail) + return docsV2OnlyError(shortcut, detail, used[0]) } -func docsV2OnlyError(shortcut, detail string) error { - return common.FlagErrorf( +func docsV2OnlyError(shortcut, detail, param string) error { + err := errs.NewValidationError( + errs.SubtypeInvalidArgument, "docs %s is v2-only; %s. Run `%s` for the current schema and examples. AI agents MUST read `%s` (XML) or `%s` (Markdown) and follow the latest format rules there. MUST NOT grep/open local SKILL.md files to discover this guidance; use `lark-cli skills read ...` so content stays version-matched with this CLI. Run `%s` for the latest command flags", shortcut, detail, @@ -100,4 +102,8 @@ func docsV2OnlyError(shortcut, detail string) error { docsMDSkillReadCommand, docsHelpCommandForShortcut(shortcut), ) + if param != "" { + err = err.WithParam(param) + } + return err } diff --git a/shortcuts/im/builders_test.go b/shortcuts/im/builders_test.go index c32bd6a32..0b16a853d 100644 --- a/shortcuts/im/builders_test.go +++ b/shortcuts/im/builders_test.go @@ -317,6 +317,17 @@ func TestShortcutValidateBranches(t *testing.T) { } }) + t.Run("ImChatSearch invalid chat-modes value", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "ok", + "chat-modes": "group,bogus", + }, nil) + err := ImChatSearch.Validate(context.Background(), runtime) + if err == nil || !strings.Contains(err.Error(), "invalid --chat-modes value") { + t.Fatalf("ImChatSearch.Validate() error = %v", err) + } + }) + t.Run("ImChatUpdate requires fields", func(t *testing.T) { runtime := newTestRuntimeContext(t, map[string]string{ "chat-id": "oc_123", @@ -693,6 +704,39 @@ func TestShortcutDryRunShapes(t *testing.T) { } }) + t.Run("ImChatSearch dry run maps chat-modes to wire values", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "team-alpha", + "chat-modes": "group,topic", + }, nil) + got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"chat_modes":["default","thread"]`) { + t.Fatalf("ImChatSearch.DryRun() chat_modes mapping = %s", got) + } + }) + + t.Run("ImChatSearch dry run maps single chat-mode topic", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "team-alpha", + "chat-modes": "topic", + }, nil) + got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"chat_modes":["thread"]`) { + t.Fatalf("ImChatSearch.DryRun() chat_modes mapping = %s", got) + } + }) + + t.Run("ImChatSearch dry run dedupes chat-modes", func(t *testing.T) { + runtime := newTestRuntimeContext(t, map[string]string{ + "query": "team-alpha", + "chat-modes": "group, group", + }, nil) + got := mustMarshalDryRun(t, ImChatSearch.DryRun(context.Background(), runtime)) + if !strings.Contains(got, `"chat_modes":["default"]`) { + t.Fatalf("ImChatSearch.DryRun() chat_modes dedupe = %s", got) + } + }) + t.Run("ImMessagesSearch dry run uses messages search endpoint", func(t *testing.T) { runtime := newMessagesSearchTestRuntimeContext(t, map[string]string{ "query": "incident", @@ -797,7 +841,7 @@ func TestShortcutDryRunShapes(t *testing.T) { "page-size": "10", }, nil) got := mustMarshalDryRun(t, ImThreadsMessagesList.DryRun(context.Background(), runtime)) - if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":10`) { + if !strings.Contains(got, `"container_id":"omt_123"`) || !strings.Contains(got, `"sort_type":"ByCreateTimeDesc"`) || !strings.Contains(got, `"page_size":"10"`) { t.Fatalf("ImThreadsMessagesList.DryRun() = %s", got) } }) @@ -857,7 +901,7 @@ func TestShortcutDryRunShapes(t *testing.T) { t.Run("ImChatList dry run includes endpoint and params", func(t *testing.T) { runtime := newTestRuntimeContext(t, map[string]string{ "user-id-type": "open_id", - "sort-type": "ByCreateTimeAsc", + "sort": "create_time", }, nil) got := mustMarshalDryRun(t, ImChatList.DryRun(context.Background(), runtime)) if !strings.Contains(got, `"/open-apis/im/v1/chats"`) { diff --git a/shortcuts/im/convert_lib/content_convert.go b/shortcuts/im/convert_lib/content_convert.go index 1ede9b79e..1292b7d33 100644 --- a/shortcuts/im/convert_lib/content_convert.go +++ b/shortcuts/im/convert_lib/content_convert.go @@ -131,7 +131,7 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, if len(senderNames) > 0 { nameCache = senderNames[0] } - return formatMessageItem(m, runtime, nameCache, nil) + return formatMessageItem(m, runtime, nameCache, nil, false) } // FormatMessageItemWithMergePrefetch is like FormatMessageItem but threads a @@ -141,10 +141,20 @@ func FormatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, // items should pre-fetch once and call this variant in the loop to avoid the // N × ~1s serial-merge_forward stall in the original code path. func FormatMessageItemWithMergePrefetch(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} { - return formatMessageItem(m, runtime, nameCache, mergePrefetch) + return formatMessageItem(m, runtime, nameCache, mergePrefetch, false) } -func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}) map[string]interface{} { +// FormatMessageItemWithMergePrefetchOpts is FormatMessageItemWithMergePrefetch +// with an explicit extractResources gate. When extractResources is true and +// the message carries downloadable resources, a "resources" block (ref list +// without local_path/size_bytes) is attached for the download enrichment stage +// to fill. The other entry points are thin extractResources=false wrappers, so +// default output is unchanged. +func FormatMessageItemWithMergePrefetchOpts(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, extractResources bool) map[string]interface{} { + return formatMessageItem(m, runtime, nameCache, mergePrefetch, extractResources) +} + +func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, nameCache map[string]string, mergePrefetch map[string][]map[string]interface{}, extractResources bool) map[string]interface{} { msgType, _ := m["msg_type"].(string) messageId, _ := m["message_id"].(string) mentions, _ := m["mentions"].([]interface{}) @@ -152,8 +162,9 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, updated, _ := m["updated"].(bool) content := "" + rawContent := "" if body, ok := m["body"].(map[string]interface{}); ok { - rawContent, _ := body["content"].(string) + rawContent, _ = body["content"].(string) content = ConvertBodyContent(msgType, &ConvertContext{ RawContent: rawContent, MentionMap: BuildMentionKeyMap(mentions), @@ -232,6 +243,20 @@ func formatMessageItem(m map[string]interface{}, runtime *common.RuntimeContext, msg["mentions"] = simplified } + if extractResources { + if refs := ExtractResourceRefs(msgType, rawContent, messageId, mergePrefetch); len(refs) > 0 { + resources := make([]map[string]interface{}, 0, len(refs)) + for _, r := range refs { + resources = append(resources, map[string]interface{}{ + "message_id": r.MessageID, + "key": r.Key, + "type": r.Type, + }) + } + msg["resources"] = resources + } + } + return msg } diff --git a/shortcuts/im/convert_lib/content_media_misc_test.go b/shortcuts/im/convert_lib/content_media_misc_test.go index 3d0dbdaea..b6b2be25d 100644 --- a/shortcuts/im/convert_lib/content_media_misc_test.go +++ b/shortcuts/im/convert_lib/content_media_misc_test.go @@ -517,6 +517,79 @@ func TestMiscConverters(t *testing.T) { } } +// TestFormatMessageItemResourcesGate verifies the resources block is only +// emitted when extractResources is on; the default path (and back-compat +// wrappers) must never add a resources key. +func TestFormatMessageItemResourcesGate(t *testing.T) { + raw := map[string]interface{}{ + "msg_type": "image", + "message_id": "om_img", + "create_time": "1710500000", + "sender": map[string]interface{}{"id": "ou_sender", "sender_type": "user"}, + "body": map[string]interface{}{"content": `{"image_key":"img_99"}`}, + } + + // Gate off via the back-compat wrapper. + off := FormatMessageItemWithMergePrefetch(raw, nil, nil, nil) + if _, ok := off["resources"]; ok { + t.Fatalf("FormatMessageItemWithMergePrefetch should not emit resources, got %#v", off["resources"]) + } + + // Gate off via plain FormatMessageItem. + plain := FormatMessageItem(raw, nil) + if _, ok := plain["resources"]; ok { + t.Fatalf("FormatMessageItem should not emit resources, got %#v", plain["resources"]) + } + + // Gate on. + on := FormatMessageItemWithMergePrefetchOpts(raw, nil, nil, nil, true) + resources, ok := on["resources"].([]map[string]interface{}) + if !ok || len(resources) != 1 { + t.Fatalf("FormatMessageItemWithMergePrefetchOpts(extract=true) resources = %#v, want 1 ref", on["resources"]) + } + r := resources[0] + if r["message_id"] != "om_img" || r["key"] != "img_99" || r["type"] != "image" { + t.Fatalf("resource ref = %#v, want {om_img,img_99,image}", r) + } + if _, ok := r["local_path"]; ok { + t.Fatalf("extract stage must not set local_path yet, got %#v", r["local_path"]) + } +} + +func TestAudioConverterFileKey(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "key and duration", raw: `{"file_key":"audio_1","duration":3500}`, want: `
" --fields ''`,同时配置新 Base 里唯一一个初始数据表的 name 和 schema;使用 `--fields` 前先读 [lark-base-field-json.md](references/lark-base-field-json.md) 或复用 `+field-create` 的字段 JSON 形状,不要猜字段属性。 +- `+base-create` 不传 `--table-name` 和 `--fields` 时,会创建一个默认 schema 的初始数据表。 - 表、字段、视图、workflow、dashboard block 的名称和 ID 必须来自真实返回,不要凭用户口述猜。 - 存储字段可写;系统字段、`formula`、`lookup` 只读;附件字段走专用 attachment 命令。 - 一次性原始记录查询优先用 `+record-list` / `+record-search` 的 filter/sort;聚合分析优先用 `+data-query`;需要长期显示在表中时,才新增 `formula` / `lookup` 字段。 diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index 3a170e946..8fb1a2141 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -56,6 +56,7 @@ lark-cli docs +update --api-version v2 --doc "文档URL或token" --command appen | `` | `token` -> app_token, `table-id` | [`lark-base`](../lark-base/SKILL.md) | | `` | 同 `` | [`lark-sheets`](../lark-sheets/SKILL.md) | | `` | 同 `` | [`lark-base`](../lark-base/SKILL.md) | +| `` | `vc-node-id` -> note_id | [`lark-note`](../lark-note/SKILL.md):先 `note +detail --note-id ` | | `` | `src-token` -> doc_token, `src-block-id` -> block_id | 用 `docs +fetch --api-version v2` 读取 src-token 文档,定位 block | ## Shortcuts(推荐优先使用) diff --git a/skills/lark-doc/references/lark-doc-md.md b/skills/lark-doc/references/lark-doc-md.md index 88e4e5425..f6a72d436 100644 --- a/skills/lark-doc/references/lark-doc-md.md +++ b/skills/lark-doc/references/lark-doc-md.md @@ -49,6 +49,7 @@ ## Shell 传参 - **首选文件传参**:`--content` 支持 `@path/to/file.md`(读文件)和 `-`(读 stdin),彻底绕开 shell 转义;多行、含特殊字符、长文本强烈推荐。字面量以 `@` 开头时用 `@@` 转义(`--pattern` 不支持 `@file`) +- **⚠️ `@file` 路径限制**:`@file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下(如 `./_content.md`),用完自行清理。 - **默认用单引号 `'...'`**:完全字面量,`$`、`` ` ``、`\`、`>`、`\` 等全部原样保留 - **双引号 `"..."`**:会展开 `$变量`、反引号和 `$(...)` 命令替换,`\` 仍参与转义,易踩坑 - **`$'...'` ANSI-C 引号**:按 C 转义解析,`\n`=换行、`\\`=单个 `\`;**zsh 下未知转义(如 `\<`)的 `\` 会被吞**,要保留字面 `\` 必须写 `\\`。只在确实需要 `\n`/`\t` 时用 diff --git a/skills/lark-doc/references/lark-doc-xml.md b/skills/lark-doc/references/lark-doc-xml.md index 6a25447b0..ad4c65e36 100644 --- a/skills/lark-doc/references/lark-doc-xml.md +++ b/skills/lark-doc/references/lark-doc-xml.md @@ -78,6 +78,12 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr ``` +## 用户名写入规则 + +- 当从 IM 消息、日历、审批、任务等来源获取到用户的 `open_id` 时,写入文档**必须**使用 `` 标签,而非纯文本名字。这样文档中会渲染为可点击的 @人。 +- 典型场景:IM 消息的 `sender`、`mentions`、reactions 的 `operator`、卡片消息中引用的用户、系统消息中的用户名、合并转发中的用户名。 +- 当只有纯文本名字而没有 `open_id` 时(如系统消息、合并转发内容),先通过 `lark-cli contact +search-user --query "名字" --as user` 反查 `open_id`,再写入 cite 标签。 + ## 表格扩展 标准 HTML table 结构不变,扩展点: - `` / `` 定义列宽,紧跟 `
` 之后:`` @@ -96,7 +102,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr | 高亮框填充 `` | `gray` + `light-{色}` + `medium-{色}` | | 单元格背景 `
` | 同文字背景 | | 按钮背景 `