diff --git a/CHANGELOG.md b/CHANGELOG.md index c4a3c0ff..2b926772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,84 @@ All notable changes to this project will be documented in this file. +## [v1.0.64] - 2026-07-02 + +### Features + +- **im**: Upgrade card send to Card 2.0 with full component reference (#1688) +- **im**: Add `+chat-members-list` shortcut for member listing (#1398) +- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671) + +### Bug Fixes + +- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722) +- **cli**: Improve secure label error handling (#1707) +- **cli**: Reduce public content token false positives +- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724) +- **doc**: Align word statistics compound tokens (#1706) + +### Documentation + +- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630) +- **doc**: Support `reference_map` in docs (#1690) +- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures + +## [v1.0.62] - 2026-07-01 + +### Features + +- **vc**: Add meeting message send shortcut (#1643) +- **doc**: Add document word statistics helper (#1697) +- **cli**: Interactive upgrade prompt for bare `lark-cli` invocation (#1498) +- **install**: Fail closed when `checksums.txt` is missing during install (#1503) + +### Bug Fixes + +- **drive**: Improve batch failure handling for push/pull/sync (#1703) +- **base**: Support JSON array input for field create (#1661) +- **task**: Expose completion state in `my tasks` output (#1641) +- **cli**: Reduce public content credential false positives (#1700) + +## [v1.0.61] - 2026-06-30 + +### Features + +- **apps**: Add `db`, `file`, `openapi-key` and observability shortcuts (#1596) +- **identity**: Add `whoami` command showing effective identity (#1666) +- **docs**: Add reference map flags (#1547) + +### Bug Fixes + +- **identity**: Correct identity diagnosis under external credential providers (#1693) +- **cli**: Harden git credential error handling (#1676) + +### Documentation + +- **doc**: Guide document copy skill usage (#1673) +- **doc**: Fix lark-doc media token examples (#1662) + +## [v1.0.60] - 2026-06-29 + +### Features + +- **affordance**: Per-command usage guidance system with markdown source (#1565) +- **event**: Support VC meeting lifecycle events (#1632) +- **sheets**: Use `office_sheet_file` parent_type for imported office spreadsheets (#1606) +- **authorization**: Expand lark-shared auth guidance and assert clean logout JSON (#1598) +- **transport**: Add `LARK_CLI_NO_PROXY_WARN` to silence proxy warning (#1647) + +### Bug Fixes + +- **install**: Load `@clack/prompts` via dynamic import to avoid `ERR_REQUIRE_ESM` (#1652) + +### Tests + +- **doc**: Derive fetch test flag defaults from `v2FetchFlags` (#1428) + +### Build + +- **ci**: Reduce public content false positives + ## [v1.0.59] - 2026-06-26 ### Features @@ -1277,6 +1355,10 @@ Bundled AI agent skills for intelligent assistance: - Bilingual documentation (English & Chinese). - CI/CD pipelines: linting, testing, coverage reporting, and automated releases. +[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64 +[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62 +[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61 +[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60 [v1.0.59]: https://github.com/larksuite/cli/releases/tag/v1.0.59 [v1.0.58]: https://github.com/larksuite/cli/releases/tag/v1.0.58 [v1.0.57]: https://github.com/larksuite/cli/releases/tag/v1.0.57 diff --git a/affordance/README.md b/affordance/README.md new file mode 100644 index 00000000..ba760874 --- /dev/null +++ b/affordance/README.md @@ -0,0 +1,49 @@ +# Affordance + +Per-command usage guidance for the CLI, authored as one markdown file per domain +(`.md`). It is surfaced in `lark-cli --help` and in the +`schema` output, and read directly at runtime (lazy, cached) — there is no build +step. Maintain these files alongside `skills/` and `shortcuts/`. + +## Format + +A small, fixed markdown subset; each file describes one domain: + + # optional `> skill: ` applies to every command below + ## the command as typed, minus `lark-cli ` + when to use this command + ### Avoid when when not to use it / which command to use instead + ### Prerequisites what you must have first (e.g. an id, and where it comes from) + ### Tips gotchas and constraints + ### Examples **description** lines, each followed by a fenced command + ### a custom section; flows through verbatim + +Reference another command with `[[command]]` — it renders as `command` in help. +Under `Avoid when` it means "use that one instead"; under `Prerequisites` +("… from [[command]]") it means "get the input there first". + +## Example + + ## messages get + Fetch the full content of a single message by id. + + ### Avoid when + - Reading several at once → use [[messages batch_get]] + + ### Prerequisites + - message_id from [[messages list]] + + ### Examples + + **Fetch one message** + ```bash + lark-cli mail user_mailbox.messages get --message-id "" + ``` + +## Notes + +- Write plain prose; the only convention is wrapping command references in `[[ ]]`. +- Keep it concise and high-signal — don't restate field/flag names, id types, or + anything the schema and flags already show; the agent infers the rest. +- Command-form headings resolve to method ids via the registry, so plural resource + names (`messages`) map to the singular method id (`message`) automatically. diff --git a/affordance/contact.md b/affordance/contact.md new file mode 100644 index 00000000..e0a71e70 --- /dev/null +++ b/affordance/contact.md @@ -0,0 +1,19 @@ +# contact +> skill: lark-contact + +## user_profiles batch_query +Bulk-fetch personal status and signature for user ids you already have. + +### Avoid when +- Need more than status/signature (name, dept, email), or don't have the open_id yet → use [[+search-user]] + +### Tips +- Off by default — set include_personal_status / include_description to true under query_option +- ids in user_ids must match --user-id-type (default open_id) + +### Examples + +**Bulk-query status and signature** +```bash +lark-cli contact user_profiles batch_query --data '{"user_ids":["ou_3a8b****6a7b"],"query_option":{"include_personal_status":true,"include_description":true}}' +``` diff --git a/cmd/api/api.go b/cmd/api/api.go index 34b60ff8..20ab7bda 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -67,8 +67,21 @@ func NewCmdApiWithContext(ctx context.Context, f *cmdutil.Factory, runF func(*AP cmd := &cobra.Command{ Use: "api ", - Short: "Generic Lark API requests", - Args: cobra.ExactArgs(2), + Short: "Raw HTTP escape hatch — call any endpoint by path (fallback when no typed command exists)", + Long: `Raw HTTP escape hatch: send any Lark API request by HTTP method + path. + +Prefer the typed domain command when one exists — it validates parameters, +shows the Risk level, gates destructive calls behind --yes, and carries usage +guidance that this raw command does not. If a domain command covers your task +(browse with ` + "`lark-cli --help`" + `), use it instead of this. + +Reach for ` + "`api`" + ` only for endpoints that have no typed command yet (e.g. +newer/preview APIs), where you already have the HTTP path from the Lark docs. + +Examples: + lark-cli api GET /open-apis/calendar/v4/calendars + lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"open_id"}' --data @body.json`, + Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { opts.Method = strings.ToUpper(args[0]) opts.Path = args[1] diff --git a/cmd/build.go b/cmd/build.go index a4716fb3..c8461e32 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -19,6 +19,7 @@ import ( "github.com/larksuite/cli/cmd/service" "github.com/larksuite/cli/cmd/skill" cmdupdate "github.com/larksuite/cli/cmd/update" + "github.com/larksuite/cli/cmd/whoami" _ "github.com/larksuite/cli/events" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/build" @@ -170,6 +171,10 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B rootCmd.SetOut(cfg.streams.Out) rootCmd.SetErr(cfg.streams.ErrOut) + // Root-only usage template (curated Usage synopsis + skills footer); see + // rootUsageTemplate. + rootCmd.SetUsageTemplate(rootUsageTemplate) + installTipsHelpFunc(rootCmd) rootCmd.SilenceErrors = true // SilenceUsage as a static field (not only in PersistentPreRun) so it also @@ -190,6 +195,7 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B rootCmd.AddCommand(auth.NewCmdAuth(f)) rootCmd.AddCommand(profile.NewCmdProfile(f)) rootCmd.AddCommand(doctor.NewCmdDoctor(f)) + rootCmd.AddCommand(whoami.NewCmdWhoami(f)) rootCmd.AddCommand(api.NewCmdApiWithContext(ctx, f, nil)) rootCmd.AddCommand(schema.NewCmdSchema(f, nil)) rootCmd.AddCommand(completion.NewCmdCompletion(f)) @@ -205,7 +211,12 @@ func buildInternal(ctx context.Context, inv cmdutil.InvocationContext, opts ...B } shortcuts.RegisterShortcutsWithContext(ctx, rootCmd, f) + groupRootCommands(rootCmd) + installUnknownSubcommandGuard(rootCmd) + // Bare `lark-cli` in an interactive terminal offers an interactive upgrade + // before printing help; non-bare invocations and non-TTY are unaffected. + installRootUpgradePrompt(f, rootCmd) if mode := f.ResolveStrictMode(ctx); mode.IsActive() && !cfg.skipStrictMode { pruneForStrictMode(rootCmd, mode) diff --git a/cmd/doctor/doctor.go b/cmd/doctor/doctor.go index a7ba30a4..bef93d03 100644 --- a/cmd/doctor/doctor.go +++ b/cmd/doctor/doctor.go @@ -129,7 +129,10 @@ func doctorRun(opts *DoctorOptions) error { if diagnostics.Bot.Available || diagnostics.User.Available { checks = append(checks, pass("identity_ready", "at least one identity is available")) } else { - checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "run: lark-cli auth status --verify")) + // No hint: this only summarizes the two checks above, which already carry + // the source-appropriate remediation. A command here would be redundant, + // or wrong (`auth status` is blocked under an external provider). + checks = append(checks, fail("identity_ready", "no usable bot or user identity is available", "")) } // ── 4 & 5. Endpoint reachability ── diff --git a/cmd/doctor/doctor_test.go b/cmd/doctor/doctor_test.go index 0f4fe8f7..76cfbd77 100644 --- a/cmd/doctor/doctor_test.go +++ b/cmd/doctor/doctor_test.go @@ -4,14 +4,19 @@ package doctor import ( + "bytes" "context" "encoding/json" + "net/http" + "strings" "testing" "github.com/spf13/cobra" + extcred "github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" ) func TestNewCmdDoctor_FlagParsing(t *testing.T) { @@ -140,14 +145,84 @@ func TestDoctorRun_SplitsBotAndMissingUserIdentity(t *testing.T) { } func assertCheck(t *testing.T, checks []checkResult, name, status string) { + t.Helper() + if got := findCheck(t, checks, name); got.Status != status { + t.Fatalf("%s status = %q, want %q", name, got.Status, status) + } +} + +func findCheck(t *testing.T, checks []checkResult, name string) checkResult { t.Helper() for _, check := range checks { if check.Name == name { - if check.Status != status { - t.Fatalf("%s status = %q, want %q", name, check.Status, status) - } - return + return check } } t.Fatalf("check %q not found in %#v", name, checks) + return checkResult{} +} + +type fakeExtProvider struct { + name string + account *extcred.Account +} + +func (p *fakeExtProvider) Name() string { return p.name } +func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) { + return p.account, nil +} +func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) { + return nil, nil +} + +// Under an external credential provider with no usable identity, the +// identity_ready hint must not point at `auth status` (blocked there); the +// per-identity checks already carry the source-appropriate escalation. +func TestDoctor_ExternalProvider_IdentityReadyHintNotBlockedCommand(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + if err := core.SaveMultiAppConfig(&core.MultiAppConfig{ + CurrentApp: "default", + Apps: []core.AppConfig{{Name: "default", AppId: "cli_x", AppSecret: core.PlainSecret("secret"), Brand: core.BrandFeishu}}, + }); err != nil { + t.Fatalf("SaveMultiAppConfig() error = %v", err) + } + + // Provider serves neither identity: bot unsupported, user supported but not + // signed in → both unavailable → identity_ready fails. + cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser)} + cred := credential.NewCredentialProvider( + []extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}}, + nil, nil, + func() (*http.Client, error) { return nil, nil }, + ) + out := &bytes.Buffer{} + f := &cmdutil.Factory{ + Config: func() (*core.CliConfig, error) { return cfg, nil }, + Credential: cred, + IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}, + } + + if err := doctorRun(&DoctorOptions{Factory: f, Ctx: context.Background(), Offline: true}); err == nil { + t.Fatalf("doctorRun() = nil, want failure when no identity is available") + } + var got struct { + Checks []checkResult `json:"checks"` + } + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v\n%s", err, out.String()) + } + + ready := findCheck(t, got.Checks, "identity_ready") + if ready.Status != "fail" { + t.Fatalf("identity_ready status = %q, want fail", ready.Status) + } + // The summary defers to the per-identity checks; it carries no hint of its + // own (a command here would be wrong under an external provider). + if ready.Hint != "" { + t.Fatalf("identity_ready should carry no hint, got %q", ready.Hint) + } + user := findCheck(t, got.Checks, "user_identity") + if !strings.Contains(user.Hint, "external") || strings.Contains(user.Hint, "auth login") { + t.Fatalf("user_identity hint not external-appropriate: %q", user.Hint) + } } diff --git a/cmd/event/list_test.go b/cmd/event/list_test.go index 1779e064..d47c3db9 100644 --- a/cmd/event/list_test.go +++ b/cmd/event/list_test.go @@ -10,10 +10,22 @@ import ( "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + eventlib "github.com/larksuite/cli/internal/event" _ "github.com/larksuite/cli/events" ) +func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) { + for _, key := range []string{ + "vc.meeting.participant_meeting_started_v1", + "vc.meeting.participant_meeting_joined_v1", + } { + if _, ok := eventlib.Lookup(key); !ok { + t.Fatalf("event.Lookup(%q) should succeed", key) + } + } +} + func TestRunList_TextOutput(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) @@ -27,6 +39,8 @@ func TestRunList_TextOutput(t *testing.T) { "im.message.receive_v1", "im.message.message_read_v1", "task.task.update_user_access_v2", + "vc.meeting.participant_meeting_started_v1", + "vc.meeting.participant_meeting_joined_v1", } { if !strings.Contains(out, want) { t.Errorf("list output missing %q; full output:\n%s", want, out) @@ -57,9 +71,15 @@ func TestRunList_JSONOutput(t *testing.T) { } } - var foundTask bool + gotKeys := map[string]map[string]interface{}{} for _, row := range rows { - if row["key"] == "task.task.update_user_access_v2" { + if key, ok := row["key"].(string); ok { + gotKeys[key] = row + } + } + var foundTask bool + for key, row := range gotKeys { + if key == "task.task.update_user_access_v2" { foundTask = true if row["single_consumer"] != true { t.Errorf("task row single_consumer = %v, want true", row["single_consumer"]) @@ -69,4 +89,12 @@ func TestRunList_JSONOutput(t *testing.T) { if !foundTask { t.Fatal("event list JSON missing task.task.update_user_access_v2") } + for _, want := range []string{ + "vc.meeting.participant_meeting_started_v1", + "vc.meeting.participant_meeting_joined_v1", + } { + if _, ok := gotKeys[want]; !ok { + t.Errorf("JSON list output missing %q", want) + } + } } diff --git a/cmd/event/schema_test.go b/cmd/event/schema_test.go index c586dc1a..562fe1b8 100644 --- a/cmd/event/schema_test.go +++ b/cmd/event/schema_test.go @@ -124,6 +124,45 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) { } } +func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) { + for _, key := range []string{ + "vc.meeting.participant_meeting_started_v1", + "vc.meeting.participant_meeting_joined_v1", + } { + t.Run(key, func(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"}) + + if err := runSchema(f, key, true); err != nil { + t.Fatalf("runSchema json: %v", err) + } + + var payload map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String()) + } + if payload["key"] != key { + t.Errorf("key = %v, want %s", payload["key"], key) + } + resolved, ok := payload["resolved_output_schema"].(map[string]interface{}) + if !ok { + t.Fatalf("resolved_output_schema missing or wrong type: %+v", payload) + } + properties, ok := resolved["properties"].(map[string]interface{}) + if !ok { + t.Fatalf("resolved_output_schema.properties missing or wrong type: %+v", resolved) + } + for _, field := range []string{"type", "event_id", "timestamp", "meeting_id", "topic", "meeting_no", "start_time", "calendar_event_id"} { + if _, ok := properties[field]; !ok { + t.Errorf("resolved output schema missing field %q: %+v", field, properties) + } + } + if _, ok := properties["end_time"]; ok { + t.Errorf("resolved output schema should not include end_time for %s: %+v", key, properties) + } + }) + } +} + func TestSchema_RendersSubscriptionKeyMarker(t *testing.T) { const syntheticKey = "test.evt_sub" t.Cleanup(func() { eventlib.UnregisterKeyForTest(syntheticKey) }) diff --git a/cmd/root.go b/cmd/root.go index 74ba18d2..8e4e52cd 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -11,9 +11,11 @@ import ( "sort" "strings" + "github.com/larksuite/cli/cmd/service" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/platform" "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdpolicy" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/deprecation" @@ -28,43 +30,60 @@ import ( const rootLong = `lark-cli — Lark/Feishu CLI tool. -USAGE: - lark-cli [subcommand] [method] [options] - lark-cli api [--params ] [--data ] - lark-cli schema +AGENT QUICKSTART (driving this as an agent? start here): + Browse commands: lark-cli --help # +shortcuts (preferred) and raw API resources + Inspect a call: lark-cli schema .. # params, types, scopes, examples + Prefer a +shortcut over the raw API resource when one matches the task. + Risk: each command's --help shows read | write | high-risk-write; + high-risk-write needs --yes, only after the user confirms. + On any API call: --jq filters JSON output, --dry-run previews the request (runs nothing). -EXAMPLES: - # View upcoming events - lark-cli calendar +agenda +EXAMPLES (one per command style, in order of preference): + lark-cli calendar +agenda # +shortcut — a high-level task, prefer these + lark-cli mail user_mailbox.messages list --user-mailbox-id me # typed command for one API method + lark-cli schema mail.user_mailbox.messages.list # inspect a method's params before calling + lark-cli api GET /open-apis/calendar/v4/calendars # raw escape hatch — any endpoint by HTTP path` - # List calendar events - lark-cli calendar events instance_view --params '{"calendar_id":"primary","start_time":"1700000000","end_time":"1700086400"}' +// rootUsageTemplate is cobra's default usage template with two root-only +// additions gated on {{if not .HasParent}}: a curated multi-form Usage synopsis +// (replacing cobra's generic "[flags] / [command]") and a human skills-setup +// footer. Subcommands render the stock template unchanged. The rest is verbatim +// cobra so the command groups and flags are untouched. +const rootUsageTemplate = `{{if .HasParent}}Usage:{{if .Runnable}} + {{.UseLine}}{{end}}{{if .HasAvailableSubCommands}} + {{.CommandPath}} [command]{{end}}{{else}}Usage: + lark-cli [subcommand] [method] [flags] + lark-cli api [--params ] [--data ] + lark-cli schema {{end}}{{if gt (len .Aliases) 0}} - # Search users - lark-cli contact +search-user --query "John" +Aliases: + {{.NameAndAliases}}{{end}}{{if .HasExample}} - # Generic API call - lark-cli api GET /open-apis/calendar/v4/calendars +Examples: +{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}} -AI AGENT SKILLS: - lark-cli pairs with AI agent skills (Claude Code, etc.) that - teach the agent Lark API patterns, best practices, and workflows. +Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}} - Install all skills: - npx skills add larksuite/cli -g -y +{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}} - Or pick specific domains: - npx skills add larksuite/cli -s lark-calendar -y - npx skills add larksuite/cli -s lark-im -y +Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}} + {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}} - Learn more: https://github.com/larksuite/cli#agent-skills +Flags: +{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}} -COMMUNITY: - GitHub: https://github.com/larksuite/cli - Issues: https://github.com/larksuite/cli/issues - Docs: https://open.feishu.cn/document/ +Global Flags: +{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}} -More help: lark-cli --help` +Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}} + {{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}} + +Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}{{if not .HasParent}} + +Skills setup (one-time, humans): npx skills add larksuite/cli -g -y — https://github.com/larksuite/cli#agent-skills{{end}} +` // Execute runs the root command and returns the process exit code. // rawInvocationArgs holds os.Args[1:] captured at Execute() entry. cobra's @@ -529,6 +548,49 @@ func availableSubcommandNames(cmd *cobra.Command) (available, deprecated []strin return available, deprecated } +// Root command help groups, so an agent sees content domains, agent tooling, and +// CLI management as distinct blocks instead of one flat alphabetical dump. +const ( + groupDomains = "lark-domains" + groupTooling = "agent-tooling" + groupManagement = "cli-management" +) + +// groupRootCommands classifies root's direct children into the help groups, +// called once after all commands are registered. Unclassified commands fall to +// cobra's "Additional Commands" section. +func groupRootCommands(root *cobra.Command) { + root.AddGroup( + &cobra.Group{ID: groupDomains, Title: "Lark domains:"}, + &cobra.Group{ID: groupTooling, Title: "Agent tooling:"}, + &cobra.Group{ID: groupManagement, Title: "CLI management:"}, + ) + tooling := map[string]bool{"api": true, "schema": true, "skills": true} + management := map[string]bool{"auth": true, "config": true, "profile": true, "doctor": true, "update": true} + for _, c := range root.Commands() { + if c.GroupID != "" { + continue + } + switch { + case tooling[c.Name()]: + c.GroupID = groupTooling + case management[c.Name()]: + c.GroupID = groupManagement + case isLarkDomain(c): + c.GroupID = groupDomains + } + } +} + +// isLarkDomain reports whether a root child is a Lark domain (service-sourced or +// shortcut-tagged), not CLI tooling. Mirrors service.PrepareDomainHelp. +func isLarkDomain(c *cobra.Command) bool { + if src, _ := cmdmeta.SourceOf(c); src == cmdmeta.SourceService { + return true + } + return cmdmeta.Domain(c) != "" +} + // flagDidYouMean is the root FlagErrorFunc (inherited by all subcommands). It // converts cobra's flag-parse errors into a typed validation envelope: an // unknown flag gets a focused "did you mean" hint (so agents recover even when @@ -610,6 +672,17 @@ func installTipsHelpFunc(root *cobra.Command) { defer func() { f.Hidden = true }() } } + // Domain and method commands compose their agent guidance into Long lazily + // here (shortcuts attach after service registration); both skip the generic + // bottom-of-help append below. + if service.PrepareDomainHelp(cmd, embeddedSkillContent) { + defaultHelp(cmd, args) + return + } + if service.PrepareMethodHelp(cmd) { + defaultHelp(cmd, args) + return + } defaultHelp(cmd, args) out := cmd.OutOrStdout() if level, ok := cmdutil.GetRisk(cmd); ok { diff --git a/cmd/root_test.go b/cmd/root_test.go index b48dc64f..e7f3f5e7 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -76,11 +76,13 @@ func TestPersistentPreRunE_ConfigSubcommands(t *testing.T) { } func TestRootLong_AgentSkillsLinkTargetsReadmeSection(t *testing.T) { - if !strings.Contains(rootLong, "https://github.com/larksuite/cli#agent-skills") { - t.Fatalf("root help should link to the README Agent Skills section, got:\n%s", rootLong) + // The human skills-install guidance now lives in the root usage-template + // footer (below the command list), not in the agent-facing Long. + if !strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#agent-skills") { + t.Fatalf("root help footer should link to the README Agent Skills section, got:\n%s", rootUsageTemplate) } - if strings.Contains(rootLong, "https://github.com/larksuite/cli#install-ai-agent-skills") { - t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootLong) + if strings.Contains(rootUsageTemplate, "https://github.com/larksuite/cli#install-ai-agent-skills") { + t.Fatalf("root help should not reference the removed install-ai-agent-skills anchor, got:\n%s", rootUsageTemplate) } } diff --git a/cmd/root_upgrade.go b/cmd/root_upgrade.go new file mode 100644 index 00000000..eadec786 --- /dev/null +++ b/cmd/root_upgrade.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bufio" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/update" + "github.com/spf13/cobra" +) + +// runRootUpgrade locates the registered `update` subcommand and runs it, so the +// interactive root-command upgrade reuses exactly `lark-cli update` behavior +// (install-method detection, output, error handling). Package-level var so +// tests can stub it and avoid real network / self-update. +var runRootUpgrade = func(cmd *cobra.Command) { + for _, c := range cmd.Root().Commands() { + if c.Name() == "update" && c.RunE != nil { + _ = c.RunE(c, nil) // update prints its own output/errors; swallow here + return + } + } +} + +// isBareRootInvocation reports whether this is a bare `lark-cli` (no subcommand, +// no flags) — the only invocation that triggers the interactive upgrade prompt. +// Mirrors unknownSubcommandRunE's "bare group prints help" branch: args empty +// AND no flag tokens in the raw invocation. +func isBareRootInvocation(args []string) bool { + return len(args) == 0 && len(flagTokensInArgs(rawInvocationArgs)) == 0 +} + +// readYes reads one line and reports whether it is an affirmative y/yes. +// EOF / empty / anything else → false (default No, matching the [y/N] prompt). +func readYes(r io.Reader) bool { + line, _ := bufio.NewReader(r).ReadString('\n') + switch strings.ToLower(strings.TrimSpace(line)) { + case "y", "yes": + return true + default: + return false + } +} + +// offerRootUpgrade prompts for an interactive upgrade when running bare +// `lark-cli` in an interactive terminal with a cached newer version. Every +// failure is swallowed — it must never affect help output or the exit code. +func offerRootUpgrade(f *cmdutil.Factory, cmd *cobra.Command) { + ios := f.IOStreams + // Gates 1/2/3: need to read stdin AND show the prompt on stderr, and require + // stdout TTY too so this only fires in a pure foreground terminal session. + if !ios.IsTerminal || !ios.OutIsTerminal || !ios.StderrIsTerminal { + return + } + // Gate 4: cached newer version. CheckCached applies opt-out (shouldSkip) + // and the IsNewer/semver validation chain; it reads the on-disk cache that + // the 24h-throttled RefreshCache maintains (CheckCached itself has no TTL). + info := update.CheckCached(build.Version) + if info == nil { + return + } + fmt.Fprintf(ios.ErrOut, "lark-cli %s available (current %s). Upgrade now? [y/N]: ", info.Latest, info.Current) + if !readYes(ios.In) { + return + } + runRootUpgrade(cmd) +} + +// installRootUpgradePrompt wraps the root command's RunE (set to +// unknownSubcommandRunE by installUnknownSubcommandGuard) so a bare `lark-cli` +// invocation offers an interactive upgrade before printing help. Non-bare +// invocations are passed straight through, unchanged. +func installRootUpgradePrompt(f *cmdutil.Factory, root *cobra.Command) { + inner := root.RunE + if inner == nil { + return + } + root.RunE = func(cmd *cobra.Command, args []string) error { + if isBareRootInvocation(args) { + offerRootUpgrade(f, cmd) + } + return inner(cmd, args) + } +} diff --git a/cmd/root_upgrade_test.go b/cmd/root_upgrade_test.go new file mode 100644 index 00000000..bc28b858 --- /dev/null +++ b/cmd/root_upgrade_test.go @@ -0,0 +1,191 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/build" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/spf13/cobra" +) + +func writeUpdateState(t *testing.T, dir, latest string) { + t.Helper() + data := fmt.Sprintf(`{"latest_version":%q,"checked_at":%d}`, latest, time.Now().Unix()) + if err := os.WriteFile(filepath.Join(dir, "update-state.json"), []byte(data), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestReadYes(t *testing.T) { + cases := map[string]bool{ + "y\n": true, "Y\n": true, "yes\n": true, "YES\n": true, " y \n": true, + "n\n": false, "\n": false, "": false, "nope\n": false, "yeah\n": false, + } + for in, want := range cases { + if got := readYes(strings.NewReader(in)); got != want { + t.Errorf("readYes(%q) = %v, want %v", in, got, want) + } + } +} + +func TestIsBareRootInvocation(t *testing.T) { + orig := rawInvocationArgs + t.Cleanup(func() { rawInvocationArgs = orig }) + + rawInvocationArgs = nil + if !isBareRootInvocation([]string{}) { + t.Error("empty args + no raw flag tokens should be bare") + } + rawInvocationArgs = []string{"--profile", "x"} + if isBareRootInvocation([]string{}) { + t.Error("flag token present → not bare") + } + rawInvocationArgs = nil + if isBareRootInvocation([]string{"im"}) { + t.Error("positional arg → not bare") + } +} + +func TestOfferRootUpgrade(t *testing.T) { + origV := build.Version + build.Version = "1.0.0" // release version so shouldSkip()==false + t.Cleanup(func() { build.Version = origV }) + + origRun := runRootUpgrade + t.Cleanup(func() { runRootUpgrade = origRun }) + + // This test builds a Factory literal (no NewDefault), so it never runs + // workspace detection; pin the process-global workspace to Local so + // statePath() resolves under LARKSUITE_CLI_CONFIG_DIR rather than a stale + // subdir inherited from a prior test in the package. + origWS := core.CurrentWorkspace() + t.Cleanup(func() { core.SetCurrentWorkspace(origWS) }) + core.SetCurrentWorkspace(core.WorkspaceLocal) + + cases := []struct { + name string + in, out, err bool + input string + latest string // "" → no state file (CheckCached nil) + optOut bool + wantPrompt, wantRun bool + }{ + {"all-tty+y", true, true, true, "y\n", "2.0.0", false, true, true}, + {"all-tty+yes", true, true, true, "yes\n", "2.0.0", false, true, true}, + {"all-tty+n", true, true, true, "n\n", "2.0.0", false, true, false}, + {"all-tty+empty", true, true, true, "\n", "2.0.0", false, true, false}, + {"all-tty+eof", true, true, true, "", "2.0.0", false, true, false}, + {"stdin-not-tty", false, true, true, "y\n", "2.0.0", false, false, false}, + {"stdout-not-tty", true, false, true, "y\n", "2.0.0", false, false, false}, + {"stderr-not-tty", true, true, false, "y\n", "2.0.0", false, false, false}, + {"no-newer-version", true, true, true, "y\n", "", false, false, false}, + {"already-latest", true, true, true, "y\n", "1.0.0", false, false, false}, // post-upgrade: current == cached latest → no prompt + {"cache-older-than-current", true, true, true, "y\n", "0.9.0", false, false, false}, + {"opt-out", true, true, true, "y\n", "2.0.0", true, false, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", dir) + // Clear env that update.shouldSkip treats as "suppress" so the + // test is deterministic regardless of host (GitHub Actions sets + // CI=true, which would otherwise suppress the prompt). + t.Setenv("CI", "") + t.Setenv("BUILD_NUMBER", "") + t.Setenv("RUN_ID", "") + t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "") + if tc.latest != "" { + writeUpdateState(t, dir, tc.latest) + } + if tc.optOut { + t.Setenv("LARKSUITE_CLI_NO_UPDATE_NOTIFIER", "1") + } + called := false + runRootUpgrade = func(*cobra.Command) { called = true } + + var errBuf bytes.Buffer + f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{ + In: strings.NewReader(tc.input), + Out: &bytes.Buffer{}, + ErrOut: &errBuf, + IsTerminal: tc.in, + OutIsTerminal: tc.out, + StderrIsTerminal: tc.err, + }} + offerRootUpgrade(f, &cobra.Command{}) + + gotPrompt := strings.Contains(errBuf.String(), "available") + if gotPrompt != tc.wantPrompt { + t.Errorf("prompt: got %v want %v (stderr=%q)", gotPrompt, tc.wantPrompt, errBuf.String()) + } + if called != tc.wantRun { + t.Errorf("runRootUpgrade called: got %v want %v", called, tc.wantRun) + } + }) + } +} + +func TestInstallRootUpgradePromptPreservesInner(t *testing.T) { + orig := rawInvocationArgs + t.Cleanup(func() { rawInvocationArgs = orig }) + rawInvocationArgs = nil + + innerCalls := 0 + root := &cobra.Command{Use: "lark-cli"} + root.RunE = func(cmd *cobra.Command, args []string) error { innerCalls++; return nil } + + f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{ + In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}, + }} + installRootUpgradePrompt(f, root) + + if err := root.RunE(root, []string{}); err != nil { + t.Fatalf("bare RunE err = %v", err) + } + if err := root.RunE(root, []string{"im"}); err != nil { + t.Fatalf("non-bare RunE err = %v", err) + } + if innerCalls != 2 { + t.Errorf("inner RunE should run for both bare and non-bare, got %d", innerCalls) + } +} + +// TestRunRootUpgradeDispatchesToUpdate covers the real runRootUpgrade dispatch +// path (not the stub used elsewhere): from any command it must locate the +// registered "update" subcommand via cmd.Root() and invoke its RunE. +func TestRunRootUpgradeDispatchesToUpdate(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} + ran := 0 + root.AddCommand(&cobra.Command{Use: "update", RunE: func(*cobra.Command, []string) error { ran++; return nil }}) + child := &cobra.Command{Use: "im"} + root.AddCommand(child) + + runRootUpgrade(child) // child.Root() resolves to root, which has "update" + + if ran != 1 { + t.Errorf("runRootUpgrade should locate and run update's RunE once, got %d", ran) + } +} + +// TestInstallRootUpgradePromptNilInnerNoop covers the inner == nil guard: +// when root has no RunE, installRootUpgradePrompt must not wrap it. +func TestInstallRootUpgradePromptNilInnerNoop(t *testing.T) { + root := &cobra.Command{Use: "lark-cli"} // RunE is nil + f := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{ + In: strings.NewReader(""), Out: &bytes.Buffer{}, ErrOut: &bytes.Buffer{}, + }} + installRootUpgradePrompt(f, root) + if root.RunE != nil { + t.Error("installRootUpgradePrompt must not wrap a nil RunE (inner==nil guard)") + } +} diff --git a/cmd/service/affordance.go b/cmd/service/affordance.go index 53fd1a2f..6a9aa804 100644 --- a/cmd/service/affordance.go +++ b/cmd/service/affordance.go @@ -4,41 +4,211 @@ package service import ( + "encoding/json" "fmt" + "io/fs" "strings" + "github.com/larksuite/cli/internal/affordance" + "github.com/larksuite/cli/internal/cmdmeta" + "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/meta" + "github.com/spf13/cobra" ) -// 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 { +// PrepareDomainHelp appends navigational guidance (routing line, risk legend, +// skill pointer) to a top-level Lark domain's description, returning false for +// anything that is not such a domain. Built lazily at help time because +// shortcuts attach after service registration. skillFS (nil-safe) gates the +// skill pointer. +// +// A hand-authored Long is preserved as the base (e.g. event's "Use 'event +// consume '…"); service domains carry only a Short at this point, so +// we fall back to it. The pristine base is captured once into an annotation so +// re-rendering does not append the guidance twice. +func PrepareDomainHelp(cmd *cobra.Command, skillFS fs.FS) bool { + if cmd.Annotations[schemaPathAnnotation] != "" { + return false // a method command + } + // Direct child of root only — so Domain() reads this command's own tag, and + // nested resource groups are excluded. + if cmd.Parent() == nil || cmd.Parent().Parent() != nil { + return false + } + // A domain is service-sourced or shortcut-tagged; CLI tooling has neither. + if src, _ := cmdmeta.SourceOf(cmd); src != cmdmeta.SourceService && cmdmeta.Domain(cmd) == "" { + return false + } + if !cmd.HasAvailableSubCommands() { + return false + } + + hasShortcuts, hasResources := false, false + for _, c := range cmd.Commands() { + if c.Hidden || c.Name() == "help" || c.Name() == "completion" { + continue + } + if strings.HasPrefix(c.Name(), "+") { + hasShortcuts = true + } else { + hasResources = true + } + } + + var b strings.Builder + b.WriteString(domainHelpBase(cmd)) + if hasShortcuts && hasResources { // routing only matters when both styles exist + b.WriteString("\n\nPrefer a +-prefixed shortcut when one matches your task; otherwise use the raw API resource below.") + } + b.WriteString("\n\nRisk levels (read | write | high-risk-write) appear in each command's --help; high-risk-write requires --yes, only after the user confirms.") + if skill := "lark-" + cmd.Name(); skillFS != nil { + if _, err := fs.Stat(skillFS, skill+"/SKILL.md"); err == nil { + fmt.Fprintf(&b, "\n\nDomain guide (concepts, command choice, conventions): lark-cli skills read %s", skill) + } + } + cmd.Long = b.String() + return true +} + +// domainHelpBase returns the description to seed domain help with — the +// hand-authored Long when present, else the Short — captured once into an +// annotation so re-rendering reuses the pristine text instead of the +// already-augmented Long. +func domainHelpBase(cmd *cobra.Command) string { + if base, ok := cmd.Annotations[domainBaseAnnotation]; ok { + return base + } + base := cmd.Long + if base == "" { + base = cmd.Short + } + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + cmd.Annotations[domainBaseAnnotation] = base + return base +} + +// methodLong is the build-time Long (description + schema pointer + +// params-only addendum). Agent guidance is added lazily by PrepareMethodHelp, +// so command construction never parses the overlay. +func methodLong(description, 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) + fmt.Fprintf(&b, "\n\nFull parameter schema:\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. +// Annotation keys PrepareMethodHelp reads to rebuild a method command's Long. +const ( + affordanceServiceAnnotation = "affordance-service" + affordanceMethodAnnotation = "affordance-method" + schemaPathAnnotation = "method-schema-path" + paramsOnlyAnnotation = "method-params-only" + domainBaseAnnotation = "affordance-domain-base" +) + +// setMethodHelpData records the coordinates PrepareMethodHelp needs (storing a +// few strings is the only build-time cost; the overlay stays untouched). +func setMethodHelpData(cmd *cobra.Command, service, methodID, schemaPath, paramsOnly string) { + if cmd.Annotations == nil { + cmd.Annotations = map[string]string{} + } + if service != "" && methodID != "" { + cmd.Annotations[affordanceServiceAnnotation] = service + cmd.Annotations[affordanceMethodAnnotation] = methodID + } + cmd.Annotations[schemaPathAnnotation] = schemaPath + if paramsOnly != "" { + cmd.Annotations[paramsOnlyAnnotation] = paramsOnly + } +} + +// PrepareMethodHelp rebuilds a generated method command's Long with the agent +// guidance at the TOP (Risk, then the affordance block, then the schema +// pointer), returning false for non-method commands. The overlay is parsed +// here — only when help is rendered. +func PrepareMethodHelp(cmd *cobra.Command) bool { + ann := cmd.Annotations + if ann == nil { + return false + } + schemaPath, ok := ann[schemaPathAnnotation] + if !ok { + return false + } + + var b strings.Builder + b.WriteString(cmd.Short) + if level, ok := cmdutil.GetRisk(cmd); ok { + // --yes asserts the USER confirmed; the agent must not self-approve. + if level == cmdutil.RiskHighRiskWrite { + fmt.Fprintf(&b, "\n\nRisk: %s (requires explicit user confirmation to execute; the agent must NOT add --yes on its own — only pass --yes after the user has confirmed)", level) + } else { + fmt.Fprintf(&b, "\n\nRisk: %s", level) + } + } + + var skills []string + if raw, ok := affordanceRaw(cmd); ok { + if block := renderAffordance(meta.Method{Affordance: raw}); block != "" { + b.WriteString("\n\n") + b.WriteString(block) + } + if a, ok := (meta.Method{Affordance: raw}).ParsedAffordance(); ok { + skills = a.Skills + } + } + + fmt.Fprintf(&b, "\n\nFull parameter schema:\n lark-cli schema %s", schemaPath) + b.WriteString(ann[paramsOnlyAnnotation]) + + if len(skills) > 0 { + b.WriteString("\n\nWorkflow skill (end-to-end usage):") + for _, s := range skills { + fmt.Fprintf(&b, "\n lark-cli skills read %s", s) + } + } + + cmd.Long = b.String() + return true +} + +// affordanceLookup is the overlay source; a package var so tests can inject. +var affordanceLookup = affordance.For + +// RenderAffordanceForCmd renders a method command's affordance block, or "" when +// it carries none. +func RenderAffordanceForCmd(cmd *cobra.Command) string { + raw, ok := affordanceRaw(cmd) + if !ok { + return "" + } + return renderAffordance(meta.Method{Affordance: raw}) +} + +func affordanceRaw(cmd *cobra.Command) (json.RawMessage, bool) { + if cmd.Annotations == nil { + return nil, false + } + service := cmd.Annotations[affordanceServiceAnnotation] + methodID := cmd.Annotations[affordanceMethodAnnotation] + if service == "" || methodID == "" { + return nil, false + } + return affordanceLookup(service, methodID) +} + +// renderAffordance renders a method's affordance as a help block, or "" when it +// has none. Sections are joined with blank lines so they scan as distinct groups. func renderAffordance(m meta.Method) string { a, ok := m.ParsedAffordance() if !ok { return "" } - var b strings.Builder + var sections []string bullets := func(title string, items []string) { var nonEmpty []string for _, it := range items { @@ -49,15 +219,18 @@ func renderAffordance(m meta.Method) string { if len(nonEmpty) == 0 { return } - fmt.Fprintf(&b, "%s:\n", title) + var s strings.Builder + fmt.Fprintf(&s, "%s:\n", title) for _, it := range nonEmpty { - fmt.Fprintf(&b, " • %s\n", it) + fmt.Fprintf(&s, " • %s\n", it) } + sections = append(sections, strings.TrimRight(s.String(), "\n")) } bullets("When to use", a.UseWhen) - bullets("Avoid when", a.DoNotUseWhen) + bullets("Avoid when", a.AvoidWhen) bullets("Prerequisites", a.Prerequisites) + bullets("Tips", a.Tips) if len(a.Examples) > 0 { var lines []string for _, ex := range a.Examples { @@ -71,10 +244,13 @@ func renderAffordance(m meta.Method) string { } } if len(lines) > 0 { - fmt.Fprintf(&b, "Examples:\n%s\n", strings.Join(lines, "\n")) + sections = append(sections, "Examples:\n"+strings.Join(lines, "\n")) } } + for _, ext := range a.Extensions { + bullets(ext.Label, ext.Items) + } bullets("Related", a.Related) - return strings.TrimRight(b.String(), "\n") + return strings.Join(sections, "\n\n") } diff --git a/cmd/service/affordance_test.go b/cmd/service/affordance_test.go index e3111f62..e5a8a550 100644 --- a/cmd/service/affordance_test.go +++ b/cmd/service/affordance_test.go @@ -8,15 +8,18 @@ import ( "strings" "testing" + "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/meta" + "github.com/spf13/cobra" ) func TestRenderAffordance(t *testing.T) { raw := json.RawMessage(`{ "use_when": ["发送文本消息"], - "do_not_use_when": ["群已解散"], + "avoid_when": ["群已解散"], "prerequisites": ["已获取 chat_id"], + "tips": ["富文本用 msg_type=post"], "examples": [ {"description":"发一条文本","command":"lark-cli im messages create --params '{...}'"}, {"command":"lark-cli im messages list"}, @@ -29,6 +32,7 @@ func TestRenderAffordance(t *testing.T) { "When to use:", "发送文本消息", "Avoid when:", "群已解散", "Prerequisites:", "已获取 chat_id", + "Tips:", "富文本用 msg_type=post", "Examples:", "发一条文本", "lark-cli im messages create --params '{...}'", "lark-cli im messages list", // example with no description -> bare command line "Related:", "im.messages.list", @@ -48,9 +52,12 @@ func TestRenderAffordance(t *testing.T) { } } -func TestServiceMethod_AffordanceInLong(t *testing.T) { +// Affordance is rendered lazily (at --help time) rather than baked into the +// command's Long, so building a command never carries the affordance block — +// even for a method whose metadata happens to declare one. +func TestServiceMethod_AffordanceNotInLong(t *testing.T) { withAff := map[string]interface{}{ - "path": "messages", "httpMethod": "POST", "description": "发送消息", + "id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息", "affordance": map[string]interface{}{ "examples": []interface{}{ map[string]interface{}{"description": "发文本", "command": "lark-cli im messages create ..."}, @@ -59,14 +66,120 @@ func TestServiceMethod_AffordanceInLong(t *testing.T) { } 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) + if strings.Contains(cmd.Long, "Examples:") { + t.Errorf("affordance must not be baked into Long (lazy):\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) + // The lookup ref is recorded so the help path can resolve it later. + if cmd.Annotations[affordanceServiceAnnotation] != "im" || cmd.Annotations[affordanceMethodAnnotation] != "messages.create" { + t.Errorf("affordance ref annotations = %v, want im/messages.create", cmd.Annotations) + } +} + +// RenderAffordanceForCmd resolves a command's overlay through the (injectable) +// lookup and renders it; commands without a ref render nothing. +func TestRenderAffordanceForCmd(t *testing.T) { + orig := affordanceLookup + t.Cleanup(func() { affordanceLookup = orig }) + affordanceLookup = func(service, methodID string) (json.RawMessage, bool) { + if service != "im" || methodID != "messages.create" { + return nil, false + } + return json.RawMessage(`{"use_when":["发文本消息"],"tips":["富文本用 msg_type=post"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true + } + + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + withRef := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"} + cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(withRef), "create", "messages", nil) + block := RenderAffordanceForCmd(cmd) + for _, want := range []string{"When to use:", "发文本消息", "Tips:", "富文本用 msg_type=post", "Examples:", "lark-cli im messages create ..."} { + if !strings.Contains(block, want) { + t.Errorf("RenderAffordanceForCmd missing %q in:\n%s", want, block) + } + } + + // No overlay for this method id -> empty block. + noRef := map[string]interface{}{"id": "x.list", "path": "x", "httpMethod": "GET", "description": "d"} + cmd2 := NewCmdServiceMethod(f, imSpec(), meta.FromMap(noRef), "list", "x", nil) + if got := RenderAffordanceForCmd(cmd2); got != "" { + t.Errorf("method with no overlay should render nothing, got:\n%s", got) + } +} + +// PrepareMethodHelp composes the guidance into Long at the top: description, +// then the affordance block, then the full-schema pointer — so an agent reads +// when-to-use/examples before the flag list. +func TestPrepareMethodHelp(t *testing.T) { + orig := affordanceLookup + t.Cleanup(func() { affordanceLookup = orig }) + affordanceLookup = func(_, _ string) (json.RawMessage, bool) { + return json.RawMessage(`{"use_when":["发文本消息"],"examples":[{"description":"发一条","command":"lark-cli im messages create ..."}]}`), true + } + + f, _, _, _ := cmdutil.TestFactory(t, testConfig) + m := map[string]interface{}{"id": "messages.create", "path": "messages", "httpMethod": "POST", "description": "发送消息"} + cmd := NewCmdServiceMethod(f, imSpec(), meta.FromMap(m), "create", "messages", nil) + + if !PrepareMethodHelp(cmd) { + t.Fatal("PrepareMethodHelp returned false for a service-method command") + } + long := cmd.Long + // Description leads; affordance block sits above the schema pointer. + descAt := strings.Index(long, "发送消息") + useAt := strings.Index(long, "When to use:") + exAt := strings.Index(long, "Examples:") + schemaAt := strings.Index(long, "Full parameter schema:") + if descAt != 0 { + t.Errorf("description should lead Long, got:\n%s", long) + } + if !(descAt < useAt && useAt < exAt && exAt < schemaAt) { + t.Errorf("order should be description < affordance < schema pointer; got desc=%d use=%d ex=%d schema=%d\n%s", descAt, useAt, exAt, schemaAt, long) + } + + // A non-service command (no schema-path annotation) is left untouched. + if PrepareMethodHelp(&cobra.Command{Use: "plain"}) { + t.Error("PrepareMethodHelp should return false for a non-service command") + } +} + +// domainCmd wires a domain-tagged command with a subcommand under a root, the +// shape PrepareDomainHelp expects. +func domainCmd(short, long string) *cobra.Command { + root := &cobra.Command{Use: "root"} + dom := &cobra.Command{Use: "event", Short: short, Long: long} + cmdmeta.SetDomain(dom, "event") + dom.AddCommand(&cobra.Command{Use: "consume", Run: func(*cobra.Command, []string) {}}) + root.AddCommand(dom) + return dom +} + +func TestPrepareDomainHelp_PreservesHandAuthoredLong(t *testing.T) { + const long = "Unified event consumption system. Use 'event consume '." + dom := domainCmd("Consume and manage real-time events", long) + + if !PrepareDomainHelp(dom, nil) { + t.Fatal("PrepareDomainHelp returned false for a domain-tagged command") + } + if !strings.HasPrefix(dom.Long, long) { + t.Errorf("hand-authored Long must lead; got:\n%s", dom.Long) + } + if !strings.Contains(dom.Long, "Risk levels") { + t.Errorf("domain guidance should be appended; got:\n%s", dom.Long) + } + + // Re-rendering must not append the guidance a second time. + PrepareDomainHelp(dom, nil) + if n := strings.Count(dom.Long, "Risk levels"); n != 1 { + t.Errorf("guidance appended %d times across re-renders, want 1:\n%s", n, dom.Long) + } +} + +// A service domain carries only a Short at help time; it seeds the base. +func TestPrepareDomainHelp_FallsBackToShort(t *testing.T) { + dom := domainCmd("Message and group chat management", "") + if !PrepareDomainHelp(dom, nil) { + t.Fatal("PrepareDomainHelp returned false for a domain-tagged command") + } + if !strings.HasPrefix(dom.Long, "Message and group chat management") { + t.Errorf("Short should seed Long when no hand-authored Long exists; got:\n%s", dom.Long) } } diff --git a/cmd/service/flaggroups_test.go b/cmd/service/flaggroups_test.go index 59d741a4..391c7c99 100644 --- a/cmd/service/flaggroups_test.go +++ b/cmd/service/flaggroups_test.go @@ -60,8 +60,11 @@ func TestServiceFlagGroups_AgentContract(t *testing.T) { 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) + // The redundant ", required|optional." prefix is gone: required-ness is + // carried by the Required:/Optional: subheadings, and the snake-case --params + // key by the schema envelope — so it isn't echoed on every flag line. + if strings.Contains(out, "chat_id, required") || strings.Contains(out, "member_id_type, optional") { + t.Errorf("redundant , required/optional prefix should not appear:\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) diff --git a/cmd/service/paramhelp.go b/cmd/service/paramhelp.go index e4a91521..cf144057 100644 --- a/cmd/service/paramhelp.go +++ b/cmd/service/paramhelp.go @@ -30,6 +30,11 @@ func fieldFacts(f meta.Field) []string { if d := sanitizeFieldDesc(f.Description); d != "" { facts = append(facts, d) } + if f.CanonicalType() == "boolean" { + // cobra shows no type word for bools and swallows a separate value as a + // positional, so spell out the presence-only contract. + facts = append(facts, "bool flag (presence = true; omit for false; takes no value)") + } if opts := f.EnumOptions(); len(opts) > 0 { facts = append(facts, "enum: "+formatEnumInline(opts)) } @@ -42,20 +47,15 @@ func fieldFacts(f meta.Field) []string { 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. +// paramFlagUsage renders the typed param flag's help line: the field's facts +// joined inline. Required/optional is not repeated here — the grouped help's +// Required:/Optional: subheadings already partition the flags — and the +// snake-case --params key is carried by the schema envelope (each param's +// property + "flag") and the params-only addendum, so it isn't echoed on every +// line either. Returns "" when the field has no facts (cobra then shows the bare +// flag with its type). 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, ". ") + "." + return strings.Join(fieldFacts(f), ". ") } // paramExample picks a concrete sample for a params-only field's --help snippet: @@ -103,8 +103,23 @@ func sanitizeOptionDesc(s string) string { return inlineClause(s, "。;;\n\r", // 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) } +// affordance, e.g. user_mailbox_id's `可以输入"me"`. The trailing doc +// cross-reference is dropped first (see cutDocRef). +func sanitizeFieldDesc(s string) string { return inlineClause(cutDocRef(s), ";;\n\r", 60) } + +// docRefRe matches a "see the docs" breadcrumb (更多信息参见…/获取方式见…/详见…). +// On the compact flag line the markdown link's URL is stripped, so the +// breadcrumb is a dead pointer — drop it. Anchored on a leading clause separator +// so a subject that runs straight into the phrase isn't orphaned. +var docRefRe = regexp.MustCompile(`[。;;,,、]\s*(更多信息|获取方式|获取方法|详见|[请可]?参[见考阅])`) + +// cutDocRef truncates s at the first doc-reference breadcrumb. +func cutDocRef(s string) string { + if loc := docRefRe.FindStringIndex(s); loc != nil { + return s[:loc[0]] + } + return s +} // formatEnumInline renders allowed values for the help line: "v=meaning" when // the value carries a (sanitized, truncated) description — so opaque numeric diff --git a/cmd/service/service.go b/cmd/service/service.go index e6344dd7..3cb6ab5d 100644 --- a/cmd/service/service.go +++ b/cmd/service/service.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "sort" "strings" "github.com/larksuite/cli/errs" @@ -64,15 +65,38 @@ func registerServiceWithContext(ctx context.Context, parent *cobra.Command, svc // 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) { + refs := apicatalog.ServiceMethods(svc, nil) + + // Collect each resource's verbs up front so resourceShort can summarize a + // resource as its verb list from the first ensureChildCommand call. + verbs := map[string][]string{} + for _, ref := range refs { + key := strings.Join(ref.ResourcePath, ".") + verbs[key] = append(verbs[key], ref.Method.Name) + } + + for _, ref := range refs { resCmd := svcCmd + var path []string for _, seg := range ref.ResourcePath { - resCmd = ensureChildCommand(resCmd, seg, seg+" operations") + path = append(path, seg) + resCmd = ensureChildCommand(resCmd, seg, resourceShort(seg, verbs[strings.Join(path, ".")])) } resCmd.AddCommand(buildMethodCommand(ctx, f, newMethodCommandSpec(ref), nil, parent.PersistentFlags())) } } +// resourceShort summarizes a resource as its sorted verb list, or the +// " operations" placeholder for an intermediate group with no methods. +func resourceShort(seg string, verbs []string) string { + if len(verbs) == 0 { + return seg + " operations" + } + sorted := append([]string(nil), verbs...) + sort.Strings(sorted) + return strings.Join(sorted, ", ") +} + // 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 { @@ -177,7 +201,19 @@ type methodCommandSpec struct { // the API declares a body. acceptsBody bool declaresBody bool - affordance string // rendered hand-authored usage guidance (when-to-use, examples); "" if none + paginates bool // method accepts a page_token param (so --page-all is meaningful) + serviceName string // owning service name (e.g. "approval"), for the lazy affordance lookup +} + +// methodPaginates reports whether a method takes a page_token param, the signal +// that makes the --page-all/--page-limit/--page-delay flags meaningful. +func methodPaginates(m meta.Method) bool { + for _, f := range m.Params() { + if f.Name == "page_token" { + return true + } + } + return false } func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec { @@ -186,6 +222,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec { method: m, schemaPath: ref.SchemaPath(), servicePath: ref.Service.ServicePath, + serviceName: ref.Service.Name, risk: m.Risk, restricts: m.RestrictsIdentity(), identities: m.Identities(), @@ -193,7 +230,7 @@ func newMethodCommandSpec(ref apicatalog.MethodRef) methodCommandSpec { fileFields: detectFileFields(m), acceptsBody: methodTakesBody(m.HTTPMethod), declaresBody: len(m.Data()) > 0 || len(m.Files()) > 0, - affordance: renderAffordance(m), + paginates: methodPaginates(m), } } @@ -254,6 +291,14 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm cmd.Flags().BoolVar(&opts.PageAll, "page-all", false, "automatically paginate through all pages") cmd.Flags().IntVar(&opts.PageLimit, "page-limit", 10, "max pages to fetch with --page-all (0 = unlimited)") cmd.Flags().IntVar(&opts.PageDelay, "page-delay", 200, "delay in ms between pages") + // Keep the pagination flags registered (a harmless no-op if passed) but hide + // them from help on non-paginating commands, so help doesn't imply a + // get/write can paginate. + if !spec.paginates { + for _, name := range []string{"page-all", "page-limit", "page-delay"} { + _ = cmd.Flags().MarkHidden(name) + } + } cmd.Flags().StringVar(&opts.Format, "format", "json", "output format: json|ndjson|table|csv") cmd.Flags().Bool("json", false, "shorthand for --format json") cmd.Flags().StringVarP(&opts.JqExpr, "jq", "q", "", "jq expression to filter JSON output") @@ -271,10 +316,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm // 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()) + // Build-time Long; the agent guidance is added lazily by PrepareMethodHelp + // (setMethodHelpData records the coordinates it needs). + paramsOnly := opts.binder.paramsOnlyHelp() + cmd.Long = methodLong(m.Description, spec.schemaPath, paramsOnly) + setMethodHelpData(cmd, spec.serviceName, m.ID, spec.schemaPath, paramsOnly) // 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 @@ -292,13 +338,11 @@ func buildMethodCommand(ctx context.Context, f *cmdutil.Factory, spec methodComm 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. + // Keep the precedence rule on the flag's own one line (not a multi-line + // note that breaks the one-entry-per-flag rhythm an agent parses). Only + // meaningful when typed flags exist to override. 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.", - }) + fl.Usage = "Raw URL/query params JSON. Supports - and @file. If both set, typed flags override matching keys in --params." } } for _, name := range []string{"as", "dry-run", "page-all", "page-limit", "page-delay", "yes"} { diff --git a/cmd/whoami/whoami.go b/cmd/whoami/whoami.go new file mode 100644 index 00000000..d5b4b387 --- /dev/null +++ b/cmd/whoami/whoami.go @@ -0,0 +1,163 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package whoami + +import ( + "context" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/identitydiag" + "github.com/larksuite/cli/internal/output" +) + +// whoamiResult is the structured output of `lark-cli whoami`. +// +// The self-vs-delegated distinction is carried by `identity`: a bot identity is +// the app acting as itself; a user identity is the app acting *on behalf of* a +// person (calls are attributed to that user, who is not necessarily present). +// onBehalfOf only *names* that person and so appears only once a user is +// resolved — a user identity that is not signed in still has identity "user" +// but no onBehalfOf yet. Do not read "no onBehalfOf" as "self"; read `identity`. +type whoamiResult struct { + Profile string `json:"profile"` + AppID string `json:"appId"` + Brand core.LarkBrand `json:"brand"` + DefaultAs string `json:"defaultAs"` + Identity string `json:"identity"` + IdentitySource string `json:"identitySource"` + Available bool `json:"available"` + TokenStatus string `json:"tokenStatus"` + OnBehalfOf *delegatedUser `json:"onBehalfOf,omitempty"` + Hint string `json:"hint,omitempty"` +} + +// delegatedUser is the user a user-identity acts on behalf of. +type delegatedUser struct { + UserName string `json:"userName,omitempty"` + OpenID string `json:"openId,omitempty"` +} + +// Options holds inputs for the whoami command. +type Options struct { + Factory *cmdutil.Factory + As string +} + +// NewCmdWhoami creates the top-level whoami command. It reports the identity +// that the next API call would actually use (resolved via Factory.ResolveAs), +// together with the active profile, app, and token status. Output is always +// JSON — whoami is consumed by agents. With the built-in credential path it is +// local-only; when an external credential provider manages tokens, resolving +// the identity may contact that provider. +func NewCmdWhoami(f *cmdutil.Factory) *cobra.Command { + opts := &Options{Factory: f} + cmd := &cobra.Command{ + Use: "whoami", + Short: "Show the current effective identity, app, profile, and token status (JSON)", + RunE: func(cmd *cobra.Command, args []string) error { + return whoamiRun(cmd, opts) + }, + } + cmdutil.DisableAuthCheck(cmd) + cmdutil.AddAPIIdentityFlag(context.Background(), cmd, f, &opts.As) + // Output is always JSON. Accept (and ignore) --json so existing + // `whoami --json` callers don't break; hide it to avoid implying a non-JSON + // mode exists. + cmd.Flags().Bool("json", true, "deprecated: output is always JSON") + _ = cmd.Flags().MarkHidden("json") + cmdutil.SetRisk(cmd, "read") + return cmd +} + +func whoamiRun(cmd *cobra.Command, opts *Options) error { + f := opts.Factory + cfg, err := f.Config() + if err != nil { + return err + } + ctx := cmd.Context() + flagAs := core.Identity(opts.As) + as := f.ResolveAs(ctx, cmd, flagAs) + // Validate as a real API call does (strict mode, then identity) so whoami + // can't preview an identity the next call would refuse. + if err := f.CheckStrictMode(ctx, as); err != nil { + return err + } + if err := f.CheckIdentity(as, []string{"user", "bot"}); err != nil { + return err + } + source := resolveSource( + cmd.Flags().Changed("as"), + flagAs, + f.IdentityAutoDetected, + f.ResolveStrictMode(ctx).ForcedIdentity(), + ) + diag := identitydiag.Diagnose(ctx, f, cfg, false) + res := buildResult(cfg, as, source, diag) + output.PrintJson(f.IOStreams.Out, res) + return nil +} + +// resolveSource derives how the effective identity became effective. +// Mirrors Factory.ResolveAs precedence: explicit flag wins; otherwise an +// auto-detected result means auto-detect; otherwise a strict-mode forced +// identity means strict-mode; otherwise it came from configured default-as. +// Values are snake_case to match the other enum fields (e.g. tokenStatus). +func resolveSource(changedAs bool, flagAs core.Identity, autoDetected bool, strictForced core.Identity) string { + if changedAs && (flagAs == core.AsUser || flagAs == core.AsBot) { + return "flag" + } + if autoDetected { + return "auto_detect" + } + if strictForced != "" { + return "strict_mode" + } + return "default_as" +} + +// buildResult maps the resolved identity and local diagnostics into the output. +// ResolveAs only ever returns user or bot, so the default branch handles user. +func buildResult(cfg *core.CliConfig, as core.Identity, source string, diag identitydiag.Result) *whoamiResult { + defaultAs := cfg.DefaultAs + if defaultAs == "" { + defaultAs = core.AsAuto + } + res := &whoamiResult{ + Profile: cfg.ProfileName, + AppID: cfg.AppID, + Brand: cfg.Brand, + DefaultAs: string(defaultAs), + Identity: string(as), + IdentitySource: source, + } + // Use the diagnosed hint as-is: it is tailored to the credential source, so + // it never says "auth login" when that is blocked under an external provider. + switch as { + case core.AsBot: + res.Available = diag.Bot.Available + res.TokenStatus = diag.Bot.Status + if !diag.Bot.Available { + res.Hint = diag.Bot.Hint + } + default: // user + res.Available = diag.User.Available + // Use Status (not the raw TokenStatus) so the vocab matches the bot + // branch: "ready" means usable for both. available stays the canonical + // usable signal; tokenStatus is the readable state behind it. + res.TokenStatus = diag.User.Status + // Set onBehalfOf only when a user is actually resolved; an unresolved + // user identity (not signed in) has no one to act on behalf of yet. + if diag.User.UserName != "" || diag.User.OpenID != "" { + res.OnBehalfOf = &delegatedUser{UserName: diag.User.UserName, OpenID: diag.User.OpenID} + } + if !diag.User.Available { + res.Hint = diag.User.Hint + } + } + return res +} diff --git a/cmd/whoami/whoami_test.go b/cmd/whoami/whoami_test.go new file mode 100644 index 00000000..08886239 --- /dev/null +++ b/cmd/whoami/whoami_test.go @@ -0,0 +1,320 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package whoami + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + extcred "github.com/larksuite/cli/extension/credential" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" + "github.com/larksuite/cli/internal/identitydiag" +) + +func TestResolveSource(t *testing.T) { + tests := []struct { + name string + changedAs bool + flagAs core.Identity + autoDetected bool + strictForced core.Identity + want string + }{ + {"explicit flag user", true, core.AsUser, false, "", "flag"}, + {"explicit flag bot", true, core.AsBot, false, "", "flag"}, + {"flag auto falls through to auto-detect", true, core.AsAuto, true, "", "auto_detect"}, + {"auto detected", false, "", true, "", "auto_detect"}, + {"strict mode", false, "", false, core.AsBot, "strict_mode"}, + {"default_as", false, "", false, "", "default_as"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveSource(tt.changedAs, tt.flagAs, tt.autoDetected, tt.strictForced) + if got != tt.want { + t.Errorf("resolveSource() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestBuildResult_UserValid(t *testing.T) { + cfg := &core.CliConfig{ProfileName: "my-app", AppID: "cli_x", Brand: core.BrandLark, DefaultAs: core.AsAuto} + diag := identitydiag.Result{ + User: identitydiag.Identity{Available: true, Status: "ready", TokenStatus: "valid", OpenID: "ou_x", UserName: "Alice"}, + } + r := buildResult(cfg, core.AsUser, "auto_detect", diag) + + if r.Identity != "user" || r.IdentitySource != "auto_detect" { + t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource) + } + // tokenStatus mirrors the unified Status vocab ("ready"), not the raw "valid". + if !r.Available || r.TokenStatus != "ready" { + t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus) + } + if r.OnBehalfOf == nil || r.OnBehalfOf.OpenID != "ou_x" || r.OnBehalfOf.UserName != "Alice" { + t.Fatalf("onBehalfOf = %#v, want Alice/ou_x", r.OnBehalfOf) + } + if r.Hint != "" { + t.Fatalf("hint = %q, want empty", r.Hint) + } + if r.Profile != "my-app" || r.AppID != "cli_x" || r.Brand != core.BrandLark { + t.Fatalf("app context = %#v", r) + } +} + +func TestBuildResult_UserMissingToken(t *testing.T) { + cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandLark} + diag := identitydiag.Result{ + User: identitydiag.Identity{Available: false, Status: "missing", Hint: "run: lark-cli auth login --help"}, // never logged in + } + r := buildResult(cfg, core.AsUser, "auto_detect", diag) + + if r.Available { + t.Fatalf("available = true, want false") + } + if r.TokenStatus != "missing" { + t.Fatalf("tokenStatus = %q, want missing", r.TokenStatus) + } + // whoami renders the diagnosed hint verbatim (single source of truth) so it + // stays correct for the external-provider path without whoami knowing about it. + if r.Hint != diag.User.Hint { + t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.User.Hint) + } + if r.DefaultAs != "auto" { + t.Fatalf("defaultAs = %q, want auto (empty normalized)", r.DefaultAs) + } +} + +func TestBuildResult_BotReady(t *testing.T) { + cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, DefaultAs: core.AsBot} + diag := identitydiag.Result{ + Bot: identitydiag.Identity{Available: true, Status: "ready"}, + } + r := buildResult(cfg, core.AsBot, "default_as", diag) + + if r.Identity != "bot" || r.IdentitySource != "default_as" { + t.Fatalf("identity/source = %q/%q", r.Identity, r.IdentitySource) + } + if !r.Available || r.TokenStatus != "ready" { + t.Fatalf("available=%v status=%q", r.Available, r.TokenStatus) + } + if r.OnBehalfOf != nil { + t.Fatalf("bot must not carry onBehalfOf: %#v", r.OnBehalfOf) + } + if r.Hint != "" { + t.Fatalf("hint = %q, want empty", r.Hint) + } +} + +func TestBuildResult_BotNotConfigured(t *testing.T) { + cfg := &core.CliConfig{ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu} + diag := identitydiag.Result{ + Bot: identitydiag.Identity{Available: false, Status: "not_configured", Hint: "run: lark-cli config --help"}, + } + r := buildResult(cfg, core.AsBot, "auto_detect", diag) + + if r.Available { + t.Fatalf("available = true, want false") + } + if r.TokenStatus != "not_configured" { + t.Fatalf("tokenStatus = %q, want not_configured", r.TokenStatus) + } + if r.Hint != diag.Bot.Hint { + t.Fatalf("hint = %q, want propagated %q", r.Hint, diag.Bot.Hint) + } +} + +func TestWhoami_BotJSON(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "test-profile", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + + cmd := NewCmdWhoami(f) + cmd.SetArgs([]string{}) // bare whoami: output is always JSON, no flag needed + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + + var got whoamiResult + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("json.Unmarshal() error = %v\n%s", err, stdout.String()) + } + if got.Identity != "bot" { + t.Fatalf("identity = %q, want bot", got.Identity) + } + if !got.Available || got.TokenStatus != "ready" { + t.Fatalf("available=%v status=%q, want true/ready", got.Available, got.TokenStatus) + } + if got.Profile != "test-profile" { + t.Fatalf("profile = %q, want test-profile", got.Profile) + } + if got.IdentitySource == "" { + t.Fatalf("identitySource empty") + } + if got.OnBehalfOf != nil { + t.Fatalf("bot (self) must not carry onBehalfOf: %#v", got.OnBehalfOf) + } +} + +func TestWhoami_RejectsInvalidAs(t *testing.T) { + for _, bad := range []string{"admin", "USER", "bogus123", ""} { + t.Run("as="+bad, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + cmd := NewCmdWhoami(f) + cmd.SetArgs([]string{"--as", bad}) + err := cmd.Execute() + if err == nil { + t.Fatalf("Execute() with --as %q = nil, want validation error", bad) + } + // Lock in the typed validation contract: an unsupported identity must + // surface as a *errs.ValidationError on --as, not just any error. + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("Execute() with --as %q: error type = %T, want *errs.ValidationError: %v", bad, err, err) + } + if ve.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want %q", ve.Subtype, errs.SubtypeInvalidArgument) + } + if ve.Param != "--as" { + t.Errorf("Param = %q, want %q", ve.Param, "--as") + } + }) + } +} + +func TestWhoami_ConfigErrorPropagates(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + }) + wantErr := fmt.Errorf("boom") + f.Config = func() (*core.CliConfig, error) { return nil, wantErr } + + cmd := NewCmdWhoami(f) + cmd.SetArgs([]string{"--json"}) + err := cmd.Execute() + if err == nil { + t.Fatalf("Execute() error = nil, want propagated config error") + } + // The f.Config() failure must propagate unchanged, not be masked by a later + // command-execution error. + if !errors.Is(err, wantErr) { + t.Fatalf("Execute() error = %v, want it to wrap %v", err, wantErr) + } +} + +func TestWhoami_StrictModeRejectsCrossIdentity(t *testing.T) { + // Bot-only account → strict mode bot. A real `--as user` call would be + // rejected by CheckStrictMode; whoami must reject it identically rather than + // previewing a user identity the next call would refuse. + f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ + ProfileName: "p", AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + SupportedIdentities: 2, // bot only + }) + cmd := NewCmdWhoami(f) + cmd.SetArgs([]string{"--as", "user", "--json"}) + err := cmd.Execute() + if err == nil { + t.Fatalf("Execute() with --as user under strict bot = nil, want strict-mode rejection") + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("error type = %T, want *errs.ValidationError: %v", err, err) + } +} + +type fakeExtProvider struct { + name string + account *extcred.Account +} + +func (p *fakeExtProvider) Name() string { return p.name } +func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) { + return p.account, nil +} +func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) { + return nil, nil // no UAT served locally; whoami runs with verify=false +} + +func externalWhoamiFactory(cfg *core.CliConfig) (*cmdutil.Factory, *bytes.Buffer) { + cred := credential.NewCredentialProvider( + []extcred.Provider{&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: cfg.AppID}}}, + nil, nil, + func() (*http.Client, error) { return nil, nil }, + ) + out := &bytes.Buffer{} + f := &cmdutil.Factory{ + Config: func() (*core.CliConfig, error) { return cfg, nil }, + Credential: cred, + IOStreams: &cmdutil.IOStreams{Out: out, ErrOut: &bytes.Buffer{}}, + } + return f, out +} + +// Regression for the external-provider blind spot: with credentials managed by +// an extension provider, a signed-in user must read as available, and an +// unavailable identity must not be told to "auth login" (which is blocked). +func TestWhoami_ExternalProvider_UserReady(t *testing.T) { + cfg := &core.CliConfig{ + ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, + SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice", + } + f, out := externalWhoamiFactory(cfg) + + cmd := NewCmdWhoami(f) + cmd.SetArgs([]string{"--as", "user", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + var got whoamiResult + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("Unmarshal: %v\n%s", err, out.String()) + } + if got.Identity != "user" || !got.Available || got.TokenStatus != "ready" { + t.Fatalf("got %#v, want user/available/ready", got) + } + if got.OnBehalfOf == nil || got.OnBehalfOf.UserName != "Alice" || got.OnBehalfOf.OpenID != "ou_x" { + t.Fatalf("onBehalfOf = %#v, want Alice/ou_x (delegated)", got.OnBehalfOf) + } + if got.Hint != "" { + t.Fatalf("hint = %q, want empty when available", got.Hint) + } +} + +func TestWhoami_ExternalProvider_UserHintNotKeychain(t *testing.T) { + cfg := &core.CliConfig{ + ProfileName: "p", AppID: "cli_x", Brand: core.BrandFeishu, + SupportedIdentities: uint8(extcred.SupportsUser), // user supported but not signed in + } + f, out := externalWhoamiFactory(cfg) + + cmd := NewCmdWhoami(f) + cmd.SetArgs([]string{"--as", "user", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute() error = %v", err) + } + var got whoamiResult + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("Unmarshal: %v\n%s", err, out.String()) + } + if got.Identity != "user" || got.Available { + t.Fatalf("got identity=%q available=%v, want user/false", got.Identity, got.Available) + } + if strings.Contains(got.Hint, "auth login") { + t.Fatalf("hint must not point at auth login under external provider: %q", got.Hint) + } + if !strings.Contains(got.Hint, "external") { + t.Fatalf("hint should explain external management: %q", got.Hint) + } +} diff --git a/content_embed.go b/content_embed.go new file mode 100644 index 00000000..e4a9a48e --- /dev/null +++ b/content_embed.go @@ -0,0 +1,41 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package main + +import ( + "embed" + "fmt" + "io/fs" + "os" + + "github.com/larksuite/cli/cmd" + "github.com/larksuite/cli/internal/affordance" +) + +// embeddedContentFS bundles the agent-readable content that must ship in lockstep +// with the binary: each skill's docs (SKILL.md + references/, plus whiteboard's +// routes/ and scenes/) and the per-domain affordance guidance (affordance/*.md). +// Machine-resource skill dirs (assets/, scripts/) are excluded. It's a whitelist — +// a new content type is omitted until added to the embed list. The embed must live +// in this root package because go:embed cannot reach up out of a package's dir. +// +//go:embed skills/*/SKILL.md skills/*/references skills/*/routes skills/*/scenes affordance/*.md +var embeddedContentFS embed.FS + +// init wires the embedded content into the CLI. It compiles into `go build .` but +// not the single-file preview build (`go build ./main.go`), so that build stays +// self-contained (shipping no embedded content). Assembly failures warn on stderr +// rather than panicking — embedded content is nice-to-have, not load-bearing. +func init() { + if sub, err := fs.Sub(embeddedContentFS, "skills"); err != nil { + fmt.Fprintln(os.Stderr, "warning: skills embed assembly failed, skills commands disabled:", err) + } else { + cmd.SetEmbeddedSkillContent(sub) + } + if sub, err := fs.Sub(embeddedContentFS, "affordance"); err != nil { + fmt.Fprintln(os.Stderr, "warning: affordance embed assembly failed, command guidance disabled:", err) + } else { + affordance.SetSource(sub) + } +} diff --git a/errs/types_test.go b/errs/types_test.go index 52e0f32e..8279c2e4 100644 --- a/errs/types_test.go +++ b/errs/types_test.go @@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) { WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send"). WithMissingScopes("mail:user_mailbox.message:send"). WithIdentity("user"). - WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth") + WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send") if got.Category != errs.CategoryAuthorization { t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization) @@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) { WithHint("run lark-cli auth login --scope calendar:event:create"). WithMissingScopes("calendar:event:create"). WithIdentity("user"). - WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth") + WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create") buf, err := json.Marshal(e) if err != nil { @@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) { "hint": "run lark-cli auth login --scope calendar:event:create", "log_id": "20260520-0a1b2c3d", "identity": "user", - "console_url": "https://open.feishu.cn/app/cli_xxx/auth", + "console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create", "missing_scopes": []any{"calendar:event:create"}, } for k, want := range wantFields { diff --git a/events/vc/participant_meeting_joined.go b/events/vc/participant_meeting_joined.go new file mode 100644 index 00000000..99ac9e76 --- /dev/null +++ b/events/vc/participant_meeting_joined.go @@ -0,0 +1,62 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + + "github.com/larksuite/cli/internal/event" +) + +// VCParticipantMeetingJoinedOutput is the flattened shape for vc.meeting.participant_meeting_joined_v1. +type VCParticipantMeetingJoinedOutput struct { + Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_joined_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"` + MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"` + Topic string `json:"topic,omitempty" desc:"Meeting topic"` + MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"` + StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"` + CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"` +} + +func processVCParticipantMeetingJoined(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event struct { + Meeting struct { + ID string `json:"id"` + Topic string `json:"topic"` + MeetingNo string `json:"meeting_no"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + CalendarEventID string `json:"calendar_event_id"` + } `json:"meeting"` + } `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + } + + meeting := envelope.Event.Meeting + out := &VCParticipantMeetingJoinedOutput{ + Type: envelope.Header.EventType, + EventID: envelope.Header.EventID, + Timestamp: envelope.Header.CreateTime, + MeetingID: meeting.ID, + Topic: meeting.Topic, + MeetingNo: meeting.MeetingNo, + StartTime: unixSecondsToLocalRFC3339(meeting.StartTime), + CalendarEventID: meeting.CalendarEventID, + } + if out.Type == "" { + out.Type = raw.EventType + } + return json.Marshal(out) +} diff --git a/events/vc/participant_meeting_lifecycle_test.go b/events/vc/participant_meeting_lifecycle_test.go new file mode 100644 index 00000000..c67b9654 --- /dev/null +++ b/events/vc/participant_meeting_lifecycle_test.go @@ -0,0 +1,281 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/larksuite/cli/internal/event" +) + +func TestVCKeys_ProcessedMeetingLifecycleRegistered(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + eventType string + schemaType reflect.Type + }{ + {eventTypeMeetingStarted, reflect.TypeOf(VCParticipantMeetingStartedOutput{})}, + {eventTypeMeetingJoined, reflect.TypeOf(VCParticipantMeetingJoinedOutput{})}, + } { + 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:meeting.meetingevent: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 def.Schema.Custom.Type != tc.schemaType { + t.Errorf("Custom schema Type = %v, want %v", def.Schema.Custom.Type, tc.schemaType) + } + }) + } +} + +func TestProcessVCParticipantMeetingLifecycle(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: eventTypeMeetingStarted, + process: processVCParticipantMeetingStarted, + }, + { + name: "joined", + eventType: eventTypeMeetingJoined, + process: processVCParticipantMeetingJoined, + }, + } { + t.Run(tc.name, func(t *testing.T) { + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_vc_lifecycle_001", + "event_type": "` + tc.eventType + `", + "create_time": "1608725989000", + "app_id": "cli_test" + }, + "event": { + "meeting": { + "id": "6911188411934433028", + "topic": "my meeting", + "meeting_no": "235812466", + "start_time": "1608883322", + "end_time": "1608883899", + "calendar_event_id": "efa67a98-06a8-4df5-8559-746c8f4477ef_0" + } + } + }` + out := runMeetingLifecycleMap(t, tc.eventType, tc.process, payload) + + if out["type"] != tc.eventType { + t.Errorf("type = %q", out["type"]) + } + if out["event_id"] != "ev_vc_lifecycle_001" { + t.Errorf("event_id = %q", out["event_id"]) + } + if out["timestamp"] != "1608725989000" { + t.Errorf("timestamp = %q", out["timestamp"]) + } + if out["meeting_id"] != "6911188411934433028" { + t.Errorf("meeting_id = %q", out["meeting_id"]) + } + if out["topic"] != "my meeting" || out["meeting_no"] != "235812466" { + t.Errorf("topic/meeting_no = %q/%q", out["topic"], out["meeting_no"]) + } + if out["calendar_event_id"] != "efa67a98-06a8-4df5-8559-746c8f4477ef_0" { + t.Errorf("calendar_event_id = %q", out["calendar_event_id"]) + } + if want := time.Unix(1608883322, 0).Local().Format(time.RFC3339); out["start_time"] != want { + t.Errorf("start_time = %q, want %q", out["start_time"], want) + } + if _, hasEndTime := out["end_time"]; hasEndTime { + t.Error("end_time should not be present in started/joined output") + } + }) + } +} + +func TestProcessVCParticipantMeetingLifecycle_InvalidMeetingTimes(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + name string + eventType string + process event.ProcessFunc + }{ + {"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted}, + {"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined}, + } { + t.Run(tc.name, func(t *testing.T) { + payload := `{ + "schema": "2.0", + "header": { + "event_id": "ev_vc_lifecycle_002", + "event_type": "` + tc.eventType + `", + "create_time": "1608725989001" + }, + "event": { + "meeting": { + "id": "meeting_invalid_time", + "start_time": "bad", + "end_time": "" + } + } + }` + out := runMeetingLifecycleRaw(t, tc.eventType, tc.process, payload) + switch tc.eventType { + case eventTypeMeetingStarted: + var started VCParticipantMeetingStartedOutput + if err := json.Unmarshal(out, &started); err != nil { + t.Fatalf("Process output is not valid started JSON: %v\nraw=%s", err, string(out)) + } + if started.StartTime != "" { + t.Errorf("StartTime = %q, want empty string", started.StartTime) + } + case eventTypeMeetingJoined: + var joined VCParticipantMeetingJoinedOutput + if err := json.Unmarshal(out, &joined); err != nil { + t.Fatalf("Process output is not valid joined JSON: %v\nraw=%s", err, string(out)) + } + if joined.StartTime != "" { + t.Errorf("StartTime = %q, want empty string", joined.StartTime) + } + } + }) + } +} + +func TestProcessVCParticipantMeetingLifecycle_MalformedPayload(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, tc := range []struct { + name string + eventType string + process event.ProcessFunc + }{ + {"started", eventTypeMeetingStarted, processVCParticipantMeetingStarted}, + {"joined", eventTypeMeetingJoined, processVCParticipantMeetingJoined}, + } { + 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 TestVCParticipantMeetingLifecycle_PreConsumeSubscriptionLifecycle(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + for _, eventType := range []string{eventTypeMeetingStarted, eventTypeMeetingJoined} { + t.Run(eventType, func(t *testing.T) { + def, ok := event.Lookup(eventType) + if !ok { + t.Fatalf("%s should be registered via Keys()", 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 != pathMeetingSubscribe { + t.Fatalf("subscribe call = %+v", calls[0]) + } + assertSubscriptionRequest(t, calls[0].body, eventType) + + cleanup() + if len(calls) != 2 { + t.Fatalf("calls after cleanup = %d, want 2", len(calls)) + } + if calls[1].method != "POST" || calls[1].path != pathMeetingUnsubscribe { + t.Fatalf("unsubscribe call = %+v", calls[1]) + } + assertSubscriptionRequest(t, calls[1].body, eventType) + }) + } +} + +func runMeetingLifecycleMap(t *testing.T, eventType string, process event.ProcessFunc, payload string) map[string]string { + t.Helper() + got := runMeetingLifecycleRaw(t, eventType, process, payload) + if got == nil { + t.Fatal("Process output is nil") + } + var out map[string]string + if err := json.Unmarshal(got, &out); err != nil { + t.Fatalf("Process output is not valid flat JSON object: %v\nraw=%s", err, string(got)) + } + return out +} + +func runMeetingLifecycleRaw(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 +} diff --git a/events/vc/participant_meeting_started.go b/events/vc/participant_meeting_started.go new file mode 100644 index 00000000..d91aa3eb --- /dev/null +++ b/events/vc/participant_meeting_started.go @@ -0,0 +1,61 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + + "github.com/larksuite/cli/internal/event" +) + +// VCParticipantMeetingStartedOutput is the flattened shape for vc.meeting.participant_meeting_started_v1. +type VCParticipantMeetingStartedOutput struct { + Type string `json:"type" desc:"Event type; always vc.meeting.participant_meeting_started_v1"` + EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"` + Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"` + MeetingID string `json:"meeting_id,omitempty" desc:"Meeting ID" kind:"meeting_id"` + Topic string `json:"topic,omitempty" desc:"Meeting topic"` + MeetingNo string `json:"meeting_no,omitempty" desc:"Meeting number"` + StartTime string `json:"start_time,omitempty" desc:"Meeting start time in RFC3339, converted to the local timezone"` + CalendarEventID string `json:"calendar_event_id,omitempty" desc:"Calendar event ID associated with the meeting"` +} + +func processVCParticipantMeetingStarted(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) { + var envelope struct { + Header struct { + EventID string `json:"event_id"` + EventType string `json:"event_type"` + CreateTime string `json:"create_time"` + } `json:"header"` + Event struct { + Meeting struct { + ID string `json:"id"` + Topic string `json:"topic"` + MeetingNo string `json:"meeting_no"` + StartTime string `json:"start_time"` + CalendarEventID string `json:"calendar_event_id"` + } `json:"meeting"` + } `json:"event"` + } + if err := json.Unmarshal(raw.Payload, &envelope); err != nil { + return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event + } + + meeting := envelope.Event.Meeting + out := &VCParticipantMeetingStartedOutput{ + Type: envelope.Header.EventType, + EventID: envelope.Header.EventID, + Timestamp: envelope.Header.CreateTime, + MeetingID: meeting.ID, + Topic: meeting.Topic, + MeetingNo: meeting.MeetingNo, + StartTime: unixSecondsToLocalRFC3339(meeting.StartTime), + CalendarEventID: meeting.CalendarEventID, + } + if out.Type == "" { + out.Type = raw.EventType + } + return json.Marshal(out) +} diff --git a/events/vc/register.go b/events/vc/register.go index bfee1241..5b4441ae 100644 --- a/events/vc/register.go +++ b/events/vc/register.go @@ -11,6 +11,8 @@ import ( ) const ( + eventTypeMeetingStarted = "vc.meeting.participant_meeting_started_v1" + eventTypeMeetingJoined = "vc.meeting.participant_meeting_joined_v1" eventTypeMeetingEnded = "vc.meeting.participant_meeting_ended_v1" eventTypeNoteGenerated = "vc.note.generated_v1" eventTypeRecordingStarted = "vc.recording.recording_started_v1" @@ -30,6 +32,38 @@ const ( // Keys returns all VC-domain EventKey definitions. func Keys() []event.KeyDefinition { return []event.KeyDefinition{ + { + Key: eventTypeMeetingStarted, + DisplayName: "Participant meeting started", + Description: "Triggered when a meeting the current user participates in has started", + EventType: eventTypeMeetingStarted, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingStartedOutput{})}, + }, + Process: processVCParticipantMeetingStarted, + PreConsume: subscriptionPreConsume(eventTypeMeetingStarted, pathMeetingSubscribe, pathMeetingUnsubscribe), + Scopes: []string{"vc:meeting.meetingevent:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeMeetingStarted}, + }, + { + Key: eventTypeMeetingJoined, + DisplayName: "Participant meeting joined", + Description: "Triggered when the current user joins a meeting", + EventType: eventTypeMeetingJoined, + Schema: event.SchemaDef{ + Custom: &event.SchemaSpec{Type: reflect.TypeOf(VCParticipantMeetingJoinedOutput{})}, + }, + Process: processVCParticipantMeetingJoined, + PreConsume: subscriptionPreConsume(eventTypeMeetingJoined, pathMeetingSubscribe, pathMeetingUnsubscribe), + Scopes: []string{"vc:meeting.meetingevent:read"}, + AuthTypes: []string{ + "user", + }, + RequiredConsoleEvents: []string{eventTypeMeetingJoined}, + }, { Key: eventTypeMeetingEnded, DisplayName: "Participant meeting ended", diff --git a/internal/affordance/affordance.go b/internal/affordance/affordance.go new file mode 100644 index 00000000..b0e49836 --- /dev/null +++ b/internal/affordance/affordance.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +// Package affordance is the lazily-loaded store of usage guidance for +// service-API methods. The source of truth is one markdown file per service in +// the top-level affordance/ tree (see mdparse.go), injected via SetSource so +// domain owners maintain it next to skills/ and shortcuts/. A service is read +// and parsed at most once, on first access, so normal command execution never +// touches it. +package affordance + +import ( + "encoding/json" + "io/fs" + "strings" + "sync" + + "github.com/larksuite/cli/internal/apicatalog" + "github.com/larksuite/cli/internal/registry" +) + +var ( + mu sync.Mutex + byService = map[string]map[string]json.RawMessage{} + tried = map[string]bool{} + mdSource fs.FS // top-level affordance/*.md tree; nil in the minimal preview build +) + +// SetSource installs the markdown guidance tree (the top-level affordance/ +// directory) as the source. Called once at startup before any lookup; clears +// the parse cache so re-sourcing (e.g. in tests) takes effect. +func SetSource(fsys fs.FS) { + mu.Lock() + defer mu.Unlock() + mdSource = fsys + byService = map[string]map[string]json.RawMessage{} + tried = map[string]bool{} +} + +// For returns the raw affordance overlay for one method, loading the owning +// service on first access. ok is false when there is no entry (absent source, +// parse failure, or unknown method all collapse to "no guidance"). +func For(service, methodID string) (json.RawMessage, bool) { + mu.Lock() + defer mu.Unlock() + if !tried[service] { + tried[service] = true + byService[service] = loadService(service) + } + raw, ok := byService[service][methodID] + return raw, ok && len(raw) > 0 +} + +// loadService parses a service's markdown guidance into per-method overlays, +// marshalling each to JSON so downstream callers keep the same wire shape. +func loadService(service string) map[string]json.RawMessage { + if mdSource == nil { + return nil + } + src, err := fs.ReadFile(mdSource, service+".md") + if err != nil { + return nil + } + m := map[string]json.RawMessage{} + for id, a := range parseDomainMD(src, commandFormResolver(service)) { + if b, err := json.Marshal(a); err == nil { + m[id] = b + } + } + return m +} + +// commandFormResolver maps a method's command-form heading ("user_mailbox.messages +// list") to its method id ("user_mailbox.message.list") via the registry's +// authoritative resource↔id table. Resource names are irregularly pluralised +// (message/messages, user_mailbox/user_mailboxes), so this cannot be guessed; the +// space→dot fallback covers domains where the two already coincide. +func commandFormResolver(service string) func(string) string { + byForm := map[string]string{} + for _, svc := range registry.EmbeddedServicesTyped() { + if svc.Name != service { + continue + } + for _, ref := range apicatalog.ServiceMethods(svc, nil) { + byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID + } + break + } + return func(h string) string { + h = strings.TrimSpace(h) + if id, ok := byForm[h]; ok { + return id + } + return strings.ReplaceAll(h, " ", ".") + } +} diff --git a/internal/affordance/affordance_test.go b/internal/affordance/affordance_test.go new file mode 100644 index 00000000..a72899a8 --- /dev/null +++ b/internal/affordance/affordance_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package affordance + +import ( + "encoding/json" + "testing" + "testing/fstest" +) + +// fixtureMD is a minimal affordance source: two methods, each with a lead +// paragraph (use_when) and a fenced example. +const fixtureMD = "# approval\n" + + "> skill: lark-approval\n\n" + + "## instances cc\n" + + "把一个审批实例抄送给指定用户。\n\n" + + "### Examples\n\n" + + "**抄送给用户**\n" + + "```bash\n" + + "lark-cli approval instances cc --data '{\"instance_code\":\"x\"}'\n" + + "```\n\n" + + "## instances get\n" + + "查询某审批实例详情。\n\n" + + "### Examples\n\n" + + "**按 code 查询**\n" + + "```bash\n" + + "lark-cli approval instances get --instance-code \"x\"\n" + + "```\n" + +func TestFor(t *testing.T) { + prev := mdSource + t.Cleanup(func() { SetSource(prev) }) // SetSource mutates package state; restore for test isolation + SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte(fixtureMD)}}) + + // A seeded method in a seeded service resolves to its overlay. + raw, ok := For("approval", "instances.cc") + if !ok { + t.Fatal(`For("approval","instances.cc") ok=false, want an overlay`) + } + var a struct { + UseWhen []string `json:"use_when"` + Examples []struct { + Command string `json:"command"` + } `json:"examples"` + } + if err := json.Unmarshal(raw, &a); err != nil { + t.Fatalf("overlay is not valid affordance JSON: %v", err) + } + if len(a.UseWhen) == 0 || len(a.Examples) == 0 || a.Examples[0].Command == "" { + t.Errorf("overlay missing use_when/examples: %s", raw) + } + + // Misses: unknown method in a known service, and an unknown service, both + // resolve to ok=false (no panic, no error) so callers treat them as "no + // guidance". + if _, ok := For("approval", "instances.no_such_method"); ok { + t.Error("unknown method should be ok=false") + } + if _, ok := For("no_such_service", "x.y"); ok { + t.Error("unknown service should be ok=false") + } + + // A second lookup of the same service is served from cache (parsed at most + // once) and stays consistent. + if _, ok := For("approval", "instances.get"); !ok { + t.Error("second lookup in a cached service should still resolve") + } +} + +// Non-bullet paragraph lines under any section are preserved as items, not +// dropped (regression: they previously only updated pending, lost without a fence). +func TestParseDomainMD_ParagraphNotDropped(t *testing.T) { + md := "# d\n\n## foo bar\nwhat it does.\n\n### Tips\n- a bullet\nplain paragraph note.\n\n### See also\nrun [[other cmd]] first.\n" + got := parseDomainMD([]byte(md), nil) // nil resolver -> space->dot, "foo bar" -> "foo.bar" + a, ok := got["foo.bar"] + if !ok { + t.Fatal("method not parsed") + } + if len(a.Tips) != 2 || a.Tips[1] != "plain paragraph note." { + t.Errorf("Tips paragraph dropped: %v", a.Tips) + } + if len(a.Extensions) != 1 || len(a.Extensions[0].Items) != 1 || a.Extensions[0].Items[0] != "run `other cmd` first." { + t.Errorf("custom-section paragraph not flowed through: %+v", a.Extensions) + } +} diff --git a/internal/affordance/mdparse.go b/internal/affordance/mdparse.go new file mode 100644 index 00000000..8ef7d251 --- /dev/null +++ b/internal/affordance/mdparse.go @@ -0,0 +1,180 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package affordance + +import ( + "regexp" + "strings" + + "github.com/larksuite/cli/internal/meta" +) + +// The affordance source is a narrow, fixed markdown subset (see src/*.md): +// +// # domain optional `> skill: ` applied to every method +// ## command e.g. `instances get` +// -> use_when (when this command is right) +// ### Avoid when -> avoid_when (links become prefer/alternative edges) +// ### Prerequisites -> prerequisites (a "…来自 [[x]]" link is a sequence edge) +// ### Tips -> tips +// ### Examples -> examples: **description** + a ```fenced``` command +// ### -> extensions[] (custom section, flows through verbatim) +// [[cmd]] -> a command reference, rendered as `cmd` +// +// Parsing is lazy and cached (see For), so the constrained grammar is read at +// most once per domain. + +var mdLink = regexp.MustCompile(`\[\[(.+?)\]\]`) + +// standardSection maps a section heading to its typed Affordance field; any +// other heading becomes an extension. +var standardSection = map[string]string{ + "Avoid when": "avoid_when", + "Prerequisites": "prerequisites", + "Tips": "tips", + "Examples": "examples", +} + +func linkToBacktick(s string) string { return mdLink.ReplaceAllString(s, "`$1`") } + +// headingToKey maps a command heading ("instances get") to its affordance key +// ("instances.get"). The space→dot rule holds where the command form matches +// the method id; domains whose resource names differ (e.g. plural "messages" +// vs id segment "message") need the registry's authoritative resource↔id table. +func headingToKey(h string) string { + return strings.ReplaceAll(strings.TrimSpace(h), " ", ".") +} + +type mdSection struct { + label string + items []string + cases []meta.AffordanceCase +} + +// parseDomainMD parses one domain's markdown into per-method Affordance values, +// keyed by method id. resolve maps a command-form heading ("user_mailbox.messages +// list") to its method id ("user_mailbox.message.list"); nil falls back to the +// space→dot rule (valid only where the command form already equals the id). +func parseDomainMD(src []byte, resolve func(string) string) map[string]meta.Affordance { + if resolve == nil { + resolve = headingToKey + } + out := map[string]meta.Affordance{} + + var skill, curKey string + var useWhen, para []string // lead paragraphs -> use_when entries (blank line separates) + var secs []*mdSection + var sec *mdSection + var pending string + var fence []string + inFence := false + + assemble := func() { + if curKey == "" { + return + } + if len(para) > 0 { + useWhen = append(useWhen, strings.TrimSpace(strings.Join(para, " "))) + para = nil + } + var a meta.Affordance + if len(useWhen) > 0 { + a.UseWhen = useWhen + } + for _, s := range secs { + switch standardSection[s.label] { + case "avoid_when": + a.AvoidWhen = s.items + case "prerequisites": + a.Prerequisites = s.items + case "tips": + a.Tips = s.items + case "examples": + a.Examples = s.cases + default: + a.Extensions = append(a.Extensions, meta.AffordanceSection{Label: s.label, Items: s.items}) + } + } + if skill != "" { + a.Skills = []string{skill} + } + out[curKey] = a + } + + reset := func() { useWhen, para, secs, sec, pending, fence, inFence = nil, nil, nil, nil, "", nil, false } + + // flushPending appends a non-bullet paragraph line that was not consumed as + // an example description (i.e. no fence followed) to the current section's + // items, so prose under any section is preserved rather than dropped. + flushPending := func() { + if sec != nil && pending != "" { + sec.items = append(sec.items, linkToBacktick(pending)) + pending = "" + } + } + + for _, raw := range strings.Split(string(src), "\n") { + line := strings.TrimRight(raw, "\r") + t := strings.TrimSpace(line) + switch { + case strings.HasPrefix(line, "## "): + flushPending() + assemble() + curKey = resolve(line[3:]) + reset() + continue + case strings.HasPrefix(line, "# "): + continue + case strings.HasPrefix(t, "> skill:"): + skill = strings.TrimSpace(t[len("> skill:"):]) + continue + case strings.HasPrefix(line, "### "): + flushPending() + sec = &mdSection{label: strings.TrimSpace(line[4:])} + secs = append(secs, sec) + pending, fence, inFence = "", nil, false + continue + } + if curKey == "" { + continue + } + if sec == nil { // lead paragraphs before any section -> use_when (blank line separates entries) + if t == "" { + if len(para) > 0 { + useWhen = append(useWhen, strings.Join(para, " ")) + para = nil + } + } else { + para = append(para, t) + } + continue + } + // inside a section: a fenced block is an example command; otherwise the + // shape follows the writing (bullet item vs **description** before a fence). + if strings.HasPrefix(t, "```") { + if !inFence { + inFence, fence = true, nil + } else { + inFence = false + sec.cases = append(sec.cases, meta.AffordanceCase{Description: pending, Command: strings.Join(fence, "\n")}) + pending = "" + } + continue + } + if inFence { + fence = append(fence, line) + continue + } + if strings.HasPrefix(t, "-") { + flushPending() + sec.items = append(sec.items, linkToBacktick(strings.TrimSpace(t[1:]))) + } else if t != "" { + flushPending() + pending = strings.Trim(t, "* ") + } + } + flushPending() + assemble() + return out +} diff --git a/internal/cmdutil/iostreams.go b/internal/cmdutil/iostreams.go index a067b67d..b5f7c65a 100644 --- a/internal/cmdutil/iostreams.go +++ b/internal/cmdutil/iostreams.go @@ -18,6 +18,9 @@ type IOStreams struct { Out io.Writer ErrOut io.Writer IsTerminal bool + // OutIsTerminal reports whether Out is an interactive terminal. Mirrors + // IsTerminal; computed once in NewIOStreams and assignable directly in tests. + OutIsTerminal 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). @@ -27,19 +30,24 @@ type IOStreams struct { } // NewIOStreams builds an IOStreams from arbitrary readers/writers. -// IsTerminal / StderrIsTerminal are derived from in's / errOut's underlying -// *os.File, if any; non-file streams (bytes.Buffer, strings.Reader, …) yield -// false. +// IsTerminal / OutIsTerminal / StderrIsTerminal are each derived from the +// underlying *os.File of in / out / errOut respectively; non-file +// readers/writers (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())) + fileIsTerminal := func(v any) bool { + if f, ok := v.(*os.File); ok { + return term.IsTerminal(int(f.Fd())) + } + return false } - stderrIsTerminal := false - if f, ok := errOut.(*os.File); ok { - stderrIsTerminal = term.IsTerminal(int(f.Fd())) + return &IOStreams{ + In: in, + Out: out, + ErrOut: errOut, + IsTerminal: fileIsTerminal(in), + OutIsTerminal: fileIsTerminal(out), + StderrIsTerminal: fileIsTerminal(errOut), } - 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/iostreams_test.go b/internal/cmdutil/iostreams_test.go new file mode 100644 index 00000000..bf6910d9 --- /dev/null +++ b/internal/cmdutil/iostreams_test.go @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package cmdutil + +import ( + "bytes" + "os" + "testing" +) + +func TestNewIOStreamsTerminalFlagsNonFile(t *testing.T) { + s := NewIOStreams(&bytes.Buffer{}, &bytes.Buffer{}, &bytes.Buffer{}) + if s.IsTerminal || s.OutIsTerminal || s.StderrIsTerminal { + t.Errorf("non-file streams must not be terminals: in=%v out=%v err=%v", + s.IsTerminal, s.OutIsTerminal, s.StderrIsTerminal) + } +} + +func TestNewIOStreamsTerminalFlagsPipe(t *testing.T) { + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer r.Close() + defer w.Close() + s := NewIOStreams(r, w, w) + if s.OutIsTerminal || s.StderrIsTerminal { + t.Errorf("os.Pipe must not be a terminal: out=%v err=%v", s.OutIsTerminal, s.StderrIsTerminal) + } +} diff --git a/internal/errclass/classify.go b/internal/errclass/classify.go index a2d1a740..bc200b4d 100644 --- a/internal/errclass/classify.go +++ b/internal/errclass/classify.go @@ -10,12 +10,14 @@ import ( "strings" "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/core" ) // ClassifyContext is the contextual data BuildAPIError uses to populate // identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL). -// Identity is a plain string ("user" / "bot" / "") so this package does not -// depend on internal/core (which would create an import cycle). +// Brand and Identity are plain strings at this boundary; ConsoleURL normalizes +// Brand through core.ParseBrand, so callers can pass a raw brand string without +// coupling this contract to core's brand enum. type ClassifyContext struct { Brand string // "feishu" | "lark" — drives console_url host AppID string // placed in console_url @@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string { return out } -// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL, -// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty -// scopes list returns the bare /auth landing page; scopes are joined with -// commas in the `q` query parameter so the console can pre-select them. +// ConsoleURL composes the Feishu/Lark open-platform application-scope apply +// page URL (the official open-pages `/page/scope-apply` entry), suitable for +// PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list +// returns the page carrying only clientID; otherwise scopes are joined with +// commas in the `scopes` query parameter so the console can pre-select them. // // brand is "feishu" or "lark"; unknown values default to feishu. func ConsoleURL(brand, appID string, scopes []string) string { if appID == "" { return "" } - host := "open.feishu.cn" - if brand == "lark" { - host = "open.larksuite.com" - } - // PathEscape on appID — it sits in the URL path. QueryEscape on the - // comma-joined scopes — they sit in the `?q=` value, and untrusted scope - // content must not be able to inject extra query parameters via `&`/`#`. - pathID := url.PathEscape(appID) + // QueryEscape both values — clientID and scopes both sit in the query + // string, and untrusted content must not be able to inject extra query + // parameters via `&`/`#`. The brand→host mapping is owned by core so the + // open-platform base URL stays a single source of truth. + base := fmt.Sprintf("%s/page/scope-apply?clientID=%s", + core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID)) if len(scopes) == 0 { - return fmt.Sprintf("https://%s/app/%s/auth", host, pathID) + return base } - return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ","))) + return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ",")) } func intFromAny(v any) int { diff --git a/internal/errclass/classify_test.go b/internal/errclass/classify_test.go index 9ba38ca1..d4dff580 100644 --- a/internal/errclass/classify_test.go +++ b/internal/errclass/classify_test.go @@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) { if !ok { t.Fatalf("expected *errs.PermissionError, got %T", err) } - if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") { - t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL) + if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") { + t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL) } } @@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) { if !ok { t.Fatalf("expected *errs.PermissionError, got %T", err) } - if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") { - t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL) + if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") { + t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL) } } @@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) { name: "ampersand in scope smuggles extra param", appID: "cli_good", scopes: []string{"scope&evil=injected"}, - wantInURL: []string{"q=scope%26evil%3Dinjected"}, - denyInURL: []string{"q=scope&evil=injected"}, + wantInURL: []string{"scopes=scope%26evil%3Dinjected"}, + denyInURL: []string{"scopes=scope&evil=injected"}, }, { name: "hash in scope splits fragment", appID: "cli_good", scopes: []string{"scope#fragment"}, - wantInURL: []string{"q=scope%23fragment"}, - denyInURL: []string{"q=scope#fragment"}, + wantInURL: []string{"scopes=scope%23fragment"}, + denyInURL: []string{"scopes=scope#fragment"}, }, { name: "question mark in appID prematurely opens query", appID: "good?q=injected", scopes: []string{"docx:document"}, - wantInURL: []string{"/app/good%3Fq=injected/auth"}, - denyInURL: []string{"/app/good?q=injected/auth"}, + wantInURL: []string{"clientID=good%3Fq%3Dinjected"}, + denyInURL: []string{"clientID=good?q=injected"}, }, { name: "hash in appID truncates URL", appID: "good#fragment", scopes: []string{"docx:document"}, - wantInURL: []string{"/app/good%23fragment/auth"}, - denyInURL: []string{"/app/good#fragment/auth"}, + wantInURL: []string{"clientID=good%23fragment"}, + denyInURL: []string{"clientID=good#fragment"}, }, { - name: "slash in appID escapes path segment", + name: "slash in appID does not open a new path segment", appID: "good/extra/segment", scopes: []string{"docx:document"}, - wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"}, + wantInURL: []string{"clientID=good%2Fextra%2Fsegment"}, }, } @@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) { if pe.MissingScopes != nil { t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes) } - if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") { - t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL) + if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") { + t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL) } } @@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) { // at the app level — re-authenticating cannot fix it. The hint must // point to the developer console regardless of caller identity, or // agents will loop on `auth login` forever. - consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact" + consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact" for _, identity := range []string{"user", "bot", ""} { got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL) if !strings.Contains(got, "developer console") { diff --git a/internal/errclass/codemeta_drive.go b/internal/errclass/codemeta_drive.go index fe231633..83ac13ec 100644 --- a/internal/errclass/codemeta_drive.go +++ b/internal/errclass/codemeta_drive.go @@ -10,8 +10,20 @@ import "github.com/larksuite/cli/errs" // ambiguous codes fall back to CategoryAPI via BuildAPIError. // BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta. var driveCodeMeta = map[int]CodeMeta{ - 1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload) - 1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters" + 1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error" + 1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error + 1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden + 1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted + 1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit + 1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload) + 1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size + 1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter + 1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied + 1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval + 1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters" + 99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed + 9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field + 2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors } func init() { mergeCodeMeta(driveCodeMeta, "drive") } diff --git a/internal/errclass/codemeta_drive_test.go b/internal/errclass/codemeta_drive_test.go index a65a94f2..39e8b4b8 100644 --- a/internal/errclass/codemeta_drive_test.go +++ b/internal/errclass/codemeta_drive_test.go @@ -27,6 +27,13 @@ func TestLookupCodeMeta_DriveCodes(t *testing.T) { // 1069302: comment endpoint's opaque "Invalid or missing parameters" // (shortcuts/drive/drive_add_comment.go) → API-side parameter rejection. {1069302, errs.CategoryAPI, errs.SubtypeInvalidParameters, false}, + // Secure label endpoint codes observed from drive +secure-label-update + // failure telemetry. + {1063001, errs.CategoryAPI, errs.SubtypeInvalidParameters, false}, + {1063002, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false}, + {1063013, errs.CategoryValidation, errs.SubtypeFailedPrecondition, false}, + {99992402, errs.CategoryAPI, errs.SubtypeInvalidParameters, false}, + {9499, errs.CategoryAPI, errs.SubtypeInvalidParameters, false}, } for _, tc := range cases { t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) { diff --git a/internal/errclass/codemeta_test.go b/internal/errclass/codemeta_test.go index bde3d088..521850e6 100644 --- a/internal/errclass/codemeta_test.go +++ b/internal/errclass/codemeta_test.go @@ -102,6 +102,35 @@ func TestLookupCodeMeta_RetryableRateLimit(t *testing.T) { } } +func TestLookupCodeMeta_DrivePushCodes(t *testing.T) { + cases := []struct { + code int + wantCat errs.Category + wantSubtype errs.Subtype + wantRetry bool + }{ + {1061001, errs.CategoryAPI, errs.SubtypeServerError, true}, + {1061002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false}, + {1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false}, + {1061007, errs.CategoryAPI, errs.SubtypeNotFound, false}, + {1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false}, + {1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false}, + {2200, errs.CategoryAPI, errs.SubtypeServerError, true}, + } + for _, tc := range cases { + t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) { + got, ok := LookupCodeMeta(tc.code) + if !ok { + t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code) + } + if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry { + t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v", + tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry) + } + }) + } +} + func TestLookupCodeMeta_Unknown(t *testing.T) { _, ok := LookupCodeMeta(999999) if ok { diff --git a/internal/identitydiag/diagnostics.go b/internal/identitydiag/diagnostics.go index f8eb648d..0e7fb1cc 100644 --- a/internal/identitydiag/diagnostics.go +++ b/internal/identitydiag/diagnostics.go @@ -13,6 +13,7 @@ import ( "strings" "time" + extcred "github.com/larksuite/cli/extension/credential" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" @@ -61,12 +62,131 @@ func Diagnose(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, veri if ctx == nil { ctx = context.Background() } + // An external provider mints tokens on demand and blocks interactive auth, + // so the built-in keychain heuristics and "auth login" hints don't apply. + if provider := activeExternalProvider(ctx, f); provider != "" { + return diagnoseExternal(ctx, f, cfg, provider, verify) + } return Result{ Bot: diagnoseBot(ctx, f, cfg, verify), User: diagnoseUser(ctx, f, cfg, verify), } } +// activeExternalProvider returns the active extension provider name, or "". +// An error degrades to the built-in path: an unreachable provider would already +// have failed the f.Config() that produced cfg. +func activeExternalProvider(ctx context.Context, f *cmdutil.Factory) string { + if f == nil || f.Credential == nil { + return "" + } + name, err := f.Credential.ActiveExtensionProviderName(ctx) + if err != nil { + return "" + } + return name +} + +func diagnoseExternal(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, verify bool) Result { + if cfg == nil || cfg.AppID == "" { + notConfigured := Identity{ + Status: StatusNotConfigured, + Message: "not configured (missing app config)", + Hint: externalCredentialHint(provider), + } + return Result{Bot: notConfigured, User: notConfigured} + } + // SupportedIdentities == 0 is "unspecified" — treat as both, per CanBot. + ids := extcred.IdentitySupport(cfg.SupportedIdentities) + supportsBot := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsBot) + supportsUser := cfg.SupportedIdentities == 0 || ids.Has(extcred.SupportsUser) + return Result{ + Bot: diagnoseExternalBot(ctx, f, cfg, provider, supportsBot, verify), + User: diagnoseExternalUser(ctx, f, cfg, provider, supportsUser, verify), + } +} + +func diagnoseExternalBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity { + if !supported { + return notProvidedExternally("Bot", provider) + } + id := Identity{Status: StatusReady, Available: true, Message: "Bot identity: ready (provided by " + provider + ")"} + if !verify { + return id + } + token, err := resolveBotToken(ctx, f, cfg) + if err != nil { + return externalVerifyFailed(id, "Bot", provider, err) + } + info, err := fetchBotInfo(ctx, f, cfg, token) + if err != nil { + return externalVerifyFailed(id, "Bot", provider, err) + } + id.Verified = boolPtr(true) + id.OpenID = info.OpenID + id.AppName = info.AppName + return id +} + +func diagnoseExternalUser(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, provider string, supported, verify bool) Identity { + if !supported { + return notProvidedExternally("User", provider) + } + // enrichUserInfo populates UserOpenId only after the provider returns and + // verifies a UAT (and clears it on failure), so a resolved open id is the + // external analogue of a keychain token being present. + if cfg.UserOpenId == "" { + return Identity{ + Status: StatusMissing, + Message: "User identity: not signed in via credential source " + provider, + Hint: externalCredentialHint(provider), + } + } + id := Identity{ + Status: StatusReady, + Available: true, + TokenStatus: StatusReady, + UserName: cfg.UserName, + OpenID: cfg.UserOpenId, + Message: "User identity: ready (provided by " + provider + ")", + } + if !verify { + return id + } + if _, err := f.Credential.ResolveToken(ctx, credential.NewTokenSpec(core.AsUser, cfg.AppID)); err != nil { + return externalVerifyFailed(id, "User", provider, err) + } + id.Verified = boolPtr(true) + return id +} + +func notProvidedExternally(label, provider string) Identity { + return Identity{ + Status: StatusNotConfigured, + Message: label + " identity: not provided by credential source " + provider, + Hint: externalCredentialHint(provider), + } +} + +// externalVerifyFailed flips id to verify-failed, keeping any identity fields +// (open id, user name) already resolved before the probe. +func externalVerifyFailed(id Identity, label, provider string, err error) Identity { + id.Available = false + id.Verified = boolPtr(false) + id.Status = StatusVerifyFailed + id.TokenStatus = "" + id.Message = label + " identity: verify failed: " + err.Error() + id.Hint = externalCredentialHint(provider) + return id +} + +// externalCredentialHint reports the constraint, not a remediation: the +// identity is the provider's to manage, not lark-cli's to fix. What to do about +// it is the caller's call — there may be no user to ask. +func externalCredentialHint(provider string) string { + return fmt.Sprintf("managed by the external credential provider %q and cannot be configured via lark-cli", provider) +} + func diagnoseBot(ctx context.Context, f *cmdutil.Factory, cfg *core.CliConfig, verify bool) Identity { if cfg == nil || cfg.AppID == "" { return Identity{ diff --git a/internal/identitydiag/diagnostics_test.go b/internal/identitydiag/diagnostics_test.go index 6d288e3b..fa4a3234 100644 --- a/internal/identitydiag/diagnostics_test.go +++ b/internal/identitydiag/diagnostics_test.go @@ -10,9 +10,11 @@ import ( "testing" "time" + extcred "github.com/larksuite/cli/extension/credential" larkauth "github.com/larksuite/cli/internal/auth" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/credential" "github.com/larksuite/cli/internal/httpmock" "github.com/zalando/go-keyring" ) @@ -348,3 +350,136 @@ func TestDiagnose_UserIdentityNeedsRefresh(t *testing.T) { t.Fatalf("token status = %q, want needs_refresh", got.User.TokenStatus) } } + +// fakeExtProvider is a minimal credential.extcred.Provider for exercising the +// external-credential diagnosis path. account makes the provider "active"; +// token (when set) satisfies ResolveToken during verify. +type fakeExtProvider struct { + name string + account *extcred.Account + token *extcred.Token +} + +func (p *fakeExtProvider) Name() string { return p.name } +func (p *fakeExtProvider) ResolveAccount(context.Context) (*extcred.Account, error) { + return p.account, nil +} +func (p *fakeExtProvider) ResolveToken(context.Context, extcred.TokenSpec) (*extcred.Token, error) { + return p.token, nil +} + +func externalFactory(prov *fakeExtProvider, cfg *core.CliConfig) *cmdutil.Factory { + cred := credential.NewCredentialProvider( + []extcred.Provider{prov}, nil, nil, + func() (*http.Client, error) { return nil, nil }, + ) + return &cmdutil.Factory{ + Config: func() (*core.CliConfig, error) { return cfg, nil }, + Credential: cred, + IOStreams: &cmdutil.IOStreams{}, + } +} + +// assertExternalHint locks the contract that an external-provider hint never +// points at interactive commands blocked under an external provider. +func assertExternalHint(t *testing.T, hint string) { + t.Helper() + if hint == "" { + t.Fatalf("hint empty, want external guidance") + } + for _, blocked := range []string{"auth login", "config --help"} { + if strings.Contains(hint, blocked) { + t.Fatalf("hint %q must not point at %q (blocked under external provider)", hint, blocked) + } + } + if !strings.Contains(hint, "external") { + t.Fatalf("hint %q should explain credentials are external", hint) + } +} + +func TestDiagnose_External_UserReady(t *testing.T) { + cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll), UserOpenId: "ou_x", UserName: "Alice"} + f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg) + + got := Diagnose(context.Background(), f, cfg, false) + // The bug this guards: the built-in path read the keychain (empty under an + // external provider) and reported the user as missing. Now availability + // follows the resolved account, so a signed-in user reads as ready. + if !got.User.Available || got.User.Status != StatusReady || got.User.TokenStatus != StatusReady { + t.Fatalf("user = %#v, want ready/available", got.User) + } + if got.User.OpenID != "ou_x" || got.User.UserName != "Alice" { + t.Fatalf("user identity = %#v", got.User) + } + if got.User.Hint != "" { + t.Fatalf("hint = %q, want empty when available", got.User.Hint) + } + if !got.Bot.Available || got.Bot.Status != StatusReady { + t.Fatalf("bot = %#v, want ready/available", got.Bot) + } +} + +func TestDiagnose_External_UserNotSignedIn(t *testing.T) { + cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsAll)} + f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg) + + got := Diagnose(context.Background(), f, cfg, false) + if got.User.Available || got.User.Status != StatusMissing { + t.Fatalf("user = %#v, want missing/unavailable", got.User) + } + assertExternalHint(t, got.User.Hint) +} + +func TestDiagnose_External_BotOnly(t *testing.T) { + cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsBot), UserOpenId: "ou_x"} + f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg) + + got := Diagnose(context.Background(), f, cfg, false) + if !got.Bot.Available || got.Bot.Status != StatusReady { + t.Fatalf("bot = %#v, want ready/available", got.Bot) + } + // Provider declares bot-only: user is unavailable even though an open id is + // present, and the hint is external (not "auth login"). + if got.User.Available || got.User.Status != StatusNotConfigured { + t.Fatalf("user = %#v, want not_configured/unavailable", got.User) + } + assertExternalHint(t, got.User.Hint) +} + +func TestDiagnose_External_UserOnly(t *testing.T) { + cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandLark, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Bob"} + f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg) + + got := Diagnose(context.Background(), f, cfg, false) + if !got.User.Available || got.User.Status != StatusReady { + t.Fatalf("user = %#v, want ready/available", got.User) + } + if got.Bot.Available || got.Bot.Status != StatusNotConfigured { + t.Fatalf("bot = %#v, want not_configured/unavailable", got.Bot) + } + assertExternalHint(t, got.Bot.Hint) +} + +func TestDiagnose_External_VerifyUserResolvesToken(t *testing.T) { + cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x", UserName: "Alice"} + f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}, token: &extcred.Token{Value: "ext-uat"}}, cfg) + + got := Diagnose(context.Background(), f, cfg, true) + if !got.User.Available || got.User.Verified == nil || !*got.User.Verified { + t.Fatalf("user = %#v, want available and verified", got.User) + } +} + +func TestDiagnose_External_VerifyUserTokenUnavailable(t *testing.T) { + cfg := &core.CliConfig{AppID: "cli_x", Brand: core.BrandFeishu, SupportedIdentities: uint8(extcred.SupportsUser), UserOpenId: "ou_x"} + f := externalFactory(&fakeExtProvider{name: "corp-sso", account: &extcred.Account{AppID: "cli_x"}}, cfg) + + got := Diagnose(context.Background(), f, cfg, true) + if got.User.Available || got.User.Status != StatusVerifyFailed { + t.Fatalf("user = %#v, want verify_failed/unavailable", got.User) + } + if got.User.Verified == nil || *got.User.Verified { + t.Fatalf("verified = %v, want false", got.User.Verified) + } + assertExternalHint(t, got.User.Hint) +} diff --git a/internal/meta/affordance.go b/internal/meta/affordance.go index ef0de661..77af8647 100644 --- a/internal/meta/affordance.go +++ b/internal/meta/affordance.go @@ -5,30 +5,39 @@ 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. +// Affordance is the typed usage guidance overlaid on a method. It is the single +// model the envelope renderer and the command help both parse, so the +// vocabulary is defined once; the JSON tags double as the envelope wire shape. +// Skills entries are skill names (or name/path) rendered as runnable +// `lark-cli skills read ` pointers. 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"` + UseWhen []string `json:"use_when,omitempty"` + AvoidWhen []string `json:"avoid_when,omitempty"` + Prerequisites []string `json:"prerequisites,omitempty"` + Tips []string `json:"tips,omitempty"` + Examples []AffordanceCase `json:"examples,omitempty"` + Extensions []AffordanceSection `json:"extensions,omitempty"` + Related []string `json:"related,omitempty"` + Skills []string `json:"skills,omitempty"` } -// AffordanceCase is one few-shot example: a one-line description and a -// ready-to-run command. +// AffordanceCase is one few-shot example: a description and a ready-to-run command. type AffordanceCase struct { - Description string `json:"description"` + Description string `json:"description,omitempty"` 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. +// AffordanceSection is a custom guidance section: any heading beyond the +// standard four (Avoid when / Prerequisites / Tips / Examples) flows through +// here with its label preserved, so authors can add sections without code +// changes. +type AffordanceSection struct { + Label string `json:"label"` + Items []string `json:"items,omitempty"` +} + +// ParsedAffordance decodes the method's overlay. ok is false when it is absent, +// malformed, or wholly empty — callers treat all three as "no guidance". func (m Method) ParsedAffordance() (Affordance, bool) { if len(m.Affordance) == 0 { return Affordance{}, false @@ -37,7 +46,7 @@ func (m Method) ParsedAffordance() (Affordance, bool) { 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 { + if len(a.UseWhen) == 0 && len(a.AvoidWhen) == 0 && len(a.Prerequisites) == 0 && len(a.Tips) == 0 && len(a.Examples) == 0 && len(a.Extensions) == 0 && len(a.Related) == 0 && len(a.Skills) == 0 { return Affordance{}, false } return a, true diff --git a/internal/meta/affordance_test.go b/internal/meta/affordance_test.go index 4dd7665a..199e18d2 100644 --- a/internal/meta/affordance_test.go +++ b/internal/meta/affordance_test.go @@ -19,7 +19,7 @@ func TestMethod_ParsedAffordance(t *testing.T) { notOK := map[string]string{ "empty payload": ``, "empty object": `{}`, - "all empty arrays": `{"use_when":[],"do_not_use_when":[],"prerequisites":[],"examples":[],"related":[]}`, + "all empty arrays": `{"use_when":[],"avoid_when":[],"prerequisites":[],"tips":[],"examples":[],"related":[]}`, "malformed string": `"not an object"`, "malformed number": `42`, "nested type mismatch": `{"examples":"should be a list"}`, @@ -35,8 +35,9 @@ func TestMethod_ParsedAffordance(t *testing.T) { // Populated affordance parses with all fields. raw := `{ "use_when": ["需要拿到当前用户的主日历 ID"], - "do_not_use_when": ["已知具体 calendar_id"], + "avoid_when": ["已知具体 calendar_id"], "prerequisites": ["user 身份登录"], + "tips": ["主日历的 calendar_id 即当前用户的 union_id"], "examples": [{"description":"获取主日历","command":"lark-cli calendar calendars primary"}], "related": ["calendars.list"] }` @@ -47,10 +48,22 @@ func TestMethod_ParsedAffordance(t *testing.T) { if len(a.UseWhen) != 1 || a.UseWhen[0] != "需要拿到当前用户的主日历 ID" { t.Errorf("UseWhen = %v", a.UseWhen) } + if len(a.Tips) != 1 || a.Tips[0] != "主日历的 calendar_id 即当前用户的 union_id" { + t.Errorf("Tips = %v", a.Tips) + } 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) } + + // A method whose only guidance is Tips still parses as populated. + tipsOnly, ok := (Method{Affordance: json.RawMessage(`{"tips":["先调用 list 拿到 id"]}`)}).ParsedAffordance() + if !ok { + t.Fatal("ParsedAffordance with only tips ok=false, want populated") + } + if len(tipsOnly.Tips) != 1 || tipsOnly.Tips[0] != "先调用 list 拿到 id" { + t.Errorf("Tips = %v", tipsOnly.Tips) + } } diff --git a/internal/output/spinner.go b/internal/output/spinner.go new file mode 100644 index 00000000..1ea7d4ad --- /dev/null +++ b/internal/output/spinner.go @@ -0,0 +1,80 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "fmt" + "io" + "sync" + "time" +) + +// spinnerFrames are braille spinner glyphs cycled to animate progress. +var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} + +const ( + spinnerInterval = 80 * time.Millisecond + spinnerHideCursor = "\x1b[?25l" + spinnerShowCursor = "\x1b[?25h" + spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line +) + +// StartSpinner renders a braille spinner with an elapsed-seconds counter to w +// until the returned stop() is called, e.g.: +// +// ⠹ Publishing dev → main... 3s +// +// It is meant for slow operations (long polls, first-time provisioning) so the +// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the +// animation never pollutes stdout — the JSON/pretty result stays clean. +// +// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is +// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on +// the stderr-TTY check (IOStreams.StderrIsTerminal), not the output format: the +// spinner is stderr-only and self-clears, so it is shown in JSON mode too. +// +// stop() clears the spinner line, restores the cursor, and blocks until the +// render goroutine has finished — so callers can safely write the result to +// stdout/stderr immediately after. Call stop() BEFORE printing the result, and +// it is safe to call more than once (e.g. an explicit call plus a defer). +func StartSpinner(w io.Writer, enabled bool, label string) func() { + if !enabled || w == nil { + return func() {} + } + + done := make(chan struct{}) + finished := make(chan struct{}) + start := time.Now() + + go func() { + defer close(finished) + frame := 0 + fmt.Fprint(w, spinnerHideCursor) + render := func() { + elapsed := int(time.Since(start).Seconds()) + fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed) + frame = (frame + 1) % len(spinnerFrames) + } + render() + ticker := time.NewTicker(spinnerInterval) + defer ticker.Stop() + for { + select { + case <-done: + fmt.Fprint(w, spinnerClearLine+spinnerShowCursor) + return + case <-ticker.C: + render() + } + } + }() + + var once sync.Once + return func() { + once.Do(func() { + close(done) + <-finished // wait for the line to be cleared before returning + }) + } +} diff --git a/internal/output/spinner_test.go b/internal/output/spinner_test.go new file mode 100644 index 00000000..c4e683fa --- /dev/null +++ b/internal/output/spinner_test.go @@ -0,0 +1,54 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package output + +import ( + "bytes" + "strings" + "testing" +) + +// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent. +func TestStartSpinner_DisabledIsNoop(t *testing.T) { + var buf bytes.Buffer + stop := StartSpinner(&buf, false, "working") + stop() + stop() // idempotent + if buf.Len() != 0 { + t.Fatalf("disabled spinner wrote %q, want nothing", buf.String()) + } +} + +// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic. +func TestStartSpinner_NilWriterIsNoop(t *testing.T) { + stop := StartSpinner(nil, true, "working") + stop() // must not panic +} + +// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop. +func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) { + var buf bytes.Buffer + stop := StartSpinner(&buf, true, "Publishing") + // The goroutine renders the first frame synchronously before selecting on + // the stop channel, so even an immediate stop() yields one full cycle. + stop() + stop() // idempotent, must not panic or double-write after finished + + out := buf.String() + if !strings.Contains(out, spinnerHideCursor) { + t.Errorf("missing hide-cursor escape:\n%q", out) + } + if !strings.Contains(out, spinnerFrames[0]) { + t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out) + } + if !strings.Contains(out, "Publishing...") { + t.Errorf("missing label:\n%q", out) + } + if !strings.Contains(out, spinnerClearLine) { + t.Errorf("missing clear-line escape:\n%q", out) + } + if !strings.HasSuffix(out, spinnerShowCursor) { + t.Errorf("must end by restoring the cursor:\n%q", out) + } +} diff --git a/internal/qualitygate/publiccontent/rules.go b/internal/qualitygate/publiccontent/rules.go index e517c31f..cb35006a 100644 --- a/internal/qualitygate/publiccontent/rules.go +++ b/internal/qualitygate/publiccontent/rules.go @@ -52,6 +52,9 @@ func isPlaceholderValue(value string) bool { normalized := strings.ToLower(trimmed) if normalized == "" || normalized == "=" || + printfPlaceholderValue(normalized) || + htmlEntityAnglePlaceholder(normalized) || + starMaskedPlaceholder(normalized) || percentWrappedPlaceholder(normalized) || angleWrappedPlaceholder(normalized) || urlWithAnglePlaceholder(normalized) || @@ -61,12 +64,42 @@ func isPlaceholderValue(value string) bool { return namedPlaceholderValue(normalized) } +func htmlEntityAnglePlaceholder(value string) bool { + if !strings.HasPrefix(value, "<") || !strings.HasSuffix(value, ">") { + return false + } + return anglePlaceholderIdentifier(strings.TrimSuffix(strings.TrimPrefix(value, "<"), ">")) +} + +func starMaskedPlaceholder(value string) bool { + var stars int + for _, r := range value { + if r == '*' { + stars++ + continue + } + return false + } + return stars >= 3 +} + func namedPlaceholderValue(value string) bool { switch value { - case "...", "placeholder", "redacted", "", "xxxx", "test-secret": + case "...", "***", "****", "placeholder", "redacted", "", "xxxx", "test-secret", "test-token", "dry-run", "dry_run": return true } - return strings.Contains(value, "cli_example") || allXPlaceholder(value) + return strings.Contains(value, "cli_example") || + allXPlaceholder(value) || + conventionalNamedPlaceholderValue(value) +} + +func printfPlaceholderValue(value string) bool { + switch value { + case "%d", "%s", "%q", "%v", "%w", "%x", "%T": + return true + default: + return false + } } func allXPlaceholder(value string) bool { @@ -81,6 +114,41 @@ func allXPlaceholder(value string) bool { return true } +func conventionalNamedPlaceholderValue(value string) bool { + if !delimitedPlaceholderIdentifier(value) { + return false + } + normalized := strings.ReplaceAll(value, "-", "_") + if rest, ok := strings.CutPrefix(normalized, "your_"); ok { + return conventionalCredentialPlaceholderName(rest) + } + if rest, ok := strings.CutSuffix(normalized, "_here"); ok { + return conventionalCredentialPlaceholderName(rest) + } + return false +} + +func conventionalCredentialPlaceholderName(value string) bool { + switch value { + case "api_key", + "access_key", + "private_key", + "secret", + "password", + "passwd", + "token", + "webhook", + "access_token", + "refresh_token", + "bearer_token", + "session_token", + "client_secret": + return true + default: + return false + } +} + func urlWithAnglePlaceholder(value string) bool { if !strings.Contains(value, "://") || !strings.Contains(value, "<") || diff --git a/internal/qualitygate/publiccontent/scan.go b/internal/qualitygate/publiccontent/scan.go index 9e9f72a5..577697a5 100644 --- a/internal/qualitygate/publiccontent/scan.go +++ b/internal/qualitygate/publiccontent/scan.go @@ -4,7 +4,10 @@ package publiccontent import ( + "encoding/base64" + "encoding/json" "fmt" + "math" "path/filepath" "sort" "strings" @@ -52,8 +55,9 @@ func scanText(file, source, text string, detectorFile bool) []Finding { keyName, _ := normalizedCredentialAssignmentKey(match[0]) if value == "" || isNonSecretLiteralValue(value) || - isBenignCodeCredentialExpression(file, value) || + isBenignCodeCredentialExpression(file, line, match[0], value) || isPlaceholderValue(value) || + isPermissionScopeIdentifierAssignment(keyName, value) || isResourceTokenPlaceholderAssignment(keyName, value) { continue } @@ -63,21 +67,27 @@ func scanText(file, source, text string, detectorFile bool) []Finding { out = append(out, newFinding("public_content_generic_credential", file, lineNo, source, redactAssignment(match[0]))) } for _, match := range jwtLikeRE.FindAllString(line, -1) { - if isSchemaDottedIdentifier(line, match) { + if !isJWTToken(match) { continue } out = append(out, newFinding("public_content_jwt_like_token", file, lineNo, source, redactToken(match))) } - for range bearerHeaderRE.FindAllString(line, -1) { + for _, match := range bearerHeaderRE.FindAllString(line, -1) { + if isPlaceholderBearerHeader(match) { + continue + } out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer ")) } for _, match := range credentialURLRE.FindAllString(line, -1) { - if isPlaceholderCredentialURL(match) { + if isPlaceholderCredentialURL(file, match) { continue } out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match))) } for _, match := range privateIPv4RE.FindAllString(line, -1) { + if !warnForPrivateIPv4(file) { + continue + } out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match)) } if source == "branch" && automationBranchRE.MatchString(line) { @@ -124,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool { if isBenignTokenField(name) && !credentialShapedValue(value) { return false } + if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) { + return false + } return isExplicitCredentialKey(name) } @@ -261,7 +274,7 @@ func isResourceTokenPlaceholderAssignment(key, value string) bool { case key == "retry_without_token" && numericStringPlaceholderValue(value): return true case tokenLikePlaceholderKey(key): - return tokenLikePlaceholderValue(value) + return tokenLikePlaceholderValue(key, value) default: return false } @@ -273,12 +286,16 @@ func tokenLikePlaceholderKey(key string) bool { strings.HasSuffix(key, "-token") } -func tokenLikePlaceholderValue(value string) bool { +func tokenLikePlaceholderValue(key, value string) bool { normalized := strings.ToLower(strings.Trim(value, `"'`)) if normalized == "" || credentialShapedIdentifier(normalized) { return false } + if authCredentialTokenKey(key) { + return false + } return resourceTokenPlaceholderValue(value) || + maskedTokenFixturePlaceholderValue(key, normalized) || isPlaceholderValue(value) || normalized == "token" || strings.Contains(normalized, "...") || @@ -288,6 +305,149 @@ func tokenLikePlaceholderValue(value string) bool { strings.HasPrefix(normalized, ".") } +func maskedTokenFixturePlaceholderValue(key, value string) bool { + if authCredentialTokenKey(key) { + return false + } + var stars, alnum int + for _, r := range value { + switch { + case r == '*': + stars++ + case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'): + alnum++ + default: + return false + } + } + return stars >= 6 && alnum > 0 +} + +func isWeakTokenCredentialKey(key string) bool { + if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) { + return false + } + return key == "token" || + strings.HasSuffix(key, "_token") || + strings.HasSuffix(key, "-token") +} + +func isStrongTokenCredentialKey(key string) bool { + parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_")) + for _, phrase := range [][2]string{ + {"access", "token"}, + {"refresh", "token"}, + {"auth", "token"}, + {"bearer", "token"}, + {"session", "token"}, + {"service", "token"}, + {"bot", "token"}, + {"api", "token"}, + {"secret", "token"}, + } { + if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) { + return true + } + } + return false +} + +func weakTokenValueLooksCredentialLike(value string) bool { + normalized := strings.ToLower(strings.Trim(value, `"'<>`)) + if normalized == "" || + isNonSecretLiteralValue(value) || + isPlaceholderValue(value) { + return false + } + candidate := unwrapCredentialValue(normalized) + return credentialShapedIdentifier(candidate) || + highEntropyCredentialValue(candidate) || + commandSubstitutionLooksCredentialLike(normalized) || + (strings.Contains(normalized, "://") && + urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized))) +} + +func unwrapCredentialValue(value string) string { + value = strings.TrimSpace(strings.Trim(value, `"'<>`)) + if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") { + value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}")) + } + value = strings.TrimPrefix(value, "$") + value = strings.Trim(value, "%") + return strings.TrimSpace(value) +} + +func highEntropyCredentialValue(value string) bool { + if len(value) < 32 { + return false + } + var hasLetter, hasDigit bool + for _, r := range value { + switch { + case r >= 'a' && r <= 'z': + hasLetter = true + case r >= '0' && r <= '9': + hasDigit = true + case r == '_' || r == '-' || r == '.' || r == '=': + default: + return false + } + } + return hasLetter && hasDigit && shannonEntropy(value) >= 3.5 +} + +func shannonEntropy(value string) float64 { + if value == "" { + return 0 + } + counts := map[rune]int{} + for _, r := range value { + counts[r]++ + } + var entropy float64 + length := float64(len([]rune(value))) + for _, count := range counts { + p := float64(count) / length + entropy -= p * log2(p) + } + return entropy +} + +func log2(value float64) float64 { + return math.Log(value) / math.Ln2 +} + +func authCredentialTokenKey(key string) bool { + switch strings.ReplaceAll(strings.ToLower(key), "-", "_") { + case "access_token", + "api_token", + "bot_token", + "refresh_token", + "secret_token", + "session_token", + "service_token", + "bearer_token", + "auth_token", + "authorization_token", + "id_token": + return true + default: + return false + } +} + +func isPermissionScopeIdentifierAssignment(key, value string) bool { + if !strings.HasSuffix(key, "_token") { + return false + } + switch strings.ToLower(strings.Trim(value, `"',;`)) { + case "read", "write", "modify", "readonly", "get_as_user": + return true + default: + return false + } +} + func idempotencyTokenPlaceholderValue(value string) bool { return numericStringPlaceholderValue(value) || uuidStringPlaceholderValue(value) } @@ -328,20 +488,87 @@ func numericStringPlaceholderValue(value string) bool { return true } -func isBenignCodeCredentialExpression(file, value string) bool { +func isBenignCodeCredentialExpression(file, line, match, value string) bool { normalized := strings.TrimSpace(value) if strings.HasPrefix(normalized, "regexp.MustCompile(") { return true } - if !sourceCodeFile(file) || quotedLiteral(value) || credentialShapedValue(value) { + if !sourceCodeFile(file) || credentialShapedValue(value) { return false } + if rhs, ok := sourceCodeTypedCredentialRHS(line, match); ok { + return isBenignTypedCredentialRHS(rhs) + } + rawValueQuoted := credentialAssignmentRawValueQuoted(match) + if sourceCodeLiteralLooksNonSecret(normalized, !rawValueQuoted) { + return true + } + if sourceCodeFormatStringLiteral(normalized) && sourceCodeFormatArgumentContext(line, match) { + return true + } + if strings.Contains(match, "+") { + return true + } + if rawValueQuoted { + return false + } + if quotedLiteral(value) { + return sourceCodeLiteralLooksNonSecret(value, false) + } return codeReferenceExpression(normalized) } +func sourceCodeTypedCredentialRHS(line, match string) (string, bool) { + idx := strings.Index(line, match) + if idx < 0 { + return "", false + } + key, ok := credentialAssignmentKey(match) + if !ok { + return "", false + } + rest := strings.TrimSpace(line[idx+len(key):]) + if !strings.HasPrefix(rest, ":") { + return "", false + } + typeAndRHS := strings.TrimSpace(strings.TrimPrefix(rest, ":")) + assignmentIdx := strings.Index(typeAndRHS, "=") + if assignmentIdx < 0 { + return "", false + } + return strings.TrimSpace(typeAndRHS[assignmentIdx+1:]), true +} + +func isBenignTypedCredentialRHS(value string) bool { + value = strings.TrimRight(strings.TrimSpace(value), ",;") + if value == "" || isNonSecretLiteralValue(value) || isPlaceholderValue(value) { + return true + } + if credentialShapedValue(value) { + return false + } + if sourceCodeLiteralLooksNonSecret(value, !quotedLiteral(value)) { + return true + } + if quotedLiteral(value) { + return false + } + return codeReferenceExpression(value) +} + +func credentialAssignmentRawValueQuoted(match string) bool { + key, ok := credentialAssignmentKey(match) + if !ok { + return false + } + rest := strings.TrimSpace(strings.TrimPrefix(match[len(key):], ":")) + rest = strings.TrimSpace(strings.TrimPrefix(rest, "=")) + return strings.HasPrefix(rest, `"`) || strings.HasPrefix(rest, `'`) +} + func sourceCodeFile(file string) bool { switch filepath.Ext(file) { - case ".go", ".py": + case ".go", ".js", ".jsx", ".py", ".ts", ".tsx": return true default: return false @@ -355,7 +582,147 @@ func quotedLiteral(value string) bool { (strings.HasPrefix(normalized, `'`) && strings.HasSuffix(normalized, `'`))) } +func sourceCodeLiteralLooksNonSecret(value string, allowNumeric bool) bool { + literal := strings.Trim(strings.TrimSpace(value), `"'`) + if strings.HasPrefix(literal, "/") { + return true + } + return (allowNumeric && numericStringPlaceholderValue(literal)) || + sourceCodeEnvVarNameLiteral(literal) || + sourceCodeAttributeNameLiteral(literal) || + sourceCodeFakeOrPlaceholderLiteral(literal) || + sourceCodeCredentialTermLiteral(literal) || + sourceCodeCredentialPrefixLiteral(literal) || + sourceCodeVocabularyLiteral(literal) || + sourceCodeSchemaTypeLiteral(literal) || + benignCredentialStatusLiteral(literal) +} + +func sourceCodeFormatArgumentContext(line, match string) bool { + idx := strings.Index(line, match) + if idx < 0 { + return false + } + prefix := line[:idx] + if semicolon := strings.LastIndex(prefix, ";"); semicolon >= 0 { + prefix = prefix[semicolon+1:] + } + return strings.Contains(prefix, "fmt.") || + strings.Contains(prefix, "log.") || + strings.Contains(prefix, "printf(") || + strings.Contains(prefix, "Printf(") || + strings.Contains(prefix, "Errorf(") || + strings.Contains(prefix, "Fprintf(") +} + +func sourceCodeFormatStringLiteral(value string) bool { + for i := 0; i < len(value)-1; i++ { + if value[i] != '%' { + continue + } + if value[i+1] == '%' { + i++ + continue + } + j := i + 1 + for j < len(value) && strings.ContainsRune("#+- 0.0123456789", rune(value[j])) { + j++ + } + if j < len(value) && strings.ContainsRune("vTtbcdoOqxXUeEfFgGspw", rune(value[j])) { + return true + } + } + return false +} + +func sourceCodeEnvVarNameLiteral(value string) bool { + if value == "" || !strings.Contains(value, "_") { + return false + } + var hasCredentialMarker bool + for _, r := range value { + switch { + case r >= 'A' && r <= 'Z': + case r >= '0' && r <= '9': + case r == '_': + default: + return false + } + } + for _, marker := range []string{"TOKEN", "SECRET", "KEY", "PASSWORD", "PASSWD"} { + if strings.Contains(value, marker) { + hasCredentialMarker = true + break + } + } + return hasCredentialMarker +} + +func sourceCodeAttributeNameLiteral(value string) bool { + normalized := strings.ToLower(value) + return strings.HasPrefix(normalized, "data-") && delimitedPlaceholderIdentifier(normalized) +} + +func sourceCodeFakeOrPlaceholderLiteral(value string) bool { + normalized := strings.ToLower(value) + return strings.HasPrefix(normalized, "fake_") || + strings.HasPrefix(normalized, "fake-") || + strings.Contains(normalized, "placeholder") || + (strings.Contains(normalized, "<") && strings.Contains(normalized, ">")) +} + +func sourceCodeCredentialTermLiteral(value string) bool { + normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_")) + return conventionalCredentialPlaceholderName(normalized) +} + +func sourceCodeCredentialPrefixLiteral(value string) bool { + switch strings.ToLower(value) { + case "appsecret:": + return true + default: + return false + } +} + +func sourceCodeVocabularyLiteral(value string) bool { + switch strings.ToLower(value) { + case "bot", "tenant", "user": + return true + default: + return false + } +} + +func sourceCodeSchemaTypeLiteral(value string) bool { + normalized := strings.ToLower(value) + return normalized == "string" || strings.HasPrefix(normalized, "string(") +} + +func benignCredentialStatusLiteral(value string) bool { + normalized := strings.ToLower(strings.ReplaceAll(value, "-", "_")) + if !delimitedPlaceholderIdentifier(normalized) { + return false + } + for _, marker := range []string{ + "bad_fmt", + "expired", + "format", + "invalid", + "missing", + "permission", + "status", + "type", + } { + if strings.Contains(normalized, marker) { + return true + } + } + return false +} + func codeReferenceExpression(value string) bool { + value = strings.TrimRight(strings.TrimSpace(value), ";") if value == "" { return false } @@ -364,7 +731,10 @@ func codeReferenceExpression(value string) bool { return true } } - return codeIdentifier(value) && !credentialNameFragment(value) + if !codeIdentifier(value) { + return false + } + return codeIdentifier(value) } func codeIdentifier(value string) bool { @@ -381,20 +751,6 @@ func codeIdentifier(value string) bool { return true } -func credentialNameFragment(value string) bool { - normalized := strings.ToLower(value) - for _, marker := range []string{"secret", "token", "password", "passwd", "key"} { - if strings.Contains(normalized, marker) { - return true - } - } - return false -} - -func isSchemaDottedIdentifier(line, match string) bool { - return strings.Contains(line, "schema ") && strings.Contains(match, "_") -} - func isNonSecretLiteralValue(value string) bool { switch strings.ToLower(strings.TrimSpace(strings.Trim(value, `"'`))) { case "true", "false", "null", "nil", "{", "[": @@ -404,6 +760,40 @@ func isNonSecretLiteralValue(value string) bool { } } +func isJWTToken(value string) bool { + parts := strings.Split(value, ".") + if len(parts) != 3 { + return false + } + header, err := decodeBase64URLSegment(parts[0]) + if err != nil || !json.Valid(header) { + return false + } + var fields map[string]interface{} + if err := json.Unmarshal(header, &fields); err != nil { + return false + } + alg, ok := fields["alg"].(string) + return ok && alg != "" +} + +func decodeBase64URLSegment(value string) ([]byte, error) { + if decoded, err := base64.RawURLEncoding.DecodeString(value); err == nil { + return decoded, nil + } + return base64.URLEncoding.DecodeString(value) +} + +func isPlaceholderBearerHeader(match string) bool { + normalized := strings.ToLower(match) + idx := strings.LastIndex(normalized, "bearer ") + if idx < 0 { + return false + } + value := strings.TrimSpace(match[idx+len("bearer "):]) + return isPlaceholderValue(value) +} + func isWebhookCredentialKey(key string) bool { return strings.Contains(strings.ReplaceAll(key, "_", ""), "webhook") } @@ -562,7 +952,7 @@ func looksLikeEqualityComparison(value string) bool { return strings.HasPrefix(strings.TrimSpace(value), "=") } -func isPlaceholderCredentialURL(raw string) bool { +func isPlaceholderCredentialURL(file, raw string) bool { userInfo, ok := credentialURLUserInfo(raw) if !ok { return false @@ -571,7 +961,8 @@ func isPlaceholderCredentialURL(raw string) bool { if !ok { return false } - return credentialURLPasswordPlaceholder(password) + return credentialURLPasswordPlaceholder(password) || + (sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password)) } func credentialURLPasswordPlaceholder(password string) bool { @@ -585,6 +976,46 @@ func credentialURLPasswordPlaceholder(password string) bool { return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded) } +func credentialURLPasswordFixture(password string) bool { + normalized := strings.ToLower(strings.Trim(password, `"'`)) + switch normalized { + case "p", + "pass", + "password", + "pat_abc", + "pw", + "s3cret", + "secret", + "t": + return true + default: + return false + } +} + +func sourceOrTestFixtureFile(file string) bool { + normalized := filepath.ToSlash(file) + return sourceCodeFile(normalized) || + strings.HasPrefix(normalized, "testdata/") || + strings.HasPrefix(normalized, "fixtures/") || + strings.Contains(normalized, "/testdata/") || + strings.Contains(normalized, "/fixtures/") +} + +func warnForPrivateIPv4(file string) bool { + normalized := filepath.ToSlash(file) + if sourceOrTestFixtureFile(normalized) { + return false + } + switch filepath.Ext(normalized) { + case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env": + return true + default: + return strings.HasPrefix(normalized, "docs/") || + strings.HasPrefix(normalized, "skills/") + } +} + func credentialURLUserInfo(raw string) (string, bool) { schemeIdx := strings.Index(raw, "://") if schemeIdx < 0 { @@ -741,7 +1172,12 @@ func sanitizeSemanticExcerpt(text string) string { text = strings.ReplaceAll(text, `"`, ``) text = strings.ReplaceAll(text, `'`, ``) text = semanticBearerHeaderRE.ReplaceAllString(text, "Authorization: Bearer ") - text = jwtLikeRE.ReplaceAllString(text, "") + text = jwtLikeRE.ReplaceAllStringFunc(text, func(match string) string { + if isJWTToken(match) { + return "" + } + return match + }) text = credentialURLRE.ReplaceAllStringFunc(text, sanitizeCredentialURL) return strings.Join(strings.Fields(text), " ") } diff --git a/internal/qualitygate/publiccontent/scan_test.go b/internal/qualitygate/publiccontent/scan_test.go index 8faed8d2..ad882597 100644 --- a/internal/qualitygate/publiccontent/scan_test.go +++ b/internal/qualitygate/publiccontent/scan_test.go @@ -61,6 +61,19 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) { } } +func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) { + got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{ + `proxy := "http://user:pass@10.0.0.1:3128"`, + `target := "socks5://admin:secret@172.16.0.1:1080"`, + `host := "192.168.0.10"`, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_private_ipv4" { + t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got) + } + } +} + func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) { benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1) if len(benign) != 0 { @@ -211,7 +224,7 @@ func TestSemanticCandidateCoversRealE2ESemanticCases(t *testing.T) { } func TestScanFileDetectsDetectorFingerprintOnlyInPublicRuleFiles(t *testing.T) { - got := ScanFile(".gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n")) + got := ScanFile("testdata/publiccontent/.gitleaks.toml", []byte("[[rules]]\nid = \"public"+"-content-leakage\"\n")) if !findingRules(got)["public_content_detector_fingerprint"] { t.Fatalf("expected detector fingerprint finding, got %#v", got) } @@ -549,7 +562,7 @@ func TestScanFileDetectsCredentialURLWithEmptyUsername(t *testing.T) { } func TestScanFileAllowsPrivateKeyStateBooleans(t *testing.T) { - got := ScanFile("internal/qualitygate/publiccontent/collect.go", []byte(strings.Join([]string{ + got := ScanFile("fixtures/scanner_state.go", []byte(strings.Join([]string{ "inPrivateKey = true", "inPrivateKey = false", "hasPrivateKey: false", @@ -632,6 +645,45 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) { } } +func TestScanFileAllowsCredentialURLFixtures(t *testing.T) { + got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{ + `proxy := "http://user:pass@proxy:8080"`, + `repo := "https://u:t@h/r.git"`, + `target := "https://attacker:pw@open.feishu.cn"`, + `proxy := "http://admin:s3cret@127.0.0.1:3128"`, + `repo := "http://x-token:PAT_abc@git.host/app_x.git"`, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_credential_url" { + t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got) + } + } +} + +func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) { + got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{ + `proxy: http://user:pass@proxy:8080`, + `repo: https://u:t@h/r.git`, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_credential_url" { + t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got) + } + } +} + +func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) { + got := ScanFile("testdata/network.md", []byte(strings.Join([]string{ + `endpoint: http://10.0.0.1:8080`, + `redis: 192.168.1.10:6379`, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_private_ipv4" { + t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got) + } + } +} + func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) { got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n")) for _, item := range got { @@ -648,6 +700,7 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test "DATABASE_URL=postgres://:real-secret@example.invalid/db", "DATABASE_URL=postgres://:" + stripeLike + "@example.invalid/db", "URL=https://:real-secret@example.invalid/path", + "REPO=https://x-token:" + stripeLike + "@git.host/app.git", }, "\n")+"\n")) var count int for _, item := range got { @@ -661,8 +714,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test } } } - if count != 3 { - t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got) + if count != 4 { + t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got) } } @@ -724,8 +777,70 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) { } } +func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) { + got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{ + `{"token":"img_abc123"}`, + `{"token":"img_live_secret"}`, + `{"token":"img_prod_key"}`, + `token=ab********cd`, + `{"image_token":"img_live_secret"}`, + `{"data_mail_token":"mail_abc123"}`, + `{"whiteboard_token":"board_v3_example"}`, + `{"want_token":"token from callback"}`, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got) + } + } +} + +func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) { + githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234" + stripeToken := "sk_" + "live_1234567890abcdef" + randomToken := strings.Join([]string{ + "a1b2c3d4", + "e5f6g7h8", + "i9j0k1l2", + "m3n4p5q6", + }, "") + got := ScanFile("docs/config.md", []byte(strings.Join([]string{ + `{"token":"` + githubToken + `"}`, + `token=` + stripeToken, + `{"image_token":"` + githubToken + `"}`, + `{"token":"` + randomToken + `"}`, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 4 { + t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got) + } +} + +func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) { + got := ScanFile("docs/config.md", []byte(strings.Join([]string{ + `{"access_token":"img_abc123"}`, + `{"api_token":"img_live_secret"}`, + `{"service_token":"ab********cd"}`, + `{"bot_token":"board_v3_example"}`, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 4 { + t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got) + } +} + func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) { - got := ScanFile("shortcuts/calendar/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n")) + got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n")) for _, item := range got { if item.Rule == "public_content_generic_credential" { t.Fatalf("test fixture secret should not be credential finding: %#v", got) @@ -734,7 +849,7 @@ func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) { } func TestScanFileAllowsRegexpTokenValidators(t *testing.T) { - got := ScanFile("shortcuts/minutes/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n")) + got := ScanFile("fixtures/minutes_detail.go", []byte("var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)\n")) for _, item := range got { if item.Rule == "public_content_generic_credential" { t.Fatalf("regexp token validator should not be credential finding: %#v", got) @@ -743,7 +858,7 @@ func TestScanFileAllowsRegexpTokenValidators(t *testing.T) { } func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) { - got := ScanFile("cmd/config/binder.go", []byte(strings.Join([]string{ + got := ScanFile("fixtures/config_binder.go", []byte(strings.Join([]string{ "AppSecret: stored,", "AccessToken: result.Token.AccessToken,", `token := runtime.Str("token")`, @@ -756,7 +871,7 @@ func TestScanFileAllowsBenignSourceCodeCredentialExpressions(t *testing.T) { } func TestScanFileAllowsPythonArgumentTokens(t *testing.T) { - got := ScanFile("skills/lark-slides/scripts/iconpark_tool.py", []byte(strings.Join([]string{ + got := ScanFile("fixtures/iconpark_tool.py", []byte(strings.Join([]string{ "def normalize_token(value: str) -> str:", " token = rest[index]", " next_token = rest[index + 1] if index + 1 < len(rest) else None", @@ -770,8 +885,174 @@ func TestScanFileAllowsPythonArgumentTokens(t *testing.T) { } } +func TestScanFileAllowsPythonCredentialTypeAnnotations(t *testing.T) { + got := ScanFile("fixtures/doc_word_stat.py", []byte(strings.Join([]string{ + "class Counter:", + " def __init__(self) -> None:", + " self._token_kind: TokenKind | None = None", + " self.access_token: AccessToken | None = None", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("python credential-shaped type annotations should not be credential findings: %#v", got) + } + } +} + +func TestScanFileAllowsSourceCodeCredentialNonSecretLiterals(t *testing.T) { + got := ScanFile("fixtures/auth_paths.go", []byte(strings.Join([]string{ + `const PathOAuthTokenV2 = "/open-apis/authen/v2/oauth/token"`, + `return fmt.Errorf("failed to remove token: %v", err)`, + `const LarkErrTokenMissing = "token_missing"`, + `const LarkErrTokenExpired = 99991677`, + `const CliAppSecret = "LARKSUITE_CLI_APP_SECRET"`, + `const LargeAttachmentTokenAttr = "data-mail-token"`, + `const fakeOfficeTokenPrefix = "fake_office_"`, + `fmt.Fprintf(w, " - token=%s filename=%s\n", att.Token, att.FileName)`, + `tokenTypeHint := "access_token"`, + `const TokenTenant Token = "tenant"`, + `const secretKeyPrefix = "appsecret:"`, + `output.PrintJson(out, map[string]interface{}{"appSecret": "****"})`, + `return &credential.TokenResult{Token: "test-token"}, nil`, + `fmt.Fprintf(w, "password=%s\n", pat)`, + `text += "(img_token:" + imgToken + ")"`, + `map[string]interface{}{"token": "string(optional, from inspect)"}`, + `this.token = token;`, + `// AppSecret: "appsecret:"`, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("source code non-secret literals should not be credential findings: %#v", got) + } + } +} + +func TestScanFileAllowsCredentialLikePublicPlaceholders(t *testing.T) { + got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{ + `app_secret=***`, + `{"token":"<wiki_token>"}`, + `{"token":"Pgrrwvr***********UnRb"}`, + `"scope_name": "auth:user_access_token:read"`, + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("public placeholders and scope identifiers should not be credential findings: %#v", got) + } + } +} + +func TestScanFileDetectsPartiallyMaskedCredentialValues(t *testing.T) { + got := ScanFile("fixtures/config.md", []byte(strings.Join([]string{ + "client_secret=realprefix***realsuffix", + "client_secret=ab********cd", + "access_token=ab********cd", + "refresh_token=realprefix********realsuffix", + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 4 { + t.Fatalf("partially masked credential findings = %d, want 4: %#v", count, got) + } +} + +func TestScanFileAllowsDryRunCredentialPlaceholders(t *testing.T) { + got := ScanFile("fixtures/ci.yml", []byte(strings.Join([]string{ + "LARKSUITE_CLI_APP_SECRET=dry-run", + "client_secret: dry_run", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("dry-run credential placeholders should not be credential findings: %#v", got) + } + } +} + +func TestScanFileDetectsTypedCredentialAssignmentsWithSecretRHS(t *testing.T) { + cases := []struct { + name string + file string + text string + }{ + { + name: "typescript simple secret", + file: "fixtures/source_secret.ts", + text: `const clientSecret: string = "real-client-secret-value"`, + }, + { + name: "typescript numeric password", + file: "fixtures/source_secret.ts", + text: `const password: string = "12345678901234567890"`, + }, + { + name: "typescript union secret", + file: "fixtures/source_secret.ts", + text: `const clientSecret: string | undefined = "real-client-secret-value"`, + }, + { + name: "python simple secret", + file: "fixtures/source_secret.py", + text: `self.client_secret: str = "real-client-secret-value"`, + }, + { + name: "python union secret", + file: "fixtures/source_secret.py", + text: `self.client_secret: str | None = "real-client-secret-value"`, + }, + { + name: "python optional secret", + file: "fixtures/source_secret.py", + text: `self.client_secret: Optional[str] = "real-client-secret-value"`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := ScanFile(tc.file, []byte(tc.text+"\n")) + if !findingRules(got)["public_content_generic_credential"] { + t.Fatalf("typed credential assignment should be reported: %#v", got) + } + }) + } +} + +func TestScanFileDetectsCredentialShapedSourceCodeLiterals(t *testing.T) { + githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234" + got := ScanFile("fixtures/source_secret.go", []byte(strings.Join([]string{ + `const ClientSecret = "real-client-secret-value"`, + `const GithubToken = "` + githubToken + `"`, + `const Password = "12345678901234567890"`, + `const ClientSecretNumber = "12345678901234567890"`, + `const ClientSecretFormat = "abc%sdefreal"`, + `fmt.Println("done"); const ClientSecret = "abc%sdefreal"`, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 6 { + t.Fatalf("source code credential-shaped literal findings = %d, want 6: %#v", count, got) + } +} + +func TestScanFileAllowsPrintfCredentialPlaceholders(t *testing.T) { + got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{ + "client_secret=%s", + "access_token=%v", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("printf placeholders should not be credential findings: %#v", got) + } + } +} + func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) { - got := ScanFile("skills/lark-doc/references/lark-doc-fetch.md", []byte(strings.Join([]string{ + got := ScanFile("fixtures/lark-doc-fetch.md", []byte(strings.Join([]string{ ``, ``, }, "\n")+"\n")) @@ -783,7 +1064,7 @@ func TestScanFileAllowsEllipsisCredentialPlaceholders(t *testing.T) { } func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) { - got := ScanFile("skills/lark-mail/references/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n")) + got := ScanFile("fixtures/lark-mail-recall.md", []byte("lark-cli schema mail.user_mailbox.sent_messages.get_recall_detail\n")) for _, item := range got { if item.Rule == "public_content_jwt_like_token" { t.Fatalf("schema dotted identifier should not be jwt finding: %#v", got) @@ -791,8 +1072,38 @@ func TestScanFileAllowsSchemaDottedIdentifiers(t *testing.T) { } } +func TestScanFileAllowsMarkdownDottedAPIIdentifiers(t *testing.T) { + got := ScanFile("fixtures/mail_api_table.md", []byte(strings.Join([]string{ + "| Method | Permission |", + "| --- | --- |", + "| `user_mailbox.sent_messages.get_recall_detail` | `mail:user_mailbox.message:readonly` |", + "| `user_mailbox.allow_sender.batch_create` | `mail:user_mailbox.message:modify` |", + "| `user_mailbox.allow_sender.batch_remove` | `mail:user_mailbox.message:modify` |", + "| `user_mailbox.blocked_sender.batch_create` | `mail:user_mailbox.message:modify` |", + "| `user_mailbox.blocked_sender.batch_remove` | `mail:user_mailbox.message:modify` |", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_jwt_like_token" { + t.Fatalf("markdown dotted API identifier should not be jwt finding: %#v", got) + } + } +} + +func TestScanFileAllowsNonJWTDottedTaxonomy(t *testing.T) { + got := ScanFile("docs/api.md", []byte(strings.Join([]string{ + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + "corehr:employment.international_assignment.custom_field.apaas_id__c:read", + "user_mailbox.sent_messages.get_recall_detail queries recall detail.", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_jwt_like_token" { + t.Fatalf("non-JWT dotted taxonomy should not be jwt finding: %#v", got) + } + } +} + func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) { - got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{ + got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{ `{"client_token":"1704067200"}`, `{"client_token":"fe599b60-450f-46ff-b2ef-9f6675625b97"}`, }, "\n")+"\n")) @@ -805,7 +1116,7 @@ func TestScanFileAllowsClientTokenIdempotencyExamples(t *testing.T) { func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) { stripeLike := "sk_" + "live_1234567890abcdef" - got := ScanFile("skills/idempotency.md", []byte(strings.Join([]string{ + got := ScanFile("fixtures/idempotency.md", []byte(strings.Join([]string{ `{"client_token":"` + stripeLike + `"}`, `{"client_token":"real-client-secret-value"}`, }, "\n")+"\n")) @@ -821,7 +1132,7 @@ func TestScanFileDetectsCredentialShapedClientTokenValues(t *testing.T) { } func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) { - got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{ + got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{ `{ "block_token": "boardXXXX" }`, `{ "resource_token": "doc_token_or_url" }`, `{ "token": "canonical_token" }`, @@ -841,7 +1152,7 @@ func TestScanFileAllowsTokenLikePlaceholderExamples(t *testing.T) { func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) { stripeLike := "sk_" + "live_1234567890abcdef" - got := ScanFile("skills/placeholders.md", []byte(strings.Join([]string{ + got := ScanFile("fixtures/placeholders.md", []byte(strings.Join([]string{ `{ "resource_token": "` + stripeLike + `" }`, `{ "block_token": "real-client-secret-value" }`, }, "\n")+"\n")) @@ -856,10 +1167,12 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T) } } -func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) { - got := ScanFile("shortcuts/minutes/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n")) - if !findingRules(got)["public_content_generic_credential"] { - t.Fatalf("non-fixture minute token should be credential finding: %#v", got) +func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) { + got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("resource-like bare token value should not be credential finding: %#v", got) + } } } @@ -958,6 +1271,19 @@ func TestScanFileDetectsJSONBearerHeaders(t *testing.T) { } } +func TestScanFileAllowsBearerHeaderPlaceholders(t *testing.T) { + got := ScanFile("docs/auth.md", []byte(strings.Join([]string{ + "Authorization: Bearer YOUR_ACCESS_TOKEN", + `{"Authorization":"Bearer ACCESS_TOKEN_HERE"}`, + "Authorization: Bearer ", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_bearer_header" { + t.Fatalf("bearer placeholder should not be bearer finding: %#v", got) + } + } +} + func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) { token := "abcdefghijklmnopqrstuvwxyz" text := "private launch plan for internal rollout on Friday\n" + @@ -975,6 +1301,22 @@ func TestSemanticCandidateRedactsJSONBearerHeaders(t *testing.T) { } } +func TestSemanticCandidateKeepsNonJWTDottedTaxonomy(t *testing.T) { + text := "private launch plan for internal rollout on Friday\n" + + "Supported MIME type: application/vnd.openxmlformats-officedocument.presentationml.presentation\n" + + got := semanticCandidate("docs/public.md", "file", text, 1) + if len(got) != 1 { + t.Fatalf("semantic candidate len = %d, want 1: %#v", len(got), got) + } + if strings.Contains(got[0].Excerpt, "") { + t.Fatalf("semantic candidate should not redact non-JWT dotted taxonomy: %#v", got[0]) + } + if !strings.Contains(got[0].Excerpt, "application/vnd.openxmlformats-officedocument.presentationml.presentation") { + t.Fatalf("semantic candidate should keep non-JWT dotted taxonomy, got %#v", got[0]) + } +} + func TestScanFileDetectsCommonProvenanceMarkers(t *testing.T) { text := strings.Join([]string{ "Generated with automated code assistant", @@ -1012,6 +1354,37 @@ func TestScanFileAllowsPercentWrappedPlaceholder(t *testing.T) { } } +func TestScanFileAllowsConventionalCredentialPlaceholders(t *testing.T) { + got := ScanFile("docs/config.md", []byte(strings.Join([]string{ + "client_secret: YOUR_CLIENT_SECRET", + "api_key: YOUR_API_KEY", + "password: YOUR_PASSWORD", + "access_token: ACCESS_TOKEN_HERE", + }, "\n")+"\n")) + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + t.Fatalf("conventional credential placeholder should not be credential finding: %#v", got) + } + } +} + +func TestScanFileDetectsCredentialShapedPlaceholderLookalikes(t *testing.T) { + stripeLike := "sk_" + "live_1234567890abcdef" + got := ScanFile("docs/config.md", []byte(strings.Join([]string{ + "client_secret: " + stripeLike + "_HERE", + "api_key: YOUR_" + stripeLike, + }, "\n")+"\n")) + var count int + for _, item := range got { + if item.Rule == "public_content_generic_credential" { + count++ + } + } + if count != 2 { + t.Fatalf("credential-shaped placeholder lookalike findings = %d, want 2: %#v", count, got) + } +} + func TestScanFileDetectsPercentWrappedCredentialValues(t *testing.T) { stripeLike := "sk_" + "live_1234567890abcdef" patLike := "gh" + "p_1234567890abcdef1234567890abcdef1234" diff --git a/internal/registry/scope_hint.go b/internal/registry/scope_hint.go index 5e7ecbf1..c1af42d9 100644 --- a/internal/registry/scope_hint.go +++ b/internal/registry/scope_hint.go @@ -59,13 +59,9 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string { if appID == "" || scope == "" { return "" } - host := "open.feishu.cn" - if brand == core.BrandLark { - host = "open.larksuite.com" - } return fmt.Sprintf( - "https://%s/page/scope-apply?clientID=%s&scopes=%s", - host, + "%s/page/scope-apply?clientID=%s&scopes=%s", + core.ResolveOpenBaseURL(brand), url.QueryEscape(appID), url.QueryEscape(scope), ) diff --git a/internal/schema/assembler.go b/internal/schema/assembler.go index 92200119..6b1c43f0 100644 --- a/internal/schema/assembler.go +++ b/internal/schema/assembler.go @@ -4,8 +4,11 @@ package schema import ( + "regexp" "sort" + "strings" + "github.com/larksuite/cli/internal/affordance" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/meta" @@ -22,7 +25,7 @@ func Convert(f meta.Field) Property { if f.Type == "file" { p.Format = "binary" } - p.Description = f.Description + p.Description = normalizeDesc(f.Description) p.Default = f.CoercedDefault() p.Example = f.CoercedExample() p.Minimum = f.MinBound() @@ -52,6 +55,24 @@ func Convert(f meta.Field) Property { return p } +var ( + sepRunRe = regexp.MustCompile(`[;;]{2,}`) + spaceRunRe = regexp.MustCompile(`[ \t]{2,}`) +) + +// normalizeDesc de-crufts a meta_data description for the envelope — strips +// markdown emphasis and collapses doubled separators/spaces — but keeps content +// (links, newlines, sentences); the compact flag-help has its own stricter pass. +func normalizeDesc(s string) string { + if s == "" { + return "" + } + s = strings.ReplaceAll(s, "**", "") + s = sepRunRe.ReplaceAllString(s, "; ") + s = spaceRunRe.ReplaceAllString(s, " ") + return strings.TrimRight(s, " ;;。.,,、\n") +} + // 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 @@ -86,6 +107,18 @@ func propsOf(fields []meta.Field) *OrderedProps { return op } +// paramPropsOf is propsOf for the params section: each property also carries +// its CLI flag (--kebab-name). +func paramPropsOf(fields []meta.Field) *OrderedProps { + op := &OrderedProps{} + for _, f := range fields { + p := Convert(f) + p.Flag = "--" + f.FlagName() + op.Set(f.Name, p) + } + return op +} + // requiredOf returns the alphabetized names of the required fields. func requiredOf(fields []meta.Field) []string { var required []string @@ -108,16 +141,17 @@ func buildInputSchema(m meta.Method) *InputSchema { Properties: &OrderedProps{}, } - 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()) + addInputObject(is, "params", "", m.Params(), true, "") + addInputObject(is, "data", "", m.Data(), false, "--data") + addInputObject(is, "file", "Binary file uploads. Each property is a file field with format:binary; CLI maps each to --file =.", m.Files(), false, "--file") if m.Risk == core.RiskHighRiskWrite { falseVal := false is.Properties.Set("yes", Property{ Type: "boolean", + Flag: "--yes", 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.", + Description: "CLI confirmation gate. Must be true to execute; lark-cli rejects with confirmation_required if absent or false. Pass --yes only after the user has explicitly confirmed; not sent to the backend.", }) } @@ -125,20 +159,24 @@ func buildInputSchema(m meta.Method) *InputSchema { return is } -// 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) { +// addInputObject adds one section (params/data/file) when it has fields, marking +// the section required at top level when any field is. asFlags tags each property +// with its --flag (params only); carrier names the section's flag (--data/--file). +func addInputObject(is *InputSchema, name, description string, fields []meta.Field, asFlags bool, carrier string) { if len(fields) == 0 { return } + props := propsOf(fields) + if asFlags { + props = paramPropsOf(fields) + } req := requiredOf(fields) is.Properties.Set(name, Property{ Type: "object", Description: description, + Carrier: carrier, Required: req, - Properties: propsOf(fields), + Properties: props, }) if len(req) > 0 { is.Required = append(is.Required, name) @@ -179,7 +217,13 @@ func buildMeta(m meta.Method) *Meta { // 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) + m := ref.Method + // The affordance overlay lives in the CLI, not the metadata; look it up + // lazily here (it takes precedence over any affordance the metadata carries). + if raw, ok := affordance.For(ref.Service.Name, m.ID); ok { + m.Affordance = raw + } + return assemble(ref.Service.Name, ref.ResourcePath, m) } // Envelopes renders the given method refs into envelopes, sorted by name. The @@ -205,7 +249,7 @@ func assemble(serviceName string, resourcePath []string, m meta.Method) Envelope return Envelope{ Name: name, - Description: m.Description, + Description: normalizeDesc(m.Description), InputSchema: buildInputSchema(m), OutputSchema: buildOutputSchema(m), Meta: buildMeta(m), diff --git a/internal/schema/assembler_test.go b/internal/schema/assembler_test.go index 0465c41d..b6bd363b 100644 --- a/internal/schema/assembler_test.go +++ b/internal/schema/assembler_test.go @@ -9,7 +9,9 @@ import ( "reflect" "strings" "testing" + "testing/fstest" + "github.com/larksuite/cli/internal/affordance" "github.com/larksuite/cli/internal/apicatalog" "github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/registry" @@ -504,6 +506,31 @@ func TestBuildMeta_AffordanceFromMethod(t *testing.T) { } } +// EnvelopeOf injects affordance from the CLI overlay (looked up lazily by +// service + method id), so a method whose metadata carries none still gets +// guidance in its envelope when an overlay entry exists. +func TestEnvelopeOf_AffordanceFromOverlay(t *testing.T) { + // The overlay source is the top-level affordance/ tree, injected at startup; + // inject a fixture so this unit test does not depend on the shipped content. + // Reset afterwards (this binary installs no source by default) for isolation. + t.Cleanup(func() { affordance.SetSource(nil) }) + affordance.SetSource(fstest.MapFS{"approval.md": &fstest.MapFile{Data: []byte( + "# approval\n> skill: lark-approval\n\n## instances get\n查询某审批实例的状态与进度。\n\n### Examples\n\n**按 code 查询**\n```bash\nlark-cli approval instances get --instance-code \"x\"\n```\n")}}) + env := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.get", Name: "get"}) + if env.Meta == nil || env.Meta.Affordance == nil { + t.Fatal("expected affordance from the approval overlay, got none") + } + if len(env.Meta.Affordance.UseWhen) == 0 || len(env.Meta.Affordance.Examples) == 0 { + t.Errorf("overlay affordance missing use_when/examples: %+v", env.Meta.Affordance) + } + + // A method id with no overlay entry carries no affordance. + bare := synthEnvelope("approval", []string{"instances"}, meta.Method{ID: "instances.no_such_method", Name: "x"}) + if bare.Meta != nil && bare.Meta.Affordance != nil { + t.Errorf("method without overlay should have no affordance, got %+v", bare.Meta.Affordance) + } +} + func TestBuildMeta_MissingDocURLOmitted(t *testing.T) { method := map[string]interface{}{ "scopes": []interface{}{"x"}, diff --git a/internal/schema/types.go b/internal/schema/types.go index 084ca469..d7e3e1c5 100644 --- a/internal/schema/types.go +++ b/internal/schema/types.go @@ -13,6 +13,10 @@ import ( ) // Envelope is the MCP Tool spec contract for a single API method command. +// +// The REST route (httpMethod/path) is deliberately NOT exposed: every +// schema-resolvable method already has a typed command, so the raw path would +// only tempt an agent toward the `api` escape hatch. type Envelope struct { Name string `json:"name"` Description string `json:"description"` @@ -44,9 +48,15 @@ type OutputSchema struct { // "params" / "data" sub-objects inside inputSchema): it lists which keys // inside that object's Properties are mandatory. Leaf fields ignore it. type Property struct { - Type string `json:"type,omitempty"` - Description string `json:"description,omitempty"` - Enum []interface{} `json:"enum,omitempty"` + Type string `json:"type,omitempty"` + Description string `json:"description,omitempty"` + // Flag is the typed CLI flag a params property maps to (e.g. "--folder-id"); + // absent on body/file fields, which travel via the section's Carrier. + Flag string `json:"flag,omitempty"` + // Carrier names the flag a whole inputSchema section travels on ("--data" / + // "--file"); empty on the params section, whose properties carry their Flag. + Carrier string `json:"carrier,omitempty"` + Enum []interface{} `json:"enum,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, diff --git a/internal/transport/warn.go b/internal/transport/warn.go index 2971b2be..bcbdafee 100644 --- a/internal/transport/warn.go +++ b/internal/transport/warn.go @@ -16,6 +16,14 @@ import ( const ( // EnvNoProxy disables automatic proxy support when set to any non-empty value. EnvNoProxy = "LARK_CLI_NO_PROXY" + + // EnvNoProxyWarn suppresses the proxy-detected warning when set to any + // non-empty value, while leaving proxy behavior unchanged. Unlike + // EnvNoProxy (which both silences the warning AND disables the proxy), this + // keeps proxy egress active. It exists so agents consuming --format json can + // keep using the proxy without the human-oriented warning line landing in + // the output stream and breaking JSON parsing. + EnvNoProxyWarn = "LARK_CLI_NO_PROXY_WARN" ) // proxyEnvKeys lists environment variables that Go's ProxyFromEnvironment reads. @@ -73,6 +81,11 @@ func redactProxyURL(raw string) string { // are redacted. Safe to call multiple times; only the first call prints. func WarnIfProxied(w io.Writer) { proxyWarningOnce.Do(func() { + // Explicit opt-out: silence the warning without touching proxy behavior. + // Checked before the plugin and env-proxy branches so it suppresses both. + if os.Getenv(EnvNoProxyWarn) != "" { + return + } // Proxy plugin mode overrides env proxies and LARK_CLI_NO_PROXY (see // Shared), so its warning and disable instructions take precedence. // Emitting the env-proxy warning here would be misleading: it tells the @@ -88,7 +101,7 @@ func WarnIfProxied(w io.Writer) { if key == "" { return } - fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy.\n", - key, redactProxyURL(val), EnvNoProxy) + fmt.Fprintf(w, "[lark-cli] [WARN] proxy detected: %s=%s — requests (including credentials) will transit through this proxy. Set %s=1 to disable proxy, or %s=1 to keep the proxy and silence this warning.\n", + key, redactProxyURL(val), EnvNoProxy, EnvNoProxyWarn) }) } diff --git a/internal/transport/warn_test.go b/internal/transport/warn_test.go index a4e10b8d..a7f5ea26 100644 --- a/internal/transport/warn_test.go +++ b/internal/transport/warn_test.go @@ -93,6 +93,47 @@ func TestWarnIfProxied_SilentWhenDisabled(t *testing.T) { } } +// TestWarnIfProxied_SilentWhenWarnOptOut verifies that LARK_CLI_NO_PROXY_WARN +// suppresses the warning while the proxy stays configured (unlike +// LARK_CLI_NO_PROXY, which also disables the proxy). +func TestWarnIfProxied_SilentWhenWarnOptOut(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + unsetProxyPluginEnv(t) + resetProxyPluginState() + proxyWarningOnce = sync.Once{} + + t.Setenv("HTTPS_PROXY", "http://proxy:8080") + t.Setenv(EnvNoProxyWarn, "1") + + var buf bytes.Buffer + WarnIfProxied(&buf) + + if buf.Len() != 0 { + t.Errorf("expected no warning when %s is set, got: %s", EnvNoProxyWarn, buf.String()) + } +} + +// TestWarnIfProxied_WarnOptOutSuppressesPluginWarning verifies that +// LARK_CLI_NO_PROXY_WARN also suppresses the proxy-plugin warning. +func TestWarnIfProxied_WarnOptOutSuppressesPluginWarning(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", "", true } + t.Cleanup(func() { proxyPluginStatus = old }) + + t.Setenv(EnvNoProxyWarn, "1") + + var buf bytes.Buffer + WarnIfProxied(&buf) + + if buf.Len() != 0 { + t.Errorf("expected no plugin warning when %s is set, got: %s", EnvNoProxyWarn, buf.String()) + } +} + // TestWarnIfProxied_OnlyOnce verifies that proxy warnings are emitted only once. func TestWarnIfProxied_OnlyOnce(t *testing.T) { t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) diff --git a/internal/update/update.go b/internal/update/update.go index 31fef085..7bdc61d9 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -25,7 +25,7 @@ import ( const ( registryURL = "https://registry.npmjs.org/@larksuite/cli/latest" cacheTTL = 24 * time.Hour - fetchTimeout = 5 * time.Second + fetchTimeout = 15 * time.Second stateFile = "update-state.json" maxBody = 256 << 10 // 256 KB diff --git a/package.json b/package.json index 61342a1a..b1706371 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@larksuite/cli", - "version": "1.0.59", + "version": "1.0.64", "description": "The official CLI for Lark/Feishu open platform", "bin": { "lark-cli": "scripts/run.js" diff --git a/scripts/install-wizard.js b/scripts/install-wizard.js index 4bc76f5d..a37ff533 100644 --- a/scripts/install-wizard.js +++ b/scripts/install-wizard.js @@ -5,7 +5,12 @@ const fs = require("fs"); const path = require("path"); const { execFileSync, execFile } = require("child_process"); -const p = require("@clack/prompts"); + +// @clack/prompts is ESM-only since v1; load it via dynamic import() so this +// CommonJS script works on all supported Node versions (require() of an ESM +// package throws ERR_REQUIRE_ESM before Node 22.12). Assigned in the entry +// point below before main() runs. +let p; const PKG = "@larksuite/cli"; const SKILLS_REPO = "https://open.feishu.cn"; @@ -374,7 +379,12 @@ async function main() { } } -main().catch((err) => { - p.cancel("Unexpected error: " + (err.message || err)); +(async () => { + p = await import("@clack/prompts"); + await main(); +})().catch((err) => { + const msg = "Unexpected error: " + (err.message || err); + if (p) p.cancel(msg); + else console.error(msg); process.exit(1); }); diff --git a/shortcuts/apps/apps_analytics.go b/shortcuts/apps/apps_analytics.go new file mode 100644 index 00000000..1f7afd1f --- /dev/null +++ b/shortcuts/apps/apps_analytics.go @@ -0,0 +1,207 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultAppsAnalyticsEnv = "online" + defaultAppsAnalyticsGranular = "day" + analyticsListEndpoint = "query_analytics_data" +) + +// AppsAnalyticsList lists online app product analytics. +var AppsAnalyticsList = common.Shortcut{ + Service: appsService, + Command: "+analytics-list", + Description: "List online app user and page-view analytics", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +analytics-list --app-id --analytics users --granularity week", + "Tip: analytics timestamps use nanoseconds; use +metric-list for request/runtime metrics.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online analytics should be listed", Required: true}, + {Name: appsEnvironmentFlag, Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"}, + {Name: "analytics", Desc: "analytics family to list", Required: true, Enum: []string{"users", "page-view"}}, + {Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, + {Name: "page", Desc: "frontend page or route filter"}, + {Name: "device-type", Desc: "device type filter", Enum: []string{"desktop", "mobile"}}, + {Name: "granularity", Default: defaultAppsAnalyticsGranular, Desc: "analytics aggregation granularity", Enum: []string{"day", "week", "month"}}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, _, _, err := buildAnalyticsListBody(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _, _, _ := buildAnalyticsListBody(rctx) + return common.NewDryRunAPI(). + POST(analyticsListPath(rctx.Str("app-id"))). + Desc("List online app analytics"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + body, types, labels, err := buildAnalyticsListBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", analyticsListPath(appID), nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := observabilitySeriesOutput{ + Items: normalizeAnalyticsSeries(data, types, labels), + HasMore: false, + } + rctx.OutFormat(out, nil, func(w io.Writer) { + rows := observabilitySeriesRows(out.Items) + sortObservabilityRowsDesc(rows, "timestamp_ns") + rows = filterObservabilityRowsWithTime(rows, "timestamp_ns") + appsPrintSchemaTable(w, rows, analyticsSeriesSchema(labels)) + }) + return nil + }, +} + +func analyticsListPath(appID string) string { + return appScopedPath(appID, analyticsListEndpoint) +} + +func buildAnalyticsListBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, error) { + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) + if env == "" { + env = defaultAppsAnalyticsEnv + } + if err := validateObservabilityEnv(env); err != nil { + return nil, nil, nil, err + } + types, labels, filter, err := analyticsTypesForCLI(rctx.Str("analytics"), rctx.Str("series"), rctx.Str("device-type")) + if err != nil { + return nil, nil, nil, err + } + since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until")) + if err != nil { + return nil, nil, nil, err + } + aggregation, err := analyticsGranularityForCLI(rctx.Str("granularity")) + if err != nil { + return nil, nil, nil, err + } + if page := strings.TrimSpace(rctx.Str("page")); page != "" { + filter["page"] = page + } + body := map[string]interface{}{ + "metric_types": types, + "start_timestamp_ns": nsNumber(since), + "end_timestamp_ns": nsNumber(until), + "time_aggregation_unit": aggregation, + "need_pack_lack_point": false, + } + if len(filter) > 0 { + body["filter"] = filter + } + return body, types, labels, nil +} + +func analyticsTypesForCLI(name, series, deviceType string) ([]string, []string, map[string]interface{}, error) { + name = strings.TrimSpace(strings.ToLower(name)) + series = strings.TrimSpace(strings.ToLower(series)) + deviceType = strings.TrimSpace(strings.ToLower(deviceType)) + filter := make(map[string]interface{}) + if deviceType != "" { + switch deviceType { + case "desktop", "mobile": + filter["device_types"] = []string{deviceType} + default: + return nil, nil, nil, appsValidationParamError("--device-type", "--device-type must be desktop or mobile") + } + } + + switch name { + case "users": + switch series { + case "": + return []string{"ACTIVE_USER", "NEW_USER", "TOTAL_USER"}, []string{"active-users", "new-users", "total-users"}, filter, nil + case "active", "active-users": + return []string{"ACTIVE_USER"}, []string{"active-users"}, filter, nil + case "new", "new-users": + return []string{"NEW_USER"}, []string{"new-users"}, filter, nil + case "total", "total-users": + return []string{"TOTAL_USER"}, []string{"total-users"}, filter, nil + default: + return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics users must be active, new, or total") + } + case "page-view": + switch series { + case "", "all": + return []string{"PAGE_VIEW"}, []string{"all"}, filter, nil + case "desktop", "desktop-view": + if err := mergeAnalyticsDeviceFilter(filter, "desktop"); err != nil { + return nil, nil, nil, err + } + return []string{"PAGE_VIEW"}, []string{"desktop"}, filter, nil + case "mobile", "mobile-view": + if err := mergeAnalyticsDeviceFilter(filter, "mobile"); err != nil { + return nil, nil, nil, err + } + return []string{"PAGE_VIEW"}, []string{"mobile"}, filter, nil + default: + return nil, nil, nil, appsValidationParamError("--series", "--series for --analytics page-view must be all, desktop, or mobile") + } + default: + return nil, nil, nil, appsValidationParamError("--analytics", "--analytics must be users or page-view") + } +} + +func mergeAnalyticsDeviceFilter(filter map[string]interface{}, deviceType string) error { + if existing, ok := filter["device_types"].([]string); ok && len(existing) > 0 && existing[0] != deviceType { + return appsValidationParamError("--device-type", "--device-type conflicts with --series") + } + filter["device_types"] = []string{deviceType} + return nil +} + +func analyticsGranularityForCLI(granularity string) (string, error) { + switch strings.TrimSpace(strings.ToLower(granularity)) { + case "", "day": + return "DAY", nil + case "week": + return "WEEK", nil + case "month": + return "MONTH", nil + default: + return "", appsValidationParamError("--granularity", "--granularity must be day, week, or month") + } +} + +func normalizeAnalyticsSeries(data map[string]interface{}, names, labels []string) []map[string]interface{} { + items := normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), false, "timestamp_ns") + fillObservabilityZeroesWhenPartiallyPresent(items, labels) + return items +} + +func analyticsSeriesSchema(labels []string) appsOutputSchema { + columns := []appsOutputColumn{ + {Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05")}, + } + for _, label := range labels { + columns = append(columns, appsOutputColumn{Key: label}) + } + return appsOutputSchema{Columns: columns, Strict: true} +} diff --git a/shortcuts/apps/apps_analytics_test.go b/shortcuts/apps/apps_analytics_test.go new file mode 100644 index 00000000..3e8eeb5d --- /dev/null +++ b/shortcuts/apps/apps_analytics_test.go @@ -0,0 +1,459 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsAnalyticsList_DryRunUsesNanoseconds(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", + "--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z", + "--granularity", "week", "--dry-run", "--as", "user", + }, factory, stdout) + if 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"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_analytics_data" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + body := env.API[0].Body + if _, ok := body["start_timestamp_ns"]; !ok { + t.Fatalf("analytics dry-run missing start_timestamp_ns: %#v", body) + } + if _, ok := body["start_timestamp"]; ok { + t.Fatalf("analytics should not use start_timestamp: %#v", body) + } + if body["time_aggregation_unit"] != "WEEK" { + t.Fatalf("time_aggregation_unit = %v", body["time_aggregation_unit"]) + } + if _, ok := body["app_env"]; ok { + t.Fatalf("analytics OpenAPI body should not include app_env: %#v", body) + } + if _, ok := body["analytics_types"]; ok { + t.Fatalf("analytics OpenAPI body should use metric_types, not analytics_types: %#v", body) + } + if body["need_pack_lack_point"] != false { + t.Fatalf("need_pack_lack_point = %#v, want false", body["need_pack_lack_point"]) + } + if _, ok := body["group_by"]; ok { + t.Fatalf("group_by is intentionally unsupported for now: %#v", body) + } + if metricTypes, ok := body["metric_types"].([]interface{}); !ok || len(metricTypes) != 3 { + t.Fatalf("metric_types = %#v", body["metric_types"]) + } + if body["start_timestamp_ns"] != "1782208800000000000" || + body["end_timestamp_ns"] != "1782208860000000000" { + t.Fatalf("analytics timestamps = %#v %#v", body["start_timestamp_ns"], body["end_timestamp_ns"]) + } +} + +func TestAppsAnalyticsList_PageViewDesktopSeriesSetsDeviceFilter(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + { + name: "series", + args: []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "page-view", + "--series", "desktop", "--page", "/home", "--dry-run", "--as", "user", + }, + }, + { + name: "device-type", + args: []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "page-view", + "--device-type", "desktop", "--dry-run", "--as", "user", + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsAnalyticsList, tc.args, 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(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + filter := env.API[0].Body["filter"].(map[string]interface{}) + deviceTypes := filter["device_types"].([]interface{}) + if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" { + t.Fatalf("device_types = %#v", deviceTypes) + } + if tc.name == "series" && filter["page"] != "/home" { + t.Fatalf("filter.page = %#v, want /home", filter["page"]) + } + }) + } +} + +func TestAppsAnalyticsList_DesktopSeriesUsesDesktopValueLabel(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "series": []interface{}{ + map[string]interface{}{ + "metric_type": "PAGE_VIEW", + "points": []interface{}{ + map[string]interface{}{ + "timestamp_ns": float64(1782208800000000000), + "value": float64(21), + }, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "page-view", + "--series", "desktop", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if len(env.Data.Items) != 1 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + if env.Data.Items[0].Values["desktop"] != float64(21) { + t.Fatalf("values = %#v, want desktop=21", env.Data.Items[0].Values) + } + if _, ok := env.Data.Items[0].Values["page-view"]; ok { + t.Fatalf("values should not use page-view label: %#v", env.Data.Items[0].Values) + } +} + +func TestAppsAnalyticsList_PrettyFormatsTimeFirst(t *testing.T) { + const rawNS = int64(1782208800000000000) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "series": []interface{}{ + map[string]interface{}{ + "metric_type": "ACTIVE_USER", + "points": []interface{}{ + map[string]interface{}{"timestamp_ns": float64(rawNS), "value": float64(7)}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--series", "active", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + wantTime := time.Unix(0, rawNS).Local().Format("2006-01-02 15:04:05") + if !strings.HasPrefix(got, "time") { + t.Fatalf("pretty output should start with time column, got:\n%s", got) + } + if !strings.Contains(got, wantTime) { + t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got) + } + if strings.Contains(got, "timestamp_ns") || strings.Contains(got, "1782208800000000000") { + t.Fatalf("pretty output should hide raw timestamp_ns, got:\n%s", got) + } +} + +func TestAppsAnalyticsList_PrettySkipsRowsWithoutTime(t *testing.T) { + const rawNS = int64(1782208800000000000) + rows := []map[string]interface{}{ + {"timestamp_ns": rawNS, "active-users": float64(7)}, + {"active-users": float64(0)}, + } + sortObservabilityRowsDesc(rows, "timestamp_ns") + rows = filterObservabilityRowsWithTime(rows, "timestamp_ns") + if len(rows) != 1 { + t.Fatalf("rows len = %d, want 1: %#v", len(rows), rows) + } + if rows[0]["timestamp_ns"] != rawNS { + t.Fatalf("remaining row = %#v", rows[0]) + } +} + +func TestAppsAnalyticsList_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "series": []interface{}{ + map[string]interface{}{ + "metric_type": "TOTAL_USER", + "points": []interface{}{ + map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(20)}, + }, + }, + map[string]interface{}{ + "metric_type": "ACTIVE_USER", + "points": []interface{}{ + map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(7)}, + }, + }, + map[string]interface{}{ + "metric_type": "NEW_USER", + "points": []interface{}{ + map[string]interface{}{"timestamp_ns": float64(1782208800000000000), "value": float64(3)}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if len(env.Data.Items) != 1 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + values := env.Data.Items[0].Values + if values["active-users"] != float64(7) || values["new-users"] != float64(3) || values["total-users"] != float64(20) { + t.Fatalf("values = %#v, want active-users=7 new-users=3 total-users=20", values) + } +} + +func TestAppsAnalyticsList_FillsMissingAndNullValuesWhenAnyValuePresent(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "timestamp_ns": "1782208800000000000", + "values": map[string]interface{}{ + "total-users": float64(4), + "active-users": nil, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + values := env.Data.Items[0].Values + if values["total-users"] != float64(4) || values["active-users"] != float64(0) || values["new-users"] != float64(0) { + t.Fatalf("values = %#v, want total-users=4 active-users=0 new-users=0", values) + } +} + +func TestAppsAnalyticsList_DoesNotFillAllNullValues(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "timestamp_ns": "1782208800000000000", + "values": map[string]interface{}{ + "total-users": nil, + "active-users": nil, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + values := env.Data.Items[0].Values + if values["total-users"] != nil || values["active-users"] != nil { + t.Fatalf("values = %#v, want existing nulls preserved", values) + } + if _, ok := values["new-users"]; ok { + t.Fatalf("values should not fill missing labels when all present values are null: %#v", values) + } +} + +func TestAppsAnalyticsList_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_analytics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + }) + + if err := runAppsShortcut(t, AppsAnalyticsList, []string{ + "+analytics-list", "--app-id", "app_x", "--analytics", "users", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.Items == nil { + t.Fatalf("items decoded as nil; stdout=%s", stdout.String()) + } + if len(env.Data.Items) != 0 || env.Data.HasMore { + t.Fatalf("empty output = items %#v has_more %v", env.Data.Items, env.Data.HasMore) + } +} + +func TestAnalyticsTypesMapping(t *testing.T) { + types, labels, filter, err := analyticsTypesForCLI("users", "", "") + if err != nil { + t.Fatal(err) + } + if strings.Join(types, ",") != "ACTIVE_USER,NEW_USER,TOTAL_USER" { + t.Fatalf("types = %#v", types) + } + if strings.Join(labels, ",") != "active-users,new-users,total-users" { + t.Fatalf("labels = %#v", labels) + } + if len(filter) != 0 { + t.Fatalf("filter = %#v, want empty", filter) + } + + types, labels, filter, err = analyticsTypesForCLI("page-view", "", "") + if err != nil { + t.Fatal(err) + } + if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "all" { + t.Fatalf("page-view all mapping = %#v %#v", types, labels) + } + if len(filter) != 0 { + t.Fatalf("filter = %#v, want empty", filter) + } + + types, labels, filter, err = analyticsTypesForCLI("page-view", "desktop", "") + if err != nil { + t.Fatal(err) + } + if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "desktop" { + t.Fatalf("page-view mapping = %#v %#v", types, labels) + } + deviceTypes := filter["device_types"].([]string) + if len(deviceTypes) != 1 || deviceTypes[0] != "desktop" { + t.Fatalf("device_types = %#v", deviceTypes) + } + + types, labels, filter, err = analyticsTypesForCLI("page-view", "mobile-view", "") + if err != nil { + t.Fatal(err) + } + if strings.Join(types, ",") != "PAGE_VIEW" || strings.Join(labels, ",") != "mobile" { + t.Fatalf("page-view mobile mapping = %#v %#v", types, labels) + } + deviceTypes = filter["device_types"].([]string) + if len(deviceTypes) != 1 || deviceTypes[0] != "mobile" { + t.Fatalf("device_types = %#v", deviceTypes) + } + + if _, _, _, err := analyticsTypesForCLI("users", "desktop", ""); err == nil { + t.Fatalf("users desktop series should fail") + } + if _, _, _, err := analyticsTypesForCLI("page-view", "tablet", ""); err == nil { + t.Fatalf("page-view tablet series should fail") + } + if _, _, _, err := analyticsTypesForCLI("page-view", "", "tablet"); err == nil { + t.Fatalf("tablet device type should fail") + } +} diff --git a/shortcuts/apps/apps_db_audit_list.go b/shortcuts/apps/apps_db_audit_list.go new file mode 100644 index 00000000..ca03092e --- /dev/null +++ b/shortcuts/apps/apps_db_audit_list.go @@ -0,0 +1,302 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsDBAuditList 列出数据表的行级审计事件(INSERT/UPDATE/DELETE 的变更追溯)。 +// +// GET /apps/{app_id}/db/audit_list(cursor 分页)。--table 可重复传多张表;--since/--until 多格式时间。 +// operator 透传 {id,name}(json 还原对象、pretty 取 name);before/after 是条件出现的 JSON +// (INSERT 无 before、DELETE 无 after),json 还原成对象。 +// +// 多表查询时,CLI 先用 schema(表是否存在)+ status(审计是否开启)在本地过滤,把不存在 / +// 未开启审计的表剔除后再查 audit_list,被剔除的表及原因放进 skipped(服务端不再返该字段)。 +var AppsDBAuditList = common.Shortcut{ + Service: appsService, + Command: "+db-audit-list", + Description: "List row-change audit events for one or more tables (cursor pagination)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-audit-list --app-id --table orders", + "Multiple tables: repeat --table; filter time with --since 7d / --until 2026-04-15.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: append([]common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Type: "string_slice", Desc: "table(s) to list audit events for (repeatable)", Required: true}, + {Name: "since", Desc: "filter: event at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"}, + {Name: "until", Desc: "filter: event at or before; same formats as --since"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, + {Name: "page-token", Desc: "pagination cursor from previous response"}, + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if err := rejectLegacyEnvFlag(rctx); err != nil { + return err + } + if len(auditListTables(rctx)) == 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required (at least one table)").WithParam("--table") + } + return normalizeTimeFlags(rctx, "since", "until") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appAuditListPath(appID)). + Desc("List Miaoda app table audit events"). + Params(buildAuditListParams(rctx, auditListTables(rctx))) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + requested := auditListTables(rctx) + env := dbEnv(rctx) + + // 多表查询:CLI 侧先用 schema(表是否存在)+ status(审计是否开启)过滤, + // 不存在 / 未开启审计的表不进 audit_list 查询,单独在 skipped 里给出原因。 + // 单表查询直接打 audit_list,由后端就 table-not-found / audit-not-enabled 报错。 + queryTables := requested + var skipped []auditSkippedEntry + if len(requested) > 1 { + queryTables, skipped, err = filterAuditTables(rctx, appID, env, requested) + if err != nil { + return withAppsHint(err, dbChangelogHint) + } + // 所有请求表都被过滤掉 → 无可查询表,直接返回空 + skipped 提示,不调 audit_list。 + if len(queryTables) == 0 { + out := map[string]interface{}{"items": []auditLogItem{}, "has_more": false, "skipped": skipped} + rctx.OutFormat(out, nil, func(w io.Writer) { + io.WriteString(w, "No audit events found.\n") + writeAuditSkipped(w, skipped, len(requested)) + }) + return nil + } + } + + data, err := rctx.CallAPITyped("GET", appAuditListPath(appID), buildAuditListParams(rctx, queryTables), nil) + if err != nil { + return withAppsHint(err, dbChangelogHint) + } + items := projectAuditLogItems(data["items"]) + data["items"] = items + // 服务端不再返 skipped;改由 CLI 算出的 skipped 写回输出。 + if len(skipped) > 0 { + data["skipped"] = skipped + } else { + delete(data, "skipped") + } + multi := len(requested) > 1 + rctx.OutFormat(data, nil, func(w io.Writer) { + renderAuditListPretty(w, items, skipped, len(requested), multi) + }) + return nil + }, +} + +// auditSkippedEntry 是被 CLI 预过滤掉的表及原因(替代已删除的服务端 skipped 字段)。 +type auditSkippedEntry struct { + Table string `json:"table"` + Reason string `json:"reason"` +} + +// filterAuditTables 用 schema(存在性)+ status(审计开关)把请求表分成「可查询」与「跳过」两组。 +func filterAuditTables(rctx *common.RuntimeContext, appID, env string, requested []string) ([]string, []auditSkippedEntry, error) { + existing, err := fetchExistingTables(rctx, appID, env) + if err != nil { + return nil, nil, err + } + enabled, err := fetchAuditEnabledTables(rctx, appID, env) + if err != nil { + return nil, nil, err + } + valid := make([]string, 0, len(requested)) + var skipped []auditSkippedEntry + for _, t := range requested { + switch { + case !existing[t]: + skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "table not found"}) + case !enabled[t]: + skipped = append(skipped, auditSkippedEntry{Table: t, Reason: "audit not enabled"}) + default: + valid = append(valid, t) + } + } + return valid, skipped, nil +} + +// fetchExistingTables 翻页拉全量表清单,返回存在表名集合(schema 命令同源接口)。 +func fetchExistingTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) { + existing := map[string]bool{} + token := "" + for { + params := map[string]interface{}{"env": env, "page_size": 100} + if token != "" { + params["page_token"] = token + } + data, err := rctx.CallAPITyped("GET", appTablesPath(appID), params, nil) + if err != nil { + return nil, err + } + for _, it := range asMapSlice(data["items"]) { + if name := common.GetString(it, "name"); name != "" { + existing[name] = true + } + } + token = common.GetString(data, "page_token") + if data["has_more"] != true || token == "" { + break + } + } + return existing, nil +} + +// fetchAuditEnabledTables 拉审计状态,返回当前已开启审计的表名集合(status 命令同源接口)。 +func fetchAuditEnabledTables(rctx *common.RuntimeContext, appID, env string) (map[string]bool, error) { + data, err := rctx.CallAPITyped("GET", appAuditStatusPath(appID), map[string]interface{}{"env": env}, nil) + if err != nil { + return nil, err + } + enabled := map[string]bool{} + for _, it := range asMapSlice(data["items"]) { + if it["enabled"] == true { + if name := common.GetString(it, "table"); name != "" { + enabled[name] = true + } + } + } + return enabled, nil +} + +// asMapSlice 把 interface{}([]interface{})里的每个 map 元素取出,非 map 丢弃。 +func asMapSlice(raw interface{}) []map[string]interface{} { + arr, _ := raw.([]interface{}) + out := make([]map[string]interface{}, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out +} + +// auditListTables 取 --table 切片,trim 去空。 +func auditListTables(rctx *common.RuntimeContext) []string { + out := make([]string, 0) + for _, t := range rctx.StrSlice("table") { + if v := strings.TrimSpace(t); v != "" { + out = append(out, v) + } + } + return out +} + +// buildAuditListParams 组装 audit_list 查询参数:env / tables(逗号拼接) / page_size 及可选 since/until/page_token。 +func buildAuditListParams(rctx *common.RuntimeContext, tables []string) map[string]interface{} { + params := map[string]interface{}{ + "env": dbEnv(rctx), + "tables": strings.Join(tables, ","), + "page_size": rctx.Int("page-size"), + } + addStr := func(flag, key string) { + if v := strings.TrimSpace(rctx.Str(flag)); v != "" { + params[key] = v + } + } + addStr("since", "since") + addStr("until", "until") + addStr("page-token", "page_token") + return params +} + +type auditLogItem struct { + EventID string `json:"event_id"` + EventTime string `json:"event_time"` + TargetTable string `json:"target_table"` + Type string `json:"type"` + Operator *operatorRef `json:"operator,omitempty"` + Summary string `json:"summary"` + Before interface{} `json:"before,omitempty"` + After interface{} `json:"after,omitempty"` +} + +// projectAuditLogItems 把服务端原始审计事件投影为白名单 auditLogItem(operator 解析、before/after 还原成对象)。 +func projectAuditLogItems(raw interface{}) []auditLogItem { + arr, _ := raw.([]interface{}) + out := make([]auditLogItem, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + row := auditLogItem{ + EventID: common.GetString(m, "event_id"), + EventTime: common.GetString(m, "event_time"), + TargetTable: common.GetString(m, "target_table"), + Type: common.GetString(m, "type"), + Operator: parseOperator(common.GetString(m, "operator")), + Summary: common.GetString(m, "summary"), + } + // before/after 条件出现:INSERT 无 before、DELETE 无 after。JSON 字符串 → 还原对象。 + if b := common.GetString(m, "before"); b != "" { + row.Before = safeParseJSON(b) + } + if a := common.GetString(m, "after"); a != "" { + row.After = safeParseJSON(a) + } + out = append(out, row) + } + return out +} + +// renderAuditListPretty 单表 5 列 / 多表 6 列(首列 target_table);末尾列出 skipped 表。 +func renderAuditListPretty(w io.Writer, items []auditLogItem, skipped []auditSkippedEntry, totalRequested int, multi bool) { + if len(items) == 0 { + io.WriteString(w, "No audit events found.\n") + writeAuditSkipped(w, skipped, totalRequested) + return + } + var headers []string + if multi { + headers = []string{"target_table", "event_time", "type", "event_id", "operator", "summary"} + } else { + headers = []string{"event_time", "type", "event_id", "operator", "summary"} + } + rows := make([][]string, 0, len(items)) + for _, it := range items { + cells := []string{dashIfEmpty(it.EventTime), it.Type, it.EventID, operatorName(it.Operator), dashIfEmpty(it.Summary)} + if multi { + cells = append([]string{dashIfEmpty(it.TargetTable)}, cells...) + } + rows = append(rows, cells) + } + renderAlignedTable(w, headers, rows) + writeAuditSkipped(w, skipped, totalRequested) +} + +// writeAuditSkipped 打 "— Skipped N of M tables: orders (audit not enabled), foo (table not found)"。 +func writeAuditSkipped(w io.Writer, skipped []auditSkippedEntry, totalRequested int) { + if len(skipped) == 0 { + return + } + parts := make([]string, 0, len(skipped)) + for _, s := range skipped { + parts = append(parts, fmt.Sprintf("%s (%s)", s.Table, s.Reason)) + } + fmt.Fprintf(w, "— Skipped %d of %d tables: %s\n", len(skipped), totalRequested, strings.Join(parts, ", ")) +} diff --git a/shortcuts/apps/apps_db_audit_set.go b/shortcuts/apps/apps_db_audit_set.go new file mode 100644 index 00000000..93f0d4f7 --- /dev/null +++ b/shortcuts/apps/apps_db_audit_set.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// 审计保留期合法取值。 +var auditRetentions = []string{"7d", "30d", "180d", "360d", "forever"} + +const dbAuditSetHint = "verify --app-id and --table; check current config with `lark-cli apps +db-audit-status --app-id `" + +// AppsDBAuditEnable 为某张表开启行级审计(变更追溯)。 +// +// POST /apps/{app_id}/db/audit_set,body {table, enabled:true, retention}。--retention 默认 7d。 +var AppsDBAuditEnable = common.Shortcut{ + Service: appsService, + Command: "+db-audit-enable", + Description: "Enable row-change audit logging for a table", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +db-audit-enable --app-id --table orders --retention 30d", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: append([]common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Desc: "table to enable audit for", Required: true}, + {Name: "retention", Default: "7d", Enum: auditRetentions, Desc: "how long to keep audit logs"}, + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return rejectLegacyEnvFlag(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appAuditSetPath(appID)). + Desc("Enable table audit"). + Params(map[string]interface{}{"env": dbEnv(rctx)}). + Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": true, "retention": rctx.Str("retention")}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + table := strings.TrimSpace(rctx.Str("table")) + retention := rctx.Str("retention") + stop := rctx.StartSpinner("Enabling audit logging for " + table) + defer stop() + data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID), + map[string]interface{}{"env": dbEnv(rctx)}, + map[string]interface{}{"table": table, "enabled": true, "retention": retention}) + stop() + if err != nil { + return withAppsHint(err, dbAuditSetHint) + } + st := auditSetStatus(data, table) + ret := common.GetString(st, "retention") + if ret == "" { + ret = retention + } + out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": true, "retention": ret} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Audit enabled for table '%s' (retention: %s)\n", common.GetString(out, "table"), ret) + }) + return nil + }, +} + +// AppsDBAuditDisable 关闭某张表的行级审计。 +// +// POST /apps/{app_id}/db/audit_set,body {table, enabled:false}。 +var AppsDBAuditDisable = common.Shortcut{ + Service: appsService, + Command: "+db-audit-disable", + Description: "Disable row-change audit logging for a table", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +db-audit-disable --app-id --table orders", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: append([]common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Desc: "table to disable audit for", Required: true}, + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return rejectLegacyEnvFlag(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appAuditSetPath(appID)). + Desc("Disable table audit"). + Params(map[string]interface{}{"env": dbEnv(rctx)}). + Body(map[string]interface{}{"table": strings.TrimSpace(rctx.Str("table")), "enabled": false}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + table := strings.TrimSpace(rctx.Str("table")) + data, err := rctx.CallAPITyped("POST", appAuditSetPath(appID), + map[string]interface{}{"env": dbEnv(rctx)}, + map[string]interface{}{"table": table, "enabled": false}) + if err != nil { + return withAppsHint(err, dbAuditSetHint) + } + st := auditSetStatus(data, table) + out := map[string]interface{}{"table": common.GetString(st, "table"), "enabled": false} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Audit disabled for table '%s'\n", common.GetString(out, "table")) + }) + return nil + }, +} + +// auditSetStatus 取响应里的 status 对象(缺失时用入参 table 兜底)。 +func auditSetStatus(data map[string]interface{}, table string) map[string]interface{} { + if st, ok := data["status"].(map[string]interface{}); ok { + if common.GetString(st, "table") == "" { + st["table"] = table + } + return st + } + return map[string]interface{}{"table": table} +} diff --git a/shortcuts/apps/apps_db_audit_status.go b/shortcuts/apps/apps_db_audit_status.go new file mode 100644 index 00000000..341e11bd --- /dev/null +++ b/shortcuts/apps/apps_db_audit_status.go @@ -0,0 +1,140 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsDBAuditStatus 查看数据表的审计开关状态(哪些表开了行级审计、保留期)。 +// +// GET /apps/{app_id}/db/audit_status。--table 指定单表(无记录时占位 enabled=false); +// 不指定返回所有已配置表。json 单表返对象、多表返数组;pretty 单表 key/value、多表表格。 +var AppsDBAuditStatus = common.Shortcut{ + Service: appsService, + Command: "+db-audit-status", + Description: "Show table audit (row-change tracking) status", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-audit-status --app-id ", + "Check one table: --table orders", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: append([]common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Desc: "show status for a single table (default: all configured tables)"}, + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return rejectLegacyEnvFlag(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appAuditStatusPath(appID)). + Desc("Get table audit status"). + Params(buildAuditStatusParams(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", appAuditStatusPath(appID), buildAuditStatusParams(rctx), nil) + if err != nil { + return withAppsHint(err, dbChangelogHint) + } + table := strings.TrimSpace(rctx.Str("table")) + items := projectAuditStatusItems(data["items"]) + // 单表查询但后端无记录 → 占位 enabled=false(与 miaoda 一致)。 + if table != "" && len(items) == 0 { + items = []map[string]interface{}{{"table": table, "enabled": false}} + } + // json:单表返对象、多表返数组。 + var out interface{} + if table != "" && len(items) == 1 { + out = items[0] + } else { + out = map[string]interface{}{"items": items} + } + rctx.OutFormat(out, nil, func(w io.Writer) { + renderAuditStatusPretty(w, items, table) + }) + return nil + }, +} + +// buildAuditStatusParams 组装 audit_status 查询参数:env 及可选 table(单表查询)。 +func buildAuditStatusParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{"env": dbEnv(rctx)} + if t := strings.TrimSpace(rctx.Str("table")); t != "" { + params["table"] = t + } + return params +} + +// projectAuditStatusItems 透出 {table, enabled, enabled_at?, retention?}。 +func projectAuditStatusItems(raw interface{}) []map[string]interface{} { + arr, _ := raw.([]interface{}) + out := make([]map[string]interface{}, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + row := map[string]interface{}{ + "table": common.GetString(m, "table"), + "enabled": m["enabled"] == true, + } + if v := common.GetString(m, "enabled_at"); v != "" { + row["enabled_at"] = v + } + if v := common.GetString(m, "retention"); v != "" { + row["retention"] = v + } + out = append(out, row) + } + return out +} + +// renderAuditStatusPretty 单表渲染 key/value、多表渲染对齐表格(table/enabled/enabled_at/retention)。 +func renderAuditStatusPretty(w io.Writer, items []map[string]interface{}, table string) { + if len(items) == 0 { + io.WriteString(w, "No audit configuration found.\n") + return + } + yesNo := func(m map[string]interface{}) string { + if m["enabled"] == true { + return "yes" + } + return "no" + } + get := func(m map[string]interface{}, k string) string { return dashIfEmpty(common.GetString(m, k)) } + // 单表 → key/value + if table != "" && len(items) == 1 { + it := items[0] + renderKeyValuePairs(w, [][2]string{ + {"table", common.GetString(it, "table")}, + {"enabled", yesNo(it)}, + {"enabled_at", get(it, "enabled_at")}, + {"retention", get(it, "retention")}, + }) + return + } + // 多表 → 表格 + headers := []string{"table", "enabled", "enabled_at", "retention"} + rows := make([][]string, 0, len(items)) + for _, it := range items { + rows = append(rows, []string{common.GetString(it, "table"), yesNo(it), get(it, "enabled_at"), get(it, "retention")}) + } + renderAlignedTable(w, headers, rows) +} diff --git a/shortcuts/apps/apps_db_audit_test.go b/shortcuts/apps/apps_db_audit_test.go new file mode 100644 index 00000000..becf9b86 --- /dev/null +++ b/shortcuts/apps/apps_db_audit_test.go @@ -0,0 +1,316 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const ( + dbAuditStatusURL = "/open-apis/spark/v1/apps/app_x/db/audit_status" + dbAuditSetURL = "/open-apis/spark/v1/apps/app_x/db/audit_set" + dbAuditListURL = "/open-apis/spark/v1/apps/app_x/db/audit_list" + dbTablesListURL = "/open-apis/spark/v1/apps/app_x/tables" +) + +// ── audit-status ── + +// TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder 验证单表查询无记录时返回 enabled:false 的占位对象(非数组)。 +func TestAppsDBAuditStatus_SingleTableObjectWithPlaceholder(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsDBAuditStatus, + []string{"+db-audit-status", "--app-id", "app_x", "--table", "orders", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + // 单表无记录 → 占位对象 enabled:false(不是数组)。 + var env struct { + Data struct { + Table string `json:"table"` + Enabled bool `json:"enabled"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(stdout.String()), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) + } + if env.Data.Table != "orders" || env.Data.Enabled { + t.Fatalf("expected placeholder {orders,false}, got %+v", env.Data) + } +} + +// TestAppsDBAuditStatus_MultiTablePrettyTable 验证多表 pretty 输出含 enabled/yes/no 列与 retention 值。 +func TestAppsDBAuditStatus_MultiTablePrettyTable(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{ + map[string]interface{}{"table": "orders", "enabled": true, "enabled_at": "2026-04-15T10:30:00Z", "retention": "30d"}, + map[string]interface{}{"table": "users", "enabled": false}, + }}}, + }) + if err := runAppsShortcut(t, AppsDBAuditStatus, + []string{"+db-audit-status", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "enabled") || !strings.Contains(got, "yes") || !strings.Contains(got, "no") || !strings.Contains(got, "30d") { + t.Fatalf("pretty table malformed:\n%s", got) + } +} + +// ── audit-enable / disable ── + +// TestAppsDBAuditEnable_RequiresTableAndValidRetention 验证缺 --table 报必填错、非法 --retention 报 ValidationError。 +func TestAppsDBAuditEnable_RequiresTableAndValidRetention(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // 缺 --table → cobra required, exit 1 + if err := runAppsShortcut(t, AppsDBAuditEnable, + []string{"+db-audit-enable", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected required --table error") + } + // 非法 retention → enum 校验 (validation) + factory2, stdout2, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBAuditEnable, + []string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "99d", "--as", "user"}, factory2, stdout2) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--retention" { + t.Fatalf("Param = %q, want --retention", ve.Param) + } +} + +// TestAppsDBAuditEnable_DryRunAndSuccess 验证 dry-run 发出 enabled:true+retention 的 POST,成功时打印 pretty 确认行。 +func TestAppsDBAuditEnable_DryRunAndSuccess(t *testing.T) { + // dry-run body {table, enabled:true, retention} + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditEnable, + []string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--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"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != dbAuditSetURL || a.Body["enabled"] != true || a.Body["retention"] != "30d" || a.Body["table"] != "orders" { + t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body) + } + + // success + factory2, stdout2, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbAuditSetURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": true, "retention": "30d"}}}, + }) + if err := runAppsShortcut(t, AppsDBAuditEnable, + []string{"+db-audit-enable", "--app-id", "app_x", "--table", "orders", "--retention", "30d", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout2.String(), "✓ Audit enabled for table 'orders' (retention: 30d)") { + t.Fatalf("pretty: %s", stdout2.String()) + } +} + +// TestAppsDBAuditDisable_DryRunAndSuccess 验证 dry-run 发出 enabled:false 的 POST,成功时打印 pretty 确认行。 +func TestAppsDBAuditDisable_DryRunAndSuccess(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditDisable, + []string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--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"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Body["enabled"] != false || env.API[0].Body["table"] != "orders" { + t.Fatalf("dry-run body=%v (want enabled:false)", env.API[0].Body) + } + + factory2, stdout2, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbAuditSetURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": map[string]interface{}{"table": "orders", "enabled": false}}}, + }) + if err := runAppsShortcut(t, AppsDBAuditDisable, + []string{"+db-audit-disable", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout2.String(), "✓ Audit disabled for table 'orders'") { + t.Fatalf("pretty: %s", stdout2.String()) + } +} + +// ── audit-list ── + +// TestAppsDBAuditList_RequiresTable 验证缺 --table 时报必填错误。 +func TestAppsDBAuditList_RequiresTable(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected required --table error") + } +} + +// TestAppsDBAuditList_DryRunJoinsTables 验证 dry-run 将多个 --table 合并为 tables=orders,users 且归一化 since。 +func TestAppsDBAuditList_DryRunJoinsTables(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--since", "7d", "--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"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "GET" || a.URL != dbAuditListURL || a.Params["tables"] != "orders,users" { + t.Fatalf("dry-run = %s %s tables=%v", a.Method, a.URL, a.Params["tables"]) + } + if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") { + t.Fatalf("since not normalized: %v", a.Params["since"]) + } +} + +// 单表查询:不预过滤、直接打 audit_list(后端就 not-found/not-enabled 报错),无 skipped。 +// TestAppsDBAuditList_SingleTableNoPreflight 验证单表查询不预过滤、operator/before/after 还原为对象、无 skipped。 +func TestAppsDBAuditList_SingleTableNoPreflight(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "has_more": false, "page_token": "", + "items": []interface{}{map[string]interface{}{ + "event_id": "01525", "event_time": "2026-04-16T10:30:00Z", "target_table": "users", + "type": "UPDATE", "operator": `{"id":"7311","name":"alice"}`, "summary": "UPDATE 1 field", + "before": `{"amount":100}`, "after": `{"amount":999}`, + }}, + }}, + }) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "users", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // operator → 对象;before/after → 还原成对象(非字符串)。 + for _, want := range []string{`"name": "alice"`, `"before"`, `"amount": 100`, `"after"`, `"amount": 999`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } + if strings.Contains(got, `"skipped"`) { + t.Errorf("single-table query must not emit skipped:\n%s", got) + } + if strings.Contains(got, `"before": "{`) { + t.Errorf("before should be an object, not a JSON string:\n%s", got) + } +} + +// TestAppsDBAuditList_SingleTableEmptyPretty 验证单表无事件时不报错、pretty 打印 "No audit events found." 且无 Skipped。 +func TestAppsDBAuditList_SingleTableEmptyPretty(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("empty audit list should NOT error (ok read), got %v", err) + } + got := stdout.String() + if !strings.Contains(got, "No audit events found.") || strings.Contains(got, "Skipped") { + t.Fatalf("expected empty, no skipped for single table:\n%s", got) + } +} + +// 多表查询:CLI 用 schema(存在性)+ status(审计开关)预过滤,只把有效表传给 audit_list, +// 不存在 / 未开启审计的表进 skipped。 +// TestAppsDBAuditList_MultiTablePreflightFilters 验证多表查询用 schema+status 预过滤,仅传有效表,不存在/未开审计的表进 skipped。 +func TestAppsDBAuditList_MultiTablePreflightFilters(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + // schema:orders/users/carts 存在,ghost 不存在。 + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbTablesListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{ + map[string]interface{}{"name": "orders"}, map[string]interface{}{"name": "users"}, map[string]interface{}{"name": "carts"}, + }}}, + }) + // status:orders/users 开启审计,carts 未开启。 + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{ + map[string]interface{}{"table": "orders", "enabled": true}, map[string]interface{}{"table": "users", "enabled": true}, + map[string]interface{}{"table": "carts", "enabled": false}, + }}}, + }) + // audit_list 只应被传入有效表 orders,users。 + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditListURL, + OnMatch: func(req *http.Request) { + if got := req.URL.Query().Get("tables"); got != "orders,users" { + t.Errorf("audit_list tables = %q, want orders,users (filtered)", got) + } + }, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{ + map[string]interface{}{"event_id": "e1", "event_time": "2026-04-16T10:30:00Z", "target_table": "orders", "type": "INSERT", "summary": "INSERT"}, + }}}, + }) + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "orders", "--table", "users", "--table", "carts", "--table", "ghost", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // skipped:carts(audit not enabled) + ghost(table not found),结构化 {table,reason}。 + for _, want := range []string{`"skipped"`, `"table": "carts"`, `"reason": "audit not enabled"`, `"table": "ghost"`, `"reason": "table not found"`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } +} + +// 多表查询且全部被过滤掉 → 不调 audit_list,直接空 + skipped 提示。 +// TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery 验证多表全部被过滤时跳过 audit_list 调用,直接输出空结果加 Skipped 提示。 +func TestAppsDBAuditList_MultiTableAllFilteredSkipsQuery(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbTablesListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"has_more": false, "items": []interface{}{ + map[string]interface{}{"name": "orders"}, + }}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbAuditStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + // 不注册 audit_list:若被调用会命中未注册请求而报错。 + if err := runAppsShortcut(t, AppsDBAuditList, + []string{"+db-audit-list", "--app-id", "app_x", "--table", "ghost1", "--table", "ghost2", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("all-filtered should still succeed (empty), got %v", err) + } + got := stdout.String() + if !strings.Contains(got, "No audit events found.") || !strings.Contains(got, "Skipped 2 of 2 tables") { + t.Fatalf("expected empty + 'Skipped 2 of 2 tables':\n%s", got) + } +} diff --git a/shortcuts/apps/apps_db_changelog_list.go b/shortcuts/apps/apps_db_changelog_list.go new file mode 100644 index 00000000..052bbf9f --- /dev/null +++ b/shortcuts/apps/apps_db_changelog_list.go @@ -0,0 +1,152 @@ +// 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 dbChangelogHint = "verify --app-id is correct; if targeting --environment dev, create it first with `lark-cli apps +db-env-create --app-id --environment dev`" + +// AppsDBChangelogList 列出应用数据库的 DDL 变更记录(建表/改表/索引等结构变更追溯)。 +// +// GET /apps/{app_id}/db/changelog_list(cursor 分页)。过滤:--table、--since/--until(多格式时间)。 +// --change-id 精确查单条(命中返单条、否则空)。operator 后端以 JSON 字符串透传 {id,name}, +// json 还原成对象、pretty 只展示 name。 +var AppsDBChangelogList = common.Shortcut{ + Service: appsService, + Command: "+db-changelog-list", + Description: "List a Miaoda app database's DDL change history (cursor pagination)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-changelog-list --app-id ", + "Pin a single change with --change-id; filter time with --since 7d / --until 2026-04-15.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: append([]common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Desc: "filter by target table"}, + {Name: "change-id", Desc: "look up a single change by id (returns that one record only)"}, + {Name: "since", Desc: "filter: changed at or after; relative (7d/2h) | date | datetime | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"}, + {Name: "until", Desc: "filter: changed at or before; same formats as --since"}, + {Name: "page-size", Type: "int", Default: "20", Desc: "page size"}, + {Name: "page-token", Desc: "pagination cursor from previous response"}, + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if err := rejectLegacyEnvFlag(rctx); err != nil { + return err + } + return normalizeTimeFlags(rctx, "since", "until") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appChangelogListPath(appID)). + Desc("List Miaoda app DDL changelog"). + Params(buildChangelogParams(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", appChangelogListPath(appID), buildChangelogParams(rctx), nil) + if err != nil { + return withAppsHint(err, dbChangelogHint) + } + items := projectChangelogItems(data["items"]) + data["items"] = items + changeID := strings.TrimSpace(rctx.Str("change-id")) + rctx.OutFormat(data, nil, func(w io.Writer) { + renderChangelogPretty(w, items, changeID) + }) + return nil + }, +} + +// buildChangelogParams 组装 changelog_list 查询参数:env / page_size 及可选 table/change_id/since/until/page_token。 +func buildChangelogParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{ + "env": dbEnv(rctx), + "page_size": rctx.Int("page-size"), + } + addStr := func(flag, key string) { + if v := strings.TrimSpace(rctx.Str(flag)); v != "" { + params[key] = v + } + } + addStr("table", "table") + addStr("change-id", "change_id") + addStr("since", "since") + addStr("until", "until") + addStr("page-token", "page_token") + return params +} + +type changelogItem struct { + ChangeID string `json:"change_id"` + ChangedAt string `json:"changed_at"` + Operator *operatorRef `json:"operator,omitempty"` + TargetTable string `json:"target_table"` + ChangeType string `json:"change_type"` + Summary string `json:"summary"` + Statement string `json:"statement,omitempty"` +} + +// projectChangelogItems 把服务端原始 DDL 变更记录投影为白名单 changelogItem(operator 解析成对象)。 +func projectChangelogItems(raw interface{}) []changelogItem { + arr, _ := raw.([]interface{}) + out := make([]changelogItem, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + out = append(out, changelogItem{ + ChangeID: common.GetString(m, "change_id"), + ChangedAt: common.GetString(m, "changed_at"), + Operator: parseOperator(common.GetString(m, "operator")), + TargetTable: common.GetString(m, "target_table"), + ChangeType: common.GetString(m, "change_type"), + Summary: common.GetString(m, "summary"), + Statement: common.GetString(m, "statement"), + }) + } + return out +} + +// renderChangelogPretty 6 列:change_id / changed_at / operator(name) / target_table / change_type / summary。 +func renderChangelogPretty(w io.Writer, items []changelogItem, changeID string) { + if len(items) == 0 { + if changeID != "" { + fmt.Fprintf(w, "No DDL change with id=%s found.\n", changeID) + } else { + io.WriteString(w, "No DDL changes found.\n") + } + return + } + headers := []string{"change_id", "changed_at", "operator", "target_table", "change_type", "summary"} + rows := make([][]string, 0, len(items)) + for _, it := range items { + rows = append(rows, []string{ + it.ChangeID, + dashIfEmpty(it.ChangedAt), + operatorName(it.Operator), + dashIfEmpty(it.TargetTable), + it.ChangeType, + dashIfEmpty(it.Summary), + }) + } + renderAlignedTable(w, headers, rows) +} diff --git a/shortcuts/apps/apps_db_changelog_list_test.go b/shortcuts/apps/apps_db_changelog_list_test.go new file mode 100644 index 00000000..a179b14e --- /dev/null +++ b/shortcuts/apps/apps_db_changelog_list_test.go @@ -0,0 +1,143 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const dbChangelogURL = "/open-apis/spark/v1/apps/app_x/db/changelog_list" + +// TestAppsDBChangelogList_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。 +func TestAppsDBChangelogList_RequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBChangelogList, + []string{"+db-changelog-list", "--app-id", " ", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--app-id" { + t.Fatalf("Param = %q, want --app-id", ve.Param) + } +} + +// TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize 验证 dry-run 透传 env/table/change_id 过滤参数并将 since 归一化为 RFC3339 UTC。 +func TestAppsDBChangelogList_DryRunFiltersAndTimeNormalize(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBChangelogList, + []string{"+db-changelog-list", "--app-id", "app_x", "--environment", "dev", "--table", "orders", + "--change-id", "01J", "--since", "2026-01-01", "--page-size", "5", "--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"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "GET" || a.URL != dbChangelogURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Params["env"] != "dev" || a.Params["table"] != "orders" || a.Params["change_id"] != "01J" { + t.Fatalf("params = %v", a.Params) + } + if s, _ := a.Params["since"].(string); !strings.HasSuffix(s, "Z") { + t.Fatalf("since not normalized to RFC3339 UTC: %v", a.Params["since"]) + } +} + +// TestAppsDBChangelogList_RejectsBadSince 验证不可解析的 --since 报 --since 的 ValidationError。 +func TestAppsDBChangelogList_RejectsBadSince(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBChangelogList, + []string{"+db-changelog-list", "--app-id", "app_x", "--since", "notatime", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--since" { + t.Fatalf("Param = %q, want --since", ve.Param) + } +} + +// TestAppsDBChangelogList_SuccessParsesOperator 验证成功响应中 operator JSON 串被解析为对象并输出变更字段。 +func TestAppsDBChangelogList_SuccessParsesOperator(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbChangelogURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "has_more": false, "page_token": "", + "items": []interface{}{map[string]interface{}{ + "change_id": "01J", "changed_at": "2026-04-15T10:30:00Z", + "operator": `{"id":"7311","name":"alice"}`, "target_table": "orders", + "change_type": "ALTER_TABLE", "summary": "add column", "statement": "ALTER TABLE orders ...", + }}, + }}, + }) + if err := runAppsShortcut(t, AppsDBChangelogList, + []string{"+db-changelog-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{`"operator"`, `"name": "alice"`, `"id": "7311"`, `"change_type": "ALTER_TABLE"`, `"statement"`} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } +} + +// TestAppsDBChangelogList_ChangeIDNotFoundPretty 验证按 --change-id 查询无结果时 pretty 打印 not-found 提示。 +func TestAppsDBChangelogList_ChangeIDNotFoundPretty(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbChangelogURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsDBChangelogList, + []string{"+db-changelog-list", "--app-id", "app_x", "--change-id", "nope", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "No DDL change with id=nope found.") { + t.Fatalf("expected not-found message, got: %s", stdout.String()) + } +} + +// TestParseOperator_Cases 验证 parseOperator 处理合法 JSON、空 name 回退 id、非 JSON 原样、空串返回 nil,以及 operatorName(nil) 为占位符。 +func TestParseOperator_Cases(t *testing.T) { + if op := parseOperator(`{"id":"1","name":"a"}`); op == nil || op.ID != "1" || op.Name != "a" { + t.Fatalf("valid: %#v", op) + } + if op := parseOperator(`{"id":"1","name":""}`); op == nil || op.Name != "1" { + t.Fatalf("name fallback to id: %#v", op) + } + if op := parseOperator("plain-user"); op == nil || op.ID != "plain-user" || op.Name != "plain-user" { + t.Fatalf("non-json raw: %#v", op) + } + if op := parseOperator(""); op != nil { + t.Fatalf("empty → nil, got %#v", op) + } + if operatorName(nil) != "—" { + t.Fatalf("nil operatorName should be —") + } +} + +// TestSafeParseJSON_Cases 验证 safeParseJSON 合法 JSON 解析为对象、非法 JSON 原样返回字符串。 +func TestSafeParseJSON_Cases(t *testing.T) { + if v := safeParseJSON(`{"a":1}`); v == nil { + t.Fatalf("valid json → object") + } + if v, ok := safeParseJSON("not json").(string); !ok || v != "not json" { + t.Fatalf("invalid json → raw string, got %v", v) + } +} diff --git a/shortcuts/apps/apps_db_data_export.go b/shortcuts/apps/apps_db_data_export.go new file mode 100644 index 00000000..77540640 --- /dev/null +++ b/shortcuts/apps/apps_db_data_export.go @@ -0,0 +1,194 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "path/filepath" + "strconv" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/shortcuts/common" +) + +const dbDataExportMaxRows = 5000 +const dbDataExportMaxBytes = 1 * 1024 * 1024 // 1 MB + +const dbDataExportHint = "verify --app-id and --table; if too large, filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets" + +// AppsDBDataExport 把应用数据表导出到本地文件(csv/json/sql)。 +// +// GET /apps/{app_id}/db/data_export,返回原始字节(非 JSON 信封)。 +// 行数不随导出文件返回:CLI 原子编排——先查 GetAppTableRecordList 的 total,再导出文件。 +// 数据格式由 --output 扩展名推断(默认 csv,缺省输出 .csv);上限 5000 行 / 1 MB。 +var AppsDBDataExport = common.Shortcut{ + Service: appsService, + Command: "+db-data-export", + Description: "Export rows from a Miaoda app table to a local file (csv/json/sql)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-data-export --app-id --table orders --output ./orders.csv", + "Format follows the --output extension: .csv / .json / .sql (default csv).", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: append([]common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "table", Desc: "source table", Required: true}, + {Name: "output", Desc: "local output path; extension picks format .csv/.json/.sql (default:
.csv)"}, + {Name: "limit", Type: "int", Default: "5000", Desc: "max rows to export (1..5000)"}, + }, dbEnvFlags("dev", []string{"dev", "online"}, "source db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if err := rejectLegacyEnvFlag(rctx); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("table")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--table is required").WithParam("--table") + } + if n := rctx.Int("limit"); n <= 0 || n > dbDataExportMaxRows { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--limit must be a positive integer ≤ %d", dbDataExportMaxRows).WithParam("--limit") + } + if err := rejectOutputTraversal(rctx.Str("output")); err != nil { + return err + } + if _, _, err := exportFormatAndOutput(rctx); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + format, _, _ := exportFormatAndOutput(rctx) + return common.NewDryRunAPI(). + GET(appDataExportPath(appID)). + Desc("Export Miaoda app table data (raw bytes)"). + Params(map[string]interface{}{ + "env": dbEnv(rctx), "table": strings.TrimSpace(rctx.Str("table")), + "format": format, "limit": rctx.Int("limit"), + }) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + table := strings.TrimSpace(rctx.Str("table")) + format, out, err := exportFormatAndOutput(rctx) + if err != nil { + return err + } + + // 原子编排第 1 步:先查总行数(records 列表的 total),再导出文件。 + // total 查询失败不阻断导出——回退到按导出文件内容数行。 + total, totalErr := queryExportTotal(rctx, appID, dbEnv(rctx), table) + + resp, err := rctx.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: appDataExportPath(appID), + QueryParams: larkcore.QueryParams{ + "env": []string{dbEnv(rctx)}, + "table": []string{table}, + "format": []string{format}, + "limit": []string{strconv.Itoa(rctx.Int("limit"))}, + }, + }) + if err != nil { + return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "export request failed").WithCause(err).WithRetryable(), dbDataExportHint) + } + // 成功是原始字节;业务错误网关以 JSON 信封 {code,msg} 返回(以 '{' 开头)。 + if b := bytes.TrimSpace(resp.RawBody); len(b) > 0 && b[0] == '{' { + if _, cerr := rctx.ClassifyAPIResponse(resp); cerr != nil { + return withAppsHint(cerr, dbDataExportHint) + } + } + if resp.StatusCode >= 400 { + return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkServer, "export failed: HTTP %d", resp.StatusCode).WithRetryable(), dbDataExportHint) + } + body := resp.RawBody + if len(body) > dbDataExportMaxBytes { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "export exceeds 1 MB limit (%d bytes); filter rows with +db-execute (WHERE/LIMIT) and export smaller subsets", len(body)) + } + + saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: int64(len(body)), + }, bytes.NewReader(body)) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output") + } + // 行数取自预查的 total(导出最多 limit 行,故取 min);total 查询失败时按导出内容数行兜底。 + rows := 0 + if totalErr == nil { + rows = total + if lim := rctx.Int("limit"); rows > lim { + rows = lim + } + } else { + rows = countDataRows(body, format) + } + resolved, perr := rctx.FileIO().ResolvePath(out) + if perr != nil || resolved == "" { + resolved = out + } + result := map[string]interface{}{ + "table": table, "output": resolved, "format": format, + "rows": rows, "size_bytes": saved.Size(), + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Exported %s → %s (%d rows)\n", table, resolved, rows) + }) + return nil + }, +} + +// queryExportTotal 调 GetAppTableRecordList(page_size=1)取 total(符合条件的记录总数)。 +// 该接口与 +db-data-export 同为 spark:app:read scope,避免导出命令被迫升级到写权限。 +func queryExportTotal(rctx *common.RuntimeContext, appID, env, table string) (int, error) { + raw, err := rctx.CallAPITyped("GET", appTableRecordsPath(appID, table), + map[string]interface{}{"env": env, "page_size": 1}, nil) + if err != nil { + return 0, err + } + return totalAsInt(raw["total"]), nil +} + +// totalAsInt 把 total 解析成 int,兼容 JSON number 与 i64-as-string 两种 wire 形态。 +func totalAsInt(v interface{}) int { + if f, ok := numericAsFloat(v); ok { + return int(f) + } + if s, ok := v.(string); ok { + if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil { + return n + } + } + return 0 +} + +// exportFormatAndOutput 由 --output 推断数据格式与落盘路径: +// 给了 --output → 取其扩展名定 format(csv/json/sql);未给 → 默认 csv、输出
.csv。 +func exportFormatAndOutput(rctx *common.RuntimeContext) (format, outPath string, err error) { + table := strings.TrimSpace(rctx.Str("table")) + out := strings.TrimSpace(rctx.Str("output")) + if out == "" { + return "csv", table + ".csv", nil + } + f, ferr := resolveDataFormat(filepath.Ext(out), true) + if ferr != nil { + return "", "", ferr + } + return f, out, nil +} diff --git a/shortcuts/apps/apps_db_data_export_test.go b/shortcuts/apps/apps_db_data_export_test.go new file mode 100644 index 00000000..f2c9121a --- /dev/null +++ b/shortcuts/apps/apps_db_data_export_test.go @@ -0,0 +1,193 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "net/http" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const dbDataExportURL = "/open-apis/spark/v1/apps/app_x/db/data_export" +const dbOrdersRecordsURL = "/open-apis/spark/v1/apps/app_x/tables/orders/records" + +// TestAppsDBDataExport_RequiresTable 验证缺 --table 时报必填错误。 +func TestAppsDBDataExport_RequiresTable(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // 缺 --table → cobra required-flag, exit 1 + err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected required-flag error for missing --table") + } +} + +// TestAppsDBDataExport_RejectsBadLimit 验证越界 --limit(0/-1/5001)均报 --limit 的 ValidationError。 +func TestAppsDBDataExport_RejectsBadLimit(t *testing.T) { + for _, lim := range []string{"0", "-1", "5001"} { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--limit", lim, "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("limit=%s err = %T %v, want *errs.ValidationError", lim, err, err) + } + if ve.Param != "--limit" { + t.Fatalf("limit=%s Param = %q, want --limit", lim, ve.Param) + } + } +} + +// TestAppsDBDataExport_RejectsBadOutputExtension 验证不支持的 --output 扩展名(.xml)报校验错误。 +func TestAppsDBDataExport_RejectsBadOutputExtension(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "dump.xml", "--as", "user"}, factory, stdout) + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected unsupported-format validation for .xml, got %v", err) + } +} + +// dry-run:format 跟随 --output 扩展名;缺省 csv。 +// TestAppsDBDataExport_DryRunFormatFromOutput 验证 dry-run 的 format 参数跟随 --output 扩展名、缺省为 csv,并带 limit。 +func TestAppsDBDataExport_DryRunFormatFromOutput(t *testing.T) { + cases := []struct{ output, wantFmt string }{ + {"", "csv"}, {"orders.csv", "csv"}, {"orders.json", "json"}, {"dump.sql", "sql"}, + } + for _, c := range cases { + factory, stdout, _ := newAppsExecuteFactory(t) + args := []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--dry-run", "--as", "user"} + if c.output != "" { + args = append(args, "--output", c.output) + } + if err := runAppsShortcut(t, AppsDBDataExport, args, 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"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "GET" || a.URL != dbDataExportURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Params["format"] != c.wantFmt || a.Params["table"] != "orders" { + t.Errorf("output=%q params.format=%v want %q", c.output, a.Params["format"], c.wantFmt) + } + if _, ok := a.Params["limit"]; !ok { + t.Errorf("dry-run missing limit param") + } + } +} + +// 成功:先查 records 列表 total 计行,再把原始字节落盘。 +// TestAppsDBDataExport_SuccessWritesFile 验证成功路径先查 records total 计行、再将导出原始字节落盘并输出 rows/format/table。 +func TestAppsDBDataExport_SuccessWritesFile(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + // 第 1 步:records 列表 total=2(行数来源)。 + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbOrdersRecordsURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 2, "has_more": false, "items": "[]"}}, + }) + // 第 2 步:导出原始字节。 + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: dbDataExportURL, + RawBody: []byte("id,name\n1,a\n2,b\n"), + Headers: http.Header{"Content-Type": []string{"text/csv"}}, + }) + if err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + b, err := os.ReadFile(dir + "/orders.csv") + if err != nil || string(b) != "id,name\n1,a\n2,b\n" { + t.Fatalf("output file wrong: %q err=%v", string(b), err) + } + got := stdout.String() + if !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"format": "csv"`) || !strings.Contains(got, `"table": "orders"`) { + t.Fatalf("output json missing fields:\n%s", got) + } +} + +// 行数取自 records total,且按 --limit 截顶(min(total, limit))。 +// TestAppsDBDataExport_RowsFromTotalCappedByLimit 验证行数取 records total 并按 --limit 截顶(total=10000、limit=100 → rows=100)。 +func TestAppsDBDataExport_RowsFromTotalCappedByLimit(t *testing.T) { + chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbOrdersRecordsURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"total": 10000, "has_more": true, "items": "[]"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbDataExportURL, + RawBody: []byte("id\n1\n2\n3\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}}, + }) + if err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--limit", "100", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), `"rows": 100`) { + t.Fatalf("expected rows capped to limit 100 from total=10000:\n%s", stdout.String()) + } +} + +// total 查询失败(records 列表报错)→ 回退按导出文件内容数行,不阻断导出。 +// TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable 验证 records total 查询失败时回退按导出文件内容数行,不阻断落盘。 +func TestAppsDBDataExport_FallsBackToFileCountWhenTotalUnavailable(t *testing.T) { + dir := chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbOrdersRecordsURL, + Body: map[string]interface{}{"code": 1254000, "msg": "records unavailable"}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbDataExportURL, + RawBody: []byte("id,name\n1,a\n2,b\n3,c\n"), Headers: http.Header{"Content-Type": []string{"text/csv"}}, + }) + if err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "orders", "--output", "orders.csv", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("export should still succeed via fallback, got %v", err) + } + b, _ := os.ReadFile(dir + "/orders.csv") + if string(b) != "id,name\n1,a\n2,b\n3,c\n" { + t.Fatalf("file not written on fallback path: %q", string(b)) + } + if !strings.Contains(stdout.String(), `"rows": 3`) { + t.Fatalf("expected fallback file-count rows:3:\n%s", stdout.String()) + } +} + +// 业务错误:网关回 JSON 信封 {code,msg}(非原始字节)→ typed error,不落盘。 +// TestAppsDBDataExport_BusinessErrorEnvelope 验证响应为 JSON 错误信封(非原始字节)时返回 typed error 且不落盘。 +func TestAppsDBDataExport_BusinessErrorEnvelope(t *testing.T) { + chdirTemp(t) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: dbDataExportURL, + RawBody: []byte(`{"code":1254043,"msg":"table not found"}`), + Headers: http.Header{"Content-Type": []string{"application/json"}}, + }) + err := runAppsShortcut(t, AppsDBDataExport, + []string{"+db-data-export", "--app-id", "app_x", "--table", "nope", "--output", "nope.csv", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String()) + } + if _, statErr := os.Stat("nope.csv"); statErr == nil { + t.Fatalf("error path must not write the output file") + } +} diff --git a/shortcuts/apps/apps_db_data_import.go b/shortcuts/apps/apps_db_data_import.go new file mode 100644 index 00000000..d3266eeb --- /dev/null +++ b/shortcuts/apps/apps_db_data_import.go @@ -0,0 +1,144 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/shortcuts/common" +) + +const dbDataImportMaxBytes = 1 * 1024 * 1024 // 1 MB + +const dbDataImportHint = "verify --app-id and --table; data file must be .csv/.json and ≤1 MB — split larger files and import in batches" + +// AppsDBDataImport 把本地 csv/json 文件直传到应用数据表(high-risk-write)。 +// +// POST /apps/{app_id}/db/data_import,multipart 表单:file_name + 可选 table + 文件本体(与 +// +file-upload / UploadFileForOpenAPI 一致)。文件的格式解析与转换在服务端 integration 层完成 +// (按 file_name 扩展名推断 csv/json),CLI 不再本地解析。表名缺省取文件名(去扩展名)。上限 1 MB。 +var AppsDBDataImport = common.Shortcut{ + Service: appsService, + Command: "+db-data-import", + Description: "Import rows from a local csv/json file into a Miaoda app table", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +db-data-import --app-id --file ./orders.csv --yes", + "Table defaults to the file name; override with --table.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: append([]common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "file", Desc: "local data file (.csv/.json), relative to cwd", Required: true}, + {Name: "table", Desc: "target table (default: file name without extension)"}, + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if err := rejectLegacyEnvFlag(rctx); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("file")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file") + } + // 文件名即可校验格式(服务端按扩展名推断)与推断表名,无需读取内容。 + if _, err := resolveDataFormat(filepath.Ext(rctx.Str("file")), false); err != nil { + return err + } + // 体积守卫前移到 Validate:用 Stat 先查大小(不读内容),dry-run 也能拦超大文件、且 + // 在读整个文件进内存之前就失败(对齐 +file-upload)。Stat 失败不在此报错,留给 Execute + // 的 ReadInputFile 产出更精确的「文件不存在/越界」错误。 + if st, serr := rctx.FileIO().Stat(strings.TrimSpace(rctx.Str("file"))); serr == nil && st.Size() > dbDataImportMaxBytes { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", st.Size()).WithParam("--file") + } + if importTableName(rctx) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot infer target table from file name; specify --table").WithParam("--table") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + fileName := filepath.Base(strings.TrimSpace(rctx.Str("file"))) + return common.NewDryRunAPI(). + POST(appDataImportPath(appID)). + Desc("Import data file into Miaoda app table (multipart upload)"). + Params(map[string]interface{}{"env": dbEnv(rctx), "table": importTableName(rctx)}). + Body(map[string]interface{}{"file_name": fileName, "file": ""}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + file := strings.TrimSpace(rctx.Str("file")) + content, err := cmdutil.ReadInputFile(rctx.FileIO(), file) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file") + } + if len(content) > dbDataImportMaxBytes { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "import data exceeds 1 MB limit (file is %d bytes); split into ≤1 MB chunks", len(content)).WithParam("--file") + } + fileName := filepath.Base(file) + table := importTableName(rctx) + + // multipart:file_name 走表单字段、文件本体走 form-files;env / table 走 query。 + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddFile("file", bytes.NewReader(content)) + + resp, err := rctx.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: appDataImportPath(appID), + QueryParams: larkcore.QueryParams{"env": []string{dbEnv(rctx)}, "table": []string{table}}, + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + return withAppsHint(errs.NewNetworkError(errs.SubtypeNetworkTransport, "import request failed").WithCause(err).WithRetryable(), dbDataImportHint) + } + data, err := rctx.ClassifyAPIResponse(resp) + if err != nil { + return withAppsHint(err, dbDataImportHint) + } + + outTable := common.GetString(data, "table") + if outTable == "" { + outTable = table + } + rows := int64(0) + if f, ok := numericAsFloat(data["rows"]); ok { + rows = int64(f) + } + out := map[string]interface{}{"file": file, "table": outTable, "rows": rows} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Imported %s → table '%s' (%d rows)\n", file, outTable, rows) + }) + return nil + }, +} + +// importTableName 取目标表名:--table 优先,否则文件名去扩展名。 +func importTableName(rctx *common.RuntimeContext) string { + if t := strings.TrimSpace(rctx.Str("table")); t != "" { + return t + } + f := strings.TrimSpace(rctx.Str("file")) + if f == "" { + return "" + } + base := filepath.Base(f) + return strings.TrimSuffix(base, filepath.Ext(base)) +} diff --git a/shortcuts/apps/apps_db_data_import_test.go b/shortcuts/apps/apps_db_data_import_test.go new file mode 100644 index 00000000..0902e2cf --- /dev/null +++ b/shortcuts/apps/apps_db_data_import_test.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "os" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const dbDataImportURL = "/open-apis/spark/v1/apps/app_x/db/data_import" + +// chdirTemp 切到临时工作目录(--file 走 cwd 内相对路径),返回该目录。 +func chdirTemp(t *testing.T) string { + t.Helper() + dir := t.TempDir() + old, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(old) }) + return dir +} + +// TestAppsDBDataImport_RequiresAppID 验证空白 --app-id 报 --app-id 的 ValidationError。 +func TestAppsDBDataImport_RequiresAppID(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", " ", "--file", "orders.csv", "--yes", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--app-id" { + t.Fatalf("Param = %q, want --app-id", ve.Param) + } +} + +// TestAppsDBDataImport_RejectsUnsupportedFormat 验证非 csv/json 文件(.txt)报不支持格式的校验错误。 +func TestAppsDBDataImport_RejectsUnsupportedFormat(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("data.txt", []byte("x\n"), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "data.txt", "--yes", "--as", "user"}, factory, stdout) + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected unsupported-format validation, got %v", err) + } +} + +// TestAppsDBDataImport_RequiresConfirmation 验证缺 --yes 时报 requires confirmation 错误。 +func TestAppsDBDataImport_RequiresConfirmation(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "requires confirmation") { + t.Fatalf("expected confirmation_required, got %v", err) + } +} + +// TestAppsDBDataImport_RejectsOversizeFile 验证超过 1MB 上限的文件报 --file 的 ValidationError。 +func TestAppsDBDataImport_RejectsOversizeFile(t *testing.T) { + chdirTemp(t) + // >1MB → size 校验 + big := append([]byte("id\n"), make([]byte, dbDataImportMaxBytes+1)...) + _ = os.WriteFile("big.csv", big, 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "big.csv", "--yes", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected 1MB limit error, got %T %v", err, err) + } + if ve.Param != "--file" { + t.Fatalf("Param = %q, want --file", ve.Param) + } +} + +// dry-run:multipart 上传——file_name + file 走 body,env + table 走 query(table 缺省取文件名)。 +// TestAppsDBDataImport_DryRunMultipartShape 验证 dry-run 的 multipart 形态:file_name+file 走 body、env+table 走 query 且不再发 format。 +func TestAppsDBDataImport_DryRunMultipartShape(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("orders.csv", []byte("id\n1\n"), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--environment", "dev", "--dry-run", "--yes", "--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"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != dbDataImportURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Body["file_name"] != "orders.csv" || a.Body["file"] == nil { + t.Fatalf("dry-run body should carry file_name + file: %v", a.Body) + } + if _, ok := a.Body["format"]; ok { + t.Fatalf("format must no longer be sent: %v", a.Body) + } + if a.Params["env"] != "dev" || a.Params["table"] != "orders" { + t.Fatalf("dry-run params (env+table) = %v", a.Params) + } +} + +// TestAppsDBDataImport_Success 验证成功导入后输出含 table、rows 与回显的 file 名。 +func TestAppsDBDataImport_Success(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("orders.csv", []byte("id,name\n1,a\n2,b\n"), 0o600) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbDataImportURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"table": "orders", "rows": 2}}, + }) + if err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "orders.csv", "--table", "orders", "--yes", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"table": "orders"`) || !strings.Contains(got, `"rows": 2`) || !strings.Contains(got, `"file": "orders.csv"`) { + t.Fatalf("output missing fields:\n%s", got) + } +} + +// TestAppsDBDataImport_TableDefaultsToFileBasename 验证未传 --table 时表名缺省取文件名去扩展名(customers.json→customers)。 +func TestAppsDBDataImport_TableDefaultsToFileBasename(t *testing.T) { + chdirTemp(t) + _ = os.WriteFile("customers.json", []byte(`[{"id":1}]`), 0o600) + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBDataImport, + []string{"+db-data-import", "--app-id", "app_x", "--file", "customers.json", "--dry-run", "--yes", "--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"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Params["table"] != "customers" { + t.Fatalf("expected table=customers (from file basename) in params, got %v", env.API[0].Params) + } +} diff --git a/shortcuts/apps/apps_db_env_create.go b/shortcuts/apps/apps_db_env_create.go index 5fe3034e..9e0830db 100644 --- a/shortcuts/apps/apps_db_env_create.go +++ b/shortcuts/apps/apps_db_env_create.go @@ -12,11 +12,11 @@ import ( "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`" +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 --environment dev`" // AppsDBEnvCreate creates a DB environment for an app(拆分单库为 dev/online 多环境)。 // -// 调 POST /apps/{app_id}/db_dev_init。--env 指定要创建的环境,由调用方传入,目前只支持 dev。 +// 调 POST /apps/{app_id}/db_dev_init。--environment 指定要创建的环境,由调用方传入,目前只支持 dev。 // 不可逆:单库一旦拆成 dev/online 双库无法回退。Risk: high-risk-write 触发框架自动注入 --yes 确认关卡。 var AppsDBEnvCreate = common.Shortcut{ Service: appsService, @@ -24,19 +24,20 @@ var AppsDBEnvCreate = common.Shortcut{ 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", + "Example: lark-cli apps +db-env-create --environment dev --sync-data --app-id --yes", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]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)"}, - }, + }, dbEnvFlags("dev", []string{"dev"}, "environment to create (only dev supported for now)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - _, err := requireAppID(rctx.Str("app-id")) - return err + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return rejectLegacyEnvFlag(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) @@ -62,7 +63,7 @@ var AppsDBEnvCreate = common.Shortcut{ } // buildDBEnvCreateBody 构造 db 环境创建 body:sync_data(bool)。 -// --env 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。 +// --environment 目前只支持 dev、服务端接口本身即创建 dev 环境,故不下发 env 字段(仅做 CLI 入参校验/前向兼容)。 func buildDBEnvCreateBody(rctx *common.RuntimeContext) map[string]interface{} { return map[string]interface{}{ "sync_data": rctx.Bool("sync-data"), diff --git a/shortcuts/apps/apps_db_env_create_test.go b/shortcuts/apps/apps_db_env_create_test.go index 0b29bd45..e72af95d 100644 --- a/shortcuts/apps/apps_db_env_create_test.go +++ b/shortcuts/apps/apps_db_env_create_test.go @@ -27,7 +27,7 @@ func TestAppsDBEnvCreate_WithYesPostsSyncData(t *testing.T) { } reg.Register(stub) if err := runAppsShortcut(t, AppsDBEnvCreate, - []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--as", "user"}, + []string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--yes", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -54,7 +54,7 @@ func TestAppsDBEnvCreate_SyncDataFalseByDefault(t *testing.T) { } reg.Register(stub) if err := runAppsShortcut(t, AppsDBEnvCreate, - []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"}, + []string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--yes", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -82,7 +82,7 @@ func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) { }, }) if err := runAppsShortcut(t, AppsDBEnvCreate, - []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"}, + []string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--sync-data", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } @@ -103,7 +103,7 @@ func TestAppsDBEnvCreate_PrettyEmitsAllFourLines(t *testing.T) { 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"}, + []string{"+db-env-create", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } @@ -116,7 +116,7 @@ func TestAppsDBEnvCreate_DryRunNoConfirm(t *testing.T) { 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"}, + []string{"+db-env-create", "--app-id", "app_x", "--environment", "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_env_migrate.go b/shortcuts/apps/apps_db_env_migrate.go new file mode 100644 index 00000000..bea63a6a --- /dev/null +++ b/shortcuts/apps/apps_db_env_migrate.go @@ -0,0 +1,191 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +const dbEnvMigrateHint = "ensure the app is multi-env (`+db-env-create`) and has pending dev changes; preview with `+db-env-diff`" + +// AppsDBEnvDiff 预览 dev→online 待发布的结构变更(不落地)。 +// +// POST /apps/{app_id}/db/env_migrate,body {dry_run:true},同步返 {from,to,changes[]}。 +// 与 +db-env-migrate 同端点、dry_run 区分;预览也需 spark:app:write scope。 +var AppsDBEnvDiff = common.Shortcut{ + Service: appsService, + Command: "+db-env-diff", + Description: "Preview pending dev→online schema changes (no apply)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-env-diff --app-id ", + "Apply the previewed changes with +db-env-migrate --yes.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + _, 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(appEnvMigratePath(appID)).Desc("Preview dev→online migration").Body(map[string]interface{}{"dry_run": true}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + stop := rctx.StartSpinner("Previewing migration diff (dev → online)") + defer stop() + data, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": true}) + stop() + if err != nil { + return withAppsHint(err, dbEnvMigrateHint) + } + from, to := common.GetString(data, "from"), common.GetString(data, "to") + changes := projectMigrationChanges(data["changes"]) + out := map[string]interface{}{"from": from, "to": to, "changes": changes} + rctx.OutFormat(out, nil, func(w io.Writer) { + renderMigrationDiff(w, from, to, changes) + }) + return nil + }, +} + +// AppsDBEnvMigrate 把 dev 的待发布结构变更发布到 online(异步,CLI 轮询至完成)。 +// +// POST /apps/{app_id}/db/env_migrate,body {dry_run:false} → task_id,轮询 env_migrate_status +// 至 success;后端 status:applied,CLI 对外统一呈现 migrated。high-risk-write。 +var AppsDBEnvMigrate = common.Shortcut{ + Service: appsService, + Command: "+db-env-migrate", + Description: "Publish pending dev→online schema changes (irreversible)", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +db-env-migrate --app-id --yes", + "Preview first with +db-env-diff.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + _, 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(appEnvMigratePath(appID)).Desc("Apply dev→online migration").Body(map[string]interface{}{"dry_run": false}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + stop := rctx.StartSpinner("Applying migration (dev → online)") + defer stop() + submit, err := rctx.CallAPITyped("POST", appEnvMigratePath(appID), nil, map[string]interface{}{"dry_run": false}) + if err != nil { + return withAppsHint(err, dbEnvMigrateHint) + } + from, to := common.GetString(submit, "from"), common.GetString(submit, "to") + taskID := common.GetString(submit, "task_id") + applied := intFromAny(submit["changes_applied"]) + if applied == 0 { + applied = len(projectMigrationChanges(submit["changes"])) + } + // 有 task_id → 异步,轮询至终态;无 task_id(同步完成)则直接用 submit 结果。 + if taskID != "" { + final, perr := pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute, + func() (map[string]interface{}, error) { + return rctx.CallAPITyped("GET", appEnvMigrateStatusPath(appID), map[string]interface{}{"task_id": taskID}, nil) + }, + func(d map[string]interface{}) (bool, error) { + switch strings.ToLower(common.GetString(d, "status")) { + case "success", "applied", "migrated": + return true, nil + case "failed": + return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", migrateFailMsg(d, taskID)), dbEnvMigrateHint) + } + return false, nil + }) + if perr != nil { + return perr + } + if n := intFromAny(final["changes_applied"]); n > 0 { + applied = n + } + } + stop() // clear spinner before printing the result + out := map[string]interface{}{"status": "migrated", "from": from, "to": to, "changes_applied": applied} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Migrated %s → %s (%d changes)\n", from, to, applied) + }) + return nil + }, +} + +type migrationChange struct { + Type string `json:"type"` + Table string `json:"table"` + Statement string `json:"statement"` +} + +// projectMigrationChanges 把服务端原始变更项投影为白名单 migrationChange(type/table/statement)。 +func projectMigrationChanges(raw interface{}) []migrationChange { + arr, _ := raw.([]interface{}) + out := make([]migrationChange, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]interface{}); ok { + out = append(out, migrationChange{ + Type: common.GetString(m, "type"), + Table: common.GetString(m, "table"), + Statement: common.GetString(m, "statement"), + }) + } + } + return out +} + +// renderMigrationDiff 渲染 dev→online 待发布变更:无变更打提示,否则逐条打 statement。 +func renderMigrationDiff(w io.Writer, from, to string, changes []migrationChange) { + if len(changes) == 0 { + fmt.Fprintf(w, "No pending changes from %s to %s.\n", from, to) + return + } + fmt.Fprintf(w, "%s → %s (%d changes):\n\n", from, to, len(changes)) + for _, c := range changes { + fmt.Fprintf(w, " %s\n", c.Statement) + } +} + +// migrateFailMsg 取发布失败信息:优先服务端 error_message,缺失则用带 task_id 的兜底文案。 +func migrateFailMsg(d map[string]interface{}, taskID string) string { + if m := common.GetString(d, "error_message"); m != "" { + return m + } + return fmt.Sprintf("migration apply failed (task_id=%s)", taskID) +} + +// intFromAny 把 JSON number / json.Number 转 int(计数用)。 +func intFromAny(v interface{}) int { + if f, ok := numericAsFloat(v); ok { + return int(f) + } + return 0 +} diff --git a/shortcuts/apps/apps_db_env_recovery_quota_test.go b/shortcuts/apps/apps_db_env_recovery_quota_test.go new file mode 100644 index 00000000..a2dee10b --- /dev/null +++ b/shortcuts/apps/apps_db_env_recovery_quota_test.go @@ -0,0 +1,369 @@ +// 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" +) + +const ( + dbEnvMigrateURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate" + dbEnvMigrateStatusURL = "/open-apis/spark/v1/apps/app_x/db/env_migrate_status" + dbRecoveryURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery" + dbRecoveryDiffURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_diff_status" + dbRecoveryApplyURL = "/open-apis/spark/v1/apps/app_x/db/env_recovery_apply_status" + dbQuotaURL = "/open-apis/spark/v1/apps/app_x/db/quota" +) + +// ── env-diff ── + +// TestAppsDBEnvDiff_DryRunBody 校验 dry-run 请求体:POST env_migrate 且 dry_run=true。 +func TestAppsDBEnvDiff_DryRunBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBEnvDiff, + []string{"+db-env-diff", "--app-id", "app_x", "--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"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != dbEnvMigrateURL || a.Body["dry_run"] != true { + t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body) + } +} + +// TestAppsDBEnvDiff_SuccessRendersChanges 验证 pretty 输出渲染出 dev → online 变更摘要及 DDL 语句。 +func TestAppsDBEnvDiff_SuccessRendersChanges(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbEnvMigrateURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "from": "dev", "to": "online", + "changes": []interface{}{ + map[string]interface{}{"type": "ALTER_TABLE", "table": "orders", "statement": "ALTER TABLE orders ADD COLUMN note text"}, + }, + }}, + }) + if err := runAppsShortcut(t, AppsDBEnvDiff, + []string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "dev → online (1 changes)") || !strings.Contains(got, "ALTER TABLE orders ADD COLUMN note text") { + t.Fatalf("pretty diff malformed:\n%s", got) + } +} + +// TestAppsDBEnvDiff_EmptyChanges 验证无变更时 pretty 输出"无待发布变更"提示。 +func TestAppsDBEnvDiff_EmptyChanges(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbEnvMigrateURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "changes": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsDBEnvDiff, + []string{"+db-env-diff", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "No pending changes from dev to online.") { + t.Fatalf("expected empty message, got: %s", stdout.String()) + } +} + +// ── env-migrate ── + +// TestAppsDBEnvMigrate_DryRunBody 校验 migrate 的 dry-run 请求体里 dry_run=false(真实迁移)。 +func TestAppsDBEnvMigrate_DryRunBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBEnvMigrate, + []string{"+db-env-migrate", "--app-id", "app_x", "--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"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Body["dry_run"] != false { + t.Fatalf("dry-run body=%v (want dry_run:false)", env.API[0].Body) + } +} + +// 异步:submit 返 task_id,status 立刻 applied → CLI 对外统一 migrated。 +func TestAppsDBEnvMigrate_AsyncPollSuccess(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbEnvMigrateURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbEnvMigrateStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "applied", "changes_applied": 3}}, + }) + if err := runAppsShortcut(t, AppsDBEnvMigrate, + []string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "✓ Migrated dev → online (3 changes)") { + t.Fatalf("pretty: %s", got) + } +} + +// TestAppsDBEnvMigrate_PollFailedSurfacesError 验证轮询到 failed 时返回 API/server_error 类型错误,携带服务端 message 与恢复 hint。 +func TestAppsDBEnvMigrate_PollFailedSurfacesError(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbEnvMigrateURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"from": "dev", "to": "online", "task_id": "t1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbEnvMigrateStatusURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"task_id": "t1", "status": "failed", "error_message": "lock timeout"}}, + }) + err := runAppsShortcut(t, AppsDBEnvMigrate, + []string{"+db-env-migrate", "--app-id", "app_x", "--yes", "--as", "user"}, factory, stdout) + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError { + t.Fatalf("got %T %v, want API/server_error typed error", err, err) + } + if !strings.Contains(p.Message, "lock timeout") { + t.Fatalf("Message = %q, want it to contain 'lock timeout'", p.Message) + } + if !strings.Contains(p.Hint, "+db-env-diff") { + t.Fatalf("Hint = %q, want the db-env-migrate recovery hint", p.Hint) + } +} + +// TestAppsDBEnvMigrate_RequiresConfirmation 验证 high-risk-write 无 --yes 时被确认门拦截。 +func TestAppsDBEnvMigrate_RequiresConfirmation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // high-risk-write 无 --yes → 应被确认门拦截(非 0 退出)。 + if err := runAppsShortcut(t, AppsDBEnvMigrate, + []string{"+db-env-migrate", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected confirmation gate without --yes") + } +} + +// ── recovery-diff ── + +// TestAppsDBRecoveryDiff_RequiresTarget 验证缺少 --target 时报必填错误。 +func TestAppsDBRecoveryDiff_RequiresTarget(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBRecoveryDiff, + []string{"+db-recovery-diff", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected required --target error") + } +} + +// TestAppsDBRecoveryDiff_DryRunNormalizesTarget 验证 dry-run 走 POST env_recovery 且 --target 被归一化为 RFC3339 UTC。 +func TestAppsDBRecoveryDiff_DryRunNormalizesTarget(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBRecoveryDiff, + []string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2026-04-15", "--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"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != dbRecoveryURL || a.Body["dry_run"] != true { + t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body) + } + if s, _ := a.Body["target"].(string); !strings.HasSuffix(s, "Z") { + t.Fatalf("target not normalized to RFC3339 UTC: %v", a.Body["target"]) + } +} + +// TestAppsDBRecoveryDiff_SuccessRendersChanges 验证 preview 成功后 pretty 渲染受影响表数、行增删与预估耗时。 +func TestAppsDBRecoveryDiff_SuccessRendersChanges(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbRecoveryURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbRecoveryDiffURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "preview_status": "success", "tables_affected": 2, "estimated_seconds": 12, + "changes": []interface{}{ + map[string]interface{}{"table": "orders", "inserted": 5, "deleted": 2}, + map[string]interface{}{"table": "carts", "action": "restore_table"}, + }, + }}, + }) + if err := runAppsShortcut(t, AppsDBRecoveryDiff, + []string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"tables affected: 2", "orders: +5 rows, -2 rows", "carts: table will be restored", "estimated time: ~12s"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } +} + +// TestAppsDBRecoveryDiff_PreviewFailed 验证 preview_status=failed 时返回 API/server_error,携带 message 与 PITR window hint。 +func TestAppsDBRecoveryDiff_PreviewFailed(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbRecoveryURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_request_id": "p1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbRecoveryDiffURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"preview_status": "failed", "error_message": "snapshot expired"}}, + }) + err := runAppsShortcut(t, AppsDBRecoveryDiff, + []string{"+db-recovery-diff", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout) + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError { + t.Fatalf("got %T %v, want API/server_error typed error", err, err) + } + if !strings.Contains(p.Message, "snapshot expired") { + t.Fatalf("Message = %q, want it to contain 'snapshot expired'", p.Message) + } + if !strings.Contains(p.Hint, "PITR window") { + t.Fatalf("Hint = %q, want the db-recovery recovery hint", p.Hint) + } +} + +// ── recovery-apply ── + +// TestAppsDBRecoveryApply_NoChangesShortCircuits 验证 status=no_changes 时短路输出"已是该状态",不再轮询。 +func TestAppsDBRecoveryApply_NoChangesShortCircuits(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbRecoveryURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "no_changes"}}, + }) + if err := runAppsShortcut(t, AppsDBRecoveryApply, + []string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "No changes — database is already at this state.") { + t.Fatalf("expected no-changes short-circuit, got: %s", stdout.String()) + } +} + +// TestAppsDBRecoveryApply_AsyncPollSuccess 验证 running → 轮询 success 后 pretty 输出恢复完成及耗时。 +func TestAppsDBRecoveryApply_AsyncPollSuccess(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: dbRecoveryURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "running"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbRecoveryApplyURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"status": "success", "restore_time_sec": 8}}, + }) + if err := runAppsShortcut(t, AppsDBRecoveryApply, + []string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout.String(), "✓ Database restored to") || !strings.Contains(stdout.String(), "(8s elapsed)") { + t.Fatalf("pretty: %s", stdout.String()) + } +} + +// TestAppsDBRecoveryApply_RequiresConfirmation 验证无 --yes 时被确认门拦截。 +func TestAppsDBRecoveryApply_RequiresConfirmation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsDBRecoveryApply, + []string{"+db-recovery-apply", "--app-id", "app_x", "--target", "2h", "--as", "user"}, factory, stdout); err == nil { + t.Fatalf("expected confirmation gate without --yes") + } +} + +// ── quota-get ── + +// TestAppsDBQuotaGet_WithQuotaPretty 验证已对接配额时 pretty 渲染存储用量、百分比及 tables/views 数。 +func TestAppsDBQuotaGet_WithQuotaPretty(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbQuotaURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "storage_used_bytes": 1048576, "storage_quota_bytes": 10485760, "usage_percent": 10.0, + "tables": 4, "views": 1, + }}, + }) + if err := runAppsShortcut(t, AppsDBQuotaGet, + []string{"+db-quota-get", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"Storage", "(10.0%)", "Tables", "4", "Views", "1"} { + if !strings.Contains(got, want) { + t.Errorf("missing %q:\n%s", want, got) + } + } +} + +// 配额未对接(storage_quota_bytes=0)→ json 删 quota/usage_percent,仅留已用量与 tables/views。 +func TestAppsDBQuotaGet_NoQuotaOmitsFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: dbQuotaURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "storage_used_bytes": 2048, "storage_quota_bytes": 0, "tables": 2, "views": 0, + }}, + }) + if err := runAppsShortcut(t, AppsDBQuotaGet, + []string{"+db-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if strings.Contains(got, "storage_quota_bytes") || strings.Contains(got, "usage_percent") { + t.Fatalf("quota fields should be omitted when not provisioned:\n%s", got) + } + if !strings.Contains(got, "storage_used_bytes") || !strings.Contains(got, "\"tables\"") { + t.Fatalf("expected used + tables retained:\n%s", got) + } +} + +// TestProjectDbQuota_WhitelistsFields 验证 projectDbQuota 白名单投影:只保留 used/tables/views(及配额已对接时的 +// quota/usage_percent),后端额外字段不透传。 +func TestProjectDbQuota_WhitelistsFields(t *testing.T) { + out := projectDbQuota(map[string]interface{}{ + "storage_used_bytes": 2048, "storage_quota_bytes": float64(0), "usage_percent": float64(0), + "tables": 2, "views": 1, "tenant_key": "leak", "internal_shard": "s1", + }) + if _, ok := out["storage_quota_bytes"]; ok { + t.Errorf("zero quota should be omitted: %v", out) + } + if out["storage_used_bytes"] != 2048 || out["tables"] != 2 || out["views"] != 1 { + t.Errorf("whitelisted fields should be kept: %v", out) + } + for _, leaked := range []string{"tenant_key", "internal_shard"} { + if _, ok := out[leaked]; ok { + t.Errorf("non-whitelisted field %q must be dropped: %v", leaked, out) + } + } + + out2 := projectDbQuota(map[string]interface{}{"storage_used_bytes": 2048, "storage_quota_bytes": float64(4096), "usage_percent": float64(50), "tables": 2}) + if _, ok := out2["storage_quota_bytes"]; !ok { + t.Errorf("non-zero quota should be kept: %v", out2) + } + if _, ok := out2["usage_percent"]; !ok { + t.Errorf("usage_percent should be kept when quota>0: %v", out2) + } +} diff --git a/shortcuts/apps/apps_db_execute.go b/shortcuts/apps/apps_db_execute.go index 4405cccf..290e470f 100644 --- a/shortcuts/apps/apps_db_execute.go +++ b/shortcuts/apps/apps_db_execute.go @@ -12,12 +12,12 @@ import ( "strconv" "strings" + "github.com/larksuite/cli/errs" "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. +// AppsDBExecute executes SQL against a Miaoda app database. // // POST /apps/{app_id}/sql_commands,CLI 永远带 ?transactional=false 进入 DBA 模式 // (不默认包事务、支持 DDL、result 字符串内嵌结构化 JSON)。 @@ -31,12 +31,18 @@ import ( // - 多语句部分失败:`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 实证)。 +// 后升级成 typed errs.APIError(CategoryAPI → exit 1),避免 agent 误判 ok:true 假成功。诊断信息 +// (第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地)写进 message+hint 文案(errs.* 信封扁平、无 +// detail 容器):失败在用户显式 BEGIN…COMMIT 事务内 → 整批回滚、前序未落库;否则前序语句已逐条 +// commit、未回滚。rolled_back 语义由 inferRolledBack 按 BEGIN/COMMIT 计数推断。 // -// JSON envelope(成功路径):CLI 把 server 返的 result 字符串解出来放进 `data.results` 数组。 +// JSON(成功路径)按 SQL 类型归一化 `data`(不透传后端 result 字符串): +// - 单 SELECT → data 是行数组 `[{...}]`(空 → `[]`) +// - 单 DML → data = `{command, rows_affected}` +// - 单 DDL → data = `{command}` +// - 多语句 → data = `[{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}]` +// +// 字段裁剪用框架原生 --jq/-q。 // // Risk: high-risk-write —— SQL 可含 DML/DDL,框架对所有执行强制 --yes 确认关卡(--dry-run 预览豁免)。 // @@ -45,51 +51,45 @@ import ( var AppsDBExecute = common.Shortcut{ Service: appsService, Command: "+db-execute", - Description: "Execute SQL (SELECT / DML / DDL) against an app database", + Description: "Execute SQL (SELECT / DML / DDL) against a Miaoda app database", Risk: "high-risk-write", Tips: []string{ `Example: lark-cli apps +db-execute --app-id --sql "SELECT * FROM orders LIMIT 10" --yes`, - `Example: lark-cli apps +db-execute --app-id --env dev --file ./migration.sql --yes`, - "Tip: filter fields with --jq, e.g. -q '.data.results[].sql_type'", + `Example: lark-cli apps +db-execute --app-id --environment dev --file ./migration.sql --yes`, + "Tip: single SELECT returns data as a row array — filter with --jq, e.g. -q '.data[].id'", }, Scopes: []string{"spark:app:write"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ - {Name: "app-id", Desc: "app id", Required: true}, + Flags: append([]common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, {Name: "sql", Desc: "SQL text; use - to read stdin. Mutually exclusive with --file", Input: []string{common.Stdin}}, {Name: "file", Desc: "path to a .sql file (relative to cwd). Mutually exclusive with --sql"}, - {Name: "env", Default: "dev", Enum: []string{"dev", "online"}, Desc: "target db environment (default dev; use --env online for the online environment)"}, - }, + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } + if err := rejectLegacyEnvFlag(rctx); 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"), - ) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--sql and --file are mutually exclusive") } if file != "" { data, err := cmdutil.ReadInputFile(rctx.FileIO(), file) if err != nil { - return appsValidationParamError("--file", "--file: %v", err).WithCause(err) + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err) } - // 归一化:把文件内容写回 --sql,下游(DryRun/Execute)统一从 sql 取。 - rctx.Cmd.Flags().Set("sql", string(data)) + // 仅本地校验非空;不把文件内容写回公开的 --sql flag(避免 SQL 内容进入 + // flag dump / 结构化日志)。下游 DryRun/Execute 由 resolveExecuteSQL 在用时重新读取。 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 errs.NewValidationError(errs.SubtypeInvalidArgument, "one of --sql or --file is required (use --sql - to read stdin)") } return nil }, @@ -97,7 +97,7 @@ var AppsDBExecute = common.Shortcut{ appID, _ := requireAppID(rctx.Str("app-id")) return common.NewDryRunAPI(). POST(appSQLPath(appID)). - Desc("Execute SQL on app database"). + Desc("Execute SQL on Miaoda app database"). Params(buildDBSQLParams(rctx)). Body(buildDBSQLBody(rctx)) }, @@ -110,27 +110,30 @@ var AppsDBExecute = common.Shortcut{ 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`") + 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 `--environment dev`") } - // server `result: string` 内嵌结构化数组 —— CLI 解出来放进 envelope 的 data.results, + // server `result: string` 内嵌结构化数组 —— CLI 解出来后按 SQL 类型归一化成 PRD 形态, // 让 json/pretty 路径都基于同一份反序列化产物渲染。 stmts := parseSQLResult(common.GetString(raw, "result")) - // 注意:data.results 在 json(默认)路径下原样透出全部行,CLI 侧不再二次截断。 - // 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出会直接 - // 返报错(而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。 - data := map[string]interface{}{"results": stmts} + // JSON data 形态(不再透传后端 result 字符串): + // - 单 SELECT → data 是行数组 [{...}](空 → []) + // - 单 DML → data = {command, rows_affected} + // - 单 DDL → data = {command} + // - 多语句 → data = [{command:"SELECT",rows:[...]} | {command,rows_affected} | {command}] + // 字段裁剪走框架原生 --jq/-q(不引入 miaoda 的 --json )。 + // 这不是无界 token 黑洞 —— server 对单条 SELECT 结果集有 1000 行硬上限,超出直接报错 + // (而非静默截断)。需要更大结果集时请在 SQL 里显式 LIMIT/分页,由调用方控制规模。 + data := shapeSQLData(stmts) // 多语句 / 单语句失败:server 仍返 code:0,把失败语句标成 ERROR 哨兵塞进 result。 - // 已落地的前序语句 + 失败语句构成 partial failure:逐条结果作为 ok:false 数据 - // 留在 stdout(机器可读)+ 非零退出信号,别让 agent 误判 ok:true 假成功。 - // pretty 模式 stdout 只打逐条 ✓/✗ 摘要(不再叠一份 JSON envelope),仅返回退出信号。 + // 升级成 typed api_error(exit 非 0),别让 agent 误判 ok:true 假成功。 + // pretty 模式仍把逐条 ✓/✗ 摘要打到 stdout(人看),再返回 error(envelope→stderr)。 if errIdx, errStmt, failed := findErrorSentinel(stmts); failed { if rctx.Format == "pretty" { renderSQLPretty(rctx.IO().Out, stmts) - return output.PartialFailure(output.ExitAPI) } - return rctx.OutPartialFailure(sqlStatementFailurePayload(stmts, errIdx, errStmt), nil) + return sqlStatementError(stmts, errIdx, errStmt) } rctx.OutFormat(data, nil, func(w io.Writer) { @@ -140,6 +143,70 @@ var AppsDBExecute = common.Shortcut{ }, } +// shapeSQLData 把解析出的 statements 归一化成 PRD 约定的 JSON `data` 形态: +// - 无语句 → [](空数组) +// - 单条语句 → singleStatementJSON(SELECT 是行数组、DML/DDL 是对象) +// - 多条语句 → []multiStatementElement(每条统一成 {command,...} 对象,SELECT 行放 rows) +// +// 不再透传后端 result 字符串(旧形态 data.results[].data 是 JSON 字符串,对 agent 不友好)。 +func shapeSQLData(stmts []map[string]interface{}) interface{} { + if len(stmts) == 0 { + return []interface{}{} + } + if len(stmts) == 1 { + return singleStatementJSON(stmts[0]) + } + out := make([]interface{}, 0, len(stmts)) + for _, s := range stmts { + out = append(out, multiStatementElement(s)) + } + return out +} + +// singleStatementJSON 单条语句的 PRD JSON 形态: +// - SELECT → 行数组(空 → []) +// - DML → {command, rows_affected} +// - DDL / OK / 其它 → {command} +func singleStatementJSON(s map[string]interface{}) interface{} { + sqlType := common.GetString(s, "sql_type") + switch { + case sqlType == "SELECT": + return selectRows(s) + case isDMLType(sqlType): + return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])} + default: + return map[string]interface{}{"command": sqlType} + } +} + +// multiStatementElement 多语句里单条的 PRD JSON 形态:与单条一致,但 SELECT 包成 +// {command:"SELECT", rows:[...]}(避免数组里直接嵌套数组造成歧义)。 +func multiStatementElement(s map[string]interface{}) map[string]interface{} { + sqlType := common.GetString(s, "sql_type") + switch { + case sqlType == "SELECT": + return map[string]interface{}{"command": "SELECT", "rows": selectRows(s)} + case isDMLType(sqlType): + return map[string]interface{}{"command": sqlType, "rows_affected": intOrZero(s["affected_rows"])} + default: + return map[string]interface{}{"command": sqlType} + } +} + +// selectRows 把 SELECT statement 的 data 字段(行 JSON 数组字符串)解析成行数组; +// 空 / 非法一律返回非 nil 的空数组(保证 JSON 序列化成 [] 而非 null)。 +func selectRows(s map[string]interface{}) []map[string]interface{} { + dataJSON := strings.TrimSpace(common.GetString(s, "data")) + if dataJSON == "" || dataJSON == "null" { + return []map[string]interface{}{} + } + var rows []map[string]interface{} + if err := json.Unmarshal([]byte(dataJSON), &rows); err != nil || rows == nil { + return []map[string]interface{}{} + } + return rows +} + // findErrorSentinel 在 statements 里找 ERROR 哨兵(server 失败时追加在失败语句位置)。 // 返回失败语句下标(0-based)、该 ERROR statement、是否命中。 func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interface{}, bool) { @@ -151,28 +218,48 @@ func findErrorSentinel(stmts []map[string]interface{}) (int, map[string]interfac return 0, nil, false } -// sqlStatementFailurePayload 把 ERROR 哨兵整理成 partial-failure 的 stdout 数据。 +// sqlStatementError 把 ERROR 哨兵升级成 typed errs.APIError(CategoryAPI → exit 1)。 // -// 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{} { +// 多语句失败的诊断信息——第几条失败 / 共几条 / 是否整批回滚 / 前序是否落地——都写进 +// message + hint 的人类可读文案(errs.* 信封是扁平字段、不带结构化 detail 容器)。文案对齐 +// miaoda-cli(src/cli/handlers/db/sql.ts、src/api/db/api.ts): +// - message 末尾 "(at statement N of M)" 给出失败位置; +// - hint 由 inferRolledBack 推断(实测后端把 BEGIN/COMMIT 也作为 statement 返回): +// 失败仍在用户显式事务内 → 服务端整批回滚,用 miaoda 原句 "Transaction rolled back; no changes persisted."; +// 否则前序语句已逐条 commit、未回滚(flat 信封无逐句 breakdown,故 hint 简述前序已落地 + 从失败处续跑)。 +func sqlStatementError(stmts []map[string]interface{}, errIdx int, errStmt map[string]interface{}) error { code, msg := parseErrorSentinel(common.GetString(errStmt, "data")) stmtNo := errIdx + 1 // 1-based 给人看 - 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) + fullMsg := fmt.Sprintf("%s (at statement %d of %d)", msg, stmtNo, len(stmts)) + + var hint string + switch { + case inferRolledBack(stmts[:errIdx]): + hint = "Transaction rolled back; no changes persisted." + case errIdx > 0: + hint = fmt.Sprintf("Earlier statements were committed and not rolled back; fix statement %d and re-run the remaining statements.", stmtNo) + default: + hint = "No statements were applied; fix the SQL and re-run." } - 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, + return errs.NewAPIError(errs.SubtypeServerError, "%s", fullMsg).WithCode(code).WithHint("%s", hint) +} + +// inferRolledBack 推断失败时是否处于用户显式事务内(→ 服务端整批回滚)。 +// 遍历已完成语句的 sql_type:BEGIN/START TRANSACTION +1,COMMIT/ROLLBACK/END -1; +// 结束 depth>0 说明事务还开着、已被服务端回滚。对齐 miaoda-cli inferRolledBack。 +func inferRolledBack(completed []map[string]interface{}) bool { + depth := 0 + for _, s := range completed { + switch strings.ToUpper(strings.TrimSpace(common.GetString(s, "sql_type"))) { + case "BEGIN", "START TRANSACTION", "START_TRANSACTION": + depth++ + case "COMMIT", "ROLLBACK", "END": + if depth > 0 { + depth-- + } + } } + return depth > 0 } // parseErrorSentinel 解析 ERROR 哨兵的 data(`{code,message}` JSON),返回数值 code 与 message。 @@ -205,15 +292,34 @@ func parseErrorSentinel(data string) (int, string) { // CLI 永远走 DBA 模式,原子性由用户在 SQL 内显式 BEGIN/COMMIT 控制;不暴露 transactional flag 给用户。 func buildDBSQLParams(rctx *common.RuntimeContext) map[string]interface{} { return map[string]interface{}{ - "env": rctx.Str("env"), + "env": dbEnv(rctx), "transactional": false, } } -// buildDBSQLBody 构造 sql 接口的 body:仅 sql(来源由 Validate 归一化到 --sql)。 +// resolveExecuteSQL 返回要执行的 SQL,在用时(DryRun/Execute)现读,使 --file 的内容 +// 不被写回公开的 --sql flag(避免泄露进 flag dump / 结构化日志)。优先 --sql(内联或 stdin, +// 已由输入框架解析到 flag 值);否则现读 --file。Validate 已先行校验可读且非空。 +func resolveExecuteSQL(rctx *common.RuntimeContext) (string, error) { + if strings.TrimSpace(rctx.Str("sql")) != "" { + return rctx.Str("sql"), nil + } + file := strings.TrimSpace(rctx.Str("file")) + if file == "" { + return "", nil + } + data, err := cmdutil.ReadInputFile(rctx.FileIO(), file) + if err != nil { + return "", err + } + return string(data), nil +} + +// buildDBSQLBody 构造 sql 接口的 body:仅 sql(由 resolveExecuteSQL 在用时解析,--file 不入 flag)。 func buildDBSQLBody(rctx *common.RuntimeContext) map[string]interface{} { + sql, _ := resolveExecuteSQL(rctx) return map[string]interface{}{ - "sql": rctx.Str("sql"), + "sql": sql, } } @@ -354,10 +460,10 @@ func renderMultiStatementPretty(w io.Writer, stmts []map[string]interface{}) { } fmt.Fprintln(w) if failedIdx >= 0 { - // CLI 永远 DBA 模式(transactional=false),失败语句之前的语句已 auto-commit 落地, - // 不存在整批回滚 —— 如实告诉用户,避免整批重跑导致重复写入。 + // CLI 永远传 transactional=false,失败语句之前的语句已逐条 commit 落地、不会整批回滚—— + // 如实告诉用户,避免整批重跑导致重复写入。 if successCount > 0 { - fmt.Fprintf(w, "(statement %d failed; %d statement%s before it already applied — DBA mode auto-commits each)\n", + fmt.Fprintf(w, "(statement %d failed; %d statement%s before it committed and not rolled back)\n", failedIdx+1, successCount, plural(int64(successCount))) } else { fmt.Fprintf(w, "(statement %d failed; no statements applied)\n", failedIdx+1) @@ -461,6 +567,7 @@ func isDMLType(sqlType string) bool { return false } +// dmlVerb 把 DML sql_type 映射成过去分词动词:INSERT→inserted / UPDATE→updated / DELETE→deleted / MERGE→merged,未知 → affected。 func dmlVerb(sqlType string) string { switch strings.ToUpper(sqlType) { case "INSERT": @@ -475,6 +582,7 @@ func dmlVerb(sqlType string) string { return "affected" } +// plural 返回英文复数后缀:n==1 时空串,否则 "s"。 func plural(n int64) string { if n == 1 { return "" diff --git a/shortcuts/apps/apps_db_execute_test.go b/shortcuts/apps/apps_db_execute_test.go index cb95d3f9..7bb277e4 100644 --- a/shortcuts/apps/apps_db_execute_test.go +++ b/shortcuts/apps/apps_db_execute_test.go @@ -5,17 +5,18 @@ package apps import ( "encoding/json" - "errors" "os" "path/filepath" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/output" ) -func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(t *testing.T) { +// TestAppsDBExecute_SingleSELECTJSONIsRowArray 断言单条 SELECT 的 JSON data 直接是行数组(不再透传 result 字符串)。 +func TestAppsDBExecute_SingleSELECTJSONIsRowArray(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -33,27 +34,134 @@ func TestAppsDBExecute_SingleSELECTJSONEnvelopeWrapsResults(t *testing.T) { factory, stdout); err != nil { t.Fatalf("execute err=%v", err) } - // JSON envelope 应该把 result 字符串 parse 之后放进 data.results + // PRD 单 SELECT:data 直接是行数组(不再是 data.results[].data 字符串) var env struct { - Data struct { - Results []map[string]interface{} `json:"results"` - } `json:"data"` + Data []map[string]interface{} `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 len(env.Data) != 1 { + t.Fatalf("data = %d rows (want 1)\n%s", len(env.Data), stdout.String()) } - if env.Data.Results[0]["sql_type"] != "SELECT" { - t.Fatalf("results[0].sql_type = %v", env.Data.Results[0]["sql_type"]) + if env.Data[0]["id"] != float64(101) || env.Data[0]["total_cents"] != float64(2500) { + t.Fatalf("data[0] = %v, want {id:101,total_cents:2500}", env.Data[0]) } } +// TestAppsDBExecute_SingleDMLJSONShape 断言单条 DML 的 JSON data 形如 {command, rows_affected}。 +func TestAppsDBExecute_SingleDMLJSONShape(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":3}]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "insert", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + // PRD 单 DML:data = {command, rows_affected} + var env struct { + Data struct { + Command string `json:"command"` + RowsAffected int `json:"rows_affected"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) + } + if env.Data.Command != "INSERT" || env.Data.RowsAffected != 3 { + t.Fatalf("data = %+v, want {command:INSERT, rows_affected:3}", env.Data) + } +} + +// TestAppsDBExecute_SingleDDLJSONShape 断言单条 DDL 的 JSON data 形如 {command}。 +func TestAppsDBExecute_SingleDDLJSONShape(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":"[]"}]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "create", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + // PRD 单 DDL:data = {command} + var env struct { + Data struct { + Command string `json:"command"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) + } + if env.Data.Command != "CREATE_TABLE" { + t.Fatalf("data.command = %q, want CREATE_TABLE", env.Data.Command) + } +} + +// TestAppsDBExecute_MultiStatementJSONShape 断言多语句的 JSON data 是元素数组,且 SELECT 包成 {command:"SELECT", rows:[...]}。 +func TestAppsDBExecute_MultiStatementJSONShape(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":"SELECT","data":"[{\"id\":999}]","record_count":1}` + + `]`, + }, + }, + }) + if err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "x", "--as", "user"}, + factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + // PRD 多语句:data 是元素数组;SELECT 包成 {command:"SELECT", rows:[...]} + var env struct { + Data []map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, stdout.String()) + } + if len(env.Data) != 2 { + t.Fatalf("data = %d elements (want 2)\n%s", len(env.Data), stdout.String()) + } + if env.Data[0]["command"] != "INSERT" || env.Data[0]["rows_affected"] != float64(1) { + t.Fatalf("data[0] = %v, want {command:INSERT, rows_affected:1}", env.Data[0]) + } + if env.Data[1]["command"] != "SELECT" { + t.Fatalf("data[1].command = %v, want SELECT", env.Data[1]["command"]) + } + rows, ok := env.Data[1]["rows"].([]interface{}) + if !ok || len(rows) != 1 { + t.Fatalf("data[1].rows = %v, want 1 row", env.Data[1]["rows"]) + } +} + +// TestAppsDBExecute_DryRunSendsTransactionalFalse 断言 dry-run 发出的请求是 POST、params 带 transactional=false(DBA 模式)且 transactional 不在 body 里。 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"}, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } @@ -85,6 +193,7 @@ func TestAppsDBExecute_DryRunSendsTransactionalFalse(t *testing.T) { } } +// TestAppsDBExecute_RejectsEmptySQL 断言 --sql 全空白时校验报错(提示需要 --sql 或 --file)。 func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) err := runAppsShortcut(t, AppsDBExecute, @@ -94,6 +203,23 @@ func TestAppsDBExecute_RejectsEmptySQL(t *testing.T) { } } +// TestAppsDBExecute_LegacyEnvFlagRejected 钉死:旧名 --env 已移除,显式传入报 validation 错并指向 --environment。 +func TestAppsDBExecute_LegacyEnvFlagRejected(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsDBExecute, + []string{"+db-execute", "--yes", "--app-id", "app_x", "--sql", "select 1", "--env", "dev", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("--env should be rejected; stdout:\n%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryValidation { + t.Fatalf("want a typed validation error, got %T: %v", err, err) + } + if !strings.Contains(p.Message, "--environment") { + t.Errorf("message should point to --environment: %q", p.Message) + } +} + // --sql 与 --file 互斥 func TestAppsDBExecute_RejectsSQLAndFileTogether(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) @@ -124,7 +250,7 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) { 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"}, + []string{"+db-execute", "--app-id", "app_x", "--environment", "dev", "--file", "m.sql", "--dry-run", "--as", "user"}, factory, stdout); err != nil { t.Fatalf("dry-run err=%v", err) } @@ -147,6 +273,7 @@ func TestAppsDBExecute_FileReadsSQLIntoBody(t *testing.T) { // 输入用 BOE 真实抓包数据(test_scripts/boe_e2e/run.log)。 // ============================================================================ +// TestAppsDBExecute_LegacyWireSingleSelect 断言 legacy 字符串数组 wire 的单 SELECT 能正常渲染表格、不回退到 RAW。 func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) { // BOE 实测:SELECT 1 AS x → result: "[\"[{\\\"x\\\":1}]\"]" factory, stdout, reg := newAppsExecuteFactory(t) @@ -178,8 +305,9 @@ func TestAppsDBExecute_LegacyWireSingleSelect(t *testing.T) { } } -func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) { - // 验证 JSON envelope 也把 legacy result 正确归一化进 data.results +// TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray 断言 legacy wire 的 SELECT 同样归一化成 PRD 行数组形态。 +func TestAppsDBExecute_LegacyWireSingleSelectJSONIsRowArray(t *testing.T) { + // 验证 legacy wire 的 SELECT 也归一化成 PRD 行数组形态(data 直接是行) factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ Method: "POST", @@ -197,24 +325,20 @@ func TestAppsDBExecute_LegacyWireSingleSelectJSONEnvelope(t *testing.T) { t.Fatalf("execute err=%v", err) } var env struct { - Data struct { - Results []map[string]interface{} `json:"results"` - } `json:"data"` + Data []map[string]interface{} `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 len(env.Data) != 1 { + t.Fatalf("data length = %d, want 1; got: %v", len(env.Data), env.Data) } - 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"]) + if env.Data[0]["x"] != float64(1) { + t.Fatalf("data[0].x = %v, want 1", env.Data[0]["x"]) } } +// TestAppsDBExecute_LegacyWireMultiSelect 断言 legacy wire 多 SELECT 输出带 Statement N header 与末尾 "✓ N statements executed" 汇总。 func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) { // BOE 实测:SELECT 1; SELECT 2 → result: "[\"[{\\\"?column?\\\":1}]\",\"[{\\\"?column?\\\":2}]\"]" factory, stdout, reg := newAppsExecuteFactory(t) @@ -244,6 +368,7 @@ func TestAppsDBExecute_LegacyWireMultiSelect(t *testing.T) { } } +// TestAppsDBExecute_LegacyWireDDLEmptyResult 断言 result 为空字符串时(legacy DDL)pretty 输出 "(empty result)"。 func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) { // BOE 实测:CREATE TABLE → result: "" (空字符串,无 rows) // 老 wire 不区分 DDL/DML/无返回,统一标 "ok" @@ -270,6 +395,7 @@ func TestAppsDBExecute_LegacyWireDDLEmptyResult(t *testing.T) { } } +// TestAppsDBExecute_LegacyWireMultiSelectWithRealTable 断言含 CJK / uuid / int 字段的真实表行能正确显示在 pretty 表格里。 func TestAppsDBExecute_LegacyWireMultiSelectWithRealTable(t *testing.T) { // BOE 实测真实表抓包(course 表第一行):复杂 JSON 含 CJK / timestamp / uuid 字段 factory, stdout, reg := newAppsExecuteFactory(t) @@ -328,6 +454,7 @@ func TestAppsDBExecute_PrettySingleSelectTable(t *testing.T) { } } +// TestAppsDBExecute_PrettyEmptySelect 断言空 SELECT 的 pretty 输出为 "(0 rows)"。 func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -350,6 +477,7 @@ func TestAppsDBExecute_PrettyEmptySelect(t *testing.T) { } } +// TestAppsDBExecute_PrettySingleDMLAndDDL 断言单条 DML 渲染 "✓ N row(s) "、各类 DDL(含细粒度动词)渲染 "✓ DDL executed"。 func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) { cases := []struct { name string @@ -386,6 +514,7 @@ func TestAppsDBExecute_PrettySingleDMLAndDDL(t *testing.T) { } } +// TestAppsDBExecute_PrettyMultiStatementsAllSuccess 断言多语句全成功时逐条 Statement 摘要 + 末尾 "✓ N statements executed"。 func TestAppsDBExecute_PrettyMultiStatementsAllSuccess(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -455,6 +584,7 @@ func TestAppsDBExecute_PrettyMultiStatementsDDL(t *testing.T) { } } +// TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel 断言多语句部分失败时 pretty 仍打逐条 ✓/✗ 摘要、声明前序已 commit 未回滚,且返回 typed error、不打成功汇总。 func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -486,19 +616,20 @@ func TestAppsDBExecute_PrettyMultiStatementsPartialFailureWithErrorSentinel(t *t 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) + // 非事务(transactional=false)前序语句已逐条 commit 落地,须如实说明「committed and not rolled back」, + // 绝不能误报整批回滚。 + if !strings.Contains(got, "committed and not rolled back") { + t.Errorf("non-tx failure must state prior statements committed & not rolled back; 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 实证:失败前的语句已落地)。 +// TestAppsDBExecute_MultiStatementFailureReturnsTypedError 钉死「多语句失败 → typed errs.APIError」: +// json 默认不再打 ok:true 假成功,而是返回 typed errs.* 错误(type=api / subtype=server_error、 +// exit=1)。失败位置在 message 的 "(at statement N of M)",前序是否落地/是否回滚写在 hint。 +// 本例无 BEGIN → 前序逐条 commit、未回滚(hint 含 "committed and not rolled back")。 func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -518,64 +649,36 @@ func TestAppsDBExecute_MultiStatementFailureReturnsTypedError(t *testing.T) { []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()) + t.Fatalf("multi-statement failure must return a typed error; stdout:\n%s", stdout.String()) } // json 失败路径不得打成功 envelope。 if strings.Contains(stdout.String(), `"ok": true`) { t.Errorf("must not emit ok:true success envelope on failure; stdout:\n%s", stdout.String()) } - var pfErr *output.PartialFailureError - if !errors.As(err, &pfErr) { - t.Fatalf("want *output.PartialFailureError, got %T: %v", err, err) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want a typed errs.* error, got %T: %v", err, err) } - if pfErr.Code != output.ExitAPI { - t.Errorf("exit = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI) + if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError { + t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype) } - payload := decodePartialFailureData(t, stdout.String()) - if got := payload["statement_index"]; got != float64(1) { - t.Errorf("statement_index = %v, want 1", got) + if p.Code != 1300002 { + t.Errorf("code = %d, want 1300002", p.Code) } - if got := payload["error_code"]; got != float64(1300002) { - t.Errorf("error_code = %v, want 1300002", got) + if !strings.Contains(p.Message, "(at statement 2 of 2)") { + t.Errorf("message missing statement locator: %q", p.Message) } - msg, _ := payload["error_message"].(string) - if !strings.Contains(msg, "(at statement 2 of 2)") { - t.Errorf("error_message missing statement locator: %q", msg) + // 无 BEGIN → 前序逐条 commit、未回滚,语义写在 hint。 + if !strings.Contains(p.Hint, "committed and not rolled back") { + t.Errorf("hint should state prior statements committed & not rolled back: %q", p.Hint) } - if got := payload["rolled_back"]; got != false { - t.Errorf("rolled_back = %v, want false (DBA mode persists prior statements)", got) + if output.ExitCodeOf(err) != output.ExitAPI { + t.Errorf("exit = %d, want %d (ExitAPI)", output.ExitCodeOf(err), output.ExitAPI) } - 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)。 +// 同样升级成 typed error:statement_index=0、completed 空、message 标注 (at statement 1 of 1)。 func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) reg.Register(&httpmock.Stub{ @@ -592,26 +695,92 @@ func TestAppsDBExecute_SingleErrorReturnsTypedError(t *testing.T) { []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()) + t.Fatalf("single ERROR sentinel must return a typed 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) + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want a typed errs.* error, 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 p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError { + t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype) } - if got := payload["statement_index"]; got != float64(0) { - t.Errorf("statement_index = %v, want 0", got) + if !strings.Contains(p.Message, "(at statement 1 of 1)") { + t.Errorf("message missing locator: %q", p.Message) } - note, _ := payload["note"].(string) - if !strings.Contains(note, "no statements were applied") { - t.Errorf("note should say nothing was applied, got %q", note) + // 第一条就失败、无落地 的语义写在 hint。 + if !strings.Contains(p.Hint, "No statements were applied") { + t.Errorf("hint should state nothing applied: %q", p.Hint) } } +// TestAppsDBExecute_TransactionFailureRolledBack 钉死「显式事务内失败 → 整批回滚」: +// 实测后端把 BEGIN 也作为 statement 返回;completed 含未配对 BEGIN → inferRolledBack 判定回滚。 +// 回滚语义现写在 hint(miaoda 原句 "Transaction rolled back; no changes persisted."),失败位置在 message。 +func TestAppsDBExecute_TransactionFailureRolledBack(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{}{ + // BOE 实测 wire:BEGIN; CREATE; INSERT(ok); INSERT(dup→ERROR) + "result": `[` + + `{"sql_type":"BEGIN","data":"[]"},` + + `{"sql_type":"CREATE_TABLE","data":"[]"},` + + `{"sql_type":"INSERT","data":"[{\"rowCount\":1}]","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("transaction failure must return a typed error; stdout:\n%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("want a typed errs.* error, got %T: %v", err, err) + } + if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError { + t.Errorf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype) + } + if !strings.Contains(p.Message, "(at statement 4 of 4)") { + t.Errorf("message missing statement locator: %q", p.Message) + } + // 事务整批回滚 / 前序未落库 的语义写在 hint(miaoda 原句)。 + if !strings.Contains(p.Hint, "Transaction rolled back; no changes persisted.") { + t.Errorf("hint should state transaction rolled back & nothing persisted: %q", p.Hint) + } +} + +// TestInferRolledBack_Cases 断言 inferRolledBack 按 BEGIN/COMMIT/ROLLBACK 计数判定失败时事务是否仍开着(即整批回滚)。 +func TestInferRolledBack_Cases(t *testing.T) { + stmt := func(t string) map[string]interface{} { return map[string]interface{}{"sql_type": t} } + cases := []struct { + name string + completed []map[string]interface{} + want bool + }{ + {"empty", nil, false}, + {"autocommit single", []map[string]interface{}{stmt("INSERT")}, false}, + {"open tx (unmatched BEGIN)", []map[string]interface{}{stmt("BEGIN"), stmt("CREATE_TABLE"), stmt("INSERT")}, true}, + {"closed tx (BEGIN+COMMIT)", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("COMMIT")}, false}, + {"reopened tx", []map[string]interface{}{stmt("BEGIN"), stmt("COMMIT"), stmt("BEGIN"), stmt("INSERT")}, true}, + {"rollback closes tx", []map[string]interface{}{stmt("BEGIN"), stmt("INSERT"), stmt("ROLLBACK")}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := inferRolledBack(c.completed); got != c.want { + t.Errorf("inferRolledBack(%s) = %v, want %v", c.name, got, c.want) + } + }) + } +} + +// TestCellString_AllKinds 断言 cellString 对 nil/string/bool/整数/小数/对象各类型的字符串化结果。 func TestCellString_AllKinds(t *testing.T) { cases := []struct { name string @@ -635,6 +804,7 @@ func TestCellString_AllKinds(t *testing.T) { } } +// TestCodeString_Forms 断言 codeString 处理 nil / "k_dl_xxx" / 纯数字串 / float64 / 不支持类型各形态。 func TestCodeString_Forms(t *testing.T) { cases := []struct { name string @@ -656,6 +826,7 @@ func TestCodeString_Forms(t *testing.T) { } } +// TestDmlVerb_AllVerbs 断言 dmlVerb 对 INSERT/UPDATE/DELETE/MERGE 的动词映射(大小写不敏感),非 DML 返回 affected。 func TestDmlVerb_AllVerbs(t *testing.T) { cases := map[string]string{ "INSERT": "inserted", @@ -671,6 +842,7 @@ func TestDmlVerb_AllVerbs(t *testing.T) { } } +// TestIntOrZero_Cases 断言 intOrZero 对 JSON number 取整、对非数字 / nil 返回 0。 func TestIntOrZero_Cases(t *testing.T) { if got := intOrZero(float64(5)); got != 5 { t.Errorf("intOrZero(5)=%d want 5", got) @@ -683,6 +855,7 @@ func TestIntOrZero_Cases(t *testing.T) { } } +// TestErrorSummary_Cases 断言 errorSummary 对空 / 非法 JSON / 带 code / 无 code 各情形生成 "message [code]" 文案。 func TestErrorSummary_Cases(t *testing.T) { cases := []struct { name, in, want string @@ -701,6 +874,7 @@ func TestErrorSummary_Cases(t *testing.T) { } } +// TestParseErrorSentinel_Cases 断言 parseErrorSentinel 解析 ERROR 哨兵 data 得到数值 code 与 message(含空 / 非法 / 空 message 回退)。 func TestParseErrorSentinel_Cases(t *testing.T) { cases := []struct { name, in string @@ -722,6 +896,7 @@ func TestParseErrorSentinel_Cases(t *testing.T) { } } +// TestIsStructuredResult_Cases 断言 isStructuredResult 仅在首元素含 sql_type 时判为新结构化形态。 func TestIsStructuredResult_Cases(t *testing.T) { if !isStructuredResult([]map[string]interface{}{{"sql_type": "SELECT"}}) { t.Error("expected structured=true when sql_type present") @@ -734,6 +909,7 @@ func TestIsStructuredResult_Cases(t *testing.T) { } } +// TestNormalizeLegacyStatement_Cases 断言 normalizeLegacyStatement 把空 / null / 非 JSON 标为 OK、把 rows 数组标为 SELECT 并带 record_count。 func TestNormalizeLegacyStatement_Cases(t *testing.T) { t.Run("empty -> OK", func(t *testing.T) { got := normalizeLegacyStatement("") @@ -764,6 +940,7 @@ func TestNormalizeLegacyStatement_Cases(t *testing.T) { }) } +// TestCellString_MarshalFallback 断言 cellString 对 json.Marshal 拒绝的类型(如 complex)回退到 fmt %v。 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. @@ -772,6 +949,7 @@ func TestCellString_MarshalFallback(t *testing.T) { } } +// TestRenderSingleStatementPretty_Branches 断言 renderSingleStatementPretty 对 SELECT/ERROR/DML/legacy OK/DDL 各分支的输出。 func TestRenderSingleStatementPretty_Branches(t *testing.T) { cases := []struct { name string @@ -795,6 +973,7 @@ func TestRenderSingleStatementPretty_Branches(t *testing.T) { } } +// TestRenderSelectRowsAsTable_Branches 断言 renderSelectRowsAsTable 对空串 / 空数组 / 非法 JSON 回退 / 正常 rows 各分支的输出。 func TestRenderSelectRowsAsTable_Branches(t *testing.T) { cases := []struct { name string @@ -816,35 +995,3 @@ func TestRenderSelectRowsAsTable_Branches(t *testing.T) { }) } } - -// 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_quota_get.go b/shortcuts/apps/apps_db_quota_get.go new file mode 100644 index 00000000..f5f1563b --- /dev/null +++ b/shortcuts/apps/apps_db_quota_get.go @@ -0,0 +1,101 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsDBQuotaGet reports an app's database storage usage and object counts. +// +// GET /apps/{app_id}/db/quota。storage_quota_bytes / usage_percent 在配额未对接(=0)时 +// 不输出(与 +file-quota-get 一致);tables / views 始终输出。 +var AppsDBQuotaGet = common.Shortcut{ + Service: appsService, + Command: "+db-quota-get", + Description: "Get an app's database storage usage", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-quota-get --app-id ", + "Example: lark-cli apps +db-quota-get --app-id --environment dev", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: append([]common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return rejectLegacyEnvFlag(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appDbQuotaPath(appID)). + Desc("Get Miaoda app database storage usage"). + Params(map[string]interface{}{"env": dbEnv(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", appDbQuotaPath(appID), map[string]interface{}{"env": dbEnv(rctx)}, nil) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := projectDbQuota(data) + rctx.OutFormat(out, nil, func(w io.Writer) { + renderDbQuotaPretty(w, out) + }) + return nil + }, +} + +// projectDbQuota 白名单投影 db quota 字段:只保留 storage_used_bytes / tables / views, +// 配额已对接时再加 storage_quota_bytes / usage_percent。不透传后端其它字段,避免无用字段消耗上下文。 +func projectDbQuota(data map[string]interface{}) map[string]interface{} { + out := map[string]interface{}{"storage_used_bytes": data["storage_used_bytes"]} + for _, k := range []string{"tables", "views"} { + if v, ok := data[k]; ok { + out[k] = v + } + } + // 配额未对接(storage_quota_bytes=0/缺失)时不输出 quota / usage_percent。 + if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 { + out["storage_quota_bytes"] = data["storage_quota_bytes"] + if v, ok := data["usage_percent"]; ok { + out["usage_percent"] = v + } + } + return out +} + +// renderDbQuotaPretty 打 Storage(已用 / 配额 (百分比))与 Tables / Views 行(标签对齐 miaoda-cli)。 +func renderDbQuotaPretty(w io.Writer, data map[string]interface{}) { + used := humanBytes(data["storage_used_bytes"]) + usage := used + if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 { + pct := "" + if p, ok := numericAsFloat(data["usage_percent"]); ok { + pct = fmt.Sprintf(" (%.1f%%)", p) + } + usage = fmt.Sprintf("%s / %s%s", used, humanBytes(data["storage_quota_bytes"]), pct) + } + pairs := [][2]string{{"Storage", usage}} + if f, ok := numericAsFloat(data["tables"]); ok { + pairs = append(pairs, [2]string{"Tables", fmt.Sprintf("%d", int64(f))}) + } + if f, ok := numericAsFloat(data["views"]); ok { + pairs = append(pairs, [2]string{"Views", fmt.Sprintf("%d", int64(f))}) + } + renderKeyValuePairs(w, pairs) +} diff --git a/shortcuts/apps/apps_db_recovery.go b/shortcuts/apps/apps_db_recovery.go new file mode 100644 index 00000000..181a01ee --- /dev/null +++ b/shortcuts/apps/apps_db_recovery.go @@ -0,0 +1,267 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +const dbRecoveryHint = "PITR window is up to 7 days back, limited by your last `+db-env-migrate`; pass --target as a time (e.g. 2h / 2026-04-15 / 2026-04-15T10:00:00Z)" + +// AppsDBRecoveryDiff 预览把数据库恢复到某个时间点会带来的变更(PITR diff,不落地)。 +// +// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:true} → preview_request_id, +// 轮询 env_recovery_diff_status 至终态,返回受影响表与行数变化。预览也需 spark:app:write scope。 +var AppsDBRecoveryDiff = common.Shortcut{ + Service: appsService, + Command: "+db-recovery-diff", + Description: "Preview restoring the database to a point in time (PITR diff)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +db-recovery-diff --app-id --target 2h", + "Apply with +db-recovery-apply --target --yes.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return normalizeTimeFlags(rctx, "target") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Preview PITR recovery"). + Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": true}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + target := rctx.Str("target") + preview, err := runRecoveryPreview(rctx, appID, target) + if err != nil { + return err + } + out := recoveryDiffOutput(target, preview) + rctx.OutFormat(out, nil, func(w io.Writer) { + renderRecoveryDiff(w, target, out) + }) + return nil + }, +} + +// AppsDBRecoveryApply 把数据库恢复到某个时间点(覆盖当前数据,异步,CLI 轮询至完成)。 +// +// POST /apps/{app_id}/db/env_recovery,body {target, dry_run:false};目标=当前态时短路 no_changes, +// 否则轮询 env_recovery_apply_status 至 success。high-risk-write。 +var AppsDBRecoveryApply = common.Shortcut{ + Service: appsService, + Command: "+db-recovery-apply", + Description: "Restore the database to a point in time (overwrites current data, irreversible)", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +db-recovery-apply --app-id --target 2026-04-15T10:00:00Z --yes", + "Preview first with +db-recovery-diff.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "target", Desc: "point in time to restore to; relative (2h/3d) | date | datetime | ISO 8601 w/ TZ", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return normalizeTimeFlags(rctx, "target") + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI().POST(appRecoveryPath(appID)).Desc("Apply PITR recovery"). + Body(map[string]interface{}{"target": rctx.Str("target"), "dry_run": false}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + target := rctx.Str("target") + stop := rctx.StartSpinner("Restoring database (target: " + target + ")") + defer stop() + submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": false}) + if err != nil { + return withAppsHint(err, dbRecoveryHint) + } + // 目标=当前态 → 后端短路 no_changes,不轮询。 + if strings.ToLower(common.GetString(submit, "status")) == "no_changes" { + stop() + out := map[string]interface{}{"status": "no_changes", "target": target} + rctx.OutFormat(out, nil, func(w io.Writer) { + io.WriteString(w, "No changes — database is already at this state.\n") + }) + return nil + } + final, perr := pollUntil(rctx.Ctx(), 2*time.Second, 2*time.Minute, + func() (map[string]interface{}, error) { + return rctx.CallAPITyped("GET", appRecoveryApplyStatusPath(appID), nil, nil) + }, + func(d map[string]interface{}) (bool, error) { + switch strings.ToLower(common.GetString(d, "status")) { + case "success", "restored", "ready": + return true, nil + case "failed": + msg := common.GetString(d, "error_message") + if msg == "" { + msg = fmt.Sprintf("recovery to %s failed", target) + } + return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint) + } + return false, nil + }) + if perr != nil { + return perr + } + stop() + out := map[string]interface{}{"status": "restored", "target": target} + if n := intFromAny(final["restore_time_sec"]); n > 0 { + out["restore_time_sec"] = n + } + rctx.OutFormat(out, nil, func(w io.Writer) { + if n, ok := out["restore_time_sec"].(int); ok { + fmt.Fprintf(w, "✓ Database restored to %s (%ds elapsed)\n", target, n) + } else { + fmt.Fprintf(w, "✓ Database restored to %s\n", target) + } + }) + return nil + }, +} + +// runRecoveryPreview 触发 PITR 预览(dry_run=true)拿 preview_request_id,轮询 diff_status 至终态。 +func runRecoveryPreview(rctx *common.RuntimeContext, appID, target string) (map[string]interface{}, error) { + stop := rctx.StartSpinner("Previewing recovery impact (target: " + target + ")") + defer stop() + submit, err := rctx.CallAPITyped("POST", appRecoveryPath(appID), nil, map[string]interface{}{"target": target, "dry_run": true}) + if err != nil { + return nil, withAppsHint(err, dbRecoveryHint) + } + prid := common.GetString(submit, "preview_request_id") + if prid == "" { + return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "recovery diff did not return preview_request_id") + } + return pollUntil(rctx.Ctx(), 1*time.Second, 2*time.Minute, + func() (map[string]interface{}, error) { + return rctx.CallAPITyped("GET", appRecoveryDiffStatusPath(appID), map[string]interface{}{"preview_request_id": prid}, nil) + }, + func(d map[string]interface{}) (bool, error) { + switch strings.ToLower(common.GetString(d, "preview_status")) { + case "success": + return true, nil + case "failed": + msg := common.GetString(d, "error_message") + if msg == "" { + msg = "recovery preview failed" + } + return false, withAppsHint(errs.NewAPIError(errs.SubtypeServerError, "%s", msg), dbRecoveryHint) + } + return false, nil + }) +} + +type recoveryChange struct { + Table string `json:"table"` + Inserted interface{} `json:"inserted,omitempty"` + Deleted interface{} `json:"deleted,omitempty"` + Action string `json:"action,omitempty"` + DroppedAt string `json:"dropped_at,omitempty"` +} + +// recoveryDiffOutput 组装 diff 输出:target / tables_affected / changes[] / estimated_seconds。 +func recoveryDiffOutput(target string, preview map[string]interface{}) map[string]interface{} { + arr, _ := preview["changes"].([]interface{}) + changes := make([]recoveryChange, 0, len(arr)) + for _, it := range arr { + m, ok := it.(map[string]interface{}) + if !ok { + continue + } + changes = append(changes, recoveryChange{ + Table: common.GetString(m, "table"), + Inserted: m["inserted"], + Deleted: m["deleted"], + Action: common.GetString(m, "action"), + DroppedAt: common.GetString(m, "dropped_at"), + }) + } + tablesAffected := intFromAny(preview["tables_affected"]) + if tablesAffected == 0 { + tablesAffected = len(changes) + } + est := intFromAny(preview["estimated_seconds"]) + if est == 0 { + est = 30 // PRD 兜底 + } + return map[string]interface{}{ + "target": target, "tables_affected": tablesAffected, + "changes": changes, "estimated_seconds": est, + } +} + +// renderRecoveryDiff 渲染 PITR 恢复预览:受影响表数、逐表变化描述及预估耗时;无变更打提示。 +func renderRecoveryDiff(w io.Writer, target string, out map[string]interface{}) { + changes, _ := out["changes"].([]recoveryChange) + if len(changes) == 0 { + io.WriteString(w, "No changes — database is already at this state.\n") + return + } + fmt.Fprintf(w, "Recovery preview (→ %s):\n\n", target) + fmt.Fprintf(w, " tables affected: %d\n", intFromAny(out["tables_affected"])) + for _, c := range changes { + fmt.Fprintf(w, " %s: %s\n", c.Table, describeRecoveryChange(c)) + } + fmt.Fprintf(w, "\n estimated time: ~%ds\n", intFromAny(out["estimated_seconds"])) +} + +// describeRecoveryChange:schema 动作 或 数据行变化二选一(无 modified,对齐设计)。 +func describeRecoveryChange(c recoveryChange) string { + switch c.Action { + case "restore_table": + return "table will be restored" + case "drop_table": + return "table will be dropped" + case "alter_table": + return "table will be altered" + case "unavailable": + if c.DroppedAt != "" { + return "diff unavailable: " + c.DroppedAt + } + return "diff unavailable" + } + parts := make([]string, 0, 2) + if n := intFromAny(c.Inserted); n != 0 { + parts = append(parts, fmt.Sprintf("+%d rows", n)) + } + if n := intFromAny(c.Deleted); n != 0 { + parts = append(parts, fmt.Sprintf("-%d rows", n)) + } + if len(parts) == 0 { + return "no changes" + } + return strings.Join(parts, ", ") +} diff --git a/shortcuts/apps/apps_db_table_get.go b/shortcuts/apps/apps_db_table_get.go index af0e63f0..5aff1852 100644 --- a/shortcuts/apps/apps_db_table_get.go +++ b/shortcuts/apps/apps_db_table_get.go @@ -11,7 +11,7 @@ import ( "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`" +const dbTableGetHint = "verify --app-id and --table are correct; list tables with `lark-cli apps +db-table-list --app-id `; if targeting --environment dev, create it first with `lark-cli apps +db-env-create --app-id --environment dev`" // AppsDBTableGet gets one table's structure (动词对齐 +db-table-list)。 // @@ -34,15 +34,17 @@ var AppsDBTableGet = common.Shortcut{ Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]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"}, - }, + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { if _, err := requireAppID(rctx.Str("app-id")); err != nil { return err } + if err := rejectLegacyEnvFlag(rctx); err != nil { + return err + } if strings.TrimSpace(rctx.Str("table")) == "" { return appsValidationParamError("--table", "--table is required") } @@ -78,7 +80,7 @@ var AppsDBTableGet = common.Shortcut{ // 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")} + params := map[string]interface{}{"env": dbEnv(rctx)} if rctx.Format == "pretty" { params["format"] = "ddl" } diff --git a/shortcuts/apps/apps_db_table_list.go b/shortcuts/apps/apps_db_table_list.go index d905531e..b24c04d8 100644 --- a/shortcuts/apps/apps_db_table_list.go +++ b/shortcuts/apps/apps_db_table_list.go @@ -13,7 +13,7 @@ import ( "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`" +const dbTableListHint = "verify --app-id is correct; if targeting --environment dev, create it first with `lark-cli apps +db-env-create --app-id --environment dev`" // AppsDBTableList lists tables in an app's database. // @@ -38,15 +38,16 @@ var AppsDBTableList = common.Shortcut{ Scopes: []string{"spark:app:read"}, AuthTypes: []string{"user"}, HasFormat: true, - Flags: []common.Flag{ + Flags: append([]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"}, - }, + }, dbEnvFlags("dev", []string{"dev", "online"}, "target db environment (default dev; use online for the online environment, or for an app whose DB is not multi-env)")...), Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { - _, err := requireAppID(rctx.Str("app-id")) - return err + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + return rejectLegacyEnvFlag(rctx) }, DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { appID, _ := requireAppID(rctx.Str("app-id")) @@ -110,7 +111,7 @@ func projectTableListItems(raw interface{}) []dbTableListItem { func buildDBTableListParams(rctx *common.RuntimeContext) map[string]interface{} { params := map[string]interface{}{ - "env": rctx.Str("env"), + "env": dbEnv(rctx), "page_size": rctx.Int("page-size"), } if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { diff --git a/shortcuts/apps/apps_db_table_list_test.go b/shortcuts/apps/apps_db_table_list_test.go index b9c5a352..85e1dd4c 100644 --- a/shortcuts/apps/apps_db_table_list_test.go +++ b/shortcuts/apps/apps_db_table_list_test.go @@ -31,7 +31,7 @@ func TestAppsDBTableList_BusinessErrorSurfacedAsTypedEnvelope(t *testing.T) { }) err := runAppsShortcut(t, AppsDBTableList, - []string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, + []string{"+db-table-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout) if err == nil { t.Fatalf("expected business error to surface, got nil; stdout=%s", stdout.String()) @@ -159,7 +159,7 @@ func TestAppsDBTableList_RequiresAppID(t *testing.T) { 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", + []string{"+db-table-list", "--app-id", "app_x", "--environment", "dev", "--page-size", "50", "--page-token", "cursor-abc", "--dry-run", "--as", "user"}, factory, stdout); err != nil { @@ -212,7 +212,7 @@ func TestAppsDBTableList_DoesNotSendIncludeStatsQuery(t *testing.T) { 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) + []string{"+db-table-list", "--app-id", "app_x", "--environment", "prod", "--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_env.go b/shortcuts/apps/apps_env.go new file mode 100644 index 00000000..57c05b5e --- /dev/null +++ b/shortcuts/apps/apps_env.go @@ -0,0 +1,412 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "sort" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultAppsEnvVarEnv = "dev" + defaultAppsEnvVarScene = 2 +) + +// AppsEnvVarList lists app environment variables without values by default. +var AppsEnvVarList = common.Shortcut{ + Service: appsService, + Command: "+env-list", + Description: "List app environment variables", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +env-list --app-id ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, + {Name: "include-values", Type: "bool", Desc: "include environment variable values"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil { + return err + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(envVarCollectionPath(appID)). + Desc("List app environment variables"). + Body(buildEnvVarListBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + includeValues := rctx.Bool("include-values") + data, err := rctx.CallAPITyped("POST", envVarCollectionPath(appID), nil, buildEnvVarListBody(rctx)) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := normalizeEnvVarListOutput(data, includeValues) + rctx.OutFormat(out, nil, func(w io.Writer) { + appsPrintSchemaTable(w, out.Items, envVarListSchema(includeValues)) + }) + return nil + }, +} + +// AppsEnvVarSet sets one app environment variable. Values are never printed. +var AppsEnvVarSet = common.Shortcut{ + Service: appsService, + Command: "+env-set", + Description: "Set an app environment variable", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +env-set --app-id --key FOO --value bar", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, + {Name: "key", Desc: "environment variable key", Required: true}, + {Name: "value", Desc: "environment variable value", Required: true, Input: []string{common.File, common.Stdin}}, + {Name: "yes", Type: "bool", Desc: "confirm setting variables in online"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil { + return err + } + if _, err := requireEnvVarKey(rctx.Str("key")); err != nil { + return err + } + if rctx.Str("value") == "" { + return appsValidationParamError("--value", "--value is required") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + key, _ := requireEnvVarKey(rctx.Str("key")) + return common.NewDryRunAPI(). + POST(envVarCreateOrUpdatePath(appID)). + Desc("Set app environment variable"). + Body(map[string]interface{}{ + "key": key, + "env": envVarEnv(rctx), + "value": "", + }) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + env := envVarEnv(rctx) + if env == "online" && !rctx.Bool("yes") { + return errs.NewConfirmationRequiredError( + errs.RiskWrite, + "apps +env-set --environment online", + "apps +env-set --environment online requires confirmation", + ).WithHint("add --yes to confirm") + } + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + key, err := requireEnvVarKey(rctx.Str("key")) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", envVarCreateOrUpdatePath(appID), nil, map[string]interface{}{ + "key": key, + "env": env, + "value": rctx.Str("value"), + }) + if err != nil { + return withAppsHint(err, envVarMutationHint(err)) + } + action := envVarStringAny(data, "action") + if action == "" { + action = "set" + } + rctx.OutFormat(map[string]interface{}{ + "key": key, + "env": env, + "action": action, + }, nil, nil) + return nil + }, +} + +// AppsEnvVarDelete deletes one or more app environment variables. +var AppsEnvVarDelete = common.Shortcut{ + Service: appsService, + Command: "+env-delete", + Description: "Delete app environment variables", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +env-delete --app-id --key FOO --yes", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"}, + {Name: "key", Type: "string_array", Desc: "environment variable key; repeatable", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if err := validateEnvVarEnv(envVarEnv(rctx)); err != nil { + return err + } + _, err := requireEnvVarKeys(rctx.StrArray("key")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + keys, _ := requireEnvVarKeys(rctx.StrArray("key")) + return common.NewDryRunAPI(). + POST(envVarDeletePath(appID)). + Desc("Delete app environment variables"). + Body(buildEnvVarDeleteBody(envVarEnv(rctx), keys)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + keys, err := requireEnvVarKeys(rctx.StrArray("key")) + if err != nil { + return err + } + env := envVarEnv(rctx) + data, err := rctx.CallAPITyped("POST", envVarDeletePath(appID), nil, buildEnvVarDeleteBody(env, keys)) + if err != nil { + return withAppsHint(err, envVarMutationHint(err)) + } + deletedKeys := envVarStringSliceAny(data, "deleted_keys", "deletedKeys") + if len(deletedKeys) == 0 { + deletedKeys = keys + } + rctx.OutFormat(map[string]interface{}{ + "env": env, + "deleted_keys": deletedKeys, + }, nil, nil) + return nil + }, +} + +func envVarEnv(rctx *common.RuntimeContext) string { + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) + if env == "" { + return defaultAppsEnvVarEnv + } + return env +} + +func envVarCollectionPath(appID string) string { + return appScopedPath(appID, "env_vars") +} + +func envVarCreateOrUpdatePath(appID string) string { + return appScopedPath(appID, "create_or_update_env_var") +} + +func envVarDeletePath(appID string) string { + return appScopedPath(appID, "delete_env_vars") +} + +func buildEnvVarListBody(rctx *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "env": envVarEnv(rctx), + "scene": defaultAppsEnvVarScene, + } +} + +func buildEnvVarDeleteBody(env string, keys []string) map[string]interface{} { + return map[string]interface{}{ + "env": env, + "keys": keys, + } +} + +func envVarMutationHint(err error) string { + if isEnvVarNotModifiableError(err) { + return "this environment variable is platform-managed and cannot be modified; remove protected keys from --key and retry only with user-defined variables" + } + return appIDListHint +} + +func isEnvVarNotModifiableError(err error) bool { + p, ok := errs.ProblemOf(err) + if !ok { + return false + } + return strings.Contains(strings.ToLower(p.Message), "not modifiable") +} + +func requireEnvVarKey(raw string) (string, error) { + key := strings.TrimSpace(raw) + if key == "" { + return "", appsValidationParamError("--key", "--key is required") + } + if !envKeyPattern.MatchString(key) { + return "", appsValidationParamError("--key", "--key must match [A-Za-z_][A-Za-z0-9_]*") + } + return key, nil +} + +func requireEnvVarKeys(raw []string) ([]string, error) { + keys := cleanRepeatedStrings(raw) + if len(keys) == 0 { + return nil, appsValidationParamError("--key", "--key is required") + } + for _, key := range keys { + if !envKeyPattern.MatchString(key) { + return nil, appsValidationParamError("--key", "--key must match [A-Za-z_][A-Za-z0-9_]*") + } + } + return keys, nil +} + +type envVarListOutput struct { + Items []map[string]interface{} `json:"items"` + PageToken string `json:"page_token"` + HasMore bool `json:"has_more"` +} + +func normalizeEnvVarListOutput(data map[string]interface{}, includeValues bool) envVarListOutput { + src := envVarResponseMap(data) + return envVarListOutput{ + Items: normalizeEnvVarItems(envVarItemsRaw(src), includeValues), + PageToken: envVarStringAny(src, "page_token", "next_page_token", "nextPageToken"), + HasMore: envVarBoolAny(src, "has_more", "hasMore"), + } +} + +func envVarResponseMap(data map[string]interface{}) map[string]interface{} { + if nested, ok := data["data"].(map[string]interface{}); ok { + return nested + } + return data +} + +func envVarItemsRaw(data map[string]interface{}) interface{} { + if raw := data["env_vars"]; raw != nil { + return raw + } + if raw := data["envVars"]; raw != nil { + return raw + } + return data["items"] +} + +func normalizeEnvVarItems(raw interface{}, includeValues bool) []map[string]interface{} { + switch typed := raw.(type) { + case []interface{}: + out := make([]map[string]interface{}, 0, len(typed)) + for _, item := range typed { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + out = append(out, filterEnvVarItem(m, includeValues)) + } + return out + case map[string]interface{}: + keys := make([]string, 0, len(typed)) + for key := range typed { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]map[string]interface{}, 0, len(keys)) + for _, key := range keys { + item := map[string]interface{}{"key": key} + if includeValues { + item["value"] = typed[key] + } + out = append(out, item) + } + return out + default: + return []map[string]interface{}{} + } +} + +func filterEnvVarItem(item map[string]interface{}, includeValues bool) map[string]interface{} { + out := make(map[string]interface{}, len(item)) + for key, value := range item { + if key == "value" && !includeValues { + continue + } + out[key] = value + } + return out +} + +func envVarListSchema(includeValues bool) appsOutputSchema { + columns := []appsOutputColumn{ + {Key: "key"}, + {Key: "env"}, + } + if includeValues { + columns = append(columns, appsOutputColumn{Key: "value"}) + } + return appsOutputSchema{Columns: columns, Strict: true} +} + +func envVarStringAny(data map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value, ok := data[key].(string); ok { + return value + } + } + return "" +} + +func envVarStringSliceAny(data map[string]interface{}, keys ...string) []string { + for _, key := range keys { + switch raw := data[key].(type) { + case []string: + return append([]string(nil), raw...) + case []interface{}: + out := make([]string, 0, len(raw)) + for _, item := range raw { + if value, ok := item.(string); ok { + out = append(out, value) + } + } + if len(out) > 0 { + return out + } + } + } + return nil +} + +func envVarBoolAny(data map[string]interface{}, keys ...string) bool { + for _, key := range keys { + if value, ok := data[key].(bool); ok { + return value + } + } + return false +} diff --git a/shortcuts/apps/apps_env_pull.go b/shortcuts/apps/apps_env_pull.go index e2242f9a..ebfc9184 100644 --- a/shortcuts/apps/apps_env_pull.go +++ b/shortcuts/apps/apps_env_pull.go @@ -62,8 +62,9 @@ var AppsEnvPull = common.Shortcut{ projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path"))) appID := strings.TrimSpace(rctx.Str("app-id")) return common.NewDryRunAPI(). - POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))). + POST(envPullVarsPath(appID)). Desc("Pull app startup env vars into the local .env.local file"). + Body(envPullVarsBody()). Set("project_path", projectPath). Set("env_file", envFile) }, @@ -80,10 +81,9 @@ var AppsEnvPull = common.Shortcut{ return err } - path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID)) - data, err := rctx.CallAPITyped("POST", path, nil, nil) + data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody()) if err != nil { - return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`") + return withAppsHint(err, envPullAPIErrorHint(err, appID)) } envVars, databaseInfo, skippedKeys, err := extractEnvPullVars(data) @@ -116,6 +116,37 @@ var AppsEnvPull = common.Shortcut{ }, } +func envPullVarsPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID)) +} + +func envPullVarsBody() map[string]interface{} { + return map[string]interface{}{ + "env": "dev", + } +} + +func envPullAPIErrorHint(err error, appID string) string { + if isEnvPullDevDBNotInitializedError(err) { + appID = strings.TrimSpace(appID) + if appID == "" { + appID = "" + } + return fmt.Sprintf("dev database is not initialized; preview creation with `lark-cli apps +db-env-create --app-id %s --environment dev --dry-run`, then run `lark-cli apps +db-env-create --app-id %s --environment dev --sync-data --yes` after confirming the irreversible split", appID, appID) + } + return appIDListHint +} + +func isEnvPullDevDBNotInitializedError(err error) bool { + p, ok := errs.ProblemOf(err) + if !ok { + return false + } + message := strings.ToLower(p.Message) + return strings.Contains(message, "multi-environment database is not initialized") || + (strings.Contains(message, "invalid db branch") && strings.Contains(message, "dev")) +} + func resolveEnvPullTarget(projectPath string) (string, string, error) { if strings.TrimSpace(projectPath) == "" { cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded. @@ -150,13 +181,19 @@ func checkEnvPullTarget(envFile string) error { func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPullDatabaseInfo, []string, error) { raw := data["env_vars"] + if raw == nil { + raw = data["envVars"] + } if raw == nil { if nested, ok := data["data"].(map[string]interface{}); ok { raw = nested["env_vars"] + if raw == nil { + raw = nested["envVars"] + } } } 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") + return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars/envVars must be an object or array of key/value entries") } var skippedKeys []string @@ -203,7 +240,7 @@ func extractEnvPullVars(data map[string]interface{}) (map[string]string, envPull } 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") + return nil, envPullDatabaseInfo{}, nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "response field env_vars/envVars must be an object or array of key/value entries") } } diff --git a/shortcuts/apps/apps_env_pull_test.go b/shortcuts/apps/apps_env_pull_test.go index 1e9b3424..beda8dc4 100644 --- a/shortcuts/apps/apps_env_pull_test.go +++ b/shortcuts/apps/apps_env_pull_test.go @@ -7,6 +7,7 @@ import ( "bytes" "encoding/json" "errors" + "net/http" "os" "path/filepath" "strings" @@ -31,6 +32,11 @@ func assertValidationError(t *testing.T, err error, wantSubstr string) { } } +func assertEnvPullBody(t *testing.T, req *http.Request) { + t.Helper() + assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"}) +} + func TestResolveEnvPullTarget_DefaultProjectPathUsesCWD(t *testing.T) { cwd := t.TempDir() oldwd, err := os.Getwd() @@ -255,7 +261,7 @@ func TestBuildEnvPullSuccessDataSuppressesEnvKeysAndValues(t *testing.T) { } } -func TestAppsEnvPull_DryRunUsesPostAndResolvedEnvFile(t *testing.T) { +func TestAppsEnvPull_DryRunUsesPostBodyAndResolvedEnvFile(t *testing.T) { factory, stdout, _ := newAppsExecuteFactory(t) projectDir := t.TempDir() @@ -272,6 +278,9 @@ func TestAppsEnvPull_DryRunUsesPostAndResolvedEnvFile(t *testing.T) { 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, `"env": "dev"`) || strings.Contains(got, `"include_values"`) { + t.Fatalf("dry-run must include only env=dev in the request body: %s", got) + } if !strings.Contains(got, filepath.Join(projectDir, ".env.local")) { t.Fatalf("dry-run must include resolved env file path: %s", got) } @@ -283,6 +292,9 @@ func TestAppsEnvPull_PrettyOutput_WithDatabaseLine(t *testing.T) { reg.Register(&httpmock.Stub{ Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/env_vars", + OnMatch: func(req *http.Request) { + assertEnvPullBody(t, req) + }, Body: map[string]interface{}{ "code": 0, "data": map[string]interface{}{ @@ -550,6 +562,68 @@ func TestAppsEnvPull_ExecuteUsesNestedDataEnvVars(t *testing.T) { } } +func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + RawBody: []byte("[]"), + OnMatch: func(req *http.Request) { + assertEnvPullBody(t, req) + }, + }) + + err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"}, + factory, stdout, + ) + if err == nil { + t.Fatalf("expected non-object JSON failure, got nil; stdout=%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype) + } + if strings.Contains(p.Hint, "apps +list") || strings.Contains(p.Hint, "--app-id") { + t.Fatalf("hint should not point to app-id/list recovery for malformed upstream JSON: %q", p.Hint) + } +} + +func TestAppsEnvPull_DevDBNotInitializedHintPointsToDBEnvCreate(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + Body: map[string]interface{}{ + "code": -1, + "msg": "Multi-environment database is not initialized for this app. Invalid DB Branch:dev", + }, + OnMatch: func(req *http.Request) { + assertEnvPullBody(t, req) + }, + }) + + err := runAppsShortcut(t, AppsEnvPull, + []string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"}, + factory, stdout, + ) + p := requireAppsAPIProblem(t, err) + if p.Code != -1 { + t.Fatalf("code = %d, want -1", p.Code) + } + for _, want := range []string{"+db-env-create", "--app-id app_x", "--environment dev", "--dry-run", "--yes"} { + if !strings.Contains(p.Hint, want) { + t.Fatalf("hint missing %q: %q", want, p.Hint) + } + } + if strings.Contains(p.Hint, "apps +list") { + t.Fatalf("hint should not point to app-id/list recovery for missing dev database: %q", p.Hint) + } +} + func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) { factory, stdout, reg := newAppsExecuteFactory(t) projectDir := t.TempDir() diff --git a/shortcuts/apps/apps_env_test.go b/shortcuts/apps/apps_env_test.go new file mode 100644 index 00000000..4913bf5c --- /dev/null +++ b/shortcuts/apps/apps_env_test.go @@ -0,0 +1,409 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +func assertEnvVarBody(t *testing.T, req *http.Request, want map[string]interface{}) { + t.Helper() + if req.URL.RawQuery != "" { + t.Fatalf("query should be empty, got %q", req.URL.RawQuery) + } + var got map[string]interface{} + if err := json.NewDecoder(req.Body).Decode(&got); err != nil { + t.Fatalf("decode body: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("body = %#v, want %#v", got, want) + } +} + +func expectedEnvVarSceneJSON() float64 { + return float64(defaultAppsEnvVarScene) +} + +func decodeEnvVarEnvelopeData(t *testing.T, stdout string) map[string]interface{} { + t.Helper() + var envelope struct { + OK bool `json:"ok"` + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode stdout: %v\n%s", err, stdout) + } + if !envelope.OK { + t.Fatalf("expected ok envelope, got %s", stdout) + } + return envelope.Data +} + +func requireEnvVarValidationProblem(t *testing.T, err error, param string) { + t.Helper() + p := requireAppsProblem(t, err, errs.CategoryValidation) + if p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("validation subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument) + } + var validation *errs.ValidationError + if !errors.As(err, &validation) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if validation.Param != param { + t.Fatalf("validation param = %q, want %q", validation.Param, param) + } +} + +func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + OnMatch: func(req *http.Request) { + assertEnvVarBody(t, req, map[string]interface{}{"env": "dev", "scene": expectedEnvVarSceneJSON()}) + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "dev"}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvVarList, + []string{"+env-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + got := stdout.String() + if strings.Contains(got, "super-secret") || strings.Contains(got, `"value"`) { + t.Fatalf("stdout must not expose values by default: %s", got) + } + data := decodeEnvVarEnvelopeData(t, got) + items, ok := data["items"].([]interface{}) + if !ok || len(items) != 1 { + t.Fatalf("items = %#v, want one item", data["items"]) + } + item, ok := items[0].(map[string]interface{}) + if !ok || item["key"] != "SECRET_TOKEN" { + t.Fatalf("item = %#v, want SECRET_TOKEN", items[0]) + } + if _, ok := item["value"]; ok { + t.Fatalf("item must not contain value by default: %#v", item) + } +} + +func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + OnMatch: func(req *http.Request) { + assertEnvVarBody(t, req, map[string]interface{}{"env": "online", "scene": expectedEnvVarSceneJSON()}) + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"key": "SECRET_TOKEN", "value": "super-secret", "env": "online"}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvVarList, + []string{"+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + got := stdout.String() + if !strings.Contains(got, "super-secret") { + t.Fatalf("stdout should include values when requested: %s", got) + } +} + +func TestAppsEnvVarList_DoesNotAcceptEnvironmentShorthand(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarList, + []string{"+env-list", "--app-id", "app_x", "-e", "online", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "unknown shorthand flag: 'e'") { + t.Fatalf("expected unknown -e shorthand, got %v", err) + } +} + +func TestAppsEnvVarList_DryRunIncludesScene(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsEnvVarList, []string{ + "+env-list", "--app-id", "app_x", "--include-values", "--dry-run", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + var dryRun struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &dryRun); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if got := dryRun.API[0].Body["scene"]; got != expectedEnvVarSceneJSON() { + t.Fatalf("body.scene = %#v, want %v; stdout:\n%s", got, expectedEnvVarSceneJSON(), stdout.String()) + } +} + +func TestAppsEnvVarList_PrettyDisplaysTable(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/env_vars", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "envVars": []interface{}{ + map[string]interface{}{"key": "API_HOST", "value": "https://example.com", "env": "online"}, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsEnvVarList, []string{ + "+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.HasPrefix(got, "key") { + t.Fatalf("pretty output should start with key column, got:\n%s", got) + } + for _, want := range []string{"API_HOST", "online", "https://example.com"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q:\n%s", want, got) + } + } + if strings.Contains(got, `"ok"`) || strings.Contains(got, `"data"`) { + t.Fatalf("pretty output should not fall back to JSON envelope:\n%s", got) + } +} + +func TestAppsEnvVarSet_OnlineRequiresYesOutsideDryRun(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarSet, + []string{"+env-set", "--app-id", "app_x", "--environment", "online", + "--key", "SECRET_TOKEN", "--value", "super-secret", "--as", "user"}, factory, stdout) + + p := requireAppsProblem(t, err, errs.CategoryConfirmation) + if p.Subtype != errs.SubtypeConfirmationRequired { + t.Fatalf("confirmation subtype = %q, want %q", p.Subtype, errs.SubtypeConfirmationRequired) + } + if !strings.Contains(p.Hint, "add --yes") { + t.Fatalf("confirmation hint missing --yes guidance: %#v", p) + } +} + +func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsEnvVarSet, + []string{"+env-set", "--app-id", "app_x", "--environment", "online", + "--key", "SECRET_TOKEN", "--value", "super-secret", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + + got := stdout.String() + if strings.Contains(got, "super-secret") { + t.Fatalf("dry-run must redact value: %s", got) + } + for _, want := range []string{`"method": "POST"`, `/open-apis/spark/v1/apps/app_x/create_or_update_env_var`} { + if !strings.Contains(got, want) { + t.Fatalf("dry-run missing %q: %s", want, got) + } + } + var dryRun struct { + API []struct { + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + if err := json.Unmarshal([]byte(got), &dryRun); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, got) + } + if len(dryRun.API) != 1 || dryRun.API[0].Body["value"] != "" || dryRun.API[0].Body["key"] != "SECRET_TOKEN" { + t.Fatalf("dry-run body = %#v, want redacted value and key", dryRun.API) + } +} + +func TestAppsEnvVarSet_ExecutesWithYesAndDoesNotEchoValue(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/create_or_update_env_var", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"action": "updated"}}, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsEnvVarSet, + []string{"+env-set", "--app-id", "app_x", "--environment", "online", + "--key", "SECRET_TOKEN", "--value", "super-secret", "--yes", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["key"] != "SECRET_TOKEN" || sent["env"] != "online" || sent["value"] != "super-secret" { + t.Fatalf("body = %#v, want real online value", sent) + } + got := stdout.String() + if strings.Contains(got, "super-secret") || strings.Contains(got, `"value"`) { + t.Fatalf("stdout must not echo value: %s", got) + } + for _, want := range []string{`"key": "SECRET_TOKEN"`, `"env": "online"`, `"action": "updated"`} { + if !strings.Contains(got, want) { + t.Fatalf("stdout missing %q: %s", want, got) + } + } +} + +func TestAppsEnvVarDelete_IsHighRiskWrite(t *testing.T) { + if AppsEnvVarDelete.Risk != "high-risk-write" { + t.Fatalf("risk = %q, want high-risk-write", AppsEnvVarDelete.Risk) + } +} + +func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/delete_env_vars", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"deleted_keys": []interface{}{"SECRET_ONE", "SECRET_TWO"}}}, + } + reg.Register(stub) + + if err := runAppsShortcut(t, AppsEnvVarDelete, + []string{"+env-delete", "--app-id", "app_x", "--environment", "online", + "--key", "SECRET_ONE", "--key", "SECRET_TWO", "--yes", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var sent map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &sent); err != nil { + t.Fatalf("decode body: %v", err) + } + if sent["env"] != "online" { + t.Fatalf("body.env = %v, want online", sent["env"]) + } + keys, ok := sent["keys"].([]interface{}) + if !ok || len(keys) != 2 || keys[0] != "SECRET_ONE" || keys[1] != "SECRET_TWO" { + t.Fatalf("body.keys = %#v, want SECRET_ONE/SECRET_TWO", sent["keys"]) + } + got := stdout.String() + for _, want := range []string{`"env": "online"`, `"deleted_keys"`, `"SECRET_ONE"`, `"SECRET_TWO"`} { + if !strings.Contains(got, want) { + t.Fatalf("stdout missing %q: %s", want, got) + } + } +} + +func TestAppsEnvVarDelete_NotModifiableHint(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/delete_env_vars", + Body: map[string]interface{}{ + "code": 400000072, + "msg": "Invalid Request: env var (INTEGRATION_TOKEN) is not modifiable", + }, + }) + + err := runAppsShortcut(t, AppsEnvVarDelete, + []string{"+env-delete", "--app-id", "app_x", "--key", "INTEGRATION_TOKEN", "--yes", "--as", "user"}, factory, stdout) + if err == nil { + t.Fatalf("expected not modifiable error, got nil; stdout=%s", stdout.String()) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Code != 400000072 { + t.Fatalf("code = %d, want 400000072", p.Code) + } + if !strings.Contains(p.Hint, "platform-managed") || !strings.Contains(p.Hint, "user-defined") { + t.Fatalf("hint = %q, want platform-managed/user-defined guidance", p.Hint) + } + if strings.Contains(p.Hint, "apps +list") { + t.Fatalf("hint should not point at app listing for protected env vars: %q", p.Hint) + } +} + +func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsEnvVarDelete, + []string{"+env-delete", "--app-id", "app_x", "--environment", "online", + "--key", "SECRET_ONE", "--key", "SECRET_TWO", "--dry-run", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("dry-run err=%v", err) + } + + var dryRun struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Body map[string]interface{} `json:"body"` + } `json:"api"` + } + got := stdout.String() + if err := json.Unmarshal([]byte(got), &dryRun); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, got) + } + if len(dryRun.API) != 1 || dryRun.API[0].Method != "POST" || dryRun.API[0].URL != "/open-apis/spark/v1/apps/app_x/delete_env_vars" { + t.Fatalf("dry-run api = %#v", dryRun.API) + } + if dryRun.API[0].Body["env"] != "online" { + t.Fatalf("dry-run body.env = %v, want online", dryRun.API[0].Body["env"]) + } + keys, ok := dryRun.API[0].Body["keys"].([]interface{}) + if !ok || len(keys) != 2 || keys[0] != "SECRET_ONE" || keys[1] != "SECRET_TWO" { + t.Fatalf("dry-run body.keys = %#v, want SECRET_ONE/SECRET_TWO", dryRun.API[0].Body["keys"]) + } +} + +func TestAppsEnvVarList_InvalidEnvTypedValidation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarList, + []string{"+env-list", "--app-id", "app_x", "--environment", "prod", "--as", "user"}, factory, stdout) + requireEnvVarValidationProblem(t, err, "--environment") +} + +func TestAppsEnvVarList_OldEnvFlagIsNotAlias(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarList, + []string{"+env-list", "--app-id", "app_x", "--env", "online", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "unknown flag: --env") { + t.Fatalf("expected old --env to be rejected, got %v", err) + } +} + +func TestAppsEnvVarSet_InvalidKeyTypedValidation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarSet, + []string{"+env-set", "--app-id", "app_x", "--key", "bad-key", + "--value", "super-secret", "--as", "user"}, factory, stdout) + requireEnvVarValidationProblem(t, err, "--key") +} + +func TestAppsEnvVarDelete_InvalidKeyTypedValidation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsEnvVarDelete, + []string{"+env-delete", "--app-id", "app_x", "--key", "bad-key", + "--yes", "--as", "user"}, factory, stdout) + requireEnvVarValidationProblem(t, err, "--key") +} diff --git a/shortcuts/apps/apps_examples_test.go b/shortcuts/apps/apps_examples_test.go index 5b977d0f..2439d83e 100644 --- a/shortcuts/apps/apps_examples_test.go +++ b/shortcuts/apps/apps_examples_test.go @@ -14,6 +14,9 @@ func TestAppsShortcutsHaveExamples(t *testing.T) { email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`) phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`) for _, s := range Shortcuts() { + if s.Hidden { + continue + } hasExample := false for _, tip := range s.Tips { if strings.HasPrefix(tip, "Example: lark-cli apps +") { @@ -50,3 +53,62 @@ func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) { } } } + +func TestAppsEnvTipsCoverConfirmations(t *testing.T) { + envSet := requireShortcutForExamples(t, "+env-set") + if !tipsContainAll(envSet.Tips, "--environment online", "--yes") { + t.Fatalf("+env-set tips must include an online write example with --environment online --yes: %#v", envSet.Tips) + } + + envDelete := requireShortcutForExamples(t, "+env-delete") + if !tipsContainAll(envDelete.Tips, "--yes") { + t.Fatalf("+env-delete tips must include --yes: %#v", envDelete.Tips) + } +} + +func TestAppsObservabilityTipsMentionOnlineOnly(t *testing.T) { + for _, cmd := range []string{ + "+log-list", + "+log-get", + "+trace-list", + "+trace-get", + "+metric-list", + "+analytics-list", + } { + shortcut := requireShortcutForExamples(t, cmd) + if !tipsContainAll(shortcut.Tips, "online-only", "--environment online") { + t.Fatalf("%s tips should mention online-only env: %#v", cmd, shortcut.Tips) + } + } +} + +func requireShortcutForExamples(t *testing.T, command string) shortcutForExamples { + t.Helper() + for _, sc := range Shortcuts() { + if sc.Command == command { + return shortcutForExamples{Tips: sc.Tips} + } + } + t.Fatalf("missing shortcut %s", command) + return shortcutForExamples{} +} + +type shortcutForExamples struct { + Tips []string +} + +func tipsContainAll(tips []string, needles ...string) bool { + for _, tip := range tips { + ok := true + for _, needle := range needles { + if !strings.Contains(tip, needle) { + ok = false + break + } + } + if ok { + return true + } + } + return false +} diff --git a/shortcuts/apps/apps_file_delete.go b/shortcuts/apps/apps_file_delete.go new file mode 100644 index 00000000..153f40a8 --- /dev/null +++ b/shortcuts/apps/apps_file_delete.go @@ -0,0 +1,148 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsFileDelete batch-deletes files by remote path(high-risk-write,框架自动注入 --yes 确认)。 +// +// POST /apps/{app_id}/storage/file_batch_remove,body {paths:[...]}。网关把该路由注册为 POST +// (DELETE-with-body 不被网关支持,实测 DELETE→404 / POST→200)。后端 results[] 与请求 paths +// 顺序一一对应:成功项带 file,失败项带 error_code(CLI 据下标回填 path)。 +// 部分失败整体仍 ok:true —— 失败项落在 data.results[].error,不翻成非 0 退出码(lark-cli 信封语义)。 +var AppsFileDelete = common.Shortcut{ + Service: appsService, + Command: "+file-delete", + Description: "Delete one or more files by remote path (batch)", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +file-delete --app-id --path /1858537546760216.png --yes", + "Repeat --path for batch delete.", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "path", Type: "string_slice", Desc: "remote file path to delete (repeatable)", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if len(cleanDeletePaths(rctx)) == 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--path is required (at least one remote path)").WithParam("--path") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appFileBatchRemovePath(appID)). + Desc("Batch delete Miaoda app files"). + Body(map[string]interface{}{"paths": cleanDeletePaths(rctx)}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + paths := cleanDeletePaths(rctx) + data, err := rctx.CallAPITyped("POST", appFileBatchRemovePath(appID), nil, map[string]interface{}{"paths": paths}) + if err != nil { + return err + } + results := projectDeleteResults(data["results"], paths) + out := map[string]interface{}{"results": results} + rctx.OutFormat(out, nil, func(w io.Writer) { + renderFileDeletePretty(w, results) + }) + return nil + }, +} + +// cleanDeletePaths 取 --path 切片,trim 去空。 +func cleanDeletePaths(rctx *common.RuntimeContext) []string { + out := make([]string, 0) + for _, p := range rctx.StrSlice("path") { + if t := strings.TrimSpace(p); t != "" { + out = append(out, t) + } + } + return out +} + +// projectDeleteResults 把后端 results[] 按下标 zip 回请求 paths,回填 path, +// 失败项把 error_code 包成 {code,message} 便于消费。 +func projectDeleteResults(raw interface{}, inputs []string) []map[string]interface{} { + arr, _ := raw.([]interface{}) + out := make([]map[string]interface{}, 0, len(inputs)) + for i, input := range inputs { + var r map[string]interface{} + if i < len(arr) { + r, _ = arr[i].(map[string]interface{}) + } + status := "ok" + if r != nil && common.GetString(r, "status") != "" { + status = common.GetString(r, "status") + } + item := map[string]interface{}{"status": status, "path": input} + if status == "ok" { + if r != nil { + if f, ok := r["file"].(map[string]interface{}); ok { + item["file_name"] = common.GetString(f, "file_name") + } + } + } else { + code := "" + if r != nil { + code = common.GetString(r, "error_code") + } + if code == "" { + code = "DELETE_FAILED" + } + item["error"] = map[string]interface{}{ + "code": code, + "message": deleteErrorMessage(code, input), + } + } + out = append(out, item) + } + return out +} + +// deleteErrorMessage 据 error_code 生成删除失败文案:FILE_NOT_FOUND 提示文件不存在,其余统一删除失败。 +func deleteErrorMessage(code, path string) string { + if code == "FILE_NOT_FOUND" { + return fmt.Sprintf("File '%s' does not exist", path) + } + return fmt.Sprintf("Failed to delete '%s'", path) +} + +// renderFileDeletePretty 逐项打 ✓ / ✗,末行汇总 deleted 计数。 +func renderFileDeletePretty(w io.Writer, results []map[string]interface{}) { + okCount := 0 + for _, r := range results { + path := common.GetString(r, "path") + if common.GetString(r, "status") == "ok" { + fmt.Fprintf(w, "✓ %s\n", path) + okCount++ + continue + } + code := "" + if e, ok := r["error"].(map[string]interface{}); ok { + code = common.GetString(e, "code") + } + fmt.Fprintf(w, "✗ %s (%s)\n", path, code) + } + fmt.Fprintf(w, "\n%d/%d deleted\n", okCount, len(results)) +} diff --git a/shortcuts/apps/apps_file_delete_test.go b/shortcuts/apps/apps_file_delete_test.go new file mode 100644 index 00000000..edfce924 --- /dev/null +++ b/shortcuts/apps/apps_file_delete_test.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const fileDeleteURL = "/open-apis/spark/v1/apps/app_x/storage/file_batch_remove" + +// TestAppsFileDelete_RequiresAppIDAndPath 验证仅含空白的 --path 去空后为空时,Validate 报 --path typed 校验错误。 +func TestAppsFileDelete_RequiresAppIDAndPath(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // 传入仅含空白的 --path:满足 cobra 的 Required 检查,但 cleanDeletePaths 去空后为空, + // 触发 Validate 内的 typed --path 校验。 + err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", " ", "--yes", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--path" { + t.Fatalf("Param = %q, want --path", ve.Param) + } +} + +// high-risk-write:无 --yes → confirmation_required(exit 10)。 +func TestAppsFileDelete_RequiresConfirmation(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--as", "user"}, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "requires confirmation") { + t.Fatalf("expected confirmation_required, got %v", err) + } +} + +// TestAppsFileDelete_DryRunSendsPaths 验证 dry-run 输出 POST file_batch_remove,body.paths 按序携带多个 --path。 +func TestAppsFileDelete_DryRunSendsPaths(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/b.png", "--yes", "--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"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != fileDeleteURL { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + paths, _ := a.Body["paths"].([]interface{}) + if len(paths) != 2 || paths[0] != "/a.png" || paths[1] != "/b.png" { + t.Fatalf("body.paths = %v", a.Body["paths"]) + } +} + +// 部分失败仍 ok:true;results 按下标 zip 回 path;失败项带 error{code,message}。 +func TestAppsFileDelete_PartialFailureStillOK(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileDeleteURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "results": []interface{}{ + map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png", "path": "/a.png"}}, + map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"}, + }, + }}, + }) + err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--as", "user"}, factory, stdout) + if err != nil { + t.Fatalf("partial failure should NOT error (ok:true semantics), got %v", err) + } + got := stdout.String() + var env struct { + Data struct { + Results []map[string]interface{} `json:"results"` + } `json:"data"` + } + if err := json.Unmarshal([]byte(got), &env); err != nil { + t.Fatalf("decode: %v\n%s", err, got) + } + if len(env.Data.Results) != 2 { + t.Fatalf("want 2 results, got %d: %s", len(env.Data.Results), got) + } + r0, r1 := env.Data.Results[0], env.Data.Results[1] + if r0["status"] != "ok" || r0["path"] != "/a.png" { + t.Errorf("result[0] = %v", r0) + } + if r1["status"] != "error" || r1["path"] != "/missing.png" { + t.Errorf("result[1] = %v (path must be back-filled by index)", r1) + } + if e, ok := r1["error"].(map[string]interface{}); !ok || e["code"] != "FILE_NOT_FOUND" { + t.Errorf("result[1].error = %v (want code FILE_NOT_FOUND)", r1["error"]) + } +} + +// TestAppsFileDelete_PrettySummary 验证 pretty 输出逐项 ✓/✗ 标记并汇总 "1/2 deleted"。 +func TestAppsFileDelete_PrettySummary(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileDeleteURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "results": []interface{}{ + map[string]interface{}{"status": "ok", "file": map[string]interface{}{"file_name": "a.png"}}, + map[string]interface{}{"status": "error", "error_code": "FILE_NOT_FOUND"}, + }, + }}, + }) + if err := runAppsShortcut(t, AppsFileDelete, + []string{"+file-delete", "--app-id", "app_x", "--path", "/a.png", "--path", "/missing.png", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{"✓ /a.png", "✗ /missing.png (FILE_NOT_FOUND)", "1/2 deleted"} { + if !strings.Contains(got, want) { + t.Errorf("pretty missing %q:\n%s", want, got) + } + } +} diff --git a/shortcuts/apps/apps_file_download.go b/shortcuts/apps/apps_file_download.go new file mode 100644 index 00000000..ac87079f --- /dev/null +++ b/shortcuts/apps/apps_file_download.go @@ -0,0 +1,125 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "net/http" + "path" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsFileDownload downloads a file to a local path via a signed URL。 +// +// 两步:POST /apps/{app_id}/storage/file_sign 拿 signed_url(presigned,直连对象存储), +// 再客户端 GET signed_url 落盘到 --output(默认远端 basename)。不单设 download 接口。 +var AppsFileDownload = common.Shortcut{ + Service: appsService, + Command: "+file-download", + Description: "Download a file to a local path (via a signed URL)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-download --app-id --path /1858537546760216.png --output ./logo.png", + "Example (omit --output): lark-cli apps +file-download --app-id --path /1858537546760216.png # saves to ./1858537546760216.png", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "path", Desc: "remote file path", Required: true}, + {Name: "output", Desc: "local output path (default: remote file basename in cwd)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if err := rejectOutputTraversal(rctx.Str("output")); err != nil { + return err + } + _, err := requireFilePath(rctx.Str("path")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + remotePath, _ := requireFilePath(rctx.Str("path")) + return common.NewDryRunAPI(). + POST(appFileSignPath(appID)). + Desc("Sign a download URL, then GET it to --output"). + Body(map[string]interface{}{"path": remotePath}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + remotePath, err := requireFilePath(rctx.Str("path")) + if err != nil { + return err + } + + // 1. 签名拿 presigned signed_url。 + signData, err := rctx.CallAPITyped("POST", appFileSignPath(appID), nil, map[string]interface{}{"path": remotePath}) + if err != nil { + return err + } + signedURL := common.GetString(signData, "signed_url") + if signedURL == "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "sign returned no signed_url") + } + + // 2. 直连 GET signed_url 落盘。 + out := strings.TrimSpace(rctx.Str("output")) + if out == "" { + out = path.Base(strings.TrimPrefix(remotePath, "/")) + if out == "" || out == "." || out == "/" { + out = "download" + } + } + req, err := http.NewRequestWithContext(rctx.Ctx(), http.MethodGet, signedURL, nil) //nolint:forbidigo // GET from a presigned object-storage URL bypasses the Lark gateway; raw HTTP required (not a Lark API call). + if err != nil { + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "build download request").WithCause(err) + } + resp, err := newFileTransferClient().Do(req) //nolint:forbidigo // see above: direct presigned-URL download, RuntimeContext.DoAPI does not apply. + if err != nil { + // dial/transport 失败是典型可重试场景。 + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed").WithCause(err).WithRetryable() + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + // 5xx 是上游瞬时故障,标 retryable;4xx(如签名过期)需重新签名而非盲重试,不标。 + if resp.StatusCode >= 500 { + return errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed: HTTP %d", resp.StatusCode).WithRetryable() + } + return errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed: HTTP %d", resp.StatusCode) + } + saved, err := rctx.FileIO().Save(out, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output: %v", err).WithParam("--output").WithCause(err) + } + resolved, perr := rctx.FileIO().ResolvePath(out) + if perr != nil || resolved == "" { + resolved = out + } + result := map[string]interface{}{ + "path": remotePath, + "output": resolved, + "size_bytes": saved.Size(), + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Downloaded %s → %s (%s)\n", remotePath, resolved, humanBytes(saved.Size())) + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_file_download_test.go b/shortcuts/apps/apps_file_download_test.go new file mode 100644 index 00000000..1d429797 --- /dev/null +++ b/shortcuts/apps/apps_file_download_test.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const fileSignURLForDownload = "/open-apis/spark/v1/apps/app_x/storage/file_sign" + +// TestAppsFileDownload_RequiresAppIDAndPath 验证仅含空白的 --path 触发 --path typed 校验错误。 +func TestAppsFileDownload_RequiresAppIDAndPath(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileDownload, + []string{"+file-download", "--app-id", "app_x", "--path", " ", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--path" { + t.Fatalf("Param = %q, want --path", ve.Param) + } +} + +// TestAppsFileDownload_DryRunSignsFirst 验证 dry-run 第一步是 POST file_sign。 +func TestAppsFileDownload_DryRunSignsFirst(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileDownload, + []string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--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"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Method != "POST" || env.API[0].URL != fileSignURLForDownload { + t.Fatalf("dry-run = %s %s (want POST sign)", env.API[0].Method, env.API[0].URL) + } +} + +// sign → 客户端 GET presigned signed_url → 落盘 --output。 +func TestAppsFileDownload_EndToEnd(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "image/png") + io.WriteString(w, "PNGDATA") + })) + defer srv.Close() + + dir := t.TempDir() + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileSignURLForDownload, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"signed_url": srv.URL}}, + }) + if err := runAppsShortcut(t, AppsFileDownload, + []string{"+file-download", "--app-id", "app_x", "--path", "/x.png", "--output", "out.png", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + b, err := os.ReadFile(filepath.Join(dir, "out.png")) + if err != nil { + t.Fatalf("read output file: %v", err) + } + if string(b) != "PNGDATA" { + t.Fatalf("downloaded content = %q, want PNGDATA", b) + } + if !strings.Contains(stdout.String(), `"size_bytes": 7`) { + t.Errorf("output json missing size_bytes:7\n%s", stdout.String()) + } +} + +// 不传 --output → 默认远端 basename。 +func TestAppsFileDownload_DefaultsOutputToBasename(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "DATA") + })) + defer srv.Close() + + dir := t.TempDir() + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileSignURLForDownload, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"signed_url": srv.URL}}, + }) + if err := runAppsShortcut(t, AppsFileDownload, + []string{"+file-download", "--app-id", "app_x", "--path", "/1858537546760216.png", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if _, err := os.Stat(filepath.Join(dir, "1858537546760216.png")); err != nil { + t.Fatalf("default output basename not written: %v", err) + } +} diff --git a/shortcuts/apps/apps_file_get.go b/shortcuts/apps/apps_file_get.go new file mode 100644 index 00000000..7fd99af2 --- /dev/null +++ b/shortcuts/apps/apps_file_get.go @@ -0,0 +1,87 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsFileGet gets one file's metadata by exact remote path(动词对齐 +file-list)。 +// +// GET /apps/{app_id}/storage/file?path=。file 仅按 path 精确寻址,无按名寻址。 +// pretty 渲染 key/value:file_name / path / size(含 bytes) / type / uploaded_by(只 name) / uploaded_at / +// download_url(条件出现)。server created_at/created_by → uploaded_at/uploaded_by。 +var AppsFileGet = common.Shortcut{ + Service: appsService, + Command: "+file-get", + Description: "Get a single file's metadata by path", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-get --app-id --path /1858537546760216.png", + "Tip: extract a single field with --jq, e.g. -q '.size_bytes' or -q '.download_url'", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "path", Desc: "remote file path", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, err := requireFilePath(rctx.Str("path")) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appFileGetPath(appID)). + Desc("Get Miaoda app file metadata"). + Params(buildFileGetParams(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", appFileGetPath(appID), buildFileGetParams(rctx), nil) + if err != nil { + return err + } + info := projectFileInfo(data) + rctx.OutFormat(info, nil, func(w io.Writer) { + renderFileGetPretty(w, info) + }) + return nil + }, +} + +// buildFileGetParams 组装 file_get 查询参数:按 path 精确寻址单文件。 +func buildFileGetParams(rctx *common.RuntimeContext) map[string]interface{} { + path, _ := requireFilePath(rctx.Str("path")) + return map[string]interface{}{"path": path} +} + +// renderFileGetPretty 输出对齐 key/value;uploaded_by 只展示 name(id 仅 json 保留)。 +func renderFileGetPretty(w io.Writer, info fileInfo) { + pairs := [][2]string{ + {"file_name", dashIfEmpty(info.FileName)}, + {"path", info.Path}, + {"size", fileSizeDetail(info.SizeBytes)}, + {"type", dashIfEmpty(info.Type)}, + } + if info.UploadedBy != nil { + pairs = append(pairs, [2]string{"uploaded_by", info.UploadedBy.Name}) + } + pairs = append(pairs, [2]string{"uploaded_at", dashIfEmpty(info.UploadedAt)}) + if info.DownloadURL != "" { + pairs = append(pairs, [2]string{"download_url", info.DownloadURL}) + } + renderKeyValuePairs(w, pairs) +} diff --git a/shortcuts/apps/apps_file_get_test.go b/shortcuts/apps/apps_file_get_test.go new file mode 100644 index 00000000..ec78811a --- /dev/null +++ b/shortcuts/apps/apps_file_get_test.go @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const fileGetURL = "/open-apis/spark/v1/apps/app_x/storage/file" + +// TestAppsFileGet_RequiresAppIDAndPath 验证空白 --app-id 与空白 --path 分别触发对应的 typed 校验错误。 +func TestAppsFileGet_RequiresAppIDAndPath(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileGet, + []string{"+file-get", "--app-id", " ", "--path", "/x.png", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--app-id" { + t.Fatalf("Param = %q, want --app-id", ve.Param) + } + factory2, stdout2, _ := newAppsExecuteFactory(t) + err2 := runAppsShortcut(t, AppsFileGet, + []string{"+file-get", "--app-id", "app_x", "--path", " ", "--as", "user"}, factory2, stdout2) + var ve2 *errs.ValidationError + if !errors.As(err2, &ve2) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err2, err2) + } + if ve2.Param != "--path" { + t.Fatalf("Param = %q, want --path", ve2.Param) + } +} + +// TestAppsFileGet_DryRunSendsPathQuery 验证 dry-run 输出 GET file,path 作为 query 参数下发。 +func TestAppsFileGet_DryRunSendsPathQuery(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileGet, + []string{"+file-get", "--app-id", "app_x", "--path", "/x.png", "--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"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + if env.API[0].Method != "GET" || env.API[0].URL != fileGetURL || env.API[0].Params["path"] != "/x.png" { + t.Fatalf("dry-run = %s %s params=%v", env.API[0].Method, env.API[0].URL, env.API[0].Params) + } +} + +// TestAppsFileGet_SuccessAndPrettyKeyValue 验证 pretty key/value 展示 size 含 bytes、uploaded_by 只显示 name 且不泄漏 user id。 +func TestAppsFileGet_SuccessAndPrettyKeyValue(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: fileGetURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "file_name": "logo.png", "path": "/1858537546760216.png", + "size_bytes": 24580, "type": "image/png", + "created_at": "2026-04-15T10:30:00Z", + "created_by": `{"id":"7311","name":"alice"}`, + }}, + }) + if err := runAppsShortcut(t, AppsFileGet, + []string{"+file-get", "--app-id", "app_x", "--path", "/1858537546760216.png", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + // pretty key/value:size 含 bytes、uploaded_by 只展示 name。 + for _, want := range []string{"file_name:", "24 KB (24580 bytes)", "uploaded_by: alice", "uploaded_at: 2026-04-15T10:30:00Z"} { + if !strings.Contains(got, want) { + t.Errorf("pretty missing %q:\n%s", want, got) + } + } + // pretty 不该泄漏 user id。 + if strings.Contains(got, "7311") { + t.Errorf("pretty should show name only, not id:\n%s", got) + } +} diff --git a/shortcuts/apps/apps_file_list.go b/shortcuts/apps/apps_file_list.go new file mode 100644 index 00000000..251d4a25 --- /dev/null +++ b/shortcuts/apps/apps_file_list.go @@ -0,0 +1,145 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "io" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsFileList lists files in a Miaoda app's storage (cursor pagination)。 +// +// GET /apps/{app_id}/storage/file_list。过滤器:--name / --path / --type / --size-gt / +// --size-lt / --uploaded-since / --uploaded-until(精确或区间),分页 --page-size/--page-token。 +// file 域不分 dev/online,无 --env。 +// +// pretty 渲染 5 列:file_name / path / size / type / uploaded_at;空结果打 "No files found."。 +// server 字段 created_at → 产品语义 uploaded_at。 +var AppsFileList = common.Shortcut{ + Service: appsService, + Command: "+file-list", + Description: "List files in a Miaoda app's storage (cursor pagination)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-list --app-id ", + "Tip: filter fields with --jq, e.g. -q '.data.items[].path'", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "name", Desc: "filter by exact file name"}, + {Name: "path", Desc: "filter by exact remote path"}, + {Name: "type", Desc: "filter by MIME type"}, + {Name: "size-gt", Type: "int", Desc: "filter: size greater than (bytes)"}, + {Name: "size-lt", Type: "int", Desc: "filter: size less than (bytes)"}, + {Name: "uploaded-since", Desc: "filter: uploaded at or after; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"}, + {Name: "uploaded-until", Desc: "filter: uploaded at or before; relative (7d/2h/30s) | date (2026-04-15) | datetime (2026-04-15T10:00:00) | ISO 8601 w/ TZ (bare date/datetime read in local timezone)"}, + {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 { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + // 设计原则三: 多格式 → 归一化为 RFC3339 UTC,回写到 flag 供 buildFileListParams 透传。 + for _, f := range []string{"uploaded-since", "uploaded-until"} { + if strings.TrimSpace(rctx.Str(f)) == "" { + continue + } + n, err := normalizeTimestamp(rctx.Str(f)) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: %v", f, err).WithParam("--" + f) + } + _ = rctx.Cmd.Flags().Set(f, n) + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(appFileListPath(appID)). + Desc("List Miaoda app files"). + Params(buildFileListParams(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", appFileListPath(appID), buildFileListParams(rctx), nil) + if err != nil { + return err + } + // 白名单投影:server created_at/created_by → uploaded_at/uploaded_by,替换原始 items[]。 + items := projectFileItems(data["items"]) + data["items"] = items + rctx.OutFormat(data, nil, func(w io.Writer) { + renderFileListPretty(w, items) + }) + return nil + }, +} + +// projectFileItems 把服务端原始 items 逐项投影为白名单 fileInfo(created_*→uploaded_*)。 +func projectFileItems(raw interface{}) []fileInfo { + arr, _ := raw.([]interface{}) + out := make([]fileInfo, 0, len(arr)) + for _, it := range arr { + if m, ok := it.(map[string]interface{}); ok { + out = append(out, projectFileInfo(m)) + } + } + return out +} + +// buildFileListParams 组装 file_list 查询参数:page_size 及可选 name/path/type/size_gt/size_lt/uploaded_since/uploaded_until/page_token。 +func buildFileListParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{ + "page_size": rctx.Int("page-size"), + } + addStr := func(flag, key string) { + if v := strings.TrimSpace(rctx.Str(flag)); v != "" { + params[key] = v + } + } + addStr("name", "name") + addStr("path", "path") + addStr("type", "type") + addStr("uploaded-since", "uploaded_since") + addStr("uploaded-until", "uploaded_until") + addStr("page-token", "page_token") + if v := rctx.Int("size-gt"); v > 0 { + params["size_gt"] = v + } + if v := rctx.Int("size-lt"); v > 0 { + params["size_lt"] = v + } + return params +} + +// renderFileListPretty 5 列对齐表:file_name / path / size / type / uploaded_at。 +func renderFileListPretty(w io.Writer, items []fileInfo) { + if len(items) == 0 { + io.WriteString(w, "No files found.\n") + return + } + headers := []string{"file_name", "path", "size", "type", "uploaded_at"} + rows := make([][]string, 0, len(items)) + for _, it := range items { + rows = append(rows, []string{ + dashIfEmpty(it.FileName), + it.Path, + humanBytes(it.SizeBytes), + dashIfEmpty(it.Type), + dashIfEmpty(it.UploadedAt), + }) + } + renderAlignedTable(w, headers, rows) +} diff --git a/shortcuts/apps/apps_file_list_test.go b/shortcuts/apps/apps_file_list_test.go new file mode 100644 index 00000000..c6616e4a --- /dev/null +++ b/shortcuts/apps/apps_file_list_test.go @@ -0,0 +1,252 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +// 设计原则三: 四种格式 → 统一 RFC3339 UTC。 +func TestNormalizeTimestamp_AllFormats(t *testing.T) { + // 空串透传 + if got, err := normalizeTimestamp(" "); err != nil || got != "" { + t.Fatalf("empty → %q,%v want \"\",nil", got, err) + } + + // ISO 8601 带 TZ:Z 原样、显式偏移换算到 UTC + mustEq := func(in, want string) { + got, err := normalizeTimestamp(in) + if err != nil || got != want { + t.Errorf("normalizeTimestamp(%q)=%q,%v want %q", in, got, err, want) + } + } + mustEq("2026-04-15T10:00:00Z", "2026-04-15T10:00:00Z") + mustEq("2026-04-15T10:00:00+08:00", "2026-04-15T02:00:00Z") // +08:00 → UTC -8h + + // date / local datetime:按本地时区解释再转 UTC(与 time.ParseInLocation 对齐) + dExp, _ := time.ParseInLocation("2006-01-02", "2026-04-15", time.Local) + mustEq("2026-04-15", dExp.UTC().Format(time.RFC3339)) + ldExp, _ := time.ParseInLocation("2006-01-02T15:04:05", "2026-04-15T10:00:00", time.Local) + mustEq("2026-04-15T10:00:00", ldExp.UTC().Format(time.RFC3339)) + + // 相对:从现在往前推,结果应 ≈ now-dur(5s 容差) + for _, c := range []struct { + in string + dur time.Duration + }{{"30s", 30 * time.Second}, {"5m", 5 * time.Minute}, {"2h", 2 * time.Hour}, {"3d", 72 * time.Hour}, {"1w", 7 * 24 * time.Hour}} { + got, err := normalizeTimestamp(c.in) + if err != nil { + t.Errorf("normalizeTimestamp(%q) err=%v", c.in, err) + continue + } + ts, perr := time.Parse(time.RFC3339, got) + if perr != nil { + t.Errorf("normalizeTimestamp(%q)=%q not RFC3339", c.in, got) + continue + } + want := time.Now().Add(-c.dur) + if diff := want.Sub(ts); diff > 5*time.Second || diff < -5*time.Second { + t.Errorf("normalizeTimestamp(%q)=%q off by %v from now-%v", c.in, got, diff, c.dur) + } + } + + // 非法格式 → error + for _, bad := range []string{"notatime", "7x", "2026/04/15", "2026-13-99"} { + if _, err := normalizeTimestamp(bad); err == nil { + t.Errorf("normalizeTimestamp(%q) expected error", bad) + } + } +} + +const fileListURL = "/open-apis/spark/v1/apps/app_x/storage/file_list" + +// TestAppsFileList_RequiresAppID 验证空白 --app-id 触发 --app-id typed 校验错误。 +func TestAppsFileList_RequiresAppID(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", " ", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--app-id" { + t.Fatalf("Param = %q, want --app-id", ve.Param) + } +} + +// 过滤器 + 分页全部进 query(size-gt/lt 走 int,uploaded_since/until 原样)。 +func TestAppsFileList_DryRunSendsFiltersAndPagination(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", + "--name", "logo.png", "--path", "/x.png", "--type", "image/png", + "--size-gt", "100", "--size-lt", "9000", + "--uploaded-since", "2026-01-01", "--uploaded-until", "2026-02-01", + "--page-size", "5", "--page-token", "cur-1", + "--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()) + } + a := env.API[0] + if a.Method != "GET" || a.URL != fileListURL { + t.Fatalf("method/url = %s %s", a.Method, a.URL) + } + // 设计原则三:date 入参会被归一化为 RFC3339 UTC,期望值用 normalizeTimestamp 计算(避开本地时区脆弱断言)。 + sinceN, _ := normalizeTimestamp("2026-01-01") + untilN, _ := normalizeTimestamp("2026-02-01") + wantStr := map[string]string{ + "name": "logo.png", "path": "/x.png", "type": "image/png", + "uploaded_since": sinceN, "uploaded_until": untilN, "page_token": "cur-1", + } + for k, v := range wantStr { + if a.Params[k] != v { + t.Errorf("params.%s = %v, want %v", k, a.Params[k], v) + } + } + // 且确实归一化成了 UTC(以 Z 结尾),不是原样透传。 + if s, _ := a.Params["uploaded_since"].(string); !strings.HasSuffix(s, "Z") { + t.Errorf("uploaded_since not normalized to RFC3339 UTC: %v", a.Params["uploaded_since"]) + } + for _, k := range []string{"size_gt", "size_lt", "page_size"} { + if _, ok := a.Params[k]; !ok { + t.Errorf("params missing %s: %v", k, a.Params) + } + } +} + +// 0 值过滤器不下发(size-gt/lt 缺省 0、空字符串过滤器)。 +func TestAppsFileList_DryRunOmitsEmptyFilters(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-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"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + for _, banned := range []string{"name", "path", "type", "size_gt", "size_lt", "uploaded_since", "uploaded_until", "page_token"} { + if _, ok := env.API[0].Params[banned]; ok { + t.Errorf("params should omit empty %s: %v", banned, env.API[0].Params) + } + } + if _, ok := env.API[0].Params["page_size"]; !ok { + t.Errorf("params should always carry page_size: %v", env.API[0].Params) + } +} + +// created_at/created_by → uploaded_at/uploaded_by;created_by 是 JSON 字符串 → parse 成对象。 +func TestAppsFileList_SuccessProjectsCreatedToUploaded(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: fileListURL, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "has_more": false, + "page_token": "", + "items": []interface{}{ + map[string]interface{}{ + "file_name": "logo.png", + "path": "/1858537546760216.png", + "size_bytes": 24580, + "type": "image/png", + "created_at": "2026-04-15T10:30:00Z", + "created_by": `{"id":"7311","name":"alice"}`, + "download_url": "/spark/app/x/1858537546760216.png", + }, + }, + }, + }, + }) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{`"uploaded_at": "2026-04-15T10:30:00Z"`, `"uploaded_by"`, `"name": "alice"`, `"id": "7311"`} { + if !strings.Contains(got, want) { + t.Errorf("stdout missing %q:\n%s", want, got) + } + } + // created_* 不应再出现在输出。 + for _, banned := range []string{"created_at", "created_by"} { + if strings.Contains(got, banned) { + t.Errorf("stdout should not contain %q (renamed to uploaded_*):\n%s", banned, got) + } + } +} + +// TestAppsFileList_PrettyTableAndEmpty 验证 pretty 非空时渲染表头与人类可读 size,空结果时输出 "No files found."。 +func TestAppsFileList_PrettyTableAndEmpty(t *testing.T) { + // 非空:5 列表头。 + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: fileListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "items": []interface{}{map[string]interface{}{ + "file_name": "logo.png", "path": "/x.png", "size_bytes": 24576, "type": "image/png", + "created_at": "2026-04-15T10:30:00Z", + }}, + }}, + }) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, "file_name") || !strings.Contains(got, "uploaded_at") || !strings.Contains(got, "24 KB") { + t.Fatalf("pretty table malformed:\n%s", got) + } + + // 空:No files found. + factory2, stdout2, reg2 := newAppsExecuteFactory(t) + reg2.Register(&httpmock.Stub{ + Method: "GET", URL: fileListURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"items": []interface{}{}}}, + }) + if err := runAppsShortcut(t, AppsFileList, + []string{"+file-list", "--app-id", "app_x", "--format", "pretty", "--as", "user"}, factory2, stdout2); err != nil { + t.Fatalf("execute err=%v", err) + } + if !strings.Contains(stdout2.String(), "No files found.") { + t.Fatalf("empty pretty should say 'No files found.', got: %s", stdout2.String()) + } +} + +// TestParseFileUser_Cases 验证 parseFileUser:合法 JSON 解析成对象,空串/非法/全空字段均返回 nil。 +func TestParseFileUser_Cases(t *testing.T) { + if u := parseFileUser(`{"id":"1","name":"a"}`); u == nil || u.ID != "1" || u.Name != "a" { + t.Fatalf("valid parse failed: %#v", u) + } + if u := parseFileUser(""); u != nil { + t.Errorf("empty → nil, got %#v", u) + } + if u := parseFileUser("not json"); u != nil { + t.Errorf("invalid → nil, got %#v", u) + } + if u := parseFileUser(`{"id":"","name":""}`); u != nil { + t.Errorf("all-empty → nil, got %#v", u) + } +} diff --git a/shortcuts/apps/apps_file_quota_get.go b/shortcuts/apps/apps_file_quota_get.go new file mode 100644 index 00000000..bc3c2f7c --- /dev/null +++ b/shortcuts/apps/apps_file_quota_get.go @@ -0,0 +1,93 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsFileQuotaGet reports an app's file-storage usage(动词对齐 +db-quota-get)。 +// +// GET /apps/{app_id}/storage/file_quota。storage_quota_bytes / usage_percent 在配额未对接(=0)时 +// 不输出(json 删字段、pretty 只打已用量)。 +var AppsFileQuotaGet = common.Shortcut{ + Service: appsService, + Command: "+file-quota-get", + Description: "Get an app's file-storage usage", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-quota-get --app-id ", + "Tip: get just the usage percent with -q '.usage_percent'", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + _, 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(appFileQuotaPath(appID)). + Desc("Get Miaoda app file-storage usage") + }, + 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", appFileQuotaPath(appID), nil, nil) + if err != nil { + return err + } + out := projectFileQuota(data) + rctx.OutFormat(out, nil, func(w io.Writer) { + renderFileQuotaPretty(w, out) + }) + return nil + }, +} + +// projectFileQuota 白名单投影 file quota 字段:只保留 agent 需要的 storage_used_bytes / files, +// 配额已对接时再加 storage_quota_bytes / usage_percent。不透传后端其它字段,避免无用字段消耗上下文。 +func projectFileQuota(data map[string]interface{}) map[string]interface{} { + out := map[string]interface{}{"storage_used_bytes": data["storage_used_bytes"]} + if v, ok := data["files"]; ok { + out["files"] = v + } + // 配额未对接(storage_quota_bytes=0/缺失)时不输出 quota / usage_percent,避免误导。 + if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 { + out["storage_quota_bytes"] = data["storage_quota_bytes"] + if v, ok := data["usage_percent"]; ok { + out["usage_percent"] = v + } + } + return out +} + +// renderFileQuotaPretty 打 Storage(已用 / 配额 (百分比))与 Files 行(标签对齐 miaoda-cli)。 +func renderFileQuotaPretty(w io.Writer, data map[string]interface{}) { + used := humanBytes(data["storage_used_bytes"]) + usage := used + if q, ok := numericAsFloat(data["storage_quota_bytes"]); ok && q > 0 { + pct := "" + if p, ok := numericAsFloat(data["usage_percent"]); ok { + pct = fmt.Sprintf(" (%.1f%%)", p) + } + usage = fmt.Sprintf("%s / %s%s", used, humanBytes(data["storage_quota_bytes"]), pct) + } + pairs := [][2]string{{"Storage", usage}} + if f, ok := numericAsFloat(data["files"]); ok { + pairs = append(pairs, [2]string{"Files", fmt.Sprintf("%d", int64(f))}) + } + renderKeyValuePairs(w, pairs) +} diff --git a/shortcuts/apps/apps_file_quota_get_test.go b/shortcuts/apps/apps_file_quota_get_test.go new file mode 100644 index 00000000..6924c0df --- /dev/null +++ b/shortcuts/apps/apps_file_quota_get_test.go @@ -0,0 +1,96 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +const fileQuotaURL = "/open-apis/spark/v1/apps/app_x/storage/file_quota" + +// TestAppsFileQuotaGet_QuotaConnectedShowsAllFields 验证配额已对接时输出 storage_quota_bytes/usage_percent/files 全字段。 +func TestAppsFileQuotaGet_QuotaConnectedShowsAllFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: fileQuotaURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "storage_used_bytes": 157286400, + "storage_quota_bytes": 1073741824, + "usage_percent": 14.6, + "files": 42, + }}, + }) + if err := runAppsShortcut(t, AppsFileQuotaGet, + []string{"+file-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, want := range []string{`"storage_quota_bytes"`, `"usage_percent"`, `"files"`} { + if !strings.Contains(got, want) { + t.Errorf("quota json missing %q:\n%s", want, got) + } + } +} + +// 配额未对接(=0):storage_quota_bytes / usage_percent 不输出。 +func TestAppsFileQuotaGet_UnconnectedOmitsQuotaFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", URL: fileQuotaURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "storage_used_bytes": 157286400, + "storage_quota_bytes": 0, + "usage_percent": 0, + "files": 42, + }}, + }) + if err := runAppsShortcut(t, AppsFileQuotaGet, + []string{"+file-quota-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + for _, banned := range []string{"storage_quota_bytes", "usage_percent"} { + if strings.Contains(got, banned) { + t.Errorf("unconnected quota should omit %q:\n%s", banned, got) + } + } + if !strings.Contains(got, `"storage_used_bytes"`) || !strings.Contains(got, `"files"`) { + t.Errorf("should still show used/files:\n%s", got) + } +} + +// TestProjectFileQuota_OmitsZeroQuotaAndDropsUnknownFields 验证 projectFileQuota 白名单投影: +// quota=0 时不输出 storage_quota_bytes/usage_percent,非零时保留;后端额外字段不透传。 +func TestProjectFileQuota_OmitsZeroQuotaAndDropsUnknownFields(t *testing.T) { + out := projectFileQuota(map[string]interface{}{ + "storage_used_bytes": 100, "storage_quota_bytes": float64(0), "usage_percent": float64(0), + "files": 3, "tenant_key": "leak", "request_id": "rid", + }) + if _, ok := out["storage_quota_bytes"]; ok { + t.Errorf("zero quota should be omitted: %v", out) + } + if _, ok := out["usage_percent"]; ok { + t.Errorf("usage_percent should be omitted when quota=0: %v", out) + } + if out["storage_used_bytes"] != 100 || out["files"] != 3 { + t.Errorf("whitelisted fields should be kept: %v", out) + } + // 白名单外的字段必须被丢弃,避免无用字段消耗 agent 上下文。 + for _, leaked := range []string{"tenant_key", "request_id"} { + if _, ok := out[leaked]; ok { + t.Errorf("non-whitelisted field %q must be dropped: %v", leaked, out) + } + } + + out2 := projectFileQuota(map[string]interface{}{"storage_used_bytes": 100, "storage_quota_bytes": float64(1024), "usage_percent": float64(9.8), "files": 3}) + if _, ok := out2["storage_quota_bytes"]; !ok { + t.Errorf("non-zero quota should be kept: %v", out2) + } + if _, ok := out2["usage_percent"]; !ok { + t.Errorf("usage_percent should be kept when quota>0: %v", out2) + } +} diff --git a/shortcuts/apps/apps_file_sign.go b/shortcuts/apps/apps_file_sign.go new file mode 100644 index 00000000..df5a19b4 --- /dev/null +++ b/shortcuts/apps/apps_file_sign.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// fileSignMaxExpiresSeconds 是签名链接最长有效期(30 天)。超出 → 校验失败。 +const fileSignMaxExpiresSeconds = 30 * 24 * 60 * 60 + +// AppsFileSign generates a temporary signed download URL for a file。 +// +// POST /apps/{app_id}/storage/file_sign,body {path, expires_in}。 +// pretty 模式只打 signed_url(便于直接管道 / curl);json 返 {file_name,path,signed_url,expires_at}。 +var AppsFileSign = common.Shortcut{ + Service: appsService, + Command: "+file-sign", + Description: "Generate a temporary signed download URL for a file", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +file-sign --app-id --path /1858537546760216.png", + "Tip: curl the signed_url directly to download.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "path", Desc: "remote file path", Required: true}, + {Name: "expires-in", Type: "int", Default: "86400", Desc: "link validity in seconds (max 2592000 = 30d)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if _, err := requireFilePath(rctx.Str("path")); err != nil { + return err + } + if rctx.Int("expires-in") > fileSignMaxExpiresSeconds { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--expires-in exceeds the maximum of %d seconds (30d)", fileSignMaxExpiresSeconds).WithParam("--expires-in") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appFileSignPath(appID)). + Desc("Sign a temporary download URL"). + Body(buildFileSignBody(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", appFileSignPath(appID), nil, buildFileSignBody(rctx)) + if err != nil { + return err + } + rctx.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintln(w, common.GetString(data, "signed_url")) + }) + return nil + }, +} + +// buildFileSignBody 组装 file_sign 请求体:path 及可选 expires_in(秒)。 +func buildFileSignBody(rctx *common.RuntimeContext) map[string]interface{} { + path, _ := requireFilePath(rctx.Str("path")) + body := map[string]interface{}{"path": path} + if v := rctx.Int("expires-in"); v > 0 { + body["expires_in"] = v + } + return body +} diff --git a/shortcuts/apps/apps_file_sign_test.go b/shortcuts/apps/apps_file_sign_test.go new file mode 100644 index 00000000..84ebbaa7 --- /dev/null +++ b/shortcuts/apps/apps_file_sign_test.go @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +const fileSignURL = "/open-apis/spark/v1/apps/app_x/storage/file_sign" + +// TestAppsFileSign_DryRunBody 验证 dry-run 输出 POST file_sign,body 携带 path 与 expires_in。 +func TestAppsFileSign_DryRunBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileSign, + []string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "3600", "--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"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != fileSignURL || a.Body["path"] != "/x.png" { + t.Fatalf("dry-run = %s %s body=%v", a.Method, a.URL, a.Body) + } + if ei, _ := a.Body["expires_in"].(float64); int(ei) != 3600 { + t.Fatalf("body.expires_in = %v, want 3600", a.Body["expires_in"]) + } +} + +// TestAppsFileSign_RejectsDurationOverMax 验证 --expires-in 超过上限时触发 --expires-in typed 校验错误。 +func TestAppsFileSign_RejectsDurationOverMax(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileSign, + []string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--expires-in", "9999999", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--expires-in" { + t.Fatalf("Param = %q, want --expires-in", ve.Param) + } +} + +// TestAppsFileSign_PrettyPrintsSignedURL 验证 pretty 只输出 signed_url 本身。 +func TestAppsFileSign_PrettyPrintsSignedURL(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: fileSignURL, + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "file_name": "x.png", "path": "/x.png", + "signed_url": "https://tos.example/x.png?sig=abc", "expires_at": "2026-04-16T10:30:00Z", + }}, + }) + if err := runAppsShortcut(t, AppsFileSign, + []string{"+file-sign", "--app-id", "app_x", "--path", "/x.png", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := strings.TrimSpace(stdout.String()) + if got != "https://tos.example/x.png?sig=abc" { + t.Fatalf("pretty should print only signed_url, got: %q", got) + } +} diff --git a/shortcuts/apps/apps_file_upload.go b/shortcuts/apps/apps_file_upload.go new file mode 100644 index 00000000..6118a000 --- /dev/null +++ b/shortcuts/apps/apps_file_upload.go @@ -0,0 +1,218 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "fmt" + "io" + "mime" + "net/http" + "path/filepath" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/shortcuts/common" +) + +// fileUploadMaxBytes 是单文件上传上限(100 MB,对齐 miaoda)。 +const fileUploadMaxBytes = 100 * 1024 * 1024 + +// AppsFileUpload uploads a local file to an app's storage(三步直传)。 +// +// 1. POST /apps/{app_id}/storage/file_pre_upload {file_name,file_size,content_type} → {upload_url,upload_id} +// 2. 客户端 PUT 文件字节到 presigned upload_url,取响应 ETag +// 3. POST /apps/{app_id}/storage/file_upload_callback {upload_id,etag} → 文件元数据 +// file_name 取本地 basename;path 由平台生成 16 位 ID(不可指定)。仅收 --file。 +var AppsFileUpload = common.Shortcut{ + Service: appsService, + Command: "+file-upload", + Description: "Upload a local file to an app's storage", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +file-upload --app-id --file ./logo.png", + "Example: lark-cli apps +file-upload --app-id --file ./report.pdf -q '.path' # print the platform-generated file path", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "Miaoda app id", Required: true}, + {Name: "file", Desc: "local file to upload (file_name = basename)", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + f := strings.TrimSpace(rctx.Str("file")) + if f == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file is required").WithParam("--file") + } + st, err := rctx.FileIO().Stat(f) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err) + } + if st.IsDir() { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file must be a file, not a directory").WithParam("--file") + } + if st.Size() > fileUploadMaxBytes { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "file size %d bytes exceeds the 100 MB upload limit", st.Size()).WithParam("--file") + } + return nil + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID, _ := requireAppID(rctx.Str("app-id")) + return common.NewDryRunAPI(). + POST(appFilePreUploadPath(appID)). + Desc("Pre-upload → client PUT bytes → callback (3-step)"). + Body(map[string]interface{}{"file_name": filepath.Base(strings.TrimSpace(rctx.Str("file")))}) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, err := requireAppID(rctx.Str("app-id")) + if err != nil { + return err + } + localPath := strings.TrimSpace(rctx.Str("file")) + content, err := cmdutil.ReadInputFile(rctx.FileIO(), localPath) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--file: %v", err).WithParam("--file").WithCause(err) + } + fileName := filepath.Base(localPath) + contentType := mimeByExt(fileName) + + // 1. pre-upload + pre, err := rctx.CallAPITyped("POST", appFilePreUploadPath(appID), nil, map[string]interface{}{ + "file_name": fileName, + "file_size": len(content), + "content_type": contentType, + }) + if err != nil { + return err + } + uploadURL := common.GetString(pre, "upload_url") + uploadID := common.GetString(pre, "upload_id") + if uploadURL == "" || uploadID == "" { + return errs.NewInternalError(errs.SubtypeInvalidResponse, "pre-upload returned no upload_url / upload_id") + } + + // 2. PUT 文件字节到 presigned URL,取 ETag(带 Content-Disposition 透传原始文件名) + etag, err := putFileBytes(rctx.Ctx(), uploadURL, content, contentType, fileName) + if err != nil { + return err + } + + // 3. callback + result, err := rctx.CallAPITyped("POST", appFileUploadCallbackPath(appID), nil, map[string]interface{}{ + "upload_id": uploadID, + "etag": etag, + }) + if err != nil { + return err + } + info := projectFileInfo(result) + rctx.OutFormat(info, nil, func(w io.Writer) { + renderFileUploadPretty(w, fileName, info) + }) + return nil + }, +} + +// putFileBytes 直连 PUT 文件字节到 presigned URL,返回响应的 ETag。 +// +// Content-Disposition 透传原始文件名:TOS 把它存成对象 metadata,callback 阶段后端 +// HeadObject 读回解析出 filename 写入 DB 的 display name。不传则后端兜底用 storage key +// (平台 16 位 ID)当文件名 —— 即「上传后文件名变成 ID」的根因。 +// +//nolint:forbidigo // direct PUT to a presigned object-storage URL bypasses the Lark gateway — raw HTTP is required (no Lark auth/gateway); RuntimeContext.DoAPI cannot target a presigned URL. +func putFileBytes(ctx context.Context, url string, content []byte, contentType, fileName string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, bytes.NewReader(content)) + if err != nil { + return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "build upload request").WithCause(err) + } + req.ContentLength = int64(len(content)) + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + // 用 mime.FormatMediaType 规范生成 Content-Disposition(自动按 RFC 2045 处理引号/转义), + // 不手工拼接 header,杜绝文件名里的特殊字符破坏 header 结构。filename 已先经 sanitizeUploadFileName + // 做 encodeURIComponent(控制字符/分隔符均 %XX 化),此处是第二道防线。 + disposition := mime.FormatMediaType("attachment", map[string]string{"filename": sanitizeUploadFileName(fileName)}) + if disposition == "" { + disposition = "attachment" + } + req.Header.Set("Content-Disposition", disposition) + resp, err := newFileTransferClient().Do(req) + if err != nil { + // dial/transport 失败是典型可重试场景。 + return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed").WithCause(err).WithRetryable() + } + defer resp.Body.Close() + io.Copy(io.Discard, io.LimitReader(resp.Body, 4096)) + if resp.StatusCode >= 400 { + // 5xx 是上游瞬时故障,标 retryable;4xx(如签名过期)需重新签名而非盲重试,不标。 + if resp.StatusCode >= 500 { + return "", errs.NewNetworkError(errs.SubtypeNetworkServer, "upload failed: HTTP %d", resp.StatusCode).WithRetryable() + } + return "", errs.NewNetworkError(errs.SubtypeNetworkTransport, "upload failed: HTTP %d", resp.StatusCode) + } + return resp.Header.Get("ETag"), nil +} + +// sanitizeUploadFileName 对齐 miaoda:先去掉 TOS 非法字符 [:"\/*?<>|,;],再 encodeURIComponent +// (UTF-8 百分号编码,兼容中文等非 ASCII,且让 Content-Disposition header 合法),空则兜底 download_file。 +func sanitizeUploadFileName(name string) string { + var b strings.Builder + for _, r := range name { + switch r { + case ':', '"', '\\', '/', '*', '?', '<', '>', '|', ',', ';': + continue + default: + b.WriteRune(r) + } + } + enc := encodeURIComponent(b.String()) + if enc == "" { + return "download_file" + } + // 防止 sanitize 后仍以 . 开头(如 .bashrc / .ssh)——下载落地可能覆盖本地隐藏文件, + // 前置下划线消除隐藏文件语义。 + if strings.HasPrefix(enc, ".") { + enc = "_" + enc + } + return enc +} + +// encodeURIComponent 复刻 JS encodeURIComponent:除 A-Za-z0-9-_.!~*'() 外按 UTF-8 字节 %XX 编码。 +func encodeURIComponent(s string) string { + const keep = "-_.!~*'()" + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || strings.IndexByte(keep, c) >= 0 { + b.WriteByte(c) + } else { + b.WriteString(fmt.Sprintf("%%%02X", c)) + } + } + return b.String() +} + +// mimeByExt 按扩展名推断 Content-Type,未知回退 application/octet-stream。 +func mimeByExt(name string) string { + if t := mime.TypeByExtension(filepath.Ext(name)); t != "" { + return t + } + return "application/octet-stream" +} + +// renderFileUploadPretty 打 ✓ Uploaded + size / download_url。 +func renderFileUploadPretty(w io.Writer, localName string, info fileInfo) { + fmt.Fprintf(w, "✓ Uploaded %s → %s\n", localName, info.Path) + fmt.Fprintf(w, "size: %s\n", fileSizeDetail(info.SizeBytes)) + if info.DownloadURL != "" { + fmt.Fprintf(w, "download_url: %s\n", info.DownloadURL) + } +} diff --git a/shortcuts/apps/apps_file_upload_test.go b/shortcuts/apps/apps_file_upload_test.go new file mode 100644 index 00000000..06dab27e --- /dev/null +++ b/shortcuts/apps/apps_file_upload_test.go @@ -0,0 +1,182 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +// TestAppsFileUpload_RequiresAppIDAndFile 验证仅含空白的 --file 经 Validate 去空后触发 --file typed 校验错误。 +func TestAppsFileUpload_RequiresAppIDAndFile(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + // --file is a cobra-required flag; pass whitespace so cobra's required check + // passes and our Validate (which trims) rejects it with a typed error. + err := runAppsShortcut(t, AppsFileUpload, + []string{"+file-upload", "--app-id", "app_x", "--file", " ", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--file" { + t.Fatalf("Param = %q, want --file", ve.Param) + } +} + +// TestAppsFileUpload_RejectsDirectory 验证 --file 指向目录时触发 --file typed 校验错误。 +func TestAppsFileUpload_RejectsDirectory(t *testing.T) { + dir := t.TempDir() + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil { + t.Fatal(err) + } + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsFileUpload, + []string{"+file-upload", "--app-id", "app_x", "--file", "sub", "--as", "user"}, factory, stdout) + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %T %v, want *errs.ValidationError", err, err) + } + if ve.Param != "--file" { + t.Fatalf("Param = %q, want --file", ve.Param) + } +} + +// TestAppsFileUpload_DryRunPreUpload 验证 dry-run 输出 POST file_pre_upload,body.file_name 取文件 basename。 +func TestAppsFileUpload_DryRunPreUpload(t *testing.T) { + // Validate 会 Stat --file(在 DryRun 之前),故 dry-run 也需要真实存在的文件。 + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + factory, stdout, _ := newAppsExecuteFactory(t) + if err := runAppsShortcut(t, AppsFileUpload, + []string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--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"` + } `json:"api"` + } + _ = json.Unmarshal([]byte(stdout.String()), &env) + a := env.API[0] + if a.Method != "POST" || a.URL != "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload" { + t.Fatalf("dry-run = %s %s", a.Method, a.URL) + } + if a.Body["file_name"] != "logo.png" { + t.Fatalf("dry-run body.file_name = %v, want logo.png (basename)", a.Body["file_name"]) + } +} + +// 三步直传:pre-upload → 客户端 PUT 字节 → callback。 +func TestAppsFileUpload_EndToEnd(t *testing.T) { + var putBody []byte + var putContentType, putCD string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + putBody, _ = io.ReadAll(r.Body) + putContentType = r.Header.Get("Content-Type") + putCD = r.Header.Get("Content-Disposition") + w.Header().Set("ETag", `"etag-123"`) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "logo.png"), []byte("PNGBYTES"), 0o600); err != nil { + t.Fatal(err) + } + oldWD, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(oldWD) }) + + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_pre_upload", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"upload_url": srv.URL, "upload_id": "up-1"}}, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", URL: "/open-apis/spark/v1/apps/app_x/storage/file_upload_callback", + Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{ + "file_name": "logo.png", "path": "/1858537546760216.png", "size_bytes": 8, "type": "image/png", + "download_url": "/spark/app/x/1858537546760216.png", + }}, + }) + + if err := runAppsShortcut(t, AppsFileUpload, + []string{"+file-upload", "--app-id", "app_x", "--file", "logo.png", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if string(putBody) != "PNGBYTES" { + t.Fatalf("PUT body = %q, want file bytes", putBody) + } + if putContentType != "image/png" { + t.Errorf("PUT Content-Type = %q, want image/png", putContentType) + } + // 原始文件名必须经 Content-Disposition 透传给 TOS(否则后端用 storage key 当文件名)。 + // 断言按解析结果(format-agnostic):mime.FormatMediaType 对无 tspecial 的名不加引号, + // 旧的写死字符串 `filename="logo.png"` 不再成立,但 filename 参数仍须等于原名。 + if disp, params, err := mime.ParseMediaType(putCD); err != nil || disp != "attachment" || params["filename"] != "logo.png" { + t.Errorf("PUT Content-Disposition = %q, want disposition=attachment filename=logo.png (parse err=%v)", putCD, err) + } + got := stdout.String() + if !strings.Contains(got, `"path": "/1858537546760216.png"`) { + t.Errorf("output missing uploaded path:\n%s", got) + } +} + +// TestSanitizeUploadFileName_Cases 验证 sanitizeUploadFileName:空格转 %20、去 TOS 非法字符、全非法兜底、非 ASCII 百分号编码。 +func TestSanitizeUploadFileName_Cases(t *testing.T) { + cases := []struct{ in, want string }{ + {"logo.png", "logo.png"}, + {"a b.png", "a%20b.png"}, // 空格 → %20(encodeURIComponent) + {`a:b/c*d?.png`, "abcd.png"}, // 去掉 TOS 非法字符 + {"///", "download_file"}, // 全非法 → 兜底 + {"中.txt", "%E4%B8%AD.txt"}, // 非 ASCII → UTF-8 百分号编码 + } + for _, c := range cases { + if got := sanitizeUploadFileName(c.in); got != c.want { + t.Errorf("sanitizeUploadFileName(%q)=%q want %q", c.in, got, c.want) + } + } +} + +// TestMimeByExt_Cases 验证 mimeByExt:按扩展名识别 image/png,未知扩展名兜底 application/octet-stream。 +func TestMimeByExt_Cases(t *testing.T) { + if got := mimeByExt("a.png"); !strings.HasPrefix(got, "image/png") { + t.Errorf("mimeByExt(a.png)=%q want image/png", got) + } + if got := mimeByExt("data.unknownext"); got != "application/octet-stream" { + t.Errorf("mimeByExt(unknown)=%q want application/octet-stream", got) + } +} diff --git a/shortcuts/apps/apps_hints_more_test.go b/shortcuts/apps/apps_hints_more_test.go index 2fed3cd6..6275cc7b 100644 --- a/shortcuts/apps/apps_hints_more_test.go +++ b/shortcuts/apps/apps_hints_more_test.go @@ -80,7 +80,7 @@ func TestAppsCreate_4xxFailureCarriesTypeHint(t *testing.T) { func TestAppsDBEnvCreate_4xxFailureCarriesHint(t *testing.T) { assertHintContains(t, AppsDBEnvCreate, - []string{"+db-env-create", "--app-id", "app_x", "--env", "dev", "--yes", "--as", "user"}, + []string{"+db-env-create", "--app-id", "app_x", "--environment", "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") @@ -96,7 +96,7 @@ func TestAppsDBTableGet_4xxFailureCarriesHint(t *testing.T) { func TestAppsDBTableList_4xxFailureCarriesHint(t *testing.T) { assertHintContains(t, AppsDBTableList, - []string{"+db-table-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, + []string{"+db-table-list", "--app-id", "app_x", "--environment", "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") diff --git a/shortcuts/apps/apps_hints_test.go b/shortcuts/apps/apps_hints_test.go index 6c959f49..a4af9254 100644 --- a/shortcuts/apps/apps_hints_test.go +++ b/shortcuts/apps/apps_hints_test.go @@ -21,6 +21,9 @@ func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) { URL: "/open-apis/spark/v1/apps/app_x/env_vars", Status: http.StatusForbidden, Body: map[string]interface{}{"msg": "permission denied"}, + OnMatch: func(req *http.Request) { + assertEnvPullBody(t, req) + }, }) err := runAppsShortcut(t, AppsEnvPull, diff --git a/shortcuts/apps/apps_logs.go b/shortcuts/apps/apps_logs.go new file mode 100644 index 00000000..0123ad53 --- /dev/null +++ b/shortcuts/apps/apps_logs.go @@ -0,0 +1,877 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "regexp" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" +) + +const ( + defaultAppsLogEnv = "online" + logSearchEndpoint = "search_logs" + resolveStackEndpoint = "resolve_stack_trace" + sourceStackStatusOK = "resolved" + sourceStackStatusError = "unresolved" + sourceStackMaxScanDepth = 8 + sourceStackMaxFrames = 2000 + defaultSourceMapPrefix = "client/assets/" +) + +var ( + jsStackFrameParenRe = regexp.MustCompile(`^\s*(?:at\s+(.+?)\s+)?\((.+):(\d+):(\d+)\)\s*$`) + jsStackFrameBareRe = regexp.MustCompile(`^\s*(?:at\s+)?(.+):(\d+):(\d+)\s*$`) +) + +// AppsLogList searches online app logs with observability filters. +var AppsLogList = common.Shortcut{ + Service: appsService, + Command: "+log-list", + Description: "Search online app logs with observability filters", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +log-list --app-id --level error --keyword timeout --since 1h", + "Tip: use --page-token from the response to fetch the next page.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true}, + {Name: appsEnvironmentFlag, Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, + {Name: "level", Type: "string_array", Desc: "log level filter; repeatable, one of DEBUG, INFO, WARN, ERROR (case-insensitive)"}, + {Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"}, + {Name: "keyword", Desc: "keyword filter applied by the log search backend"}, + {Name: "module", Desc: "module name filter"}, + {Name: "user-id", Desc: "end user ID filter"}, + {Name: "page", Desc: "frontend page or route filter"}, + {Name: "api", Desc: "API path/name filter"}, + {Name: "min-duration", Type: "int", Desc: "minimum duration in milliseconds; must be non-negative"}, + {Name: "max-duration", Type: "int", Desc: "maximum duration in milliseconds; must be non-negative and >= --min-duration"}, + {Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", defaultAppsPageSize), Desc: "page size, 1..100"}, + {Name: "page-token", Desc: "pagination cursor from a previous log search response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, err := buildLogSearchBody(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildLogSearchBody(rctx) + return common.NewDryRunAPI(). + POST(logSearchPath(rctx.Str("app-id"))). + Desc("Search online app logs"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + body, err := buildLogSearchBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", logSearchPath(appID), nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := normalizeLogSearchResponse(data) + rctx.OutFormat(out, nil, func(w io.Writer) { + appsPrintSchemaTable(w, appsProjectRows(logListRows(out.Items), logSummarySchema), logSummarySchema) + }) + return nil + }, +} + +// AppsLogGet fetches one log by log ID through the search_logs endpoint. +var AppsLogGet = common.Shortcut{ + Service: appsService, + Command: "+log-get", + Description: "Get one online app log by log ID", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +log-get --app-id --log-id ", + "Tip: +log-get searches online logs with limit=1; use +log-list first if the log ID is unknown.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true}, + {Name: "log-id", Desc: "log ID to fetch", Required: true}, + {Name: appsEnvironmentFlag, Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("log-id")) == "" { + return appsValidationParamError("--log-id", "--log-id is required") + } + return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag)) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST(logSearchPath(rctx.Str("app-id"))). + Desc("Search online app logs by log ID"). + Body(buildLogGetSearchBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + data, err := callLogGetSearch(rctx, appID, buildLogGetSearchBody(rctx)) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := normalizeLogSearchResponse(data) + if len(out.Items) == 0 { + return appsFailedPreconditionParamError("--log-id", "log not found"). + WithHint("verify --log-id and --environment online") + } + log := out.Items[0] + enrichLogSourceStack(rctx, appID, log) + rctx.OutFormat(log, nil, func(w io.Writer) { + appsPrintSchemaTable(w, appsProjectRows([]map[string]interface{}{logSummaryRow(log)}, logSummarySchema), logSummarySchema) + }) + return nil + }, +} + +func callLogGetSearch(rctx *common.RuntimeContext, appID string, body map[string]interface{}) (map[string]interface{}, error) { + resp, err := rctx.DoAPI(&larkcore.ApiReq{ + HttpMethod: "POST", + ApiPath: logSearchPath(appID), + Body: body, + }) + if err != nil { + return nil, err + } + data, err := rctx.ClassifyAPIResponse(resp) + if err == nil && data != nil { + return data, nil + } + if flex, ok := flexibleLogSearchData(resp.RawBody); ok && (err == nil || isNonObjectInvalidResponse(err)) { + return flex, nil + } + return data, err +} + +type logSearchOutput struct { + Items []map[string]interface{} `json:"items"` + PageToken string `json:"page_token,omitempty"` + HasMore bool `json:"has_more"` +} + +func logSearchPath(appID string) string { + return appScopedPath(appID, logSearchEndpoint) +} + +func resolveStackPath(appID string) string { + return appScopedPath(appID, resolveStackEndpoint) +} + +func buildLogSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) + if env == "" { + env = defaultAppsLogEnv + } + if err := validateObservabilityEnv(env); err != nil { + return nil, err + } + if err := validateAppsPageSize(rctx.Int("page-size")); err != nil { + return nil, err + } + body := map[string]interface{}{ + "app_env": appsObservabilityBackendEnv, + "limit": rctx.Int("page-size"), + } + if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { + body["page_token"] = token + } + if err := addLogSearchTimeRange(body, rctx); err != nil { + return nil, err + } + filter, err := buildLogSearchFilter(rctx) + if err != nil { + return nil, err + } + if len(filter) > 0 { + body["filter"] = filter + } + return body, nil +} + +func buildLogGetSearchBody(rctx *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "app_env": appsObservabilityBackendEnv, + "limit": 1, + "filter": map[string]interface{}{ + "log_ids": []string{strings.TrimSpace(rctx.Str("log-id"))}, + }, + } +} + +func addLogSearchTimeRange(body map[string]interface{}, rctx *common.RuntimeContext) error { + since, until, hasSince, hasUntil, err := parseAppsTimeRange("--since", rctx.Str("since"), "--until", rctx.Str("until")) + if err != nil { + return err + } + if hasSince { + body["start_timestamp_ns"] = nsNumber(since) + } + if hasUntil { + body["end_timestamp_ns"] = nsNumber(until) + } + return nil +} + +func buildLogSearchFilter(rctx *common.RuntimeContext) (map[string]interface{}, error) { + filter := make(map[string]interface{}) + levels, err := normalizeLogLevels(rctx.StrArray("level")) + if err != nil { + return nil, err + } + if len(levels) > 0 { + filter["levels"] = levels + } + if traceIDs := cleanRepeatedStrings(rctx.StrArray("trace-id")); len(traceIDs) > 0 { + filter["trace_ids"] = traceIDs + } + addTrimmedLogFilterString(filter, "keyword", rctx.Str("keyword")) + addTrimmedLogFilterStrings(filter, "modules", rctx.Str("module")) + addTrimmedLogFilterStrings(filter, "user_ids", rctx.Str("user-id")) + addTrimmedLogFilterStrings(filter, "pages", rctx.Str("page")) + addTrimmedLogFilterStrings(filter, "apis", rctx.Str("api")) + if err := addDurationFilters(filter, rctx); err != nil { + return nil, err + } + return filter, nil +} + +func addTrimmedLogFilterStrings(filter map[string]interface{}, key, value string) { + if value = strings.TrimSpace(value); value != "" { + filter[key] = []string{value} + } +} + +func addTrimmedLogFilterString(filter map[string]interface{}, key, value string) { + if value = strings.TrimSpace(value); value != "" { + filter[key] = value + } +} + +func addDurationFilters(filter map[string]interface{}, rctx *common.RuntimeContext) error { + hasMin := rctx.Changed("min-duration") + hasMax := rctx.Changed("max-duration") + minDuration := rctx.Int("min-duration") + maxDuration := rctx.Int("max-duration") + if hasMin { + if minDuration < 0 { + return appsValidationParamError("--min-duration", "--min-duration must be non-negative") + } + filter["min_duration_ms"] = minDuration + } + if hasMax { + if maxDuration < 0 { + return appsValidationParamError("--max-duration", "--max-duration must be non-negative") + } + filter["max_duration_ms"] = maxDuration + } + if hasMin && hasMax && minDuration > maxDuration { + return appsValidationParamError("--max-duration", "--max-duration must be greater than or equal to --min-duration") + } + return nil +} + +func normalizeLogLevels(values []string) ([]string, error) { + values = cleanRepeatedStrings(values) + if len(values) == 0 { + return nil, nil + } + out := make([]string, 0, len(values)) + for _, value := range values { + level := strings.ToUpper(strings.TrimSpace(value)) + switch level { + case "DEBUG", "INFO", "WARN", "ERROR": + out = append(out, level) + default: + return nil, appsValidationParamError("--level", "--level must be one of DEBUG, INFO, WARN, ERROR") + } + } + return out, nil +} + +func normalizeLogSearchResponse(data map[string]interface{}) logSearchOutput { + items := firstMapSlice(data, "items", "log_items", "logItems") + normalized := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + normalized = append(normalized, normalizeLogItem(item)) + } + return logSearchOutput{ + Items: normalized, + PageToken: firstLogString(data, "page_token", "next_page_token", "pageToken", "nextPageToken"), + HasMore: firstLogBool(data, "has_more", "hasMore"), + } +} + +func normalizeLogItem(item map[string]interface{}) map[string]interface{} { + out := cloneMap(item) + normalizeObservabilityAttributes(out) + copyFirstAlias(out, item, "log_id", "log_id", "id", "logID", "logId") + copyFirstAlias(out, item, "trace_id", "trace_id", "traceID", "traceId") + copyFirstAlias(out, item, "timestamp_ns", "timestamp_ns", "timestampNs") + copyFirstAlias(out, item, "severity_text", "severity_text", "severityText") + if level := firstItemString(out, "level", "severity_text", "severityText"); level != "" { + out["level"] = level + } + return out +} + +func firstMapSlice(data map[string]interface{}, keys ...string) []map[string]interface{} { + for _, key := range keys { + raw, ok := data[key] + if !ok { + continue + } + switch items := raw.(type) { + case []map[string]interface{}: + return items + case []interface{}: + out := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out + } + } + return nil +} + +func flexibleLogSearchData(raw []byte) (map[string]interface{}, bool) { + var result interface{} + if err := json.Unmarshal(raw, &result); err != nil { + return nil, false + } + switch value := result.(type) { + case []interface{}: + return map[string]interface{}{"items": value}, true + case map[string]interface{}: + data, ok := value["data"] + if !ok { + return nil, false + } + items, ok := data.([]interface{}) + if !ok { + return nil, false + } + out := map[string]interface{}{"items": items} + for _, key := range []string{"page_token", "next_page_token", "pageToken", "nextPageToken", "has_more", "hasMore"} { + if v, present := value[key]; present { + out[key] = v + } + } + return out, true + default: + return nil, false + } +} + +func isNonObjectInvalidResponse(err error) bool { + p, ok := errs.ProblemOf(err) + return ok && p.Category == errs.CategoryInternal && p.Subtype == errs.SubtypeInvalidResponse +} + +func firstLogString(data map[string]interface{}, keys ...string) string { + for _, key := range keys { + if s, ok := data[key].(string); ok && strings.TrimSpace(s) != "" { + return s + } + } + return "" +} + +func firstLogBool(data map[string]interface{}, keys ...string) bool { + for _, key := range keys { + if b, ok := data[key].(bool); ok { + return b + } + } + return false +} + +func copyFirstAlias(dst, src map[string]interface{}, canonical string, keys ...string) { + for _, key := range keys { + if value, ok := src[key]; ok { + dst[canonical] = value + return + } + } +} + +func cloneMap(src map[string]interface{}) map[string]interface{} { + dst := make(map[string]interface{}, len(src)+4) + for key, value := range src { + dst[key] = value + } + return dst +} + +func logListRows(items []map[string]interface{}) []map[string]interface{} { + rows := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + rows = append(rows, logSummaryRow(item)) + } + return rows +} + +var logSummarySchema = appsOutputSchema{ + Columns: []appsOutputColumn{ + {Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05.000")}, + {Key: "level"}, + {Key: "module"}, + {Key: "user_id"}, + {Key: "duration_ms", Format: appsFormatDurationMS}, + {Key: "trace_id"}, + {Key: "log_id"}, + {Key: "message"}, + }, + Strict: true, +} + +func logSummaryRow(item map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "log_id": item["log_id"], + "level": firstItemString(item, "level", "severity_text"), + "trace_id": item["trace_id"], + "timestamp_ns": item["timestamp_ns"], + "module": firstLogDetailValue(item, "module"), + "user_id": firstLogDetailValue(item, "user_id"), + "duration_ms": firstLogDetailValue(item, "duration_ms"), + "message": firstItemString(item, "message", "body"), + } +} + +func firstLogDetailValue(item map[string]interface{}, key string) interface{} { + if value, ok := item[key]; ok { + return value + } + return appsAttributeValue(item["attributes"], key) +} + +func firstItemString(item map[string]interface{}, keys ...string) string { + for _, key := range keys { + if s, ok := item[key].(string); ok && strings.TrimSpace(s) != "" { + return s + } + } + return "" +} + +func enrichLogSourceStack(rctx *common.RuntimeContext, appID string, log map[string]interface{}) { + if !shouldResolveSourceStack(log) { + return + } + body, ok := extractSourceStackResolveBody(log) + if !ok { + log["source_stack_status"] = sourceStackStatusError + log["source_stack_reason"] = "source stack fields incomplete" + return + } + data, err := rctx.CallAPITyped("POST", resolveStackPath(appID), nil, body) + if err != nil { + if _, typed := errs.ProblemOf(err); typed { + markSourceStackResolveError(log, err) + } + return + } + stack := firstLogValue(data, "source_stack", "sourceStack", "frames") + if stack == nil { + stack = data + } + log["source_stack_status"] = sourceStackStatusOK + log["source_stack"] = stack +} + +func markSourceStackResolveError(log map[string]interface{}, err error) { + log["source_stack_status"] = sourceStackStatusError + log["source_stack_reason"] = "resolve_stack_trace failed" + if problem, ok := errs.ProblemOf(err); ok { + if problem.Code != 0 { + log["source_stack_error_code"] = problem.Code + log["source_stack_reason"] = fmt.Sprintf("resolve_stack_trace failed: code %d", problem.Code) + } + if problem.LogID != "" { + log["source_stack_log_id"] = problem.LogID + } + } +} + +func shouldResolveSourceStack(log map[string]interface{}) bool { + level := strings.ToUpper(firstItemString(log, "level", "severity_text", "severityText")) + if level != "ERROR" { + return false + } + if _, ok := extractSourceStackResolveBody(log); ok { + return true + } + return hasFrontendSourceMapSignal(log) +} + +func hasFrontendSourceMapSignal(value interface{}) bool { + switch v := value.(type) { + case map[string]interface{}: + for key, nested := range v { + if isSourceMapSignal(key) || hasFrontendSourceMapSignal(nested) { + return true + } + } + case []interface{}: + for _, nested := range v { + if hasFrontendSourceMapSignal(nested) { + return true + } + } + case string: + return isSourceMapSignal(v) || strings.Contains(strings.ToLower(v), ".js") + } + return false +} + +func isSourceMapSignal(value string) bool { + normalized := strings.NewReplacer("-", "_", " ", "_").Replace(strings.ToLower(value)) + return strings.Contains(normalized, "source_map") || strings.Contains(normalized, "sourcemap") +} + +func extractSourceStackResolveBody(log map[string]interface{}) (map[string]interface{}, bool) { + sources := collectSourceStackMaps(log) + commitID := firstStringInMaps(sources, "commit_id", "commitID", "commitId", "release_commit_id", "releaseCommitID", "releaseCommitId") + prefix := firstStringInMaps(sources, "source_map_file_prefix", "sourceMapFilePrefix", "source_map_prefix", "sourceMapPrefix") + if prefix == "" && firstStringInMaps(sources, "release_commit_id", "releaseCommitID", "releaseCommitId") != "" { + prefix = defaultSourceMapPrefix + } + frames := firstFramesInMaps( + sources, + "frames", + "stack_frames", + "stackFrames", + "source_stack_frames", + "sourceStackFrames", + "stack", + "stack_trace", + "stackTrace", + "error_stack", + "errorStack", + "exception_stack", + "exceptionStack", + "message", + "body", + ) + if commitID == "" || prefix == "" || len(frames) == 0 { + return nil, false + } + body := map[string]interface{}{ + "commit_id": commitID, + "source_map_file_prefix": prefix, + "frames": frames, + } + if tenantID := firstStringInMaps(sources, "tenant_id", "tenantID", "tenantId"); tenantID != "" { + body["tenant_id"] = tenantID + } + return body, true +} + +func collectSourceStackMaps(value interface{}) []map[string]interface{} { + out := make([]map[string]interface{}, 0, 8) + collectSourceStackMapsInto(value, 0, &out) + return out +} + +func collectSourceStackMapsInto(value interface{}, depth int, out *[]map[string]interface{}) { + if depth > sourceStackMaxScanDepth || value == nil { + return + } + switch v := value.(type) { + case map[string]interface{}: + *out = append(*out, v) + for _, nested := range v { + collectSourceStackMapsInto(nested, depth+1, out) + } + case []interface{}: + if attrs := observabilityKVList(v); len(attrs) > 0 { + *out = append(*out, attrs) + for _, nested := range attrs { + collectSourceStackMapsInto(nested, depth+1, out) + } + } + for _, nested := range v { + collectSourceStackMapsInto(nested, depth+1, out) + } + case []map[string]interface{}: + for _, nested := range v { + collectSourceStackMapsInto(nested, depth+1, out) + } + case string: + if parsed := parseJSONObjectString(v); parsed != nil { + collectSourceStackMapsInto(parsed, depth+1, out) + } + } +} + +func firstStringInMaps(sources []map[string]interface{}, keys ...string) string { + for _, source := range sources { + if s := firstLogString(source, keys...); s != "" { + return s + } + } + return "" +} + +func firstFramesInMaps(sources []map[string]interface{}, keys ...string) []interface{} { + for _, key := range keys { + for _, source := range sources { + frames := normalizeFrames(source[key]) + if len(frames) > 0 { + return frames + } + } + } + return nil +} + +func normalizeFrames(raw interface{}) []interface{} { + switch frames := raw.(type) { + case []interface{}: + out := make([]interface{}, 0, len(frames)) + for _, frame := range frames { + if normalized, ok := normalizeFrame(frame); ok { + out = append(out, normalized) + if len(out) >= sourceStackMaxFrames { + return out + } + } + } + return out + case []map[string]interface{}: + out := make([]interface{}, 0, len(frames)) + for _, frame := range frames { + if normalized, ok := normalizeFrame(frame); ok { + out = append(out, normalized) + if len(out) >= sourceStackMaxFrames { + return out + } + } + } + return out + case string: + return parseFrameString(frames) + default: + return nil + } +} + +func normalizeFrame(frame interface{}) (map[string]interface{}, bool) { + switch f := frame.(type) { + case map[string]interface{}: + return normalizeFrameMap(f) + case map[string]string: + m := make(map[string]interface{}, len(f)) + for key, value := range f { + m[key] = value + } + return normalizeFrameMap(m) + case string: + parsed := parseJSStackFrameLine(f) + if _, ok := parsed["file_name"]; !ok { + return nil, false + } + return parsed, true + default: + return nil, false + } +} + +func normalizeFrameMap(frame map[string]interface{}) (map[string]interface{}, bool) { + fileName := normalizeSourceFrameFileName(firstLogString(frame, "file_name", "fileName", "filename", "file", "url")) + line, lineOK := firstFrameInt(frame, "line", "line_number", "lineNumber") + column, columnOK := firstFrameInt(frame, "column", "col", "column_number", "columnNumber") + if fileName == "" || !lineOK || !columnOK { + return nil, false + } + out := map[string]interface{}{ + "file_name": fileName, + "line": line, + "column": column, + } + if fn := firstLogString(frame, "function", "function_name", "functionName", "method", "methodName"); fn != "" { + out["function"] = fn + } + return out, true +} + +func normalizeSourceFrameFileName(fileName string) string { + fileName = strings.TrimSpace(fileName) + if fileName == "" { + return "" + } + parts := strings.FieldsFunc(fileName, func(r rune) bool { + return r == '/' || r == '?' || r == '#' + }) + for i := len(parts) - 1; i >= 0; i-- { + if part := strings.TrimSpace(parts[i]); part != "" { + return part + } + } + return fileName +} + +func firstFrameInt(frame map[string]interface{}, keys ...string) (int, bool) { + for _, key := range keys { + if value, ok := frame[key]; ok { + if n, valid := frameInt(value); valid { + return n, true + } + } + } + return 0, false +} + +func frameInt(value interface{}) (int, bool) { + switch v := value.(type) { + case int: + return positiveFrameInt(v) + case int64: + if v > int64(^uint(0)>>1) { + return 0, false + } + return positiveFrameInt(int(v)) + case float64: + if v != float64(int(v)) { + return 0, false + } + return positiveFrameInt(int(v)) + case json.Number: + n, err := strconv.Atoi(v.String()) + if err != nil { + return 0, false + } + return positiveFrameInt(n) + case string: + return parsePositiveInt(v) + default: + return 0, false + } +} + +func positiveFrameInt(n int) (int, bool) { + if n < 1 { + return 0, false + } + return n, true +} + +func parseFrameString(raw string) []interface{} { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + var decoded []interface{} + if err := json.Unmarshal([]byte(raw), &decoded); err == nil { + return normalizeFrames(decoded) + } + lines := strings.Split(raw, "\n") + out := make([]interface{}, 0, len(lines)) + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + if frame, ok := normalizeFrame(parseJSStackFrameLine(line)); ok { + out = append(out, frame) + if len(out) >= sourceStackMaxFrames { + return out + } + } + } + return out +} + +func parseJSStackFrameLine(line string) map[string]interface{} { + if frame := parseJSStackFrameMatch(line, jsStackFrameParenRe.FindStringSubmatch(line)); frame != nil { + return frame + } + if frame := parseJSStackFrameMatch(line, jsStackFrameBareRe.FindStringSubmatch(line)); frame != nil { + return frame + } + return map[string]interface{}{"raw": line} +} + +func parseJSStackFrameMatch(raw string, match []string) map[string]interface{} { + if match == nil { + return nil + } + switch len(match) { + case 4: + line, lineOK := parsePositiveInt(match[2]) + column, columnOK := parsePositiveInt(match[3]) + if lineOK && columnOK { + return map[string]interface{}{"file_name": normalizeSourceFrameFileName(match[1]), "line": line, "column": column} + } + case 5: + line, lineOK := parsePositiveInt(match[3]) + column, columnOK := parsePositiveInt(match[4]) + if lineOK && columnOK { + out := map[string]interface{}{ + "file_name": normalizeSourceFrameFileName(match[2]), + "line": line, + "column": column, + } + if fn := strings.TrimSpace(match[1]); fn != "" { + out["function"] = fn + } + return out + } + } + return map[string]interface{}{"raw": raw} +} + +func parseJSONObjectString(raw string) map[string]interface{} { + raw = strings.TrimSpace(raw) + if raw == "" || !strings.HasPrefix(raw, "{") { + return nil + } + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(raw), &parsed); err != nil { + return nil + } + return parsed +} + +func parsePositiveInt(raw string) (int, bool) { + n, err := strconv.Atoi(strings.TrimSpace(raw)) + if err != nil || n < 1 { + return 0, false + } + return n, true +} + +func firstLogValue(data map[string]interface{}, keys ...string) interface{} { + for _, key := range keys { + if value, ok := data[key]; ok { + return value + } + } + return nil +} diff --git a/shortcuts/apps/apps_logs_test.go b/shortcuts/apps/apps_logs_test.go new file mode 100644 index 00000000..e456aa2f --- /dev/null +++ b/shortcuts/apps/apps_logs_test.go @@ -0,0 +1,664 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsLogList_DryRunBuildsSearchLogsBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsLogList, []string{ + "+log-list", "--app-id", "app_x", "--level", "error", + "--trace-id", "trace-1", + "--keyword", "timeout", "--module", "frontend", "--user-id", "ou_1", + "--page", "/home", "--api", "/api/orders", "--min-duration", "200", + "--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z", + "--page-size", "20", "--dry-run", "--as", "user", + }, factory, stdout) + if 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"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/search_logs" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + if env.API[0].Body["app_env"] != "runtime" || env.API[0].Body["limit"] != float64(20) { + t.Fatalf("body = %#v", env.API[0].Body) + } + filter := env.API[0].Body["filter"].(map[string]interface{}) + if got := filter["keyword"]; got != "timeout" { + t.Fatalf("filter.keyword = %v", got) + } + for key, want := range map[string]string{ + "modules": "frontend", + "user_ids": "ou_1", + "pages": "/home", + "apis": "/api/orders", + } { + values, ok := filter[key].([]interface{}) + if !ok || len(values) != 1 || values[0] != want { + t.Fatalf("filter.%s = %#v, want [%q]", key, filter[key], want) + } + } + if env.API[0].Body["start_timestamp_ns"] != "1782208800000000000" || + env.API[0].Body["end_timestamp_ns"] != "1782208860000000000" { + t.Fatalf("timestamps = %#v %#v", env.API[0].Body["start_timestamp_ns"], env.API[0].Body["end_timestamp_ns"]) + } +} + +func TestAppsLogList_DoesNotAcceptLogIDFlag(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsLogList, []string{ + "+log-list", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user", + }, factory, stdout) + if err == nil || !strings.Contains(err.Error(), "unknown flag: --log-id") { + t.Fatalf("expected unknown --log-id flag, got %v", err) + } +} + +func TestAppsLogList_RejectsDevEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout) + requireAppsValidationParam(t, err, "--environment") +} + +func TestAppsLogGet_SearchesByLogIDLimitOne(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{"log_id": "LOG1", "level": "INFO"}, + }, + }, + }, + } + reg.Register(stub) + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--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.Fatal(err) + } + if sent["limit"] != float64(1) { + t.Fatalf("limit = %v, want 1", sent["limit"]) + } + if sent["app_env"] != "runtime" { + t.Fatalf("app_env = %v, want runtime", sent["app_env"]) + } +} + +func TestAppsLogGet_AcceptsDataArraySearchResponse(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + RawBody: []byte(`{ + "code": 0, + "data": [ + { + "log_id": "LOG7655249917057764881", + "level": "ERROR", + "attributes": { + "commit_id": "commit_array", + "source_map_file_prefix": "sourcemaps/array", + "frames": [{"file":"main.js","line":10,"column":20}] + } + } + ] + }`), + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/App.tsx", "line": 7, "column": 9}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") { + t.Fatalf("stdout missing resolved source stack from data array response: %s", got) + } +} + +func TestAppsLogList_NormalizesResponseVariantsAndCanonicalLevel(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "logItems": []interface{}{ + map[string]interface{}{ + "id": "LOG1", + "traceID": "trace-1", + "timestampNs": "1782209472123456789", + "severityText": "ERROR", + }, + }, + "nextPageToken": "tok-next", + "hasMore": true, + }, + }, + }) + + if err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + PageToken string `json:"page_token"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.PageToken != "tok-next" || !env.Data.HasMore { + t.Fatalf("pagination = token %q has_more %v", env.Data.PageToken, env.Data.HasMore) + } + if len(env.Data.Items) != 1 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + item := env.Data.Items[0] + if item["level"] != "ERROR" || item["severity_text"] != "ERROR" || item["severityText"] != "ERROR" { + t.Fatalf("level fields = %#v", item) + } +} + +func TestAppsLogList_NormalizesKVAttributesToObject(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG1", + "attributes": []interface{}{ + map[string]interface{}{"key": "app_env", "value": "runtime"}, + map[string]interface{}{"key": "duration_ms", "value": "8263"}, + map[string]interface{}{"key": "module", "value": "gateway"}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + attrs, ok := env.Data.Items[0]["attributes"].(map[string]interface{}) + if !ok { + t.Fatalf("attributes = %#v, want object", env.Data.Items[0]["attributes"]) + } + if attrs["app_env"] != "runtime" || attrs["duration_ms"] != "8263" || attrs["module"] != "gateway" { + t.Fatalf("attributes = %#v", attrs) + } +} + +func TestAppsLogGet_PrettyFormatsTimestamp(t *testing.T) { + const rawNS = int64(1782209472123456789) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG1", + "level": "ERROR", + "trace_id": "trace-1", + "timestamp_ns": rawNS, + "message": "boom", + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsLogGet, []string{ + "+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + wantTime := time.Unix(0, rawNS).Local().Format("2006-01-02 15:04:05.000") + if !strings.HasPrefix(got, "time") { + t.Fatalf("pretty output should start with time column, got:\n%s", got) + } + if !strings.Contains(got, wantTime) { + t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got) + } + if strings.Contains(got, "timestamp_ns") || strings.Contains(got, "1782209472123456789") { + t.Fatalf("pretty output should hide raw timestamp_ns, got:\n%s", got) + } +} + +func TestAppsLogGet_ResolvesSourceStackWhenFieldsPresent(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG1", + "level": "ERROR", + "attributes": map[string]interface{}{ + "commit_id": "commit_1", + "source_map_file_prefix": "sourcemaps/app", + "frames": []interface{}{ + map[string]interface{}{"file": "main.js", "line": 10, "column": 20}, + }, + }, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/App.tsx", "line": 7, "column": 9}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil { + t.Fatal(err) + } + if sent["commit_id"] != "commit_1" || sent["source_map_file_prefix"] != "sourcemaps/app" { + t.Fatalf("resolve body missing source map fields: %#v", sent) + } + frames, ok := sent["frames"].([]interface{}) + if !ok || len(frames) != 1 { + t.Fatalf("resolve frames = %#v", sent["frames"]) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") { + t.Fatalf("stdout missing resolved source stack: %s", got) + } +} + +func TestAppsLogGet_ResolvesSourceStackFromNestedKVAttributes(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG7655249917057764881", + "severityText": "ERROR", + "attributes": []interface{}{ + map[string]interface{}{"key": "commit_id", "value": "commit_nested"}, + map[string]interface{}{"key": "source_map_file_prefix", "value": "sourcemaps/nested"}, + map[string]interface{}{ + "key": "exception", + "value": map[string]interface{}{ + "stackTrace": strings.Join([]string{ + "TypeError: failed to render", + " at render (https://cdn.example.com/assets/main.js:12:34)", + " at https://cdn.example.com/assets/chunk.js:56:78", + }, "\n"), + }, + }, + }, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/App.tsx", "line": 12, "column": 34}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil { + t.Fatal(err) + } + if sent["commit_id"] != "commit_nested" || sent["source_map_file_prefix"] != "sourcemaps/nested" { + t.Fatalf("resolve body missing nested source map fields: %#v", sent) + } + frames, ok := sent["frames"].([]interface{}) + if !ok || len(frames) != 2 { + t.Fatalf("resolve frames = %#v, want parsed stack frames", sent["frames"]) + } + frame, ok := frames[0].(map[string]interface{}) + if !ok { + t.Fatalf("parsed frame = %#v, want object", frames[0]) + } + if frame["function"] != "render" || frame["file_name"] != "main.js" || frame["line"] != float64(12) || frame["column"] != float64(34) { + t.Fatalf("parsed frame = %#v", frame) + } + bare, ok := frames[1].(map[string]interface{}) + if !ok { + t.Fatalf("bare frame = %#v, want object", frames[1]) + } + if bare["file_name"] != "chunk.js" || bare["line"] != float64(56) || bare["column"] != float64(78) { + t.Fatalf("bare frame = %#v", bare) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") { + t.Fatalf("stdout missing resolved source stack: %s", got) + } +} + +func TestAppsLogGet_ResolvesSourceStackFromReleaseCommitJSONStack(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG7655249917057764881", + "severityText": "ERROR", + "attributes": map[string]interface{}{ + "tenant_id": "110564", + "release_commit_id": "4b393e4e0ca9ca1a855ba4585bc6750a7db2266f", + "stack": `[{"fileName":"main.js","line":3348,"column":540585},` + + `{"fileName":"main.js","line":3107,"column":51935},` + + `{"fileName":"main.js","line":62,"column":12516}]`, + }, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/App.tsx", "line": 42, "column": 7}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil { + t.Fatal(err) + } + if sent["commit_id"] != "4b393e4e0ca9ca1a855ba4585bc6750a7db2266f" || sent["source_map_file_prefix"] != defaultSourceMapPrefix || sent["tenant_id"] != "110564" { + t.Fatalf("resolve body missing release source map fields: %#v", sent) + } + frames, ok := sent["frames"].([]interface{}) + if !ok || len(frames) != 3 { + t.Fatalf("resolve frames = %#v, want all valid generated frames", sent["frames"]) + } + first, ok := frames[0].(map[string]interface{}) + if !ok { + t.Fatalf("first frame = %#v, want object", frames[0]) + } + if first["file_name"] != "main.js" || first["line"] != float64(3348) || first["column"] != float64(540585) { + t.Fatalf("first frame = %#v", first) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") { + t.Fatalf("stdout missing resolved source stack: %s", got) + } +} + +func TestAppsLogGet_ResolvesSourceStackFromJSONBodyStack(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG_BODY_STACK", + "severityText": "ERROR", + "attributes": map[string]interface{}{ + "release_commit_id": "commit_body", + }, + "body": `{"error":{"stack":"AxiosError: failed\n at request (https://cdn.example.com/client/assets/body.js:9:88)"}}`, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "source_stack": []interface{}{ + map[string]interface{}{"file": "src/request.ts", "line": 9, "column": 88}, + }, + }, + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG_BODY_STACK", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + var sent map[string]interface{} + if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil { + t.Fatal(err) + } + if sent["commit_id"] != "commit_body" || sent["source_map_file_prefix"] != defaultSourceMapPrefix { + t.Fatalf("resolve body missing body stack source map fields: %#v", sent) + } + frames, ok := sent["frames"].([]interface{}) + if !ok || len(frames) != 1 { + t.Fatalf("resolve frames = %#v, want parsed JSON body stack frame", sent["frames"]) + } + frame, ok := frames[0].(map[string]interface{}) + if !ok { + t.Fatalf("frame = %#v, want object", frames[0]) + } + if frame["function"] != "request" || frame["file_name"] != "body.js" || frame["line"] != float64(9) || frame["column"] != float64(88) { + t.Fatalf("frame = %#v", frame) + } + if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/request.ts") { + t.Fatalf("stdout missing resolved source stack: %s", got) + } +} + +func TestAppsLogGet_SourceStackMissingFieldsDoesNotFail(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG1", + "level": "ERROR", + "message": "TypeError at https://cdn.example.com/main.js:10:20", + "attributes": map[string]interface{}{"commit_id": "commit_1"}, + }, + }, + }, + }, + } + reg.Register(search) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); !strings.Contains(got, `"log_id": "LOG1"`) { + t.Fatalf("stdout missing original log: %s", got) + } else if !strings.Contains(got, `"source_stack_status": "unresolved"`) { + t.Fatalf("stdout missing unresolved source stack status: %s", got) + } else if !strings.Contains(got, `"source_stack_reason"`) { + t.Fatalf("stdout missing sanitized source stack reason: %s", got) + } + for _, banned := range []string{"secret", "token", "raw request payload"} { + if strings.Contains(strings.ToLower(stdout.String()), banned) { + t.Fatalf("stdout leaked %q: %s", banned, stdout.String()) + } + } +} + +func TestAppsLogGet_ErrorNonFrontendMissingFieldsDoesNotMarkUnresolved(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG1", + "level": "ERROR", + "message": "go stack trace: database query failed", + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + if got := stdout.String(); strings.Contains(got, "source_stack_status") { + t.Fatalf("non-frontend error log should not be marked unresolved: %s", got) + } +} + +func TestAppsLogGet_SourceStackResolveFailureIsRedacted(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + search := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_logs", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "log_items": []interface{}{ + map[string]interface{}{ + "log_id": "LOG1", + "level": "ERROR", + "attributes": map[string]interface{}{ + "commit_id": "commit_1", + "source_map_file_prefix": "sourcemaps/app", + "frames": []interface{}{ + map[string]interface{}{"file": "main.js", "line": 10, "column": 20}, + }, + }, + }, + }, + }, + }, + } + resolve := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace", + Body: map[string]interface{}{ + "code": 999, + "msg": "secret token raw request payload should be redacted", + }, + } + reg.Register(search) + reg.Register(resolve) + + if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.Contains(got, `"source_stack_status": "unresolved"`) { + t.Fatalf("stdout missing unresolved status: %s", got) + } + if !strings.Contains(got, `"source_stack_error_code": 999`) { + t.Fatalf("stdout missing resolve error code: %s", got) + } + for _, banned := range []string{"secret", "token", "raw request payload"} { + if strings.Contains(strings.ToLower(got), banned) { + t.Fatalf("stdout leaked %q: %s", banned, got) + } + } +} diff --git a/shortcuts/apps/apps_metrics.go b/shortcuts/apps/apps_metrics.go new file mode 100644 index 00000000..1f68e48a --- /dev/null +++ b/shortcuts/apps/apps_metrics.go @@ -0,0 +1,587 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "encoding/json" + "fmt" + "io" + "sort" + "strings" + "time" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultAppsMetricEnv = "online" + defaultAppsMetricDownSample = "1m" + metricListEndpoint = "query_metrics_data" + defaultObservabilityRangeDays = 30 +) + +// AppsMetricList lists online app observability metrics. +var AppsMetricList = common.Shortcut{ + Service: appsService, + Command: "+metric-list", + Description: "List online app request, latency, CPU, and memory metrics", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +metric-list --app-id --metric requests --series total --since 1d", + "Tip: metric timestamps use seconds; use +analytics-list for PV/UV-style analytics.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online metrics should be listed", Required: true}, + {Name: appsEnvironmentFlag, Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"}, + {Name: "metric", Desc: "metric family to list", Required: true, Enum: []string{"requests", "latency", "cpu", "memory"}}, + {Name: "series", Desc: "metric series within the family, such as total/error or p50/p99"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to now"}, + {Name: "page", Type: "string_array", Desc: "frontend page or route filter; repeatable"}, + {Name: "api", Type: "string_array", Desc: "API path/name filter; repeatable"}, + {Name: "down-sample", Default: defaultAppsMetricDownSample, Desc: "metric down-sample interval", Enum: []string{"1m", "1h", "1d"}}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, _, _, _, err := buildMetricListBody(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _, _, _, _ := buildMetricListBody(rctx) + return common.NewDryRunAPI(). + POST(metricListPath(rctx.Str("app-id"))). + Desc("List online app metrics"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + body, names, labels, fillZero, err := buildMetricListBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", metricListPath(appID), nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := observabilitySeriesOutput{ + Items: normalizeMetricSeries(data, names, labels, fillZero), + HasMore: false, + } + rctx.OutFormat(out, nil, func(w io.Writer) { + rows := observabilitySeriesRows(out.Items) + sortObservabilityRowsDesc(rows, "timestamp") + rows = filterObservabilityRowsWithTime(rows, "timestamp") + appsPrintSchemaTable(w, rows, metricSeriesSchema(labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "latency")) + }) + return nil + }, +} + +type observabilitySeriesOutput struct { + Items []map[string]interface{} `json:"items"` + HasMore bool `json:"has_more"` +} + +func metricListPath(appID string) string { + return appScopedPath(appID, metricListEndpoint) +} + +func buildMetricListBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, bool, error) { + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) + if env == "" { + env = defaultAppsMetricEnv + } + if err := validateObservabilityEnv(env); err != nil { + return nil, nil, nil, false, err + } + names, labels, err := metricNamesForCLI(rctx.Str("metric"), rctx.Str("series")) + if err != nil { + return nil, nil, nil, false, err + } + since, until, err := defaultedObservabilityTimeRange(rctx.Str("since"), rctx.Str("until")) + if err != nil { + return nil, nil, nil, false, err + } + downSample := strings.TrimSpace(rctx.Str("down-sample")) + if !rctx.Changed("down-sample") { + downSample = appsMetricDownSampleForRange(since, until) + } else if downSample == "" { + downSample = defaultAppsMetricDownSample + } + body := map[string]interface{}{ + "metric_names": names, + "start_timestamp": secNumber(since), + "end_timestamp": secNumber(until), + "down_sample": downSample, + "need_pack_lack_point": false, + } + if filter := buildMetricListFilter(rctx); len(filter) > 0 { + body["filter"] = filter + } + return body, names, labels, strings.TrimSpace(strings.ToLower(rctx.Str("metric"))) == "requests", nil +} + +func appsMetricDownSampleForRange(since, until time.Time) string { + d := until.Sub(since) + switch { + case d <= 6*time.Hour: + return "1m" + case d <= 7*24*time.Hour: + return "1h" + default: + return "1d" + } +} + +func buildMetricListFilter(rctx *common.RuntimeContext) map[string]interface{} { + filter := make(map[string]interface{}) + if pages := cleanRepeatedStrings(rctx.StrArray("page")); len(pages) > 0 { + filter["pages"] = pages + } + if apis := cleanRepeatedStrings(rctx.StrArray("api")); len(apis) > 0 { + filter["apis"] = apis + } + return filter +} + +func defaultedObservabilityTimeRange(sinceRaw, untilRaw string) (time.Time, time.Time, error) { + since, until, hasSince, hasUntil, err := parseAppsTimeRange("--since", sinceRaw, "--until", untilRaw) + if err != nil { + return time.Time{}, time.Time{}, err + } + if !hasUntil { + until = time.Now() + } + if !hasSince { + since = until.Add(-defaultObservabilityRangeDays * 24 * time.Hour) + } + if since.After(until) { + return time.Time{}, time.Time{}, appsValidationParamError("--until", "--until must be greater than or equal to --since") + } + return since, until, nil +} + +func metricNamesForCLI(metric, series string) ([]string, []string, error) { + metric = strings.TrimSpace(strings.ToLower(metric)) + series = strings.TrimSpace(strings.ToLower(series)) + switch metric { + case "requests": + switch series { + case "": + return []string{"client_api_request_count", "client_api_request_error_count"}, []string{"total", "error"}, nil + case "total": + return []string{"client_api_request_count"}, []string{"total"}, nil + case "error": + return []string{"client_api_request_error_count"}, []string{"error"}, nil + default: + return nil, nil, appsValidationParamError("--series", "--series for --metric requests must be total or error") + } + case "latency": + switch series { + case "": + return []string{"client_api_request_latency_p50", "client_api_request_latency_p99"}, []string{"p50", "p99"}, nil + case "p50": + return []string{"client_api_request_latency_p50"}, []string{"p50"}, nil + case "p99": + return []string{"client_api_request_latency_p99"}, []string{"p99"}, nil + default: + return nil, nil, appsValidationParamError("--series", "--series for --metric latency must be p50 or p99") + } + case "cpu": + if series != "" { + return nil, nil, appsValidationParamError("--series", "--metric cpu does not support --series") + } + return []string{"cpu_usage"}, []string{"cpu"}, nil + case "memory": + if series != "" { + return nil, nil, appsValidationParamError("--series", "--metric memory does not support --series") + } + return []string{"mem_usage"}, []string{"memory"}, nil + default: + return nil, nil, appsValidationParamError("--metric", "--metric must be one of requests, latency, cpu, memory") + } +} + +func normalizeMetricSeries(data map[string]interface{}, names, labels []string, fillZero bool) []map[string]interface{} { + return normalizeObservabilitySeries(data, labels, observabilityNameLabels(names, labels), fillZero, "timestamp") +} + +func normalizeObservabilitySeries(data map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} { + if series := observabilityMapSlice(data["series"]); len(series) > 0 { + return mergeObservabilitySeries(series, labels, nameLabels, fillZero, timeField) + } + if items := observabilityMapSlice(data["items"]); len(items) > 0 { + if observabilityHasNestedPoints(items) { + return mergeObservabilitySeries(items, labels, nameLabels, fillZero, timeField) + } + return normalizeObservabilityPoints(items, labels, nameLabels, fillZero, timeField) + } + for _, key := range []string{"points", "data_points", "dataPoints"} { + if points := observabilityMapSlice(data[key]); len(points) > 0 { + return normalizeObservabilityPoints(points, labels, nameLabels, fillZero, timeField) + } + } + return []map[string]interface{}{} +} + +func observabilityHasNestedPoints(items []map[string]interface{}) bool { + for _, item := range items { + if len(observabilityNestedPoints(item)) > 0 { + return true + } + } + return false +} + +func mergeObservabilitySeries(series []map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} { + index := make(map[string]int) + items := make([]map[string]interface{}, 0) + for i, serie := range series { + label := observabilitySeriesLabel(serie, labels, nameLabels, i) + if label == "" { + continue + } + points := observabilityNestedPoints(serie) + if len(points) == 0 { + points = []map[string]interface{}{serie} + } + for _, point := range points { + timestamp := observabilityTimestamp(point, timeField) + dimensions := observabilityDimensions(point) + key := observabilityPointKey(timestamp, dimensions) + pos, ok := index[key] + if !ok { + pos = len(items) + index[key] = pos + items = append(items, map[string]interface{}{ + timeField: timestamp, + "dimensions": dimensions, + "values": map[string]interface{}{}, + }) + } + values := items[pos]["values"].(map[string]interface{}) + values[label] = observabilityPointValue(point, label, nameLabels) + } + } + if fillZero { + fillObservabilityZeroes(items, labels) + } + return items +} + +func normalizeObservabilityPoints(points []map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool, timeField string) []map[string]interface{} { + items := make([]map[string]interface{}, 0, len(points)) + for _, point := range points { + values := observabilityPointValues(point, labels, nameLabels, fillZero) + items = append(items, map[string]interface{}{ + timeField: observabilityTimestamp(point, timeField), + "dimensions": observabilityDimensions(point), + "values": values, + }) + } + return items +} + +func fillObservabilityZeroes(items []map[string]interface{}, labels []string) { + for _, item := range items { + values, ok := item["values"].(map[string]interface{}) + if !ok { + values = map[string]interface{}{} + item["values"] = values + } + for _, label := range labels { + if value, ok := values[label]; !ok || value == nil { + values[label] = 0 + } + } + } +} + +func fillObservabilityZeroesWhenPartiallyPresent(items []map[string]interface{}, labels []string) { + for _, item := range items { + values, ok := item["values"].(map[string]interface{}) + if !ok || !observabilityHasAnyNonNullValue(values) { + continue + } + for _, label := range labels { + if value, ok := values[label]; !ok || value == nil { + values[label] = 0 + } + } + } +} + +func observabilityHasAnyNonNullValue(values map[string]interface{}) bool { + for _, value := range values { + if value != nil { + return true + } + } + return false +} + +func observabilityPointValues(point map[string]interface{}, labels []string, nameLabels map[string]string, fillZero bool) map[string]interface{} { + values := make(map[string]interface{}, len(labels)) + switch raw := firstObservabilityValue(point, "values", "value_map", "valueMap"); v := raw.(type) { + case map[string]interface{}: + for _, label := range labels { + if value, ok := v[label]; ok { + values[label] = value + } + } + for name, label := range nameLabels { + if value, ok := v[name]; ok { + values[label] = value + } + } + case []interface{}: + for i, rawItem := range v { + if item, ok := rawItem.(map[string]interface{}); ok { + name := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "metric_name", "metricName", "name"))) + label := nameLabels[name] + if label == "" && i < len(labels) { + label = labels[i] + } + if label != "" { + values[label] = firstObservabilityValue(item, "value") + } + continue + } + if i < len(labels) { + values[labels[i]] = rawItem + } + } + } + for _, label := range labels { + if value, ok := point[label]; ok { + values[label] = value + } + } + if len(labels) == 1 { + if value, ok := point["value"]; ok { + values[labels[0]] = value + } + } + if fillZero { + for _, label := range labels { + if value, ok := values[label]; !ok || value == nil { + values[label] = 0 + } + } + } + return values +} + +func observabilityPointValue(point map[string]interface{}, label string, nameLabels map[string]string) interface{} { + if value, ok := point["value"]; ok { + return value + } + switch raw := firstObservabilityValue(point, "values", "value_map", "valueMap"); values := raw.(type) { + case map[string]interface{}: + for name, mappedLabel := range nameLabels { + if mappedLabel == label { + if value, ok := values[name]; ok { + return value + } + } + } + return values[label] + case []interface{}: + for _, rawItem := range values { + item, ok := rawItem.(map[string]interface{}) + if !ok { + continue + } + name := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "metric_name", "metricName", "name"))) + if nameLabels[name] == label { + return firstObservabilityValue(item, "value") + } + } + for _, rawItem := range values { + if _, ok := rawItem.(map[string]interface{}); !ok { + return rawItem + } + } + } + return nil +} + +func observabilityNestedPoints(item map[string]interface{}) []map[string]interface{} { + for _, key := range []string{"data_points", "dataPoints", "points", "items"} { + if points := observabilityMapSlice(item[key]); len(points) > 0 { + return points + } + } + return nil +} + +func observabilityMapSlice(raw interface{}) []map[string]interface{} { + switch items := raw.(type) { + case []map[string]interface{}: + return items + case []interface{}: + out := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out + default: + return nil + } +} + +func observabilitySeriesLabel(serie map[string]interface{}, labels []string, nameLabels map[string]string, index int) string { + for _, key := range []string{"label", "series", "name", "metric_name", "metricName", "metric_type", "metricType"} { + if value, ok := serie[key].(string); ok { + value = strings.TrimSpace(value) + if label := nameLabels[value]; label != "" { + return label + } + if containsObservabilityLabel(labels, value) { + return value + } + } + } + if index >= 0 && index < len(labels) { + return labels[index] + } + return "" +} + +func containsObservabilityLabel(labels []string, value string) bool { + for _, label := range labels { + if value == label { + return true + } + } + return false +} + +func observabilityTimestamp(point map[string]interface{}, timeField string) interface{} { + keys := []string{timeField} + if timeField == "timestamp_ns" { + keys = append(keys, "timestampNs", "time_ns", "timeNs", "time", "ts") + } else { + keys = append(keys, "timestampSec", "time", "ts") + } + return firstObservabilityValue(point, keys...) +} + +func observabilityDimensions(point map[string]interface{}) map[string]interface{} { + for _, key := range []string{"dimensions", "dimension", "labels", "tags"} { + if dimensions, ok := point[key].(map[string]interface{}); ok { + return cloneMap(dimensions) + } + if dimensions := observabilityKVList(point[key]); len(dimensions) > 0 { + return dimensions + } + } + return map[string]interface{}{} +} + +func observabilityNameLabels(names, labels []string) map[string]string { + out := make(map[string]string, len(names)) + for i, name := range names { + if i < len(labels) { + out[name] = labels[i] + } + } + return out +} + +func observabilityKVList(raw interface{}) map[string]interface{} { + items := observabilityMapSlice(raw) + if len(items) == 0 { + return nil + } + out := make(map[string]interface{}, len(items)) + for _, item := range items { + key := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "key", "name"))) + if key == "" { + continue + } + out[key] = firstObservabilityValue(item, "value") + } + return out +} + +func firstObservabilityValue(m map[string]interface{}, keys ...string) interface{} { + for _, key := range keys { + if value, ok := m[key]; ok { + return value + } + } + return nil +} + +func observabilityPointKey(timestamp interface{}, dimensions map[string]interface{}) string { + encoded, err := json.Marshal(dimensions) + if err != nil { + return fmt.Sprintf("%v|%v", timestamp, dimensions) + } + return fmt.Sprintf("%v|%s", timestamp, string(encoded)) +} + +func observabilitySeriesRows(items []map[string]interface{}) []map[string]interface{} { + rows := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + row := map[string]interface{}{} + for key, value := range item { + if key == "values" { + if values, ok := value.(map[string]interface{}); ok { + for label, metricValue := range values { + row[label] = metricValue + } + } + continue + } + row[key] = value + } + rows = append(rows, row) + } + return rows +} + +func metricSeriesSchema(labels []string, durationValues bool) appsOutputSchema { + columns := []appsOutputColumn{ + {Key: "timestamp", Label: "time", Format: appsFormatSec("2006-01-02 15:04:05")}, + } + for _, label := range labels { + col := appsOutputColumn{Key: label} + if durationValues { + col.Format = appsFormatDurationMS + } + columns = append(columns, col) + } + return appsOutputSchema{Columns: columns, Strict: true} +} + +func sortObservabilityRowsDesc(rows []map[string]interface{}, key string) { + sort.SliceStable(rows, func(i, j int) bool { + left, leftOK := appsInt64Value(rows[i][key]) + right, rightOK := appsInt64Value(rows[j][key]) + if !leftOK || !rightOK { + return false + } + return left > right + }) +} + +func filterObservabilityRowsWithTime(rows []map[string]interface{}, key string) []map[string]interface{} { + out := rows[:0] + for _, row := range rows { + if _, ok := appsInt64Value(row[key]); ok { + out = append(out, row) + } + } + return out +} diff --git a/shortcuts/apps/apps_metrics_test.go b/shortcuts/apps/apps_metrics_test.go new file mode 100644 index 00000000..3fa03249 --- /dev/null +++ b/shortcuts/apps/apps_metrics_test.go @@ -0,0 +1,298 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestMetricNamesMapping(t *testing.T) { + got, labels, err := metricNamesForCLI("requests", "") + if err != nil { + t.Fatal(err) + } + if strings.Join(got, ",") != "client_api_request_count,client_api_request_error_count" { + t.Fatalf("names = %#v", got) + } + if strings.Join(labels, ",") != "total,error" { + t.Fatalf("labels = %#v", labels) + } + if _, _, err := metricNamesForCLI("cpu", "p99"); err == nil { + t.Fatalf("cpu with p99 should fail") + } +} + +func TestAppsMetricList_DryRunUsesSeconds(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", + "--series", "total", "--since", "2026-06-23T10:00:00Z", + "--until", "2026-06-23T10:01:00Z", "--down-sample", "1m", + "--dry-run", "--as", "user", + }, factory, stdout) + if 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"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/query_metrics_data" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + body := env.API[0].Body + if _, ok := body["start_timestamp"]; !ok { + t.Fatalf("metric dry-run missing start_timestamp: %#v", body) + } + if _, ok := body["start_timestamp_ns"]; ok { + t.Fatalf("metric should not use start_timestamp_ns: %#v", body) + } + if _, ok := body["app_env"]; ok { + t.Fatalf("metric OpenAPI body should not include app_env: %#v", body) + } + if body["start_timestamp"] != "1782208800" || body["end_timestamp"] != "1782208860" { + t.Fatalf("metric timestamps = %v %v", body["start_timestamp"], body["end_timestamp"]) + } + if body["down_sample"] != "1m" { + t.Fatalf("down_sample = %v", body["down_sample"]) + } +} + +func TestAppsMetricList_AutoDownSampleByRange(t *testing.T) { + for _, tc := range []struct { + name string + since string + until string + want string + }{ + {name: "short", since: "2026-06-23T10:00:00Z", until: "2026-06-23T12:00:00Z", want: "1m"}, + {name: "medium", since: "2026-06-21T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1h"}, + {name: "long", since: "2026-06-01T10:00:00Z", until: "2026-06-23T10:00:00Z", want: "1d"}, + } { + t.Run(tc.name, func(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", + "--since", tc.since, "--until", tc.until, "--dry-run", "--as", "user", + }, factory, stdout) + if 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(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if got := env.API[0].Body["down_sample"]; got != tc.want { + t.Fatalf("down_sample = %#v, want %q; stdout:\n%s", got, tc.want, stdout.String()) + } + }) + } +} + +func TestAppsMetricList_RejectsDevEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", "--environment", "dev", "--as", "user", + }, factory, stdout) + requireAppsValidationParam(t, err, "--environment") +} + +func TestAppsMetricList_FillsMissingRequestValuesWithZero(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "points": []interface{}{ + map[string]interface{}{ + "timestamp": float64(1782208800), + "dimensions": map[string]interface{}{"page": "/home"}, + "values": []interface{}{ + map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)}, + }, + }, + map[string]interface{}{ + "timestamp": float64(1782208860), + "dimensions": map[string]interface{}{"page": "/settings"}, + "values": []interface{}{ + map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(8)}, + map[string]interface{}{"metric_name": "client_api_request_error_count", "value": nil}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.HasMore { + t.Fatalf("has_more = true, want false") + } + if len(env.Data.Items) != 2 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + for i, item := range env.Data.Items { + if item.Values["error"] != float64(0) { + t.Fatalf("item %d error = %#v, want 0; values=%#v", i, item.Values["error"], item.Values) + } + } +} + +func TestAppsMetricList_PrettyFormatsTimeFirst(t *testing.T) { + const rawSec = int64(1782208800) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "points": []interface{}{ + map[string]interface{}{ + "timestamp": float64(rawSec), + "values": []interface{}{ + map[string]interface{}{"metric_name": "client_api_request_count", "value": float64(12)}, + map[string]interface{}{"metric_name": "client_api_request_error_count", "value": float64(1)}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + wantTime := time.Unix(rawSec, 0).Local().Format("2006-01-02 15:04:05") + if !strings.HasPrefix(got, "time") { + t.Fatalf("pretty output should start with time column, got:\n%s", got) + } + if !strings.Contains(got, wantTime) { + t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, got) + } + if strings.Contains(got, "timestamp") || strings.Contains(got, "1782208800") { + t.Fatalf("pretty output should hide raw timestamp, got:\n%s", got) + } +} + +func TestAppsMetricList_NamedSeriesDoesNotDependOnBackendOrder(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "series": []interface{}{ + map[string]interface{}{ + "name": "client_api_request_error_count", + "points": []interface{}{ + map[string]interface{}{"timestamp": float64(1782208800), "value": float64(2)}, + }, + }, + map[string]interface{}{ + "name": "client_api_request_count", + "points": []interface{}{ + map[string]interface{}{"timestamp": float64(1782208800), "value": float64(10)}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "requests", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []struct { + Values map[string]interface{} `json:"values"` + } `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if len(env.Data.Items) != 1 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + values := env.Data.Items[0].Values + if values["total"] != float64(10) || values["error"] != float64(2) { + t.Fatalf("values = %#v, want total=10 error=2", values) + } +} + +func TestAppsMetricList_EmptyResponseOutputsEmptyItemsArray(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/query_metrics_data", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + }) + + if err := runAppsShortcut(t, AppsMetricList, []string{ + "+metric-list", "--app-id", "app_x", "--metric", "latency", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.Items == nil { + t.Fatalf("items decoded as nil; stdout=%s", stdout.String()) + } + if len(env.Data.Items) != 0 || env.Data.HasMore { + t.Fatalf("empty output = items %#v has_more %v", env.Data.Items, env.Data.HasMore) + } +} diff --git a/shortcuts/apps/apps_observability_common.go b/shortcuts/apps/apps_observability_common.go new file mode 100644 index 00000000..22d2b711 --- /dev/null +++ b/shortcuts/apps/apps_observability_common.go @@ -0,0 +1,202 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/internal/validate" +) + +const ( + defaultAppsPageSize = 50 + maxAppsPageSize = 100 + appsEnvironmentFlag = "environment" + + // The CLI exposes the user-facing online environment, while the + // observability backend stores online app runtime telemetry under runtime. + appsObservabilityBackendEnv = "runtime" +) + +func appScopedPath(appID, suffix string) string { + base := apiBasePath + "/apps/" + validate.EncodePathSegment(strings.TrimSpace(appID)) + suffix = strings.TrimLeft(strings.TrimSpace(suffix), "/") + if suffix == "" { + return base + } + return base + "/" + suffix +} + +func validateObservabilityEnv(env string) error { + switch strings.TrimSpace(env) { + case "", "online": + return nil + default: + return appsValidationParamError("--environment", "observability commands only support online (got %q)", env). + WithHint("only online is supported; omit --environment to use the default online environment") + } +} + +func validateEnvVarEnv(env string) error { + switch strings.TrimSpace(env) { + case "dev", "online": + return nil + default: + return appsValidationParamError("--environment", "env var commands only support --environment dev or --environment online (got %q)", env) + } +} + +func validateAppsPageSize(n int) error { + if n < 1 || n > maxAppsPageSize { + return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxAppsPageSize) + } + return nil +} + +func cleanRepeatedStrings(values []string) []string { + if len(values) == 0 { + return nil + } + seen := make(map[string]struct{}, len(values)) + out := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + +func normalizeObservabilityAttributes(item map[string]interface{}) { + kv := observabilityKVList(item["attributes"]) + if len(kv) > 0 { + item["attributes"] = kv + } +} + +func parseAppsTimeRange(sinceName, sinceRaw, untilName, untilRaw string) (time.Time, time.Time, bool, bool, error) { + var since, until time.Time + var hasSince, hasUntil bool + now := time.Now() + if strings.TrimSpace(sinceRaw) != "" { + parsed, err := parseAppsTimeFlag(sinceName, sinceRaw, now) + if err != nil { + return time.Time{}, time.Time{}, false, false, err + } + since = parsed + hasSince = true + } + if strings.TrimSpace(untilRaw) != "" { + parsed, err := parseAppsTimeFlag(untilName, untilRaw, now) + if err != nil { + return since, time.Time{}, hasSince, false, err + } + until = parsed + hasUntil = true + } + if hasSince && hasUntil && since.After(until) { + return since, until, true, true, appsValidationParamError(untilName, "%s must be greater than or equal to %s", untilName, sinceName) + } + return since, until, hasSince, hasUntil, nil +} + +func parseAppsTimeFlag(param, raw string, now time.Time) (time.Time, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return time.Time{}, appsValidationParamError(param, "%s is required", param) + } + if d, ok := parseAppsRelativeDuration(raw); ok { + return now.Add(-d), nil + } + if t, err := time.Parse(time.RFC3339Nano, raw); err == nil { + return t, nil + } + for _, layout := range []string{ + "2006-01-02", + "2006-01-02T15:04:05", + "2006-01-02T15:04:05.000", + } { + if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil { + return t, nil + } + } + return time.Time{}, appsValidationParamError(param, "invalid %s %q: expected relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), YYYY-MM-DD, local YYYY-MM-DDTHH:mm:ss(.SSS), or RFC3339", param, raw) +} + +func parseAppsRelativeDuration(s string) (time.Duration, bool) { + s = strings.TrimSpace(s) + if len(s) < 2 { + return 0, false + } + unit := s[len(s)-1] + number := s[:len(s)-1] + if number == "" { + return 0, false + } + seenDot := false + seenFractionDigit := false + for i := 0; i < len(number); i++ { + ch := number[i] + if ch == '.' { + if seenDot || i == 0 { + return 0, false + } + seenDot = true + continue + } + if ch < '0' || ch > '9' { + return 0, false + } + if seenDot { + seenFractionDigit = true + } + } + if seenDot && !seenFractionDigit { + return 0, false + } + n, err := strconv.ParseFloat(number, 64) + if err != nil || n <= 0 { + return 0, false + } + var unitDuration time.Duration + switch unit { + case 's': + unitDuration = time.Second + case 'm': + unitDuration = time.Minute + case 'h': + unitDuration = time.Hour + case 'd': + unitDuration = 24 * time.Hour + case 'w': + unitDuration = 7 * 24 * time.Hour + default: + return 0, false + } + const maxDuration = time.Duration(1<<63 - 1) + if n > float64(maxDuration)/float64(unitDuration) { + return 0, false + } + duration := time.Duration(n * float64(unitDuration)) + if duration <= 0 { + return 0, false + } + return duration, true +} + +func nsNumber(t time.Time) string { + return strconv.FormatInt(t.UnixNano(), 10) +} + +func secNumber(t time.Time) string { + return strconv.FormatInt(t.Unix(), 10) +} diff --git a/shortcuts/apps/apps_observability_common_test.go b/shortcuts/apps/apps_observability_common_test.go new file mode 100644 index 00000000..9fed5e66 --- /dev/null +++ b/shortcuts/apps/apps_observability_common_test.go @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/errs" +) + +func requireAppsValidationParam(t *testing.T, err error, want string) *errs.Problem { + t.Helper() + p := requireAppsValidationProblem(t, err) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected validation error with param %q, got %T: %v", want, err, err) + } + if validationErr.Param != want { + t.Fatalf("param = %q, want %s", validationErr.Param, want) + } + return p +} + +func TestAppsObservabilityValidateEnvOnlyOnline(t *testing.T) { + if err := validateObservabilityEnv(""); err != nil { + t.Fatalf("empty env should default/pass as online: %v", err) + } + if err := validateObservabilityEnv("online"); err != nil { + t.Fatalf("online should pass: %v", err) + } + err := validateObservabilityEnv("dev") + p := requireAppsValidationParam(t, err, "--environment") + if p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %#v, want invalid_argument param --environment", p) + } + if !strings.Contains(p.Hint, "only online is supported") { + t.Fatalf("hint = %q, want only-online guidance", p.Hint) + } +} + +func TestAppsObservabilityPageSizeRange(t *testing.T) { + for _, n := range []int{1, 50, 100} { + if err := validateAppsPageSize(n); err != nil { + t.Fatalf("page size %d should pass: %v", n, err) + } + } + for _, n := range []int{0, 101} { + err := validateAppsPageSize(n) + requireAppsValidationParam(t, err, "--page-size") + } +} + +func TestAppsObservabilityCommonHelpers(t *testing.T) { + if got := appScopedPath("app/x", "observability/logs"); got != "/open-apis/spark/v1/apps/app%2Fx/observability/logs" { + t.Fatalf("appScopedPath = %q", got) + } + for _, env := range []string{"dev", "online"} { + if err := validateEnvVarEnv(env); err != nil { + t.Fatalf("validateEnvVarEnv(%q) err=%v", env, err) + } + } + requireAppsValidationParam(t, validateEnvVarEnv(""), "--environment") + requireAppsValidationParam(t, validateEnvVarEnv("boe"), "--environment") + got := cleanRepeatedStrings([]string{" a ", "b", "a", "", "b", "c"}) + want := []string{"a", "b", "c"} + if len(got) != len(want) { + t.Fatalf("cleanRepeatedStrings len=%d, want %d: %v", len(got), len(want), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("cleanRepeatedStrings[%d]=%q, want %q", i, got[i], want[i]) + } + } + ts := time.Date(2026, 6, 23, 10, 11, 12, 123456789, time.UTC) + if got := nsNumber(ts); got != "1782209472123456789" { + t.Fatalf("nsNumber = %q", got) + } + if got := secNumber(ts); got != "1782209472" { + t.Fatalf("secNumber = %q", got) + } +} + +func TestParseAppsTimeAcceptsSupportedInputs(t *testing.T) { + now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.Local) + cases := []struct { + raw string + want time.Time + wantOffset *int + }{ + {raw: "30s", want: now.Add(-30 * time.Second)}, + {raw: "5m", want: now.Add(-5 * time.Minute)}, + {raw: "2h", want: now.Add(-2 * time.Hour)}, + {raw: "1.5h", want: now.Add(-90 * time.Minute)}, + {raw: "0.5d", want: now.Add(-12 * time.Hour)}, + {raw: "3d", want: now.Add(-72 * time.Hour)}, + {raw: "1w", want: now.Add(-7 * 24 * time.Hour)}, + {raw: "2026-06-23", want: time.Date(2026, 6, 23, 0, 0, 0, 0, time.Local)}, + {raw: "2026-06-23T10:11:12", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.Local)}, + {raw: "2026-06-23T10:11:12.123", want: time.Date(2026, 6, 23, 10, 11, 12, 123000000, time.Local)}, + {raw: "2026-06-23T10:11:12Z", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.UTC), wantOffset: ptrInt(0)}, + {raw: "2026-06-23T10:11:12+08:00", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.FixedZone("", 8*60*60)), wantOffset: ptrInt(8 * 60 * 60)}, + } + for _, tc := range cases { + got, err := parseAppsTimeFlag("--since", tc.raw, now) + if err != nil { + t.Fatalf("parseAppsTimeFlag(%q) err=%v", tc.raw, err) + } + if !got.Equal(tc.want) { + t.Fatalf("parseAppsTimeFlag(%q)=%s, want %s", tc.raw, got.Format(time.RFC3339Nano), tc.want.Format(time.RFC3339Nano)) + } + if tc.wantOffset != nil { + _, offset := got.Zone() + if offset != *tc.wantOffset { + t.Fatalf("parseAppsTimeFlag(%q) zone offset=%d, want %d", tc.raw, offset, *tc.wantOffset) + } + } + } +} + +func TestParseAppsTimeRejectsUnsupportedInputs(t *testing.T) { + for _, in := range []string{"2026/06/23", "yesterday", "2026-06-23 10:11:12", "999999999999999999w", "2147483647w"} { + _, _, _, _, err := parseAppsTimeRange("--since", in, "--until", "") + requireAppsValidationParam(t, err, "--since") + } +} + +func TestParseAppsTimeRangeRejectsSinceAfterUntil(t *testing.T) { + _, _, _, _, err := parseAppsTimeRange("--since", "2026-06-24", "--until", "2026-06-23") + requireAppsValidationParam(t, err, "--until") +} + +func ptrInt(n int) *int { + return &n +} diff --git a/shortcuts/apps/apps_openapi_key_common.go b/shortcuts/apps/apps_openapi_key_common.go new file mode 100644 index 00000000..7517ab72 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_common.go @@ -0,0 +1,233 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// API Key 端点 path 模板。前缀复用 apiBasePath = "/open-apis/spark/v1"(同包)。 +const ( + oapiKeyListPath = apiBasePath + "/apps/%s/oapi_apikeys" // GET(list) / POST(create) + oapiKeyItemPath = apiBasePath + "/apps/%s/oapi_apikeys/%s" // GET / PATCH / DELETE + oapiKeyRefreshPath = apiBasePath + "/apps/%s/oapi_apikeys/%s/refresh" // POST(reset) +) + +// maskAPIKey 把原始 api_key 收敛为非敏感预览:末 4 位前缀 "****"。 +// 空串或 <=4 位统一返回 "****"。 +func maskAPIKey(s string) string { + if len(s) <= 4 { + return "****" + } + return "****" + s[len(s)-4:] +} + +// redactKeyInfo 返回 app_open_api_key_info 的副本,剥离原始 api_key 并补 masked +// key_preview。非颁发命令(list/get/update/enable/disable)一律经此处理,确保原始 +// 密钥不从这些路径泄露。不修改入参。 +func redactKeyInfo(info map[string]interface{}) map[string]interface{} { + out := make(map[string]interface{}, len(info)+1) + for k, v := range info { + if k == "api_key" { + continue + } + out[k] = v + } + if raw, ok := info["api_key"].(string); ok { + out["key_preview"] = maskAPIKey(raw) + } else { + out["key_preview"] = "****" + } + return out +} + +// allowedScopeAPIMethods is the HTTP method whitelist for --scope-api / request_scope. +var allowedScopeAPIMethods = map[string]struct{}{ + "GET": {}, "POST": {}, "PUT": {}, "PATCH": {}, "DELETE": {}, +} + +// validateScopeAPIMethod rejects methods outside the whitelist (e.g. TRACE, CONNECT, empty). +func validateScopeAPIMethod(method string) error { + if _, ok := allowedScopeAPIMethods[method]; !ok { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "http method %q not allowed; use one of GET, POST, PUT, PATCH, DELETE", method) + } + return nil +} + +// validateScopeAPIPath enforces basic openapi route hygiene as a first line of defense. +func validateScopeAPIPath(p string) error { + if p == "" || !strings.HasPrefix(p, "/") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "http path must start with '/', got %q", p) + } + if strings.Contains(p, "..") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "http path must not contain '..': %q", p) + } + if strings.Contains(p, "//") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "http path must not contain '//': %q", p) + } + return nil +} + +// validateRequestScopeFields constrains a request_scope object to the documented +// schema: only allow_all (bool) and http_infos ([{http_method, http_path}]). This +// closes the raw --scope escape hatch from injecting undocumented fields. +func validateRequestScopeFields(rs map[string]interface{}) error { + for k := range rs { + switch k { + case "allow_all", "http_infos": + default: + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown field %q; only allow_all and http_infos are allowed", k) + } + } + if v, ok := rs["allow_all"]; ok { + if _, isBool := v.(bool); !isBool { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "allow_all must be a boolean") + } + } + if v, ok := rs["http_infos"]; ok { + arr, isArr := v.([]interface{}) + if !isArr { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "http_infos must be an array") + } + for _, item := range arr { + m, isMap := item.(map[string]interface{}) + if !isMap { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "each http_infos entry must be an object") + } + for k := range m { + switch k { + case "http_method", "http_path": + default: + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "unknown field %q in http_infos entry; only http_method and http_path are allowed", k) + } + } + method, _ := m["http_method"].(string) + if err := validateScopeAPIMethod(method); err != nil { + return err + } + path, _ := m["http_path"].(string) + if err := validateScopeAPIPath(path); err != nil { + return err + } + } + } + return nil +} + +// parseRawScope parses a raw --scope JSON value: it must be an object that +// conforms to the request_scope schema (validated by validateRequestScopeFields). +func parseRawScope(scopeRaw string) (map[string]interface{}, error) { + var rs interface{} + if err := json.Unmarshal([]byte(scopeRaw), &rs); err != nil { + return nil, err + } + obj, ok := rs.(map[string]interface{}) + if !ok { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope must be a JSON object") + } + if err := validateRequestScopeFields(obj); err != nil { + return nil, err + } + return obj, nil +} + +// parseScopeAPI parses a "--scope-api" value 'METHOD /openapi/path' into a snake_case +// httpInfo, validating the method against the whitelist and the path format. +func parseScopeAPI(s string) (map[string]interface{}, error) { + fields := strings.Fields(strings.TrimSpace(s)) + if len(fields) != 2 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "expected 'METHOD /path', got %q", s) + } + method := strings.ToUpper(fields[0]) + if err := validateScopeAPIMethod(method); err != nil { + return nil, err + } + path := fields[1] + if err := validateScopeAPIPath(path); err != nil { + return nil, err + } + return map[string]interface{}{"http_method": method, "http_path": path}, nil +} + +// buildRequestScope assembles config.request_scope (snake_case) from the scope flags. +// Returns (nil, nil) when no scope flag is set. Raw --scope is the escape hatch and +// is mutually exclusive with --scope-all / --scope-api. +func buildRequestScope(scopeAll bool, scopeAPIs []string, scopeRaw string) (interface{}, error) { + scopeRaw = strings.TrimSpace(scopeRaw) + hasFriendly := scopeAll || len(scopeAPIs) > 0 + if scopeRaw != "" { + if hasFriendly { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--scope cannot be combined with --scope-all / --scope-api").WithParam("--scope") + } + return parseRawScope(scopeRaw) + } + if !hasFriendly { + return nil, nil + } + rs := map[string]interface{}{"allow_all": scopeAll} + if len(scopeAPIs) > 0 { + infos := make([]interface{}, 0, len(scopeAPIs)) + for _, a := range scopeAPIs { + info, err := parseScopeAPI(a) + if err != nil { + return nil, err + } + infos = append(infos, info) + } + rs["http_infos"] = infos + } + return rs, nil +} + +// buildKeyConfig assembles the snake_case config object. Returns nil when nothing is set. +func buildKeyConfig(scopeAll bool, scopeAPIs []string, scopeRaw string, hasAllowPreview, allowPreview bool) (map[string]interface{}, error) { + rs, err := buildRequestScope(scopeAll, scopeAPIs, scopeRaw) + if err != nil { + return nil, err + } + if rs == nil && !hasAllowPreview { + return nil, nil + } + cfg := map[string]interface{}{} + if rs != nil { + cfg["request_scope"] = rs + } + if hasAllowPreview { + cfg["is_allow_access_preview"] = allowPreview + } + return cfg, nil +} + +// oapiKeyValidateScopeFlags validates the scope flag combination (shared by create/update). +func oapiKeyValidateScopeFlags(rctx *common.RuntimeContext) error { + scopeRaw := strings.TrimSpace(rctx.Str("scope")) + scopeAPIs := rctx.StrArray("scope-api") + if scopeRaw != "" && (rctx.Bool("scope-all") || len(scopeAPIs) > 0) { + return appsValidationParamError("--scope", "--scope cannot be combined with --scope-all / --scope-api"). + WithHint("use either --scope (raw JSON) OR --scope-all/--scope-api, not both") + } + if scopeRaw != "" { + if _, err := parseRawScope(scopeRaw); err != nil { + return appsValidationParamError("--scope", "invalid --scope: %s", err). + WithHint("--scope takes a JSON object with only allow_all (bool) and http_infos ([{http_method, http_path}]); methods: GET, POST, PUT, PATCH, DELETE") + } + } + for _, a := range scopeAPIs { + if _, err := parseScopeAPI(a); err != nil { + return appsValidationParamError("--scope-api", "invalid --scope-api: %s", err). + WithHint("format: 'METHOD /openapi/path'; method one of GET, POST, PUT, PATCH, DELETE; path starts with '/', no '..' or '//'") + } + } + return nil +} diff --git a/shortcuts/apps/apps_openapi_key_common_test.go b/shortcuts/apps/apps_openapi_key_common_test.go new file mode 100644 index 00000000..df4a248b --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_common_test.go @@ -0,0 +1,356 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "reflect" + "testing" +) + +func TestMaskAPIKey(t *testing.T) { + cases := map[string]string{ + "": "****", + "abcd": "****", + "xxxxxxxxxxxx": "****xxxx", + } + for in, want := range cases { + if got := maskAPIKey(in); got != want { + t.Errorf("maskAPIKey(%q) = %q, want %q", in, got, want) + } + } +} + +func TestRedactKeyInfo_StripsRawKey(t *testing.T) { + in := map[string]interface{}{ + "api_key_id": "k1", + "api_key": "xxxxxxxxxxxx", + "name": "partner-test", + "status": float64(1), + } + out := redactKeyInfo(in) + if _, ok := out["api_key"]; ok { + t.Fatalf("redactKeyInfo must strip api_key, got %v", out) + } + if out["key_preview"] != "****xxxx" { + t.Errorf("key_preview = %v, want ****xxxx", out["key_preview"]) + } + if out["name"] != "partner-test" || out["api_key_id"] != "k1" { + t.Errorf("non-secret fields must be preserved, got %v", out) + } + // input not mutated + if _, ok := in["api_key"]; !ok { + t.Errorf("redactKeyInfo must not mutate input") + } +} + +func TestParseScopeAPI(t *testing.T) { + t.Run("valid", func(t *testing.T) { + info, err := parseScopeAPI("GET /openapi/v1/orders") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info["http_method"] != "GET" { + t.Errorf("http_method = %v, want GET", info["http_method"]) + } + if info["http_path"] != "/openapi/v1/orders" { + t.Errorf("http_path = %v, want /openapi/v1/orders", info["http_path"]) + } + }) + t.Run("lowercase method uppercased", func(t *testing.T) { + info, err := parseScopeAPI("post /openapi/x") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info["http_method"] != "POST" { + t.Errorf("http_method = %v, want POST", info["http_method"]) + } + }) + t.Run("too few fields", func(t *testing.T) { + if _, err := parseScopeAPI("GET"); err == nil { + t.Errorf("one-word input must error") + } + }) + t.Run("too many fields", func(t *testing.T) { + if _, err := parseScopeAPI("GET /openapi/x extra"); err == nil { + t.Errorf("three-word input must error") + } + }) +} + +func TestValidateScopeAPIMethod(t *testing.T) { + for _, m := range []string{"GET", "POST", "PUT", "PATCH", "DELETE"} { + if err := validateScopeAPIMethod(m); err != nil { + t.Errorf("validateScopeAPIMethod(%q) = %v, want nil", m, err) + } + } + for _, m := range []string{"TRACE", "CONNECT", "OPTIONS", "HEAD", "", "get"} { + if err := validateScopeAPIMethod(m); err == nil { + t.Errorf("validateScopeAPIMethod(%q) = nil, want error", m) + } + } +} + +func TestValidateScopeAPIPath(t *testing.T) { + for _, p := range []string{"/openapi/orders", "/openapi/v1/x"} { + if err := validateScopeAPIPath(p); err != nil { + t.Errorf("validateScopeAPIPath(%q) = %v, want nil", p, err) + } + } + for _, p := range []string{"", "openapi/x", "/openapi/../admin", "/..", "/openapi//x", "//x"} { + if err := validateScopeAPIPath(p); err == nil { + t.Errorf("validateScopeAPIPath(%q) = nil, want error", p) + } + } +} + +func TestValidateRequestScopeFields(t *testing.T) { + ok := []map[string]interface{}{ + {"allow_all": true}, + {"allow_all": false, "http_infos": []interface{}{ + map[string]interface{}{"http_method": "GET", "http_path": "/openapi/x"}, + }}, + {}, + } + for _, rs := range ok { + if err := validateRequestScopeFields(rs); err != nil { + t.Errorf("validateRequestScopeFields(%v) = %v, want nil", rs, err) + } + } + bad := []map[string]interface{}{ + {"foo": 1}, // unknown top-level field + {"allow_all": "yes"}, // wrong type + {"http_infos": "x"}, // not an array + {"http_infos": []interface{}{"x"}}, // entry not an object + {"http_infos": []interface{}{map[string]interface{}{"http_method": "TRACE", "http_path": "/x"}}}, // bad method + {"http_infos": []interface{}{map[string]interface{}{"http_method": "GET", "http_path": "../x"}}}, // bad path + {"http_infos": []interface{}{map[string]interface{}{"http_method": "GET", "http_path": "/x", "extra": 1}}}, // unknown entry field + } + for _, rs := range bad { + if err := validateRequestScopeFields(rs); err == nil { + t.Errorf("validateRequestScopeFields(%v) = nil, want error", rs) + } + } +} + +func TestParseRawScope(t *testing.T) { + if _, err := parseRawScope(`{"allow_all":true}`); err != nil { + t.Errorf("valid object errored: %v", err) + } + for _, raw := range []string{`["x"]`, `"s"`, `123`, `{"foo":1}`, `{bad`} { + if _, err := parseRawScope(raw); err == nil { + t.Errorf("parseRawScope(%q) = nil, want error", raw) + } + } +} + +func TestParseScopeAPI_Rejects(t *testing.T) { + bad := []string{"TRACE /openapi/x", "CONNECT /x", "GET ../admin", "GET openapi/x", "GET /a//b"} + for _, in := range bad { + if _, err := parseScopeAPI(in); err == nil { + t.Errorf("parseScopeAPI(%q) = nil, want error", in) + } + } + // regression: legitimate input still parses (and lowercases the method) + info, err := parseScopeAPI("get /openapi/orders") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info["http_method"] != "GET" || info["http_path"] != "/openapi/orders" { + t.Errorf("info = %v", info) + } +} + +func TestBuildRequestScope_RawValidation(t *testing.T) { + // unknown field now rejected (HIGH-2) + if _, err := buildRequestScope(false, nil, `{"foo":1}`); err == nil { + t.Errorf("raw scope with unknown field must error") + } + // non-object rejected + if _, err := buildRequestScope(false, nil, `["x"]`); err == nil { + t.Errorf("non-object raw scope must error") + } + // nested bad method rejected + if _, err := buildRequestScope(false, nil, `{"http_infos":[{"http_method":"TRACE","http_path":"/x"}]}`); err == nil { + t.Errorf("raw scope with bad nested method must error") + } + // regression: documented fields pass + if _, err := buildRequestScope(false, nil, `{"allow_all":true}`); err != nil { + t.Errorf("valid raw scope errored: %v", err) + } +} + +func TestBuildRequestScope(t *testing.T) { + t.Run("nothing set -> nil", func(t *testing.T) { + rs, err := buildRequestScope(false, nil, "") + if err != nil || rs != nil { + t.Fatalf("expected nil,nil got rs=%v err=%v", rs, err) + } + }) + t.Run("scope-all only", func(t *testing.T) { + rs, err := buildRequestScope(true, nil, "") + if err != nil { + t.Fatalf("err = %v", err) + } + m := rs.(map[string]interface{}) + if m["allow_all"] != true { + t.Errorf("allow_all = %v, want true", m["allow_all"]) + } + if _, ok := m["http_infos"]; ok { + t.Errorf("http_infos should not appear when no scope-api provided") + } + }) + t.Run("scope-api adds http_infos", func(t *testing.T) { + rs, err := buildRequestScope(false, []string{"GET /openapi/x"}, "") + if err != nil { + t.Fatalf("err = %v", err) + } + m := rs.(map[string]interface{}) + if m["allow_all"] != false { + t.Errorf("allow_all = %v, want false", m["allow_all"]) + } + infos := m["http_infos"].([]interface{}) + if len(infos) != 1 { + t.Fatalf("http_infos len = %d, want 1", len(infos)) + } + info := infos[0].(map[string]interface{}) + if info["http_method"] != "GET" || info["http_path"] != "/openapi/x" { + t.Errorf("info = %v", info) + } + }) + t.Run("raw scope passthrough", func(t *testing.T) { + rs, err := buildRequestScope(false, nil, `{"allow_all":true}`) + if err != nil { + t.Fatalf("err = %v", err) + } + m := rs.(map[string]interface{}) + if m["allow_all"] != true { + t.Errorf("allow_all = %v, want true", m["allow_all"]) + } + }) + t.Run("raw + scope-all -> error", func(t *testing.T) { + if _, err := buildRequestScope(true, nil, `{"allow_all":true}`); err == nil { + t.Errorf("raw + scope-all must error") + } + }) + t.Run("raw + scope-api -> error", func(t *testing.T) { + if _, err := buildRequestScope(false, []string{"GET /openapi/x"}, `{"allow_all":true}`); err == nil { + t.Errorf("raw + scope-api must error") + } + }) + t.Run("invalid raw json -> error", func(t *testing.T) { + if _, err := buildRequestScope(false, nil, "{bad"); err == nil { + t.Errorf("invalid json must error") + } + }) +} + +func TestBuildKeyConfig(t *testing.T) { + t.Run("nothing set -> nil", func(t *testing.T) { + cfg, err := buildKeyConfig(false, nil, "", false, false) + if err != nil || cfg != nil { + t.Fatalf("empty -> nil, got cfg=%v err=%v", cfg, err) + } + }) + t.Run("scope-all -> snake_case request_scope", func(t *testing.T) { + cfg, err := buildKeyConfig(true, nil, "", false, false) + if err != nil { + t.Fatalf("err = %v", err) + } + rs := cfg["request_scope"].(map[string]interface{}) + if rs["allow_all"] != true { + t.Errorf("allow_all = %v, want true", rs["allow_all"]) + } + if _, ok := cfg["is_allow_access_preview"]; ok { + t.Errorf("is_allow_access_preview should not appear") + } + }) + t.Run("scope-api -> snake_case http_infos", func(t *testing.T) { + cfg, err := buildKeyConfig(false, []string{"GET /openapi/x"}, "", false, false) + if err != nil { + t.Fatalf("err = %v", err) + } + rs := cfg["request_scope"].(map[string]interface{}) + if rs["allow_all"] != false { + t.Errorf("allow_all = %v, want false", rs["allow_all"]) + } + infos := rs["http_infos"].([]interface{}) + if len(infos) != 1 { + t.Fatalf("http_infos len = %d, want 1", len(infos)) + } + info := infos[0].(map[string]interface{}) + if info["http_method"] != "GET" || info["http_path"] != "/openapi/x" { + t.Errorf("info = %v", info) + } + }) + t.Run("raw scope passthrough", func(t *testing.T) { + cfg, err := buildKeyConfig(false, nil, `{"allow_all":true}`, false, false) + if err != nil { + t.Fatalf("err = %v", err) + } + rs := cfg["request_scope"].(map[string]interface{}) + if rs["allow_all"] != true { + t.Errorf("allow_all = %v", rs["allow_all"]) + } + }) + t.Run("allow-preview only -> is_allow_access_preview", func(t *testing.T) { + cfg, err := buildKeyConfig(false, nil, "", true, true) + if err != nil { + t.Fatalf("err = %v", err) + } + if _, ok := cfg["request_scope"]; ok { + t.Errorf("request_scope should not appear when not set") + } + if cfg["is_allow_access_preview"] != true { + t.Errorf("is_allow_access_preview = %v, want true", cfg["is_allow_access_preview"]) + } + }) + t.Run("scope-all + allow-preview -> both snake_case keys", func(t *testing.T) { + cfg, err := buildKeyConfig(true, nil, "", true, false) + if err != nil { + t.Fatalf("err = %v", err) + } + if _, ok := cfg["request_scope"]; !ok { + t.Errorf("request_scope missing") + } + if cfg["is_allow_access_preview"] != false { + t.Errorf("is_allow_access_preview = %v, want false", cfg["is_allow_access_preview"]) + } + // ensure no camelCase keys + if _, ok := cfg["requestScope"]; ok { + t.Errorf("found camelCase key requestScope — must use snake_case") + } + if _, ok := cfg["isAllowAccessPreview"]; ok { + t.Errorf("found camelCase key isAllowAccessPreview — must use snake_case") + } + }) + t.Run("raw + scope-all -> error", func(t *testing.T) { + if _, err := buildKeyConfig(true, nil, `{"allow_all":true}`, false, false); err == nil { + t.Errorf("raw + scope-all must error") + } + }) + t.Run("invalid json -> error", func(t *testing.T) { + if _, err := buildKeyConfig(false, nil, "{bad", false, false); err == nil { + t.Errorf("invalid json must error") + } + }) + t.Run("no camelCase keys emitted", func(t *testing.T) { + cfg, err := buildKeyConfig(false, []string{"GET /openapi/x"}, "", true, true) + if err != nil { + t.Fatalf("err = %v", err) + } + if _, ok := cfg["requestScope"]; ok { + t.Errorf("camelCase requestScope must not appear") + } + if _, ok := cfg["isAllowAccessPreview"]; ok { + t.Errorf("camelCase isAllowAccessPreview must not appear") + } + rs := cfg["request_scope"].(map[string]interface{}) + infos := rs["http_infos"].([]interface{}) + info := infos[0].(map[string]interface{}) + wantInfo := map[string]interface{}{"http_method": "GET", "http_path": "/openapi/x"} + if !reflect.DeepEqual(info, wantInfo) { + t.Errorf("info = %v, want %v", info, wantInfo) + } + }) +} diff --git a/shortcuts/apps/apps_openapi_key_create.go b/shortcuts/apps/apps_openapi_key_create.go new file mode 100644 index 00000000..173e4128 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_create.go @@ -0,0 +1,110 @@ +// 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" +) + +// AppsOpenAPIKeyCreate creates an open API key. The raw secret is returned ONCE. +var AppsOpenAPIKeyCreate = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-create", + Description: "Create an open API key (returns the raw secret once)", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +openapi-key-create --app-id --name partner-test", + "Example: lark-cli apps +openapi-key-create --app-id --name orders-readonly --scope-api 'GET /openapi/orders'", + "Example: lark-cli apps +openapi-key-create --app-id --name full-access --scope-all", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "name", Desc: "API key name", Required: true}, + {Name: "scope-all", Type: "bool", Desc: "grant access to all /openapi/** routes (request_scope.allow_all)"}, + {Name: "scope-api", Type: "string_array", Desc: "grant one route, repeatable: 'METHOD /openapi/path' (from the app's docs/openapi.json)"}, + {Name: "scope", Desc: "advanced: raw JSON for config.request_scope (mutually exclusive with --scope-all/--scope-api)"}, + {Name: "allow-preview", Type: "bool", Desc: "allow preview-env access (config.is_allow_access_preview)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := oapiKeyValidateAppID(rctx); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("name")) == "" { + return appsValidationParamError("--name", "--name is required"). + WithHint("provide a human-readable key name, e.g. --name partner-readonly") + } + return oapiKeyValidateScopeFlags(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + body, _ := buildOpenAPIKeyCreateBody(rctx) + return common.NewDryRunAPI(). + POST(fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID))). + Desc("Create open API key"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + body, err := buildOpenAPIKeyCreateBody(rctx) + if err != nil { + return appsValidationParamError("--scope", "invalid scope: %v", err). + WithHint("--scope must be valid JSON for config.request_scope; or use --scope-all / --scope-api") + } + path := fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID)) + data, err := rctx.CallAPITyped("POST", path, nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + return outputIssuedKey(rctx, data) + }, +} + +// buildOpenAPIKeyCreateBody builds {name, config?}. +func buildOpenAPIKeyCreateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { + body := map[string]interface{}{"name": strings.TrimSpace(rctx.Str("name"))} + cfg, err := buildKeyConfig(rctx.Bool("scope-all"), rctx.StrArray("scope-api"), rctx.Str("scope"), rctx.Changed("allow-preview"), rctx.Bool("allow-preview")) + if err != nil { + return nil, err + } + if cfg != nil { + body["config"] = cfg + } + return body, nil +} + +// outputIssuedKey emits {api_key_id, api_key(raw, once), info(redacted)} for +// create/reset, plus a one-time stderr warning. The raw secret is NEVER persisted. +func outputIssuedKey(rctx *common.RuntimeContext, data map[string]interface{}) error { + info := common.GetMap(data, "info") + raw := common.GetString(info, "api_key") + if raw == "" { + raw = common.GetString(data, "api_key") // reset returns top-level api_key + } + out := map[string]interface{}{ + "api_key_id": firstNonEmpty(common.GetString(data, "api_key_id"), common.GetString(info, "api_key_id")), + "api_key": raw, + "info": redactKeyInfo(info), + } + fmt.Fprintln(rctx.IO().ErrOut, "warning: this api_key is shown only once and is NOT stored by lark-cli — copy it now and store it in your own secret manager.") + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "API key ID: %v\nAPI key: %v (shown once)\n", out["api_key_id"], raw) + }) + return nil +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} diff --git a/shortcuts/apps/apps_openapi_key_create_test.go b/shortcuts/apps/apps_openapi_key_create_test.go new file mode 100644 index 00000000..e234ad78 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_create_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// createFlagDefs returns the flag type map for +openapi-key-create tests. +func createFlagDefs() map[string]string { + return map[string]string{ + "app-id": "string", + "name": "string", + "scope-all": "bool", + "scope-api": "string_array", + "scope": "string", + "allow-preview": "bool", + } +} + +func TestOpenAPIKeyCreateExecute_ReturnsRawOnce(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + createFlagDefs(), + map[string]string{"app-id": "app_x", "name": "partner-test"}) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "api_key_id": "k1", + "info": map[string]interface{}{ + "api_key_id": "k1", "name": "partner-test", + "api_key": "xxxxxxxxxxxx", "status": float64(1), + }, + }, + }, + }) + if err := AppsOpenAPIKeyCreate.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + out := stdoutBuf.String() + // create surfaces the raw secret ONCE at top-level api_key + if !strings.Contains(out, "xxxxxxxxxxxx") { + t.Fatalf("create must surface raw api_key once: %s", out) + } + // nested info must be redacted — raw key appears exactly once (top-level only) + if strings.Count(out, "xxxxxxxxxxxx") != 1 { + t.Errorf("raw key must appear exactly once (top-level only): %s", out) + } + if !strings.Contains(out, "****xxxx") { + t.Errorf("redacted info must carry key_preview: %s", out) + } +} + +func TestOpenAPIKeyCreate_MissingName(t *testing.T) { + rctx, _, _ := newOpenAPIKeyRCtx(t, + createFlagDefs(), + map[string]string{"app-id": "app_x"}) + if err := AppsOpenAPIKeyCreate.Validate(context.Background(), rctx); err == nil { + t.Errorf("missing --name must fail validation") + } +} + +func TestOpenAPIKeyCreate_InvalidScope(t *testing.T) { + rctx, _, _ := newOpenAPIKeyRCtx(t, + createFlagDefs(), + map[string]string{"app-id": "app_x", "name": "n", "scope": "{bad"}) + if err := AppsOpenAPIKeyCreate.Validate(context.Background(), rctx); err == nil { + t.Errorf("invalid --scope json must fail validation") + } +} + +func TestOpenAPIKeyCreate_ScopeRawAndFriendlyMutuallyExclusive(t *testing.T) { + rctx, _, _ := newOpenAPIKeyRCtx(t, + createFlagDefs(), + map[string]string{"app-id": "app_x", "name": "n", "scope": `{"allowAll":true}`, "scope-all": "true"}) + if err := AppsOpenAPIKeyCreate.Validate(context.Background(), rctx); err == nil { + t.Errorf("--scope + --scope-all must fail validation") + } +} diff --git a/shortcuts/apps/apps_openapi_key_delete.go b/shortcuts/apps/apps_openapi_key_delete.go new file mode 100644 index 00000000..88b7717b --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_delete.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsOpenAPIKeyDelete permanently deletes an open API key (irreversible). +var AppsOpenAPIKeyDelete = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-delete", + Description: "Delete an open API key (irreversible; prefer +openapi-key-disable)", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +openapi-key-delete --app-id --key-id --yes", + "Preview: add --dry-run to see the request without deleting", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "key-id", Desc: "API key ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI().DELETE(oapiKeyItemURL(rctx)).Desc("Delete open API key") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + id := strings.TrimSpace(rctx.Str("key-id")) + if _, err := rctx.CallAPITyped("DELETE", oapiKeyItemURL(rctx), nil, nil); err != nil { + return withAppsHint(err, oapiKeyNotFoundHint(rctx)) + } + out := map[string]interface{}{"api_key_id": id, "deleted": true} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "deleted API key ID: %s\n", id) + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_openapi_key_delete_test.go b/shortcuts/apps/apps_openapi_key_delete_test.go new file mode 100644 index 00000000..81a427cb --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_delete_test.go @@ -0,0 +1,35 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestOpenAPIKeyDeleteMeta_HighRisk(t *testing.T) { + if AppsOpenAPIKeyDelete.Risk != "high-risk-write" { + t.Errorf("delete must be high-risk-write, got %q", AppsOpenAPIKeyDelete.Risk) + } +} + +func TestOpenAPIKeyDeleteExecute(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + map[string]string{"app-id": "string", "key-id": "string", "yes": "bool"}, + map[string]string{"app-id": "app_x", "key-id": "1", "yes": "true"}) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1", + Body: map[string]interface{}{"code": 0, "msg": "", "data": map[string]interface{}{}}, + }) + if err := AppsOpenAPIKeyDelete.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if !strings.Contains(stdoutBuf.String(), "\"deleted\"") && !strings.Contains(stdoutBuf.String(), "deleted") { + t.Errorf("expected deleted marker: %s", stdoutBuf.String()) + } +} diff --git a/shortcuts/apps/apps_openapi_key_disable.go b/shortcuts/apps/apps_openapi_key_disable.go new file mode 100644 index 00000000..4174b7e7 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_disable.go @@ -0,0 +1,33 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsOpenAPIKeyDisable disables (status=0) an open API key — the minimal safety brake. +var AppsOpenAPIKeyDisable = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-disable", + Description: "Disable an open API key (minimal safety brake)", + Risk: "write", + Tips: []string{"Example: lark-cli apps +openapi-key-disable --app-id --key-id "}, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "key-id", Desc: "API key ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI().PATCH(oapiKeyItemURL(rctx)).Desc("Disable open API key").Body(openAPIKeyStatusBody(keyStatusDisable)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + return execOpenAPIKeyStatus(rctx, keyStatusDisable) + }, +} diff --git a/shortcuts/apps/apps_openapi_key_enable.go b/shortcuts/apps/apps_openapi_key_enable.go new file mode 100644 index 00000000..c2df7a82 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_enable.go @@ -0,0 +1,53 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +// app_open_api_key_status enum: 0=DISABLE, 1=ENABLE. +const ( + keyStatusDisable = 0 + keyStatusEnable = 1 +) + +// AppsOpenAPIKeyEnable enables (status=1) an open API key. +var AppsOpenAPIKeyEnable = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-enable", + Description: "Enable an open API key", + Risk: "write", + Tips: []string{"Example: lark-cli apps +openapi-key-enable --app-id --key-id "}, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "key-id", Desc: "API key ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI().PATCH(oapiKeyItemURL(rctx)).Desc("Enable open API key").Body(openAPIKeyStatusBody(keyStatusEnable)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + return execOpenAPIKeyStatus(rctx, keyStatusEnable) + }, +} + +// openAPIKeyStatusBody builds the PATCH body for a status change. +func openAPIKeyStatusBody(status int) map[string]interface{} { + return map[string]interface{}{"status": status} +} + +// execOpenAPIKeyStatus PATCHes status and prints the redacted info. +func execOpenAPIKeyStatus(rctx *common.RuntimeContext, status int) error { + data, err := rctx.CallAPITyped("PATCH", oapiKeyItemURL(rctx), nil, openAPIKeyStatusBody(status)) + if err != nil { + return withAppsHint(err, oapiKeyNotFoundHint(rctx)) + } + return outputRedactedInfo(rctx, data) +} diff --git a/shortcuts/apps/apps_openapi_key_get.go b/shortcuts/apps/apps_openapi_key_get.go new file mode 100644 index 00000000..20ddf6bc --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_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" +) + +// AppsOpenAPIKeyGet returns one open API key's detail (redacted). +var AppsOpenAPIKeyGet = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-get", + Description: "Get an open API key detail (secret redacted)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +openapi-key-get --app-id --key-id ", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "key-id", Desc: "API key ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + return oapiKeyValidateKeyID(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET(oapiKeyItemURL(rctx)). + Desc("Get open API key detail") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("GET", oapiKeyItemURL(rctx), nil, nil) + if err != nil { + return withAppsHint(err, oapiKeyNotFoundHint(rctx)) + } + return outputRedactedInfo(rctx, data) + }, +} + +// oapiKeyItemURL builds the per-key item path from --app-id / --key-id. +func oapiKeyItemURL(rctx *common.RuntimeContext) string { + return fmt.Sprintf(oapiKeyItemPath, + validate.EncodePathSegment(strings.TrimSpace(rctx.Str("app-id"))), + validate.EncodePathSegment(strings.TrimSpace(rctx.Str("key-id")))) +} + +// oapiKeyNotFoundHint points a failed per-key call at +openapi-key-list. +func oapiKeyNotFoundHint(rctx *common.RuntimeContext) string { + return "verify --key-id; list keys with `lark-cli apps +openapi-key-list --app-id " + + strings.TrimSpace(rctx.Str("app-id")) + "`" +} + +// outputRedactedInfo emits {info: } for get/update/enable/disable. +func outputRedactedInfo(rctx *common.RuntimeContext, data map[string]interface{}) error { + info := common.GetMap(data, "info") + red := redactKeyInfo(info) + out := map[string]interface{}{"info": red} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "API key ID: %v\nname: %v\nstatus: %v\nkey_preview: %v\n", + red["api_key_id"], red["name"], red["status"], red["key_preview"]) + }) + return nil +} diff --git a/shortcuts/apps/apps_openapi_key_get_test.go b/shortcuts/apps/apps_openapi_key_get_test.go new file mode 100644 index 00000000..f23ef14f --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_get_test.go @@ -0,0 +1,49 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestOpenAPIKeyGetExecute_Redacts(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + map[string]string{"app-id": "string", "key-id": "string"}, + map[string]string{"app-id": "app_x", "key-id": "1"}) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "info": map[string]interface{}{ + "api_key_id": "k1", "name": "partner-test", + "api_key": "xxxxxxxxxxxx", "status": float64(1), + }, + }, + }, + }) + if err := AppsOpenAPIKeyGet.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if strings.Contains(stdoutBuf.String(), "xxxxxxxxxxxx") { + t.Fatalf("get output leaked raw api key: %s", stdoutBuf.String()) + } + if !strings.Contains(stdoutBuf.String(), "****xxxx") { + t.Errorf("expected key_preview: %s", stdoutBuf.String()) + } +} + +func TestOpenAPIKeyGetExecute_MissingKeyID(t *testing.T) { + rctx, _, _ := newOpenAPIKeyRCtx(t, + map[string]string{"app-id": "string", "key-id": "string"}, + map[string]string{"app-id": "app_x"}) + if err := AppsOpenAPIKeyGet.Validate(context.Background(), rctx); err == nil { + t.Errorf("missing --key-id must fail validation") + } +} diff --git a/shortcuts/apps/apps_openapi_key_list.go b/shortcuts/apps/apps_openapi_key_list.go new file mode 100644 index 00000000..f61f6987 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_list.go @@ -0,0 +1,104 @@ +// 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" +) + +// AppsOpenAPIKeyList lists an app's open API keys (redacted; raw secret never shown). +var AppsOpenAPIKeyList = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-list", + Description: "List an app's open API keys (secrets redacted)", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +openapi-key-list --app-id ", + "Example: lark-cli apps +openapi-key-list --app-id --limit 10", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "limit", Type: "int", Desc: "page size (server default if omitted)"}, + {Name: "offset", Type: "int", Desc: "page offset"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + return oapiKeyValidateAppID(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + appID := strings.TrimSpace(rctx.Str("app-id")) + return common.NewDryRunAPI(). + GET(fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID))). + Desc("List open API keys"). + Params(buildOpenAPIKeyListParams(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID := strings.TrimSpace(rctx.Str("app-id")) + path := fmt.Sprintf(oapiKeyListPath, validate.EncodePathSegment(appID)) + data, err := rctx.CallAPITyped("GET", path, buildOpenAPIKeyListParams(rctx), nil) + if err != nil { + return withAppsHint(err, appIDListHint) + } + infos := common.GetSlice(data, "infos") + redacted := make([]interface{}, 0, len(infos)) + for _, it := range infos { + if m, ok := it.(map[string]interface{}); ok { + redacted = append(redacted, redactKeyInfo(m)) + } else { + redacted = append(redacted, it) + } + } + out := map[string]interface{}{"infos": redacted} + rctx.OutFormat(out, nil, func(w io.Writer) { + fmt.Fprintf(w, "%d key(s)\n", len(redacted)) + for _, it := range redacted { + if m, ok := it.(map[string]interface{}); ok { + fmt.Fprintf(w, "- %v %v %v\n", m["api_key_id"], m["name"], m["key_preview"]) + } + } + }) + return nil + }, +} + +// buildOpenAPIKeyListParams builds the optional limit/offset query params. +func buildOpenAPIKeyListParams(rctx *common.RuntimeContext) map[string]interface{} { + params := map[string]interface{}{} + if rctx.Changed("limit") { + params["limit"] = rctx.Int("limit") + } + if rctx.Changed("offset") { + params["offset"] = rctx.Int("offset") + } + return params +} + +// oapiKeyValidateAppID validates --app-id presence. Shared by all openapi-key commands. +func oapiKeyValidateAppID(rctx *common.RuntimeContext) error { + if strings.TrimSpace(rctx.Str("app-id")) == "" { + return appsValidationParamError("--app-id", "--app-id is required"). + WithHint("list your apps with `lark-cli apps +list`") + } + return nil +} + +// oapiKeyValidateKeyID validates --app-id and --key-id presence. +func oapiKeyValidateKeyID(rctx *common.RuntimeContext) error { + if err := oapiKeyValidateAppID(rctx); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("key-id")) == "" { + return appsValidationParamError("--key-id", "--key-id is required"). + WithHint("find key ids with `lark-cli apps +openapi-key-list --app-id `") + } + return nil +} diff --git a/shortcuts/apps/apps_openapi_key_list_test.go b/shortcuts/apps/apps_openapi_key_list_test.go new file mode 100644 index 00000000..6e3548b1 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_list_test.go @@ -0,0 +1,91 @@ +// 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" +) + +// newOpenAPIKeyRCtx 构造带指定 flag 的 RuntimeContext。flags 是 name->value, +// bool flag 传 "true"/"false"。被本组所有命令测试复用。 +func newOpenAPIKeyRCtx(t *testing.T, flagDefs map[string]string, flags map[string]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-openapi-key"} + cmd.SetContext(context.Background()) + for name, typ := range flagDefs { + switch typ { + case "bool": + cmd.Flags().Bool(name, false, "") + case "int": + cmd.Flags().Int(name, 0, "") + case "string_array": + cmd.Flags().StringArray(name, nil, "") + default: + cmd.Flags().String(name, "", "") + } + } + for name, val := range flags { + _ = cmd.Flags().Set(name, val) + } + rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser) + return rctx, stdoutBuf, reg +} + +func TestOpenAPIKeyListMeta(t *testing.T) { + if AppsOpenAPIKeyList.Command != "+openapi-key-list" || AppsOpenAPIKeyList.Risk != "read" { + t.Errorf("meta mismatch: %+v", AppsOpenAPIKeyList) + } + if len(AppsOpenAPIKeyList.Scopes) != 1 || AppsOpenAPIKeyList.Scopes[0] != "spark:app:read" { + t.Errorf("scopes = %v", AppsOpenAPIKeyList.Scopes) + } +} + +func TestOpenAPIKeyListExecute_Redacts(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + map[string]string{"app-id": "string", "limit": "int", "offset": "int"}, + map[string]string{"app-id": "app_x"}) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "infos": []interface{}{ + map[string]interface{}{ + "api_key_id": "k1", "name": "partner-test", + "api_key": "xxxxxxxxxxxx", "status": float64(1), + }, + }, + }, + }, + }) + if err := AppsOpenAPIKeyList.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + out := stdoutBuf.String() + if strings.Contains(out, "xxxxxxxxxxxx") { + t.Fatalf("list output leaked raw api key: %s", out) + } + if !strings.Contains(out, "****xxxx") { + t.Errorf("expected masked key_preview in output: %s", out) + } + _ = json.Valid +} diff --git a/shortcuts/apps/apps_openapi_key_reset.go b/shortcuts/apps/apps_openapi_key_reset.go new file mode 100644 index 00000000..7013d84e --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_reset.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsOpenAPIKeyReset rotates (refreshes) an open API key, returning a new raw secret ONCE. +var AppsOpenAPIKeyReset = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-reset", + Description: "Reset (rotate) an open API key; returns a new raw secret once", + Risk: "high-risk-write", + Tips: []string{ + "Example: lark-cli apps +openapi-key-reset --app-id --key-id --yes", + "Preview: add --dry-run to see the request without rotating", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "key-id", Desc: "API key ID", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { return oapiKeyValidateKeyID(rctx) }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI().POST(oapiKeyRefreshURL(rctx)).Desc("Reset (rotate) open API key") + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + data, err := rctx.CallAPITyped("POST", oapiKeyRefreshURL(rctx), nil, nil) + if err != nil { + return withAppsHint(err, oapiKeyNotFoundHint(rctx)) + } + return outputIssuedKey(rctx, data) + }, +} + +// oapiKeyRefreshURL builds the refresh path from --app-id / --key-id. +func oapiKeyRefreshURL(rctx *common.RuntimeContext) string { + return fmt.Sprintf(oapiKeyRefreshPath, + validate.EncodePathSegment(strings.TrimSpace(rctx.Str("app-id"))), + validate.EncodePathSegment(strings.TrimSpace(rctx.Str("key-id")))) +} diff --git a/shortcuts/apps/apps_openapi_key_reset_test.go b/shortcuts/apps/apps_openapi_key_reset_test.go new file mode 100644 index 00000000..045646b0 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_reset_test.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestOpenAPIKeyResetMeta_HighRisk(t *testing.T) { + if AppsOpenAPIKeyReset.Risk != "high-risk-write" { + t.Errorf("reset must be high-risk-write, got %q", AppsOpenAPIKeyReset.Risk) + } +} + +func TestOpenAPIKeyResetExecute_ReturnsNewRaw(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + map[string]string{"app-id": "string", "key-id": "string", "yes": "bool"}, + map[string]string{"app-id": "app_x", "key-id": "1", "yes": "true"}) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1/refresh", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "api_key": "xxxxxxxxxxxx", + "info": map[string]interface{}{"api_key_id": "k1", "name": "k", "api_key": "xxxxxxxxxxxx", "status": float64(1)}, + }, + }, + }) + if err := AppsOpenAPIKeyReset.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + out := stdoutBuf.String() + if !strings.Contains(out, "xxxxxxxxxxxx") { + t.Fatalf("reset must surface the new raw secret once: %s", out) + } + if strings.Count(out, "xxxxxxxxxxxx") != 1 { + t.Errorf("raw key must appear exactly once (top-level only, info must be redacted): %s", out) + } + if !strings.Contains(out, "****xxxx") { + t.Errorf("redacted info must carry key_preview: %s", out) + } +} diff --git a/shortcuts/apps/apps_openapi_key_status_test.go b/shortcuts/apps/apps_openapi_key_status_test.go new file mode 100644 index 00000000..0be15201 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_status_test.go @@ -0,0 +1,43 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestOpenAPIKeyEnableExecute_StatusOne(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + map[string]string{"app-id": "string", "key-id": "string"}, + map[string]string{"app-id": "app_x", "key-id": "1"}) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "info": map[string]interface{}{"api_key_id": "k1", "name": "k", "api_key": "xxxxxxxxxxxx", "status": float64(1)}, + }, + }, + }) + if err := AppsOpenAPIKeyEnable.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if strings.Contains(stdoutBuf.String(), "xxxxxxxxxxxx") { + t.Fatalf("enable leaked raw api_key") + } +} + +func TestOpenAPIKeyStatusBody(t *testing.T) { + if b := openAPIKeyStatusBody(1); b["status"] != 1 { + t.Errorf("enable body = %v", b) + } + if b := openAPIKeyStatusBody(0); b["status"] != 0 { + t.Errorf("disable body = %v", b) + } +} diff --git a/shortcuts/apps/apps_openapi_key_update.go b/shortcuts/apps/apps_openapi_key_update.go new file mode 100644 index 00000000..e6ea7f0d --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_update.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsOpenAPIKeyUpdate updates an open API key's name and/or config (not status). +var AppsOpenAPIKeyUpdate = common.Shortcut{ + Service: appsService, + Command: "+openapi-key-update", + Description: "Update an open API key's name and/or scope", + Risk: "write", + Tips: []string{ + "Example: lark-cli apps +openapi-key-update --app-id --key-id --name partner-prod", + }, + Scopes: []string{"spark:app:write"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID", Required: true}, + {Name: "key-id", Desc: "API key ID", Required: true}, + {Name: "name", Desc: "new name"}, + {Name: "scope-all", Type: "bool", Desc: "grant access to all /openapi/** routes (request_scope.allow_all)"}, + {Name: "scope-api", Type: "string_array", Desc: "grant one route, repeatable: 'METHOD /openapi/path' (from the app's docs/openapi.json)"}, + {Name: "scope", Desc: "advanced: raw JSON for config.request_scope (mutually exclusive with --scope-all/--scope-api)"}, + {Name: "allow-preview", Type: "bool", Desc: "allow preview-env access (config.is_allow_access_preview)"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if err := oapiKeyValidateKeyID(rctx); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("name")) == "" && + !rctx.Changed("scope-all") && + len(rctx.StrArray("scope-api")) == 0 && + strings.TrimSpace(rctx.Str("scope")) == "" && + !rctx.Changed("allow-preview") { + return appsValidationParamError("--name", "at least one of --name / --scope-all / --scope-api / --scope / --allow-preview is required"). + WithHint("pass at least one of --name / --scope-all / --scope-api / --scope / --allow-preview") + } + return oapiKeyValidateScopeFlags(rctx) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildOpenAPIKeyUpdateBody(rctx) + return common.NewDryRunAPI(). + PATCH(oapiKeyItemURL(rctx)). + Desc("Update open API key"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + body, err := buildOpenAPIKeyUpdateBody(rctx) + if err != nil { + return appsValidationParamError("--scope", "invalid scope: %v", err) + } + data, err := rctx.CallAPITyped("PATCH", oapiKeyItemURL(rctx), nil, body) + if err != nil { + return withAppsHint(err, oapiKeyNotFoundHint(rctx)) + } + return outputRedactedInfo(rctx, data) + }, +} + +// buildOpenAPIKeyUpdateBody builds {name?, config?} with only provided fields. +func buildOpenAPIKeyUpdateBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { + body := map[string]interface{}{} + if name := strings.TrimSpace(rctx.Str("name")); name != "" { + body["name"] = name + } + cfg, err := buildKeyConfig(rctx.Bool("scope-all"), rctx.StrArray("scope-api"), rctx.Str("scope"), rctx.Changed("allow-preview"), rctx.Bool("allow-preview")) + if err != nil { + return nil, err + } + if cfg != nil { + body["config"] = cfg + } + return body, nil +} diff --git a/shortcuts/apps/apps_openapi_key_update_test.go b/shortcuts/apps/apps_openapi_key_update_test.go new file mode 100644 index 00000000..4f6c6fb5 --- /dev/null +++ b/shortcuts/apps/apps_openapi_key_update_test.go @@ -0,0 +1,63 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "strings" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +// updateFlagDefs returns the flag type map for +openapi-key-update tests. +func updateFlagDefs() map[string]string { + return map[string]string{ + "app-id": "string", + "key-id": "string", + "name": "string", + "scope-all": "bool", + "scope-api": "string_array", + "scope": "string", + "allow-preview": "bool", + } +} + +func TestOpenAPIKeyUpdate_RequiresOneField(t *testing.T) { + rctx, _, _ := newOpenAPIKeyRCtx(t, + updateFlagDefs(), + map[string]string{"app-id": "app_x", "key-id": "1"}) + err := AppsOpenAPIKeyUpdate.Validate(context.Background(), rctx) + if err == nil { + t.Errorf("update with no changeable field must fail validation") + } + if err != nil && !strings.Contains(err.Error(), "at least one of --name / --scope-all / --scope-api / --scope / --allow-preview is required") { + t.Errorf("unexpected error message: %v", err) + } +} + +func TestOpenAPIKeyUpdateExecute_Redacts(t *testing.T) { + rctx, stdoutBuf, reg := newOpenAPIKeyRCtx(t, + updateFlagDefs(), + map[string]string{"app-id": "app_x", "key-id": "1", "name": "partner-prod"}) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/spark/v1/apps/app_x/oapi_apikeys/1", + Body: map[string]interface{}{ + "code": 0, "msg": "", + "data": map[string]interface{}{ + "info": map[string]interface{}{ + "api_key_id": "k1", "name": "partner-prod", + "api_key": "xxxxxxxxxxxx", "status": float64(1), + }, + }, + }, + }) + if err := AppsOpenAPIKeyUpdate.Execute(context.Background(), rctx); err != nil { + t.Fatalf("Execute() = %v", err) + } + if strings.Contains(stdoutBuf.String(), "xxxxxxxxxxxx") { + t.Fatalf("update leaked raw api key: %s", stdoutBuf.String()) + } +} diff --git a/shortcuts/apps/apps_output_schema.go b/shortcuts/apps/apps_output_schema.go new file mode 100644 index 00000000..19cebbd7 --- /dev/null +++ b/shortcuts/apps/apps_output_schema.go @@ -0,0 +1,351 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "fmt" + "io" + "math" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +type appsCellFormatter func(interface{}) string + +type appsOutputColumn struct { + Key string + Label string + Value func(map[string]interface{}) interface{} + Format appsCellFormatter +} + +type appsOutputSchema struct { + Columns []appsOutputColumn + Strict bool +} + +func appsProjectRows(rows []map[string]interface{}, schema appsOutputSchema) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(rows)) + for _, row := range rows { + out = append(out, appsProjectRow(row, schema)) + } + return out +} + +func appsProjectRow(row map[string]interface{}, schema appsOutputSchema) map[string]interface{} { + out := make(map[string]interface{}, len(schema.Columns)) + declared := make(map[string]struct{}, len(schema.Columns)) + for _, col := range schema.Columns { + if col.Key == "" { + continue + } + declared[col.Key] = struct{}{} + value := row[col.Key] + if col.Value != nil { + value = col.Value(row) + } + if value != nil { + out[col.Key] = value + } + } + if !schema.Strict { + for key, value := range row { + if _, ok := declared[key]; !ok { + out[key] = value + } + } + } + return out +} + +func appsPrintSchemaTable(w io.Writer, rows []map[string]interface{}, schema appsOutputSchema) { + if len(rows) == 0 { + fmt.Fprintln(w, "(no data)") + return + } + headers := make([]string, 0, len(schema.Columns)) + for _, col := range schema.Columns { + if col.Key == "" { + continue + } + headers = append(headers, appsColumnLabel(col)) + } + if len(headers) == 0 { + fmt.Fprintln(w, "(no data)") + return + } + matrix := make([][]string, 0, len(rows)+1) + matrix = append(matrix, headers) + for _, row := range rows { + line := make([]string, 0, len(schema.Columns)) + for _, col := range schema.Columns { + if col.Key == "" { + continue + } + value := row[col.Key] + if col.Value != nil { + value = col.Value(row) + } + line = append(line, appsFormatCell(value, col.Format)) + } + matrix = append(matrix, line) + } + widths := appsColumnWidths(matrix) + for i, row := range matrix { + cells := make([]string, len(row)) + for j, cell := range row { + cells[j] = appsPad(cell, widths[j]) + } + fmt.Fprintln(w, strings.TrimRight(strings.Join(cells, " "), " ")) + if i == 0 { + sep := make([]string, len(widths)) + for j, width := range widths { + sep[j] = strings.Repeat("─", width) + } + fmt.Fprintln(w, strings.Join(sep, " ")) + } + } +} + +func appsColumnLabel(col appsOutputColumn) string { + if col.Label != "" { + return col.Label + } + return col.Key +} + +func appsFormatCell(value interface{}, formatter appsCellFormatter) string { + if formatter != nil { + return formatter(value) + } + return appsDefaultCell(value) +} + +func appsDefaultCell(value interface{}) string { + if value == nil { + return "" + } + switch v := value.(type) { + case string: + return v + case json.Number: + return v.String() + case bool: + return strconv.FormatBool(v) + case int: + return strconv.Itoa(v) + case int8, int16, int32, int64: + return fmt.Sprintf("%d", v) + case uint, uint8, uint16, uint32, uint64: + return fmt.Sprintf("%d", v) + case float32: + return appsFormatFloat(float64(v)) + case float64: + return appsFormatFloat(v) + default: + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprint(v) + } + return string(b) + } +} + +func appsFormatFloat(value float64) string { + if math.Trunc(value) == value { + return strconv.FormatInt(int64(value), 10) + } + return strconv.FormatFloat(value, 'f', -1, 64) +} + +func appsColumnWidths(matrix [][]string) []int { + if len(matrix) == 0 { + return nil + } + widths := make([]int, len(matrix[0])) + for _, row := range matrix { + for i, cell := range row { + if width := utf8.RuneCountInString(cell); width > widths[i] { + widths[i] = width + } + } + } + return widths +} + +func appsPad(s string, width int) string { + delta := width - utf8.RuneCountInString(s) + if delta <= 0 { + return s + } + return s + strings.Repeat(" ", delta) +} + +func appsFormatNS(layout string) appsCellFormatter { + return func(value interface{}) string { + ns, ok := appsInt64Value(value) + if !ok || ns <= 0 { + return appsDefaultCell(value) + } + return time.Unix(0, ns).Local().Format(layout) + } +} + +func appsFormatSec(layout string) appsCellFormatter { + return func(value interface{}) string { + sec, ok := appsInt64Value(value) + if !ok || sec <= 0 { + return appsDefaultCell(value) + } + return time.Unix(sec, 0).Local().Format(layout) + } +} + +func appsFormatDurationMS(value interface{}) string { + ms, ok := appsFloat64Value(value) + if !ok || ms < 0 { + return appsDefaultCell(value) + } + switch { + case ms < 1: + return fmt.Sprintf("%.2fms", ms) + case ms < 1000: + return fmt.Sprintf("%.0fms", ms) + case ms < 60000: + return fmt.Sprintf("%.2fs", ms/1000) + case ms < 3600000: + return fmt.Sprintf("%.1fm", ms/60000) + default: + return fmt.Sprintf("%.1fh", ms/3600000) + } +} + +func appsInt64Value(value interface{}) (int64, bool) { + switch v := value.(type) { + case int: + return int64(v), true + case int8: + return int64(v), true + case int16: + return int64(v), true + case int32: + return int64(v), true + case int64: + return v, true + case uint: + return appsUint64ToInt64(uint64(v)) + case uint8: + return int64(v), true + case uint16: + return int64(v), true + case uint32: + return int64(v), true + case uint64: + return appsUint64ToInt64(v) + case float32: + f := float64(v) + if math.Trunc(f) == f && f <= float64(math.MaxInt64) && f >= float64(math.MinInt64) { + return int64(f), true + } + case float64: + if math.Trunc(v) == v && v <= float64(math.MaxInt64) && v >= float64(math.MinInt64) { + return int64(v), true + } + case json.Number: + if n, err := v.Int64(); err == nil { + return n, true + } + if f, err := v.Float64(); err == nil && math.Trunc(f) == f { + return int64(f), true + } + case string: + raw := strings.TrimSpace(v) + if n, err := strconv.ParseInt(raw, 10, 64); err == nil { + return n, true + } + if f, err := strconv.ParseFloat(raw, 64); err == nil && math.Trunc(f) == f { + return int64(f), true + } + } + return 0, false +} + +func appsFloat64Value(value interface{}) (float64, bool) { + switch v := value.(type) { + case int: + return float64(v), true + case int8: + return float64(v), true + case int16: + return float64(v), true + case int32: + return float64(v), true + case int64: + return float64(v), true + case uint: + return float64(v), true + case uint8: + return float64(v), true + case uint16: + return float64(v), true + case uint32: + return float64(v), true + case uint64: + return float64(v), true + case float32: + return float64(v), true + case float64: + return v, true + case json.Number: + f, err := v.Float64() + return f, err == nil + case string: + f, err := strconv.ParseFloat(strings.TrimSpace(v), 64) + return f, err == nil + default: + return 0, false + } +} + +func appsUint64ToInt64(value uint64) (int64, bool) { + if value > uint64(math.MaxInt64) { + return 0, false + } + return int64(value), true +} + +func appsAttrValue(key string) func(map[string]interface{}) interface{} { + return func(row map[string]interface{}) interface{} { + return appsAttributeValue(row["attributes"], key) + } +} + +func appsAttributeValue(raw interface{}, key string) interface{} { + switch attrs := raw.(type) { + case map[string]interface{}: + return attrs[key] + case []interface{}: + for _, rawItem := range attrs { + item, ok := rawItem.(map[string]interface{}) + if !ok { + continue + } + itemKey := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "key", "name"))) + if itemKey == key { + return firstObservabilityValue(item, "value") + } + } + case []map[string]interface{}: + for _, item := range attrs { + itemKey := strings.TrimSpace(fmt.Sprint(firstObservabilityValue(item, "key", "name"))) + if itemKey == key { + return firstObservabilityValue(item, "value") + } + } + } + return nil +} diff --git a/shortcuts/apps/apps_output_schema_test.go b/shortcuts/apps/apps_output_schema_test.go new file mode 100644 index 00000000..9a5ff744 --- /dev/null +++ b/shortcuts/apps/apps_output_schema_test.go @@ -0,0 +1,56 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" + "time" +) + +func TestAppsOutputSchemaProjectsAndFormats(t *testing.T) { + row := map[string]interface{}{ + "timestamp_ns": "1782209472123456789", + "level": "ERROR", + "extra": "ignored", + "attributes": map[string]interface{}{ + "module": "frontend", + "duration_ms": "1234.5", + }, + } + schema := appsOutputSchema{ + Columns: []appsOutputColumn{ + {Key: "timestamp_ns", Label: "time", Format: appsFormatNS("2006-01-02 15:04:05.000")}, + {Key: "module", Value: appsAttrValue("module")}, + {Key: "duration_ms", Value: appsAttrValue("duration_ms"), Format: appsFormatDurationMS}, + {Key: "level"}, + }, + Strict: true, + } + + projected := appsProjectRow(row, schema) + if len(projected) != 4 { + t.Fatalf("projected field count = %d, want 4: %#v", len(projected), projected) + } + if projected["module"] != "frontend" || projected["duration_ms"] != "1234.5" { + t.Fatalf("projected derived fields = %#v", projected) + } + if _, ok := projected["extra"]; ok { + t.Fatalf("strict projection should drop extra field: %#v", projected) + } + + var b strings.Builder + appsPrintSchemaTable(&b, []map[string]interface{}{projected}, schema) + out := b.String() + wantTime := time.Unix(0, 1782209472123456789).Local().Format("2006-01-02 15:04:05.000") + if !strings.HasPrefix(out, "time") { + t.Fatalf("pretty output should start with schema label time, got:\n%s", out) + } + if !strings.Contains(out, wantTime) { + t.Fatalf("pretty output missing formatted time %q:\n%s", wantTime, out) + } + if strings.Contains(out, "1782209472123456789") { + t.Fatalf("pretty output should not contain raw timestamp:\n%s", out) + } +} diff --git a/shortcuts/apps/apps_plugin_install.go b/shortcuts/apps/apps_plugin_install.go new file mode 100644 index 00000000..e0fda3ec --- /dev/null +++ b/shortcuts/apps/apps_plugin_install.go @@ -0,0 +1,420 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginInstall downloads a plugin package from the registry, extracts it +// to node_modules, and updates package.json actionPlugins. +// +// Without --name it batch-installs all plugins declared in actionPlugins that +// are not yet present in node_modules. +var AppsPluginInstall = common.Shortcut{ + Service: appsService, + Command: "+plugin-install", + Description: "Install a plugin package (download, extract, update package.json)", + Risk: "write", + ConditionalScopes: []string{"spark:app:read"}, + Scopes: []string{}, + AuthTypes: []string{"user"}, + Tips: []string{ + "Run in project root (like npm); does NOT take --app-id", + "Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate (install or update to latest)", + "Example: lark-cli apps +plugin-install --name @official-plugins/ai-text-generate --version 1.0.0 (install or update to specific version)", + "Example: lark-cli apps +plugin-install (batch install all declared plugins from package.json actionPlugins)", + }, + Flags: []common.Flag{ + {Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate); omit to install all declared plugins"}, + {Name: "version", Desc: "plugin version (e.g. 1.0.0); omit to install latest"}, + {Name: "file", Desc: "install from a local .tgz file (dev/test only)", Hidden: true}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + key := strings.TrimSpace(rctx.Str("name")) + if key == "" { + return common.NewDryRunAPI(). + POST(apiBasePath+"/plugin/versions/batch_query"). + Desc("Batch-install all declared plugins from package.json actionPlugins"). + Set("request_body", `{"plugin_keys": [], "latest_only": false}`) + } + version := strings.TrimSpace(rctx.Str("version")) + isLatest := version == "" || version == "latest" + desc := fmt.Sprintf("Query version for %s, then download .tgz", key) + if isLatest { + desc = fmt.Sprintf("Install latest version of %s (omit --version to install latest)", key) + } + return common.NewDryRunAPI(). + POST(apiBasePath+"/plugin/versions/batch_query"). + Desc(desc). + Set("request_body", fmt.Sprintf(`{"plugin_keys": ["%s"], "latest_only": %v}`, key, isLatest)). + Set("download_body", fmt.Sprintf(`{"plugin_key": "%s", "plugin_version": "%s"}`, key, version)) + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath("") + if err != nil { + return err + } + if key := strings.TrimSpace(rctx.Str("name")); key != "" { + if err := validatePluginKey(key); err != nil { + return err + } + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath("") + if err != nil { + return err + } + + if localTgz := strings.TrimSpace(rctx.Str("file")); localTgz != "" { + return pluginInstallLocal(rctx, projectPath, localTgz) + } + + key := strings.TrimSpace(rctx.Str("name")) + if key == "" { + return pluginInstallAll(ctx, rctx, projectPath) + } + version := strings.TrimSpace(rctx.Str("version")) + return pluginInstallOne(ctx, rctx, projectPath, key, version) + }, +} + +// pluginInstallOne installs a single plugin by key and optional version. +func pluginInstallOne(ctx context.Context, rctx *common.RuntimeContext, projectPath, key, version string) error { + if key == "" { + return appsValidationParamError("--name", "--name is required") + } + + // Check if already installed with same version (pre-API fast path) + if version != "" && version != "latest" { + if installed := pluginInstalledVersion(projectPath, key); installed == version { + pluginSyncActionPlugins(projectPath, key, version) + result := map[string]interface{}{ + "key": key, "version": version, "status": "already_installed", + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ %s@%s is already installed\n", key, version) + }) + return nil + } + } + + // Resolve version via API + resolvedVersion, err := pluginResolveVersion(ctx, rctx, key, version) + if err != nil { + return err + } + + // Post-API check: latest may resolve to the already-installed version + if installed := pluginInstalledVersion(projectPath, key); installed == resolvedVersion { + pluginSyncActionPlugins(projectPath, key, resolvedVersion) + result := map[string]interface{}{ + "key": key, "version": resolvedVersion, "status": "already_installed", + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ %s@%s is already up to date\n", key, resolvedVersion) + }) + return nil + } + + // Download tgz + tgzData, err := pluginDownloadPackage(ctx, rctx, key, resolvedVersion) + if err != nil { + return err + } + + // Extract to node_modules + destDir, err := secureModulePath(projectPath, key) + if err != nil { + return err + } + if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; clean before extract. + return appsFileIOError(err, "cannot clean %s", destDir) + } + if err := os.MkdirAll(destDir, 0o755); err != nil { //nolint:forbidigo + return appsFileIOError(err, "cannot create %s", destDir) + } + if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil { + return appsFileIOError(err, "cannot extract plugin package for %s", key) + } + + // Check peer dependencies + missingPeers := pluginCheckPeerDeps(projectPath, key) + + // Update package.json + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + pluginSetActionPlugin(pkg, key, resolvedVersion) + if err := pluginWritePackageJSON(projectPath, pkg); err != nil { + return appsFileIOError(err, "cannot update package.json") + } + + result := map[string]interface{}{ + "key": key, "version": resolvedVersion, "status": "installed", + } + if len(missingPeers) > 0 { + result["missing_peer_dependencies"] = missingPeers + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Installed %s@%s\n", key, resolvedVersion) + if len(missingPeers) > 0 { + fmt.Fprintf(w, "⚠ Missing peer dependencies: %s\n", strings.Join(missingPeers, ", ")) + fmt.Fprintln(w, " Run 'npm install' in the project directory to install them.") + } + }) + return nil +} + +// pluginInstallAll installs all plugins declared in actionPlugins that are +// missing from node_modules. +func pluginInstallAll(ctx context.Context, rctx *common.RuntimeContext, projectPath string) error { + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + declared := pluginGetActionPlugins(pkg) + if len(declared) == 0 { + rctx.OutFormat(map[string]interface{}{"installed": 0}, nil, func(w io.Writer) { + fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.") + }) + return nil + } + + var installed int + for key, version := range declared { + existing := pluginInstalledVersion(projectPath, key) + if existing != "" && existing == version { + continue + } + if err := pluginInstallOne(ctx, rctx, projectPath, key, version); err != nil { + return errs.NewInternalError(errs.SubtypeUnknown, "install %s failed", key).WithCause(err) + } + installed++ + } + + if installed == 0 { + rctx.OutFormat(map[string]interface{}{"installed": 0, "status": "all_up_to_date"}, nil, func(w io.Writer) { + fmt.Fprintln(w, "All declared plugins are already installed.") + }) + } + return nil +} + +// pluginInstallLocal installs a plugin from a local .tgz file, skipping API calls. +// Reads plugin key and version from the extracted package.json inside the tgz. +func pluginInstallLocal(rctx *common.RuntimeContext, projectPath, tgzPath string) error { + tgzData, err := os.ReadFile(tgzPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local tgz read. + if err != nil { + return appsValidationParamError("--file", "cannot read tgz file %s: %v", tgzPath, err).WithCause(err) + } + + // Extract to a temp dir first to read package.json + tmpDir, err := os.MkdirTemp(projectPath, ".plugin-tmp-*") //nolint:forbidigo // same FS as node_modules to avoid EXDEV on Rename + if err != nil { + return appsFileIOError(err, "cannot create temp dir") + } + defer os.RemoveAll(tmpDir) //nolint:forbidigo + + if err := pluginExtractTGZ(bytes.NewReader(tgzData), tmpDir); err != nil { + return appsFileIOError(err, "cannot extract tgz") + } + + // Read key and version from extracted package.json + pkgData, err := os.ReadFile(filepath.Join(tmpDir, "package.json")) //nolint:forbidigo + if err != nil { + return appsFileIOError(err, "tgz does not contain package.json") + } + var pkgMeta map[string]interface{} + if err := json.Unmarshal(pkgData, &pkgMeta); err != nil { + return appsFileIOError(err, "invalid package.json in tgz") + } + key, _ := pkgMeta["name"].(string) + version, _ := pkgMeta["version"].(string) + if key == "" { + return appsValidationParamError("--file", "package.json in tgz missing 'name' field") + } + if version == "" { + version = "0.0.0" + } + + // Move to node_modules + destDir, err := secureModulePath(projectPath, key) + if err != nil { + return err + } + if err := os.RemoveAll(destDir); err != nil { //nolint:forbidigo + return appsFileIOError(err, "cannot clean %s", destDir) + } + if err := os.MkdirAll(filepath.Dir(destDir), 0o755); err != nil { //nolint:forbidigo + return appsFileIOError(err, "cannot create parent dir for %s", destDir) + } + if err := os.Rename(tmpDir, destDir); err != nil { //nolint:forbidigo + // rename may fail across filesystems; fall back to re-extract + if err2 := os.MkdirAll(destDir, 0o755); err2 != nil { //nolint:forbidigo + return appsFileIOError(err2, "cannot create %s", destDir) + } + if err2 := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err2 != nil { + return appsFileIOError(err2, "cannot extract plugin to %s", destDir) + } + } + + // Update package.json actionPlugins + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + pluginSetActionPlugin(pkg, key, version) + if err := pluginWritePackageJSON(projectPath, pkg); err != nil { + return appsFileIOError(err, "cannot update package.json") + } + + result := map[string]interface{}{ + "key": key, "version": version, "status": "installed", "source": "local", + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Installed %s@%s (from local %s)\n", key, version, tgzPath) + }) + return nil +} + +// pluginResolveVersion calls the batch_query API to resolve version info. +func pluginResolveVersion(ctx context.Context, rctx *common.RuntimeContext, key, version string) (resolvedVersion string, err error) { + isLatest := version == "" || version == "latest" + body := map[string]interface{}{ + "plugin_keys": []interface{}{key}, + "latest_only": isLatest, + } + + data, err := rctx.CallAPITyped("POST", apiBasePath+"/plugin/versions/batch_query", nil, body) + if err != nil { + p, ok := errs.ProblemOf(err) + if ok && p.Subtype == errs.SubtypeInvalidResponse { + p.Message = fmt.Sprintf("plugin registry API is not available (returned non-JSON for %s)", key) + p.Hint = "the plugin registry endpoint may not be registered yet; check with the backend team" + return "", err + } + return "", withAppsHint(err, fmt.Sprintf("failed to fetch plugin version for %s; check plugin key spelling and network", key)) + } + + // Response: data.items is a flat list of plugin_version objects + match := pluginFindVersionInItems(data, key, version) + if match == nil { + hint := "check plugin key spelling" + if !isLatest { + hint = fmt.Sprintf("version %q not found for %s; omit --version to install latest", version, key) + } + return "", appsValidationError("no version found for plugin %q", key). + WithHint(hint) + } + // API returns "version" (not "plugin_version") + rv, _ := match["version"].(string) + if rv == "" { + return "", appsValidationError("incomplete version info for plugin %q", key). + WithHint("API returned version info without version field; contact plugin maintainer") + } + return rv, nil +} + +// pluginFindVersionInItems extracts data.items and finds a matching version. +func pluginFindVersionInItems(data map[string]interface{}, key, version string) map[string]interface{} { + raw, ok := data["items"] + if !ok { + return nil + } + arr, ok := raw.([]interface{}) + if !ok { + return nil + } + isLatest := version == "" || version == "latest" + for _, v := range arr { + item, ok := v.(map[string]interface{}) + if !ok { + continue + } + // API returns "key" (not "plugin_key") + pk, _ := item["key"].(string) + if pk != key { + continue + } + if isLatest { + return item + } + pv, _ := item["version"].(string) + if pv == version { + return item + } + } + return nil +} + +// pluginDownloadPackage downloads a plugin .tgz via the download_package API. +// The endpoint is POST with JSON body {plugin_key, plugin_version}. + +const pluginDownloadMaxBytes = 10 * 1024 * 1024 + +func pluginDownloadPackage(ctx context.Context, rctx *common.RuntimeContext, key, version string) ([]byte, error) { + apiPath := apiBasePath + "/plugin/versions/download_package" + body, _ := json.Marshal(map[string]string{ + "plugin_key": key, + "plugin_version": version, + }) + + resp, err := rctx.DoAPIStream(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: apiPath, + Body: bytes.NewReader(body), + }) + if err != nil { + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed for %s@%s: %v", key, version, err). + WithHint("check network connectivity and retry"). + WithRetryable(). + WithCause(err) + } + defer resp.Body.Close() + if resp.StatusCode >= 500 { + return nil, errs.NewNetworkError(errs.SubtypeNetworkServer, "download failed for %s@%s: HTTP %d", key, version, resp.StatusCode). + WithHint("plugin registry returned a server error; retry after a short wait"). + WithRetryable() + } + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + hint := "check plugin key and version spelling" + if resp.StatusCode == 403 { + hint = "download token may have expired; retry the install to get a fresh token" + } else if resp.StatusCode == 404 { + hint = fmt.Sprintf("package %s@%s not found in registry; check plugin key and version", key, version) + } + return nil, errs.NewAPIError(errs.SubtypeUnknown, "download failed for %s@%s: HTTP %d: %s", key, version, resp.StatusCode, string(respBody)). + WithHint(hint) + } + data, err := io.ReadAll(io.LimitReader(resp.Body, pluginDownloadMaxBytes+1)) + if err != nil { + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "download failed for %s@%s: %v", key, version, err). + WithHint("check network connectivity and retry"). + WithRetryable(). + WithCause(err) + } + if len(data) > pluginDownloadMaxBytes { + return nil, errs.NewAPIError(errs.SubtypeUnknown, "plugin package %s@%s exceeds %d MB size limit", key, version, pluginDownloadMaxBytes/(1024*1024)). + WithHint("contact plugin maintainer to reduce package size") + } + return data, nil +} diff --git a/shortcuts/apps/apps_plugin_install_test.go b/shortcuts/apps/apps_plugin_install_test.go new file mode 100644 index 00000000..9ce3cfa4 --- /dev/null +++ b/shortcuts/apps/apps_plugin_install_test.go @@ -0,0 +1,181 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestPluginInstall_SinglePlugin(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{}) + chdirTest(t, dir) + + factory, stdout, reg := newAppsExecuteFactory(t) + + // Mock batch_query API (new protocol: plugin_keys array, response data.items flat list) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/plugin/versions/batch_query", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "key": "@test/my-plugin", + "version": "1.0.0", + "download_approach": "inner", + "status": "active", + }, + }, + }, + }, + }) + + // Mock download API (POST with JSON body, returns binary tgz) + tgzData := buildTestTGZ(t, map[string]string{ + "manifest.json": `{"actions":[]}`, + "package.json": `{"name":"@test/my-plugin","version":"1.0.0"}`, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/plugin/versions/download_package", + RawBody: tgzData, + ContentType: "application/octet-stream", + }) + + err := runAppsShortcut(t, AppsPluginInstall, []string{ + "+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0", + "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify file extracted + manifestPath := filepath.Join(dir, "node_modules", "@test/my-plugin", "manifest.json") + if _, err := os.Stat(manifestPath); err != nil { + t.Fatalf("manifest.json not extracted: %v", err) + } + + // Verify package.json updated + pkg, _ := pluginReadPackageJSON(dir) + ap := pluginGetActionPlugins(pkg) + if v := ap["@test/my-plugin"]; v != "1.0.0" { + t.Errorf("actionPlugins[@test/my-plugin] = %q, want 1.0.0", v) + } + + // Verify output + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + if data["status"] != "installed" { + t.Errorf("status = %v, want installed", data["status"]) + } +} + +func TestPluginInstall_AlreadyInstalled(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + // Create an existing installed plugin with package.json containing version + pkgDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(pkgDir, 0o755) + os.WriteFile(filepath.Join(pkgDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginInstall, []string{ + "+plugin-install", "--name", "@test/my-plugin", "--version", "1.0.0", + "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + if data["status"] != "already_installed" { + t.Errorf("status = %v, want already_installed", data["status"]) + } +} + +// --- tgz helpers --- + +func TestPluginExtractTGZ(t *testing.T) { + tgzData := buildTestTGZ(t, map[string]string{ + "manifest.json": `{"actions":[]}`, + "README.md": "# Hello", + }) + + destDir := t.TempDir() + if err := pluginExtractTGZ(bytes.NewReader(tgzData), destDir); err != nil { + t.Fatalf("extract error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(destDir, "manifest.json")) + if err != nil { + t.Fatalf("manifest.json not extracted: %v", err) + } + if string(data) != `{"actions":[]}` { + t.Errorf("manifest.json content = %q", string(data)) + } +} + +func TestPluginExtractTGZ_PathTraversal(t *testing.T) { + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + tw.WriteHeader(&tar.Header{ + Name: "package/../../../etc/passwd", + Size: 5, + Mode: 0o644, + Typeflag: tar.TypeReg, + }) + tw.Write([]byte("evil!")) + tw.Close() + gz.Close() + + destDir := t.TempDir() + if err := pluginExtractTGZ(&buf, destDir); err != nil { + t.Fatalf("extract should not error, but skip bad entries: %v", err) + } + if _, err := os.Stat(filepath.Join(destDir, "..", "..", "etc", "passwd")); err == nil { + t.Error("path traversal should have been blocked") + } +} + +// buildTestTGZ creates a .tgz in memory with files under a "package/" prefix. +func buildTestTGZ(t *testing.T, files map[string]string) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + for name, content := range files { + tw.WriteHeader(&tar.Header{ + Name: "package/" + name, + Size: int64(len(content)), + Mode: 0o644, + Typeflag: tar.TypeReg, + }) + tw.Write([]byte(content)) + } + + tw.Close() + gz.Close() + return buf.Bytes() +} diff --git a/shortcuts/apps/apps_plugin_list.go b/shortcuts/apps/apps_plugin_list.go new file mode 100644 index 00000000..7f5f7e18 --- /dev/null +++ b/shortcuts/apps/apps_plugin_list.go @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginList lists plugin packages declared in package.json actionPlugins, +// cross-referencing with node_modules to report installation status. +var AppsPluginList = common.Shortcut{ + Service: appsService, + Command: "+plugin-list", + Description: "List locally installed plugin packages and their installation status", + Risk: "read", + Scopes: []string{}, + Tips: []string{ + "Run in project root (like npm); does NOT take --app-id", + "Example: lark-cli apps +plugin-list", + "Example: lark-cli apps +plugin-list --format pretty", + }, + Flags: []common.Flag{}, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + Desc("List declared plugin packages and installation status"). + Set("action", "list"). + Set("source", "package.json actionPlugins + node_modules") + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath("") + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + projectPath, err := pluginResolveProjectPath("") + if err != nil { + return err + } + + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + + declared := pluginGetActionPlugins(pkg) + plugins := make([]interface{}, 0, len(declared)) + for key, version := range declared { + installed := pluginInstalledVersion(projectPath, key) + status := "declared_not_installed" + if installed != "" { + status = "installed" + } + plugins = append(plugins, map[string]interface{}{ + "key": key, + "version": version, + "status": status, + }) + } + + data := map[string]interface{}{"plugins": plugins} + rctx.OutFormat(data, &output.Meta{Count: len(plugins)}, func(w io.Writer) { + if len(plugins) == 0 { + fmt.Fprintln(w, "No plugins declared in package.json actionPlugins.") + return + } + rows := make([]map[string]interface{}, 0, len(plugins)) + for _, p := range plugins { + rows = append(rows, p.(map[string]interface{})) + } + output.PrintTable(w, rows) + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_plugin_list_test.go b/shortcuts/apps/apps_plugin_list_test.go new file mode 100644 index 00000000..196e014d --- /dev/null +++ b/shortcuts/apps/apps_plugin_list_test.go @@ -0,0 +1,121 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestPluginList_Empty(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{}) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginList, []string{ + "+plugin-list", "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + plugins, _ := data["plugins"].([]interface{}) + if len(plugins) != 0 { + t.Errorf("expected 0 plugins, got %d", len(plugins)) + } +} + +func TestPluginList_Installed(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + manifestDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(manifestDir, 0o755) + os.WriteFile(filepath.Join(manifestDir, "package.json"), []byte(`{"version":"1.0.0"}`), 0o644) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginList, []string{ + "+plugin-list", "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + plugins, _ := data["plugins"].([]interface{}) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + p := plugins[0].(map[string]interface{}) + if p["status"] != "installed" { + t.Errorf("status = %v, want installed", p["status"]) + } +} + +func TestPluginList_DeclaredNotInstalled(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/missing": "1.0.0", + }, + }) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginList, []string{ + "+plugin-list", "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + plugins, _ := data["plugins"].([]interface{}) + if len(plugins) != 1 { + t.Fatalf("expected 1 plugin, got %d", len(plugins)) + } + p := plugins[0].(map[string]interface{}) + if p["status"] != "declared_not_installed" { + t.Errorf("status = %v, want declared_not_installed", p["status"]) + } +} + +// --- helpers --- + +func chdirTest(t *testing.T, dir string) { + t.Helper() + prev, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { os.Chdir(prev) }) //nolint:errcheck +} + +func writeTestPkgJSON(t *testing.T, dir string, pkg map[string]interface{}) { + t.Helper() + data, err := json.Marshal(pkg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "package.json"), data, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/shortcuts/apps/apps_plugin_uninstall.go b/shortcuts/apps/apps_plugin_uninstall.go new file mode 100644 index 00000000..b71d3b3d --- /dev/null +++ b/shortcuts/apps/apps_plugin_uninstall.go @@ -0,0 +1,90 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "os" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +// AppsPluginUninstall removes a plugin package from node_modules and its +// entry from package.json actionPlugins. +var AppsPluginUninstall = common.Shortcut{ + Service: appsService, + Command: "+plugin-uninstall", + Description: "Uninstall a plugin package (remove from node_modules and package.json)", + Risk: "write", + Scopes: []string{}, + Tips: []string{ + "Run in project root (like npm); does NOT take --app-id", + "Example: lark-cli apps +plugin-uninstall --name @official-plugins/ai-text-generate", + }, + Flags: []common.Flag{ + {Name: "name", Desc: "plugin key (e.g. @official-plugins/ai-text-generate)", Required: true}, + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + key := strings.TrimSpace(rctx.Str("name")) + return common.NewDryRunAPI(). + Desc("Uninstall plugin package (remove from node_modules and package.json)"). + Set("action", "uninstall"). + Set("plugin_key", key). + Set("remove_dir", fmt.Sprintf("node_modules/%s", key)). + Set("update_file", "package.json actionPlugins") + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if key := strings.TrimSpace(rctx.Str("name")); key == "" { + return appsValidationParamError("--name", "--name is required") + } else if err := validatePluginKey(key); err != nil { + return err + } + projectPath, err := pluginResolveProjectPath("") + if err != nil { + return err + } + return pluginCheckProjectDir(projectPath) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + key := strings.TrimSpace(rctx.Str("name")) + projectPath, err := pluginResolveProjectPath("") + if err != nil { + return err + } + + // Block uninstall if any instances still reference this plugin package. + if err := pluginCheckDependentInstances(projectPath, key); err != nil { + return err + } + + pkgDir, err := secureModulePath(projectPath, key) + if err != nil { + return err + } + if err := os.RemoveAll(pkgDir); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; remove plugin directory. + return appsFileIOError(err, "cannot remove %s", pkgDir) + } + + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return err + } + pluginRemoveActionPlugin(pkg, key) + if err := pluginWritePackageJSON(projectPath, pkg); err != nil { + return appsFileIOError(err, "cannot update package.json") + } + + result := map[string]interface{}{ + "key": key, + "removed": true, + } + rctx.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "✓ Plugin uninstalled: %s\n", key) + }) + return nil + }, +} diff --git a/shortcuts/apps/apps_plugin_uninstall_test.go b/shortcuts/apps/apps_plugin_uninstall_test.go new file mode 100644 index 00000000..d59072db --- /dev/null +++ b/shortcuts/apps/apps_plugin_uninstall_test.go @@ -0,0 +1,187 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/errs" +) + +func TestPluginUninstall_Basic(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(pluginDir, 0o755) + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/my-plugin", + "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Verify node_modules removed + if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { + t.Error("node_modules plugin dir should be removed") + } + + // Verify package.json updated + pkg, _ := pluginReadPackageJSON(dir) + ap := pluginGetActionPlugins(pkg) + if _, ok := ap["@test/my-plugin"]; ok { + t.Error("actionPlugins should no longer contain @test/my-plugin") + } +} + +func TestPluginUninstall_NotInstalled(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{}) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/not-here", + "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("uninstalling non-existent plugin should succeed: %v", err) + } + + var env map[string]interface{} + json.Unmarshal(stdout.Bytes(), &env) + data, _ := env["data"].(map[string]interface{}) + if data["removed"] != true { + t.Errorf("removed = %v, want true", data["removed"]) + } +} + +func TestPluginUninstall_BlockedByDependentInstance(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + // Install plugin + pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(pluginDir, 0o755) + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) + + // Create a capability that references this plugin + capDir := filepath.Join(dir, "server", "capabilities") + os.MkdirAll(capDir, 0o755) + writeTestCapJSON(t, capDir, "my-instance.json", map[string]interface{}{ + "id": "my-instance", + "pluginKey": "@test/my-plugin", + "name": "My Instance", + }) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/my-plugin", + "--format", "json", "--as", "user", + }, factory, stdout) + if err == nil { + t.Fatal("expected error when uninstalling a plugin with dependent instances, got nil") + } + + // Verify plugin directory still exists (blocked) + if _, err := os.Stat(pluginDir); err != nil { + t.Errorf("plugin directory should still exist after blocked uninstall: %v", err) + } + + // Verify error mentions the dependent instance + prob, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected a typed error, got %v", err) + } + if prob.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %s, want %s", prob.Subtype, errs.SubtypeFailedPrecondition) + } + if prob.Hint == "" { + t.Error("hint should be non-empty") + } +} + +func TestPluginUninstall_WithUnrelatedInstances(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "actionPlugins": map[string]interface{}{ + "@test/my-plugin": "1.0.0", + }, + }) + pluginDir := filepath.Join(dir, "node_modules", "@test/my-plugin") + os.MkdirAll(pluginDir, 0o755) + os.WriteFile(filepath.Join(pluginDir, "manifest.json"), []byte("{}"), 0o644) + + // Create a capability that references a DIFFERENT plugin — should not block + capDir := filepath.Join(dir, "server", "capabilities") + os.MkdirAll(capDir, 0o755) + writeTestCapJSON(t, capDir, "other-instance.json", map[string]interface{}{ + "id": "other-instance", + "pluginKey": "@test/other-plugin", + "name": "Other Instance", + }) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/my-plugin", + "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("uninstall should succeed when instances reference different plugins: %v", err) + } + + // Verify plugin was removed + if _, err := os.Stat(pluginDir); !os.IsNotExist(err) { + t.Error("plugin directory should be removed") + } +} + +func TestPluginUninstall_PreservesOtherPlugins(t *testing.T) { + dir := t.TempDir() + writeTestPkgJSON(t, dir, map[string]interface{}{ + "name": "my-app", + "actionPlugins": map[string]interface{}{ + "@test/remove-me": "1.0.0", + "@test/keep-me": "2.0.0", + }, + }) + chdirTest(t, dir) + + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsPluginUninstall, []string{ + "+plugin-uninstall", "--name", "@test/remove-me", + "--format", "json", "--as", "user", + }, factory, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pkg, _ := pluginReadPackageJSON(dir) + ap := pluginGetActionPlugins(pkg) + if _, ok := ap["@test/remove-me"]; ok { + t.Error("@test/remove-me should be removed from actionPlugins") + } + if v, ok := ap["@test/keep-me"]; !ok || v != "2.0.0" { + t.Errorf("@test/keep-me should be preserved, got %q", v) + } + if name, _ := pkg["name"].(string); name != "my-app" { + t.Errorf("other fields should be preserved, name = %q", name) + } +} diff --git a/shortcuts/apps/apps_security_fixes_test.go b/shortcuts/apps/apps_security_fixes_test.go new file mode 100644 index 00000000..b08f5308 --- /dev/null +++ b/shortcuts/apps/apps_security_fixes_test.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "strings" + "testing" +) + +// TestRejectOutputTraversal pins HIGH-3: --output rejects absolute paths and +// any .. traversal component; empty and ordinary relative paths pass. +func TestRejectOutputTraversal(t *testing.T) { + ok := []string{"", "out.csv", "./out.csv", "sub/dir/out.csv", "a..b.csv", "foo..bar/x.csv"} + for _, p := range ok { + if err := rejectOutputTraversal(p); err != nil { + t.Errorf("rejectOutputTraversal(%q) = %v, want nil", p, err) + } + } + bad := []string{"/etc/passwd", "../x", "../../etc/cron.d/evil", "sub/../../x", "./../x"} + for _, p := range bad { + if err := rejectOutputTraversal(p); err == nil { + t.Errorf("rejectOutputTraversal(%q) = nil, want validation error", p) + } + } +} + +// TestSanitizeUploadFileName pins HIGH-4 / LOW-1: control & separator chars are +// neutralized (percent-encoded, no raw CR/LF/TAB/NUL/quote) and the result never +// starts with a dot (hidden-file overwrite guard). +func TestSanitizeUploadFileName(t *testing.T) { + // LOW-1: dotfiles get a leading underscore. + for _, in := range []string{".bashrc", ".ssh", "..hidden"} { + got := sanitizeUploadFileName(in) + if strings.HasPrefix(got, ".") { + t.Errorf("sanitizeUploadFileName(%q) = %q, must not start with '.'", in, got) + } + } + // HIGH-4: header-breaking / control chars must not survive raw. + raw := "a\r\nb\tc\x00d\"e.png" + got := sanitizeUploadFileName(raw) + for _, bad := range []string{"\r", "\n", "\t", "\x00", "\"", " "} { + if strings.Contains(got, bad) { + t.Errorf("sanitizeUploadFileName(%q) = %q, still contains raw %q", raw, got, bad) + } + } + if got == "" { + t.Error("sanitizeUploadFileName returned empty for non-empty input") + } +} diff --git a/shortcuts/apps/apps_traces.go b/shortcuts/apps/apps_traces.go new file mode 100644 index 00000000..e22c9507 --- /dev/null +++ b/shortcuts/apps/apps_traces.go @@ -0,0 +1,664 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "context" + "fmt" + "io" + "strconv" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + defaultAppsTraceEnv = "online" + traceSearchEndpoint = "search_traces" + traceGetEndpoint = "trace" +) + +// AppsTraceList searches online app traces with observability filters. +var AppsTraceList = common.Shortcut{ + Service: appsService, + Command: "+trace-list", + Description: "Search online app traces with observability filters", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +trace-list --app-id --trace-id ", + "Tip: use --page-token from the response to fetch the next page.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online traces should be searched", Required: true}, + {Name: appsEnvironmentFlag, Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, + {Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"}, + {Name: "root-span", Desc: "root span keyword filter applied by the trace search backend"}, + {Name: "user-id", Desc: "end user ID filter"}, + {Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, + {Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"}, + {Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", defaultAppsPageSize), Desc: "page size, 1..100"}, + {Name: "page-token", Desc: "pagination cursor from a previous trace search response"}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + _, err := buildTraceSearchBody(rctx) + return err + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + body, _ := buildTraceSearchBody(rctx) + return common.NewDryRunAPI(). + POST(traceSearchPath(rctx.Str("app-id"))). + Desc("Search online app traces"). + Body(body) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + body, err := buildTraceSearchBody(rctx) + if err != nil { + return err + } + data, err := rctx.CallAPITyped("POST", traceSearchPath(appID), nil, body) + if err != nil { + return withAppsHint(err, appIDListHint) + } + out := normalizeTraceSearchResponse(data) + rctx.OutFormat(out, nil, func(w io.Writer) { + appsPrintSchemaTable(w, appsProjectRows(traceListRows(out.Items), traceSummarySchema), traceSummarySchema) + }) + return nil + }, +} + +// AppsTraceGet fetches one online app trace by trace ID. +var AppsTraceGet = common.Shortcut{ + Service: appsService, + Command: "+trace-get", + Description: "Get one online app trace by trace ID", + Risk: "read", + Tips: []string{ + "Example: lark-cli apps +trace-get --app-id --trace-id ", + "Tip: use +trace-list first if the trace ID is unknown.", + }, + Scopes: []string{"spark:app:read"}, + AuthTypes: []string{"user"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "app-id", Desc: "app ID whose online trace should be fetched", Required: true}, + {Name: appsEnvironmentFlag, Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"}, + {Name: "trace-id", Desc: "trace ID to fetch", Required: true}, + }, + Validate: func(ctx context.Context, rctx *common.RuntimeContext) error { + if _, err := requireAppID(rctx.Str("app-id")); err != nil { + return err + } + if strings.TrimSpace(rctx.Str("trace-id")) == "" { + return appsValidationParamError("--trace-id", "--trace-id is required") + } + return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag)) + }, + DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + POST(traceGetPath(rctx.Str("app-id"))). + Desc("Get online app trace by trace ID"). + Body(buildTraceGetBody(rctx)) + }, + Execute: func(ctx context.Context, rctx *common.RuntimeContext) error { + appID, _ := requireAppID(rctx.Str("app-id")) + data, err := rctx.CallAPITyped("POST", traceGetPath(appID), nil, buildTraceGetBody(rctx)) + if err != nil { + return withAppsHint(err, appIDListHint) + } + trace := normalizeTraceDetail(data) + rctx.OutFormat(trace, nil, func(w io.Writer) { + appsPrintSchemaTable(w, appsProjectRows([]map[string]interface{}{traceDetailSummary(trace)}, traceSummarySchema), traceSummarySchema) + }) + return nil + }, +} + +type traceSearchOutput struct { + Items []map[string]interface{} `json:"items"` + PageToken string `json:"page_token,omitempty"` + HasMore bool `json:"has_more"` +} + +func traceSearchPath(appID string) string { + return appScopedPath(appID, traceSearchEndpoint) +} + +func traceGetPath(appID string) string { + return appScopedPath(appID, traceGetEndpoint) +} + +func buildTraceSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, error) { + env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag)) + if env == "" { + env = defaultAppsTraceEnv + } + if err := validateObservabilityEnv(env); err != nil { + return nil, err + } + if err := validateAppsPageSize(rctx.Int("page-size")); err != nil { + return nil, err + } + body := map[string]interface{}{ + "app_env": appsObservabilityBackendEnv, + "limit": rctx.Int("page-size"), + } + if token := strings.TrimSpace(rctx.Str("page-token")); token != "" { + body["page_token"] = token + } + if err := addTraceSearchTimeRange(body, rctx); err != nil { + return nil, err + } + if filter := buildTraceSearchFilter(rctx); len(filter) > 0 { + body["filter"] = filter + } + return body, nil +} + +func buildTraceGetBody(rctx *common.RuntimeContext) map[string]interface{} { + return map[string]interface{}{ + "app_env": appsObservabilityBackendEnv, + "trace_id": strings.TrimSpace(rctx.Str("trace-id")), + } +} + +func addTraceSearchTimeRange(body map[string]interface{}, rctx *common.RuntimeContext) error { + since, until, hasSince, hasUntil, err := parseAppsTimeRange("--since", rctx.Str("since"), "--until", rctx.Str("until")) + if err != nil { + return err + } + if hasSince { + body["start_timestamp_ns"] = nsNumber(since) + } + if hasUntil { + body["end_timestamp_ns"] = nsNumber(until) + } + return nil +} + +func buildTraceSearchFilter(rctx *common.RuntimeContext) map[string]interface{} { + filter := make(map[string]interface{}) + if traceIDs := cleanRepeatedStrings(rctx.StrArray("trace-id")); len(traceIDs) > 0 { + filter["trace_ids"] = traceIDs + } + addTrimmedTraceFilterString(filter, "keyword", rctx.Str("root-span")) + addTrimmedTraceFilterStrings(filter, "user_ids", rctx.Str("user-id")) + return filter +} + +func addTrimmedTraceFilterString(filter map[string]interface{}, key, value string) { + if value = strings.TrimSpace(value); value != "" { + filter[key] = value + } +} + +func addTrimmedTraceFilterStrings(filter map[string]interface{}, key, value string) { + if value = strings.TrimSpace(value); value != "" { + filter[key] = []string{value} + } +} + +func normalizeTraceSearchResponse(data map[string]interface{}) traceSearchOutput { + items, sourceKey := firstTraceMapSliceWithKey(data, "items", "trace_items", "traceItems", "spans", "span_items", "spanItems") + normalized := normalizeTraceSummaries(items) + if isTraceSpanItemsKey(sourceKey) { + normalized = aggregateTraceSpanSummaries(items) + } + return traceSearchOutput{ + Items: normalized, + PageToken: firstLogString(data, "page_token", "next_page_token", "pageToken", "nextPageToken"), + HasMore: firstLogBool(data, "has_more", "hasMore"), + } +} + +func firstTraceMapSliceWithKey(data map[string]interface{}, keys ...string) ([]map[string]interface{}, string) { + for _, key := range keys { + raw, ok := data[key] + if !ok { + continue + } + return traceMapSlice(raw), key + } + return nil, "" +} + +func traceMapSlice(raw interface{}) []map[string]interface{} { + switch items := raw.(type) { + case []map[string]interface{}: + return items + case []interface{}: + out := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + if m, ok := item.(map[string]interface{}); ok { + out = append(out, m) + } + } + return out + default: + return nil + } +} + +func isTraceSpanItemsKey(key string) bool { + switch key { + case "spans", "span_items", "spanItems": + return true + default: + return false + } +} + +func normalizeTraceSummaries(items []map[string]interface{}) []map[string]interface{} { + if len(items) == 0 { + return nil + } + if hasRepeatedTraceID(items) { + return aggregateTraceSpanSummaries(items) + } + out := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + out = append(out, normalizeTraceSummary(item)) + } + return out +} + +func hasRepeatedTraceID(items []map[string]interface{}) bool { + seen := make(map[string]struct{}, len(items)) + for _, item := range items { + traceID := firstTraceString(item, "trace_id", "traceID", "traceId") + if traceID == "" { + continue + } + if _, ok := seen[traceID]; ok { + return true + } + seen[traceID] = struct{}{} + } + return false +} + +func normalizeTraceSummary(item map[string]interface{}) map[string]interface{} { + out := cloneMap(item) + copyFirstAlias(out, item, "trace_id", "trace_id", "traceID", "traceId") + copyFirstAlias(out, item, "start_time_ns", "start_time_ns", "startTimeNs") + copyFirstAlias(out, item, "root_span", "root_span", "rootSpan") + copyFirstAlias(out, item, "user_id", "user_id", "userID", "userId") + copyFirstAlias(out, item, "duration_ms", "duration_ms", "durationMs") + copyFirstAlias(out, item, "status", "status") + copyFirstAlias(out, item, "span_count", "span_count", "spanCount") + return out +} + +func aggregateTraceSpanSummaries(spans []map[string]interface{}) []map[string]interface{} { + groups := make([]traceSpanGroup, 0, len(spans)) + indexByTraceID := make(map[string]int, len(spans)) + ungrouped := make([]map[string]interface{}, 0) + for _, span := range spans { + span = normalizeTraceSpan(span) + traceID := firstTraceString(span, "trace_id", "traceID", "traceId") + if traceID == "" { + ungrouped = append(ungrouped, normalizeTraceSummary(span)) + continue + } + idx, ok := indexByTraceID[traceID] + if !ok { + indexByTraceID[traceID] = len(groups) + groups = append(groups, traceSpanGroup{traceID: traceID, spans: []map[string]interface{}{span}}) + continue + } + groups[idx].spans = append(groups[idx].spans, span) + } + out := make([]map[string]interface{}, 0, len(groups)+len(ungrouped)) + for _, group := range groups { + out = append(out, buildTraceSpanSummary(group.traceID, group.spans)) + } + out = append(out, ungrouped...) + return out +} + +type traceSpanGroup struct { + traceID string + spans []map[string]interface{} +} + +func buildTraceSpanSummary(traceID string, spans []map[string]interface{}) map[string]interface{} { + root := selectTraceRootCandidate(spans) + summary := normalizeTraceSummary(root) + summary["trace_id"] = traceID + summary["span_count"] = len(spans) + if firstItemString(summary, "root_span") == "" { + if rootName := firstItemString(root, "name", "span_name", "spanName"); rootName != "" { + summary["root_span"] = rootName + } else if fallbackName := firstTraceSpanName(spans); fallbackName != "" { + summary["root_span"] = fallbackName + } + } + if firstItemString(summary, "user_id") == "" { + if userID := firstStringInTraceSpans(spans, "user_id", "userID", "userId"); userID != "" { + summary["user_id"] = userID + } + } + if startValue, ok := earliestTraceSpanValue(spans, "start_time_ns", "startTimeNs"); ok { + summary["start_time_ns"] = startValue + } + if durationValue, ok := maxTraceSpanValue(spans, "duration_ms", "durationMs"); ok { + summary["duration_ms"] = durationValue + } + if status := aggregateTraceSpanStatus(spans); status != "" { + summary["status"] = status + } + return summary +} + +func selectTraceRootCandidate(spans []map[string]interface{}) map[string]interface{} { + for _, span := range spans { + if firstItemString(span, "root_span", "rootSpan") != "" { + return span + } + } + for _, span := range spans { + if isTraceRootParentCandidate(span) { + return span + } + } + for _, span := range spans { + if firstItemString(span, "name", "span_name", "spanName") != "" { + return span + } + } + if len(spans) == 0 { + return map[string]interface{}{} + } + return spans[0] +} + +func isTraceRootParentCandidate(span map[string]interface{}) bool { + parent, ok := firstTraceValue(span, "parent_span_id", "parentSpanID", "parentSpanId") + if !ok || parent == nil { + return true + } + parentID, ok := parent.(string) + return ok && strings.TrimSpace(parentID) == "" +} + +func firstTraceSpanName(spans []map[string]interface{}) string { + return firstStringInTraceSpans(spans, "name", "span_name", "spanName") +} + +func firstStringInTraceSpans(spans []map[string]interface{}, keys ...string) string { + for _, span := range spans { + if value := firstItemString(span, keys...); value != "" { + return value + } + } + return "" +} + +func earliestTraceSpanValue(spans []map[string]interface{}, keys ...string) (interface{}, bool) { + var bestValue interface{} + var bestNumber traceNumber + var found bool + for _, span := range spans { + value, number, ok := firstTraceNumericValue(span, keys...) + if !ok { + continue + } + if !found || number.less(bestNumber) { + bestValue = value + bestNumber = number + found = true + } + } + return bestValue, found +} + +func maxTraceSpanValue(spans []map[string]interface{}, keys ...string) (interface{}, bool) { + var bestValue interface{} + var bestNumber traceNumber + var found bool + for _, span := range spans { + value, number, ok := firstTraceNumericValue(span, keys...) + if !ok { + continue + } + if !found || number.greater(bestNumber) { + bestValue = value + bestNumber = number + found = true + } + } + return bestValue, found +} + +func firstTraceNumericValue(span map[string]interface{}, keys ...string) (interface{}, traceNumber, bool) { + value, ok := firstTraceValue(span, keys...) + if !ok { + return nil, traceNumber{}, false + } + number, ok := parseTraceNumber(value) + return value, number, ok +} + +type traceNumber struct { + floatValue float64 + intValue int64 + exactInt bool +} + +func (n traceNumber) less(other traceNumber) bool { + if n.exactInt && other.exactInt { + return n.intValue < other.intValue + } + return n.floatValue < other.floatValue +} + +func (n traceNumber) greater(other traceNumber) bool { + if n.exactInt && other.exactInt { + return n.intValue > other.intValue + } + return n.floatValue > other.floatValue +} + +func parseTraceNumber(value interface{}) (traceNumber, bool) { + switch v := value.(type) { + case int: + return exactTraceInt(int64(v)), true + case int8: + return exactTraceInt(int64(v)), true + case int16: + return exactTraceInt(int64(v)), true + case int32: + return exactTraceInt(int64(v)), true + case int64: + return exactTraceInt(v), true + case uint: + return traceUintNumber(uint64(v)) + case uint8: + return traceUintNumber(uint64(v)) + case uint16: + return traceUintNumber(uint64(v)) + case uint32: + return traceUintNumber(uint64(v)) + case uint64: + return traceUintNumber(v) + case float32: + return traceFloatNumber(float64(v)), true + case float64: + return traceFloatNumber(v), true + case string: + raw := strings.TrimSpace(v) + if number, err := strconv.ParseInt(raw, 10, 64); err == nil { + return exactTraceInt(number), true + } + number, err := strconv.ParseFloat(raw, 64) + return traceFloatNumber(number), err == nil + default: + return traceNumber{}, false + } +} + +func exactTraceInt(value int64) traceNumber { + return traceNumber{floatValue: float64(value), intValue: value, exactInt: true} +} + +func traceFloatNumber(value float64) traceNumber { + return traceNumber{floatValue: value} +} + +func traceUintNumber(value uint64) (traceNumber, bool) { + const maxInt64AsUint = uint64(1<<63 - 1) + if value <= maxInt64AsUint { + return exactTraceInt(int64(value)), true + } + return traceFloatNumber(float64(value)), true +} + +func aggregateTraceSpanStatus(spans []map[string]interface{}) string { + firstStatus := "" + for _, span := range spans { + status := firstItemString(span, "status") + if status == "" { + continue + } + if strings.EqualFold(status, "ERROR") { + return "ERROR" + } + if firstStatus == "" { + firstStatus = status + } + } + return firstStatus +} + +func normalizeTraceDetail(data map[string]interface{}) map[string]interface{} { + trace := firstTraceMap(data, "trace", "trace_detail", "traceDetail") + if trace == nil { + trace = data + } + out := normalizeTraceObject(trace) + if spans := firstMapSlice(trace, "spans", "span_items", "spanItems"); len(spans) > 0 { + normalized := make([]map[string]interface{}, 0, len(spans)) + for _, span := range spans { + normalized = append(normalized, normalizeTraceSpan(span)) + } + out["spans"] = normalized + if firstTraceString(out, "trace_id") == "" { + if traceID := firstTraceString(normalized[0], "trace_id"); traceID != "" { + out["trace_id"] = traceID + } + } + } + return out +} + +func normalizeTraceObject(trace map[string]interface{}) map[string]interface{} { + out := cloneMap(trace) + normalizeObservabilityAttributes(out) + copyFirstAlias(out, trace, "trace_id", "trace_id", "traceID", "traceId") + copyFirstAlias(out, trace, "is_break", "is_break", "isBreak") + return out +} + +func normalizeTraceSpan(span map[string]interface{}) map[string]interface{} { + out := cloneMap(span) + normalizeObservabilityAttributes(out) + copyFirstAlias(out, span, "trace_id", "trace_id", "traceID", "traceId") + copyFirstAlias(out, span, "span_id", "span_id", "spanID", "spanId") + copyFirstAlias(out, span, "parent_span_id", "parent_span_id", "parentSpanID", "parentSpanId") + copyFirstAlias(out, span, "start_time_ns", "start_time_ns", "startTimeNs", "start_time_unix_nano", "startTimeUnixNano") + copyFirstAlias(out, span, "end_time_ns", "end_time_ns", "endTimeNs", "end_time_unix_nano", "endTimeUnixNano") + copyFirstAlias(out, span, "duration_ms", "duration_ms", "durationMs") + copyFirstAlias(out, span, "is_break", "is_break", "isBreak") + for _, key := range []string{"duration_ms", "user_id", "status", "module"} { + if _, ok := out[key]; !ok { + if value := appsAttributeValue(span["attributes"], key); value != nil { + out[key] = value + } + } + } + return out +} + +func traceListRows(items []map[string]interface{}) []map[string]interface{} { + rows := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + rows = append(rows, traceSummaryRow(item)) + } + return rows +} + +var traceSummarySchema = appsOutputSchema{ + Columns: []appsOutputColumn{ + {Key: "start_time_ns", Label: "start-time", Format: appsFormatNS("2006-01-02 15:04:05.000")}, + {Key: "root_span", Label: "root-span"}, + {Key: "user_id", Label: "user-id"}, + {Key: "duration_ms", Label: "duration", Format: appsFormatDurationMS}, + {Key: "trace_id", Label: "trace-id"}, + }, + Strict: true, +} + +func traceDetailSummary(trace map[string]interface{}) map[string]interface{} { + if spans := traceMapSlice(trace["spans"]); len(spans) > 0 { + summaries := aggregateTraceSpanSummaries(spans) + if len(summaries) > 0 { + summary := summaries[0] + for _, key := range []string{"trace_id", "is_break"} { + if value, ok := trace[key]; ok { + summary[key] = value + } + } + return summary + } + } + return traceSummaryRow(trace) +} + +func traceSummaryRow(item map[string]interface{}) map[string]interface{} { + return map[string]interface{}{ + "trace_id": item["trace_id"], + "start_time_ns": item["start_time_ns"], + "root_span": firstItemString(item, "root_span", "name", "span_name"), + "user_id": item["user_id"], + "duration_ms": item["duration_ms"], + "status": item["status"], + "span_count": item["span_count"], + } +} + +func firstTraceMap(data map[string]interface{}, keys ...string) map[string]interface{} { + for _, key := range keys { + if value, ok := data[key].(map[string]interface{}); ok { + return value + } + } + return nil +} + +func firstTraceString(data map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value, ok := firstTraceValue(data, key); ok { + if s, ok := value.(string); ok && strings.TrimSpace(s) != "" { + return s + } + } + } + return "" +} + +func firstTraceValue(data map[string]interface{}, keys ...string) (interface{}, bool) { + for _, key := range keys { + if value, ok := data[key]; ok { + return value, true + } + } + return nil, false +} diff --git a/shortcuts/apps/apps_traces_test.go b/shortcuts/apps/apps_traces_test.go new file mode 100644 index 00000000..4768b1f9 --- /dev/null +++ b/shortcuts/apps/apps_traces_test.go @@ -0,0 +1,453 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/larksuite/cli/internal/httpmock" +) + +func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsTraceList, []string{ + "+trace-list", "--app-id", "app_x", "--trace-id", "trace-1", + "--root-span", "gateway", "--user-id", "ou_1", + "--since", "2026-06-23T10:00:00Z", "--until", "2026-06-23T10:01:00Z", + "--page-size", "10", "--dry-run", "--as", "user", + }, factory, stdout) + if 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"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/search_traces" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + if env.API[0].Body["app_env"] != "runtime" || env.API[0].Body["limit"] != float64(10) { + t.Fatalf("body = %#v", env.API[0].Body) + } + filter := env.API[0].Body["filter"].(map[string]interface{}) + traceIDs := filter["trace_ids"].([]interface{}) + if len(traceIDs) != 1 || traceIDs[0] != "trace-1" { + t.Fatalf("filter.trace_ids = %#v", traceIDs) + } + if got := filter["keyword"]; got != "gateway" { + t.Fatalf("filter.keyword = %v", got) + } + userIDs := filter["user_ids"].([]interface{}) + if len(userIDs) != 1 || userIDs[0] != "ou_1" { + t.Fatalf("filter.user_ids = %#v", userIDs) + } + if env.API[0].Body["start_timestamp_ns"] != "1782208800000000000" || + env.API[0].Body["end_timestamp_ns"] != "1782208860000000000" { + t.Fatalf("timestamps = %#v %#v", env.API[0].Body["start_timestamp_ns"], env.API[0].Body["end_timestamp_ns"]) + } +} + +func TestAppsTraceList_RejectsDevEnv(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout) + requireAppsValidationParam(t, err, "--environment") +} + +func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) { + factory, stdout, _ := newAppsExecuteFactory(t) + err := runAppsShortcut(t, AppsTraceGet, []string{ + "+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--dry-run", "--as", "user", + }, factory, stdout) + if 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"` + } `json:"api"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode dry-run: %v\n%s", err, stdout.String()) + } + if env.API[0].Method != "POST" || env.API[0].URL != "/open-apis/spark/v1/apps/app_x/trace" { + t.Fatalf("method/url = %s %s", env.API[0].Method, env.API[0].URL) + } + if env.API[0].Body["app_env"] != "runtime" || env.API[0].Body["trace_id"] != "trace-1" { + t.Fatalf("body = %#v", env.API[0].Body) + } +} + +func TestNormalizeTraceSummaries_DeduplicatesSpanList(t *testing.T) { + got := normalizeTraceSummaries([]map[string]interface{}{ + {"trace_id": "trace-1", "name": "gateway"}, + {"traceId": "trace-1", "name": "handler"}, + }) + if len(got) != 1 { + t.Fatalf("summaries len = %d, want 1: %#v", len(got), got) + } + if got[0]["trace_id"] != "trace-1" || got[0]["span_count"] != 2 { + t.Fatalf("summary = %#v", got[0]) + } +} + +func TestNormalizeTraceSummaries_PrefersRootCandidateOverChildOrder(t *testing.T) { + got := normalizeTraceSummaries([]map[string]interface{}{ + { + "trace_id": "trace-1", + "parent_span_id": "span-root", + "name": "child", + "status": "ERROR", + "start_time_ns": "200", + "duration_ms": 10, + }, + { + "traceID": "trace-1", + "parentSpanID": "", + "spanName": "root", + "status": "OK", + "startTimeNs": "100", + "durationMs": 200, + "userID": "ou_root", + "parent_span_id": "", + }, + }) + if len(got) != 1 { + t.Fatalf("summaries len = %d, want 1: %#v", len(got), got) + } + summary := got[0] + if summary["trace_id"] != "trace-1" || summary["span_count"] != 2 { + t.Fatalf("summary identity/count = %#v", summary) + } + if summary["root_span"] != "root" { + t.Fatalf("root_span = %#v, want root: %#v", summary["root_span"], summary) + } + if summary["status"] != "ERROR" { + t.Fatalf("status = %#v, want ERROR: %#v", summary["status"], summary) + } + if summary["start_time_ns"] != "100" { + t.Fatalf("start_time_ns = %#v, want earliest 100: %#v", summary["start_time_ns"], summary) + } + if summary["duration_ms"] != 200 { + t.Fatalf("duration_ms = %#v, want max 200: %#v", summary["duration_ms"], summary) + } + if summary["user_id"] != "ou_root" { + t.Fatalf("user_id = %#v, want root candidate user: %#v", summary["user_id"], summary) + } +} + +func TestAppsTraceList_NormalizesTraceItemsPaginationVariants(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_traces", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "traceItems": []interface{}{ + map[string]interface{}{ + "traceID": "trace-1", + "startTimeNs": "1782209472123456789", + "rootSpan": "gateway", + "userID": "ou_1", + "durationMs": float64(123), + "spanCount": float64(7), + }, + }, + "nextPageToken": "tok-next", + "hasMore": true, + }, + }, + }) + + if err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + PageToken string `json:"page_token"` + HasMore bool `json:"has_more"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if env.Data.PageToken != "tok-next" || !env.Data.HasMore { + t.Fatalf("pagination = token %q has_more %v", env.Data.PageToken, env.Data.HasMore) + } + if len(env.Data.Items) != 1 { + t.Fatalf("items len = %d", len(env.Data.Items)) + } + item := env.Data.Items[0] + if item["trace_id"] != "trace-1" || item["root_span"] != "gateway" || item["user_id"] != "ou_1" { + t.Fatalf("item aliases = %#v", item) + } + if item["span_count"] != float64(7) { + t.Fatalf("span_count = %#v", item["span_count"]) + } +} + +func TestAppsTraceList_AggregatesSpansSourceWithSingleSpanPerTrace(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_traces", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "spans": []interface{}{ + map[string]interface{}{ + "traceID": "trace-1", + "name": "gateway", + }, + map[string]interface{}{ + "trace_id": "trace-2", + "span_name": "worker", + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Items []map[string]interface{} `json:"items"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if len(env.Data.Items) != 2 { + t.Fatalf("items len = %d, want 2: %#v", len(env.Data.Items), env.Data.Items) + } + wantRootSpan := map[string]string{ + "trace-1": "gateway", + "trace-2": "worker", + } + for _, item := range env.Data.Items { + traceID, ok := item["trace_id"].(string) + if !ok || traceID == "" { + t.Fatalf("missing canonical trace_id: %#v", item) + } + if item["span_count"] != float64(1) { + t.Fatalf("span_count for %s = %#v, want 1: %#v", traceID, item["span_count"], item) + } + if item["root_span"] != wantRootSpan[traceID] { + t.Fatalf("root_span for %s = %#v, want %q: %#v", traceID, item["root_span"], wantRootSpan[traceID], item) + } + } +} + +func TestAppsTraceList_PrettyUsesTraceSummaryColumns(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/search_traces", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "traceItems": []interface{}{ + map[string]interface{}{ + "traceID": "trace-1", + "startTimeNs": "1782232472381701316", + "rootSpan": "GET /app/app_x/api/note-records", + "userID": "1846640196867498", + "durationMs": float64(414), + "status": "OK", + "spanCount": float64(4), + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsTraceList, []string{ + "+trace-list", "--app-id", "app_x", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + if !strings.HasPrefix(got, "start-time") { + t.Fatalf("pretty output should start with start-time column, got:\n%s", got) + } + for _, want := range []string{"root-span", "user-id", "duration", "trace-id", "GET /app/app_x/api/note-records", "414ms"} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q:\n%s", want, got) + } + } + for _, banned := range []string{"span_count", "span-count", "status", "duration_ms", "root_span", "trace_id"} { + if strings.Contains(got, banned) { + t.Fatalf("pretty output should not include %q:\n%s", banned, got) + } + } +} + +func TestAppsTraceGet_PrettySummarizesSpans(t *testing.T) { + const rawNS = int64(1782232472381701316) + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "is_break": false, + "spans": []interface{}{ + map[string]interface{}{ + "trace_id": "trace-1", + "name": "GET /app/app_x", + "span_id": "root", + "parent_span_id": "", + "start_time_unix_nano": "1782232472381701316", + "end_time_unix_nano": "1782232480645457992", + "attributes": []interface{}{ + map[string]interface{}{"key": "duration_ms", "value": "8263.76"}, + map[string]interface{}{"key": "user_id", "value": "1826968659245100"}, + }, + }, + map[string]interface{}{ + "trace_id": "trace-1", + "name": "child", + "span_id": "child", + "parent_span_id": "root", + "start_time_unix_nano": "1782232480448000000", + "attributes": []interface{}{ + map[string]interface{}{"key": "duration_ms", "value": "184.89"}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsTraceGet, []string{ + "+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--format", "pretty", "--as", "user", + }, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + got := stdout.String() + wantTime := time.Unix(0, rawNS).Local().Format("2006-01-02 15:04:05.000") + if !strings.HasPrefix(got, "start-time") { + t.Fatalf("pretty output should start with start-time columns, got:\n%s", got) + } + for _, want := range []string{"root-span", "user-id", "duration", "trace-id", "trace-1", "GET /app/app_x", "1826968659245100", wantTime} { + if !strings.Contains(got, want) { + t.Fatalf("pretty output missing %q:\n%s", want, got) + } + } + for _, banned := range []string{"start_time_ns", "1782232472381701316", "span_count", "span-count", "status", "duration_ms", "root_span", "trace_id"} { + if strings.Contains(got, banned) { + t.Fatalf("pretty output should not include %q:\n%s", banned, got) + } + } +} + +func TestAppsTraceGet_NormalizesTraceDetailCamelFields(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "traceDetail": map[string]interface{}{ + "traceID": "trace-1", + "isBreak": true, + "spans": []interface{}{ + map[string]interface{}{ + "spanID": "span-1", + "parentSpanID": "root", + "traceID": "trace-1", + "startTimeNs": "1782209472123456789", + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsTraceGet, []string{"+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if _, wrapped := env.Data["trace"]; wrapped { + t.Fatalf("trace-get should output the trace object directly: %#v", env.Data) + } + if env.Data["trace_id"] != "trace-1" || env.Data["is_break"] != true { + t.Fatalf("trace aliases = %#v", env.Data) + } + spans := env.Data["spans"].([]interface{}) + span := spans[0].(map[string]interface{}) + if span["span_id"] != "span-1" || span["parent_span_id"] != "root" || span["trace_id"] != "trace-1" { + t.Fatalf("span aliases = %#v", span) + } +} + +func TestAppsTraceGet_NormalizesKVAttributesToObject(t *testing.T) { + factory, stdout, reg := newAppsExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/spark/v1/apps/app_x/trace", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "spans": []interface{}{ + map[string]interface{}{ + "trace_id": "trace-1", + "span_id": "span-1", + "attributes": []interface{}{ + map[string]interface{}{"key": "app_env", "value": "runtime"}, + map[string]interface{}{"key": "duration_ms", "value": "8263"}, + map[string]interface{}{"key": "module", "value": "gateway"}, + }, + }, + }, + }, + }, + }) + + if err := runAppsShortcut(t, AppsTraceGet, []string{"+trace-get", "--app-id", "app_x", "--trace-id", "trace-1", "--as", "user"}, factory, stdout); err != nil { + t.Fatalf("execute err=%v", err) + } + + var env struct { + Data struct { + Spans []map[string]interface{} `json:"spans"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + attrs, ok := env.Data.Spans[0]["attributes"].(map[string]interface{}) + if !ok { + t.Fatalf("attributes = %#v, want object", env.Data.Spans[0]["attributes"]) + } + if attrs["app_env"] != "runtime" || attrs["duration_ms"] != "8263" || attrs["module"] != "gateway" { + t.Fatalf("attributes = %#v", attrs) + } +} diff --git a/shortcuts/apps/common.go b/shortcuts/apps/common.go index 5a8b59b9..8a962760 100644 --- a/shortcuts/apps/common.go +++ b/shortcuts/apps/common.go @@ -4,6 +4,7 @@ package apps import ( + "path/filepath" "strings" "github.com/larksuite/cli/errs" @@ -39,3 +40,28 @@ func withAppsHint(err error, hint string) error { } return err } + +// rejectOutputTraversal is a defense-in-depth pre-check on a user-supplied +// --output path. The authoritative guard is the local FileIO layer +// (validate.SafeOutputPath sandboxes every write to the cwd, resolving .. and +// symlinks), so traversal is already blocked at write time; this gives an +// earlier, clearer validation error and pins the contract in the command layer. +// Empty (use server-derived default) passes through. Absolute paths and any +// ".." path component are rejected. +func rejectOutputTraversal(output string) error { + o := strings.TrimSpace(output) + if o == "" { + return nil + } + if filepath.IsAbs(o) { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--output must be a relative path within the current directory, got %q", o).WithParam("--output") + } + for _, seg := range strings.Split(filepath.Clean(o), string(filepath.Separator)) { + if seg == ".." { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--output must not contain .. path traversal, got %q", o).WithParam("--output") + } + } + return nil +} diff --git a/shortcuts/apps/db_common.go b/shortcuts/apps/db_common.go index 9223e46e..982c9bee 100644 --- a/shortcuts/apps/db_common.go +++ b/shortcuts/apps/db_common.go @@ -4,12 +4,79 @@ package apps import ( + "context" + "encoding/json" "fmt" "strings" + "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" ) +// ── db 环境 flag:--environment 是唯一受理名;旧名 --env 已移除 ── +// +// 硬改名:标准名 --environment(带默认/枚举)正常注册并受理;旧名 --env 仅注册为隐藏 flag, +// 目的是「传了能被识别并给出清晰报错」而非继续受理——一旦显式传 --env,在 Validate 阶段直接 +// 返回 validation 错、指向 --environment。所有 DryRun/Execute 经 dbEnv() 只读 --environment。 + +// dbEnvFlags 返回环境 flag 对,供各 db 命令 append 进自己的 Flags。 +func dbEnvFlags(def string, enum []string, desc string) []common.Flag { + return []common.Flag{ + {Name: "environment", Default: def, Enum: enum, Desc: desc}, + {Name: "env", Hidden: true, Desc: "removed: use --environment"}, + } +} + +// dbEnv 取环境值:只认标准 --environment(含其默认值);旧名 --env 不再受理(见 rejectLegacyEnvFlag)。 +func dbEnv(rctx *common.RuntimeContext) string { + return rctx.Str("environment") +} + +// rejectLegacyEnvFlag 在 Validate 阶段拦截已移除的 --env:显式传了就报清晰的 validation 错,指向 --environment。 +func rejectLegacyEnvFlag(rctx *common.RuntimeContext) error { + if rctx.Changed("env") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--env is no longer supported; use --environment instead").WithParam("--env") + } + return nil +} + +// pollUntil 轮询异步任务直到 check 判定终态。async migrate/recovery 用:dataloom 立即返 +// task_id/preview_request_id,CLI 自己 poll(避免单连接长挂被网关/SDK 30s 中断)。 +// 首次立即 fetch(不睡);check 返 done→返回;返 err→透传(失败终态);否则按 interval 间隔重试至 maxWait。 +func pollUntil(ctx context.Context, interval, maxWait time.Duration, + fetch func() (map[string]interface{}, error), + check func(map[string]interface{}) (done bool, err error)) (map[string]interface{}, error) { + maxAttempts := int(maxWait / interval) + if maxAttempts < 1 { + maxAttempts = 1 + } + for i := 0; ; i++ { + data, err := fetch() + if err != nil { + return nil, err + } + done, cerr := check(data) + if cerr != nil { + return nil, cerr + } + if done { + return data, nil + } + if i+1 >= maxAttempts { + // async 任务多半还在服务端推进,poll 超时是可重试的——标 retryable 让 agent 重新轮询而非放弃。 + return nil, errs.NewNetworkError(errs.SubtypeNetworkTimeout, "timed out waiting for completion after %s", maxWait).WithRetryable() + } + select { + case <-ctx.Done(): + return nil, errs.NewNetworkError(errs.SubtypeNetworkTransport, "cancelled while waiting").WithCause(ctx.Err()) + case <-time.After(interval): + } + } +} + // URL helpers for the db CLI commands. // appTablesPath 返回 app db 表列表 URL(复用存量「获取数据表列表」接口)。 @@ -32,11 +99,167 @@ func appDbEnvCreatePath(appID string) string { return fmt.Sprintf("%s/apps/%s/db_dev_init", apiBasePath, validate.EncodePathSegment(appID)) } +// ── 多环境发布(env diff/migrate)/ 数据恢复(recovery)/ 配额 路由 ── + +// appEnvMigratePath 返回 dev→online 发布(预览/落地共用)URL:db/env_migrate。 +func appEnvMigratePath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_migrate", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appEnvMigrateStatusPath 返回发布异步任务状态查询 URL:db/env_migrate_status。 +func appEnvMigrateStatusPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_migrate_status", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appRecoveryPath 返回 PITR 数据恢复(预览/落地共用)URL:db/env_recovery。 +func appRecoveryPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_recovery", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appRecoveryDiffStatusPath 返回恢复预览(diff)异步状态查询 URL:db/env_recovery_diff_status。 +func appRecoveryDiffStatusPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_recovery_diff_status", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appRecoveryApplyStatusPath 返回恢复落地异步状态查询 URL:db/env_recovery_apply_status。 +func appRecoveryApplyStatusPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/env_recovery_apply_status", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appDbQuotaPath 返回 db 配额查询 URL:db/quota。 +func appDbQuotaPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/quota", apiBasePath, validate.EncodePathSegment(appID)) +} + +// ── 变更追溯(changelog / audit)路由 ── + +// appChangelogListPath 返回 DDL 变更记录列表 URL:db/changelog_list。 +func appChangelogListPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/changelog_list", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appAuditStatusPath 返回表审计开关状态查询 URL:db/audit_status。 +func appAuditStatusPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/audit_status", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appAuditSetPath 返回表审计开关设置 URL:db/audit_set。 +func appAuditSetPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/audit_set", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appAuditListPath 返回行级审计事件列表 URL:db/audit_list。 +func appAuditListPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/audit_list", apiBasePath, validate.EncodePathSegment(appID)) +} + +// operatorRef 是 operator 的 {id,name}。后端用 JSON 字符串内嵌透传,CLI parse: +// json 输出还原成对象(下游能区分同名用户),pretty 只取 name。 +type operatorRef struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// parseOperator 解析 operator 字符串:空→nil;非 JSON→{raw,raw};JSON→{id,name}(name 空兜底 id)。 +func parseOperator(raw string) *operatorRef { + s := strings.TrimSpace(raw) + if s == "" { + return nil + } + if !strings.HasPrefix(s, "{") { + return &operatorRef{ID: s, Name: s} + } + var o operatorRef + if json.Unmarshal([]byte(s), &o) != nil { + return &operatorRef{ID: s, Name: s} + } + if o.Name == "" { + o.Name = o.ID + } + return &o +} + +// operatorName 取 operator 的展示名(pretty),空用 "—"。 +func operatorName(op *operatorRef) string { + if op == nil || op.Name == "" { + return "—" + } + return op.Name +} + +// safeParseJSON 把 before/after 的 JSON 字符串还原成结构化对象供下游消费;失败时透传原始串。 +func safeParseJSON(s string) interface{} { + var v interface{} + if json.Unmarshal([]byte(s), &v) == nil { + return v + } + return s +} + +// appDataImportPath 返回 db 数据导入 URL(新增 db/ 域段路由)。 +func appDataImportPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/data_import", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appDataExportPath 返回 db 数据导出 URL(返原始字节)。 +func appDataExportPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/db/data_export", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appTableRecordsPath 返回数据表记录列表 URL(复用 GetAppTableRecordList,其 total 即符合条件的记录总数)。 +func appTableRecordsPath(appID, table string) string { + return appTablePath(appID, table) + "/records" +} + +// resolveDataFormat 由文件扩展名推断数据格式。lark-cli 的 --format 已被框架占用(输出渲染), +// 故数据格式从文件名推断:import 接受 csv/json,export 还接受 sql。 +func resolveDataFormat(ext string, allowSQL bool) (string, error) { + raw := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(ext)), ".") + switch raw { + case "csv", "json": + return raw, nil + case "sql": + if allowSQL { + return "sql", nil + } + } + if allowSQL { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported data format %q (file must end in .csv, .json or .sql)", raw) + } + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported data format %q (file must end in .csv or .json)", raw) +} + +// countDataRows 粗估数据行数(用于导入上限校验、导出兜底计数)。 +// csv:非空行数 - 1(表头);json:顶层数组长度,非数组算 1,解析失败算 0。 +func countDataRows(body []byte, format string) int { + if format == "csv" { + lines := 0 + for _, ln := range strings.Split(string(body), "\n") { + if strings.TrimRight(ln, "\r") != "" { + lines++ + } + } + if lines > 0 { + return lines - 1 + } + return 0 + } + var arr []json.RawMessage + if err := json.Unmarshal(body, &arr); err == nil { + return len(arr) + } + var obj map[string]json.RawMessage + if err := json.Unmarshal(body, &obj); err == nil { + return 1 + } + return 0 +} + // 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 "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--app-id is required").WithParam("--app-id") } return id, nil } diff --git a/shortcuts/apps/file_common.go b/shortcuts/apps/file_common.go new file mode 100644 index 00000000..a4b961e3 --- /dev/null +++ b/shortcuts/apps/file_common.go @@ -0,0 +1,228 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "regexp" + "strconv" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var ( + reTsRelative = regexp.MustCompile(`^([0-9]+)([smhdw])$`) + reTsDate = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}$`) + reTsLocalDateTime = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}$`) +) + +// normalizeTimestamp 实现设计原则三的 多格式输入,统一归一化为 RFC3339 UTC: +// - 相对:30s / 5m / 2h / 3d / 1w(从现在往前推) +// - date:2026-04-15(本地时区 00:00:00) +// - local datetime:2026-04-15T10:00:00(本地时区,T 分隔) +// - ISO 8601 带 TZ:...Z(UTC)/ ...+08:00(显式偏移) +// +// 归一化到 UTC 是必须的:服务端对无 TZ 的串按 UTC 裸解析,故 date / local datetime 的「本地」 +// 语义只能在 CLI 端换算;相对时间服务端也不认。空串原样返回(调用方据此跳过该过滤)。 +func normalizeTimestamp(raw string) (string, error) { + s := strings.TrimSpace(raw) + if s == "" { + return "", nil + } + if m := reTsRelative.FindStringSubmatch(s); m != nil { + n, _ := strconv.Atoi(m[1]) + var unit time.Duration + switch m[2] { + case "s": + unit = time.Second + case "m": + unit = time.Minute + case "h": + unit = time.Hour + case "d": + unit = 24 * time.Hour + case "w": + unit = 7 * 24 * time.Hour + } + return time.Now().Add(-time.Duration(n) * unit).UTC().Format(time.RFC3339), nil + } + if reTsDate.MatchString(s) { + t, err := time.ParseInLocation("2006-01-02", s, time.Local) + if err != nil { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid date %q", s) + } + return t.UTC().Format(time.RFC3339), nil + } + if reTsLocalDateTime.MatchString(s) { + t, err := time.ParseInLocation("2006-01-02T15:04:05", s, time.Local) + if err != nil { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid local datetime %q", s) + } + return t.UTC().Format(time.RFC3339), nil + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return t.UTC().Format(time.RFC3339), nil + } + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid timestamp %q (want relative 7d/2h/30s, date 2026-04-15, datetime 2026-04-15T10:00:00, or ISO 8601 with TZ)", s) +} + +// newFileTransferClient 直传 / 直下对象存储 presigned URL 用(绕开 Lark 网关,无需 auth、无超时以容纳大文件)。 +// +//nolint:forbidigo // presigned object-storage transfer bypasses the Lark gateway — raw http.Client is required (no Lark auth, no gateway routing); not a Lark API call, so RuntimeContext.DoAPI does not apply. +func newFileTransferClient() *http.Client { + return &http.Client{Transport: http.DefaultTransport} +} + +// URL helpers for the file (storage) CLI commands. +// +// 全部走 spark OpenAPI,path 形如 /open-apis/spark/v1/apps/{app_id}/storage/。 +// 路由段不含 HTTP 方法名(file_get→file、file_delete→file_batch_remove、file_quota_get→file_quota)。 + +// appFileListPath 返回文件列表 URL:storage/file_list。 +func appFileListPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_list", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appFileGetPath 返回单文件元数据 URL:storage/file(file_get→file,路由不含方法名)。 +func appFileGetPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appFileSignPath 返回临时签名下载 URL 生成接口:storage/file_sign。 +func appFileSignPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_sign", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appFilePreUploadPath 返回上传预处理(取 presigned 直传地址)URL:storage/file_pre_upload。 +func appFilePreUploadPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_pre_upload", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appFileUploadCallbackPath 返回直传完成回调(登记文件)URL:storage/file_upload_callback。 +func appFileUploadCallbackPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_upload_callback", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appFileBatchRemovePath 返回批量删除文件 URL:storage/file_batch_remove(file_delete→file_batch_remove)。 +func appFileBatchRemovePath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_batch_remove", apiBasePath, validate.EncodePathSegment(appID)) +} + +// appFileQuotaPath 返回存储配额查询 URL:storage/file_quota(file_quota_get→file_quota)。 +func appFileQuotaPath(appID string) string { + return fmt.Sprintf("%s/apps/%s/storage/file_quota", apiBasePath, validate.EncodePathSegment(appID)) +} + +// requireFilePath trims --path and rejects blank, returning a uniform validation error. +func requireFilePath(raw string) (string, error) { + p := strings.TrimSpace(raw) + if p == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--path is required").WithParam("--path") + } + return p, nil +} + +// fileUser 是 uploaded_by 的 {id,name}。OpenAPI 以 created_by 的 JSON 字符串透传,CLI parse。 +type fileUser struct { + ID string `json:"id"` + Name string `json:"name"` +} + +// fileInfo 是 file 命令对外输出的白名单字段。 +// OpenAPI 字段 created_at / created_by → CLI 产品语义 uploaded_at / uploaded_by。 +type fileInfo struct { + FileName string `json:"file_name"` + Path string `json:"path"` + SizeBytes interface{} `json:"size_bytes,omitempty"` + Type string `json:"type,omitempty"` + UploadedBy *fileUser `json:"uploaded_by,omitempty"` + UploadedAt string `json:"uploaded_at,omitempty"` + DownloadURL string `json:"download_url,omitempty"` +} + +// projectFileInfo 把 server 原始 file map 投影为 CLI fileInfo(created_*→uploaded_*)。 +func projectFileInfo(m map[string]interface{}) fileInfo { + return fileInfo{ + FileName: common.GetString(m, "file_name"), + Path: common.GetString(m, "path"), + SizeBytes: m["size_bytes"], + Type: common.GetString(m, "type"), + UploadedBy: parseFileUser(common.GetString(m, "created_by")), + UploadedAt: common.GetString(m, "created_at"), + DownloadURL: common.GetString(m, "download_url"), + } +} + +// parseFileUser 解析 created_by 的 JSON 字符串 {id,name};空 / 非法 / 全空 → nil。 +func parseFileUser(raw string) *fileUser { + s := strings.TrimSpace(raw) + if s == "" { + return nil + } + var u fileUser + if err := json.Unmarshal([]byte(s), &u); err != nil { + return nil + } + if u.ID == "" && u.Name == "" { + return nil + } + return &u +} + +// normalizeTimeFlags 把若干时间 flag(如 --since/--until/--uploaded-since)就地归一化为 RFC3339 UTC +// 并回写,供 build*Params 透传。空 flag 跳过;非法格式 → validation 错误。复用 normalizeTimestamp。 +func normalizeTimeFlags(rctx *common.RuntimeContext, flags ...string) error { + for _, f := range flags { + if strings.TrimSpace(rctx.Str(f)) == "" { + continue + } + n, err := normalizeTimestamp(rctx.Str(f)) + if err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--%s: %v", f, err).WithParam("--" + f) + } + _ = rctx.Cmd.Flags().Set(f, n) + } + return nil +} + +// dashIfEmpty 空白串用 "—" 占位(pretty 列对齐)。 +func dashIfEmpty(s string) string { + if strings.TrimSpace(s) == "" { + return "—" + } + return s +} + +// fileSizeDetail 把 size_bytes 渲染成 "24 KB (24580 bytes)"(pretty 单文件详情用)。 +func fileSizeDetail(raw interface{}) string { + n, ok := numericAsFloat(raw) + if !ok { + return "—" + } + return fmt.Sprintf("%s (%d bytes)", humanBytes(raw), int64(n)) +} + +// renderKeyValuePairs 输出对齐的 key: value(key 列按最长 key 右填充)。 +func renderKeyValuePairs(w io.Writer, pairs [][2]string) { + width := 0 + for _, p := range pairs { + if dw := displayWidth(p[0]); dw > width { + width = dw + } + } + for _, p := range pairs { + io.WriteString(w, p[0]+":") + if pad := width - displayWidth(p[0]); pad > 0 { + io.WriteString(w, strings.Repeat(" ", pad)) + } + io.WriteString(w, " "+p[1]+"\n") + } +} diff --git a/shortcuts/apps/git_credential.go b/shortcuts/apps/git_credential.go index 2a39d02e..7b0ea606 100644 --- a/shortcuts/apps/git_credential.go +++ b/shortcuts/apps/git_credential.go @@ -488,7 +488,7 @@ func issuedFromData(appID string, data map[string]interface{}) (*gitcred.IssuedC // handled locally. func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.ClassifyContext) (map[string]any, error) { if err != nil { - return nil, client.WrapDoAPIError(err) + return nil, redactGitCredentialIssueError(client.WrapDoAPIError(err)) } detail := logIDDetail(resp) if resp == nil || len(resp.RawBody) == 0 { @@ -501,7 +501,7 @@ func parseIssueCredentialData(resp *larkcore.ApiResp, err error, cc errclass.Cla if jsonErr != nil || hasCode || resp.StatusCode >= http.StatusBadRequest { data, cerr := common.ClassifyAPIResponseWith(resp, cc) if cerr != nil { - return nil, withAppsHint(cerr, gitCredentialIssueHint) + return nil, redactGitCredentialIssueError(withAppsHint(cerr, gitCredentialIssueHint)) } if data != nil { result = data @@ -536,6 +536,7 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error { if message == "" { message = "Git credential API returned non-zero BaseResp status" } + message = gitcred.RedactCredentialText(message) baseErr := errs.NewAPIError(errs.SubtypeUnknown, "Issue app Git credential: %s", message).WithCode(int(code)) if logID != "" { baseErr = baseErr.WithLogID(logID) @@ -545,6 +546,17 @@ func checkGitInfoBaseResp(result map[string]any, logID string) error { return nil } +func redactGitCredentialIssueError(err error) error { + if err == nil { + return nil + } + if p, ok := errs.ProblemOf(err); ok { + p.Message = gitcred.RedactCredentialText(p.Message) + p.Hint = gitcred.RedactCredentialText(p.Hint) + } + return err +} + func logIDDetail(resp *larkcore.ApiResp) map[string]any { logID := logIDString(resp) if logID == "" { diff --git a/shortcuts/apps/git_credential_test.go b/shortcuts/apps/git_credential_test.go index ee71c367..b48b96b6 100644 --- a/shortcuts/apps/git_credential_test.go +++ b/shortcuts/apps/git_credential_test.go @@ -12,7 +12,6 @@ import ( "net/http" "os" "path/filepath" - "regexp" "strconv" "strings" "testing" @@ -1027,25 +1026,24 @@ func TestParseIssueCredentialDataBusinessCodeHasHintNotRetryable(t *testing.T) { } } -// 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" +// TestParseIssueCredentialDataRedactsCredentialErrorMessage verifies that the +// git-credential boundary does not pass server-provided credential details into +// the user-visible typed envelope message. +func TestParseIssueCredentialDataRedactsCredentialErrorMessage(t *testing.T) { + samplePAT := testPublicSafeJoin("pat", "-sample") + samplePassword := "sample-password" + serverMsg := "permission denied: " + + testCredentialAssignment("token", samplePAT) + " " + + testCredentialAssignment("password", samplePassword) + " " + + testCredentialURLWithUserInfo("example.com/repo.git", samplePAT) header := http.Header{"X-Tt-Logid": []string{"log_x"}} for _, tc := range []struct { - name string - resp *larkcore.ApiResp + name string + resp *larkcore.ApiResp + wantType errs.Category + wantSubtype errs.Subtype + wantCode int }{ { name: "http error path", @@ -1054,6 +1052,9 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) { RawBody: []byte(`{"msg":"` + serverMsg + `"}`), Header: header, }, + wantType: errs.CategoryAPI, + wantSubtype: errs.SubtypeUnknown, + wantCode: http.StatusForbidden, }, { name: "business code path", @@ -1062,6 +1063,9 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) { RawBody: []byte(`{"code":999,"msg":"` + serverMsg + `"}`), Header: header, }, + wantType: errs.CategoryAPI, + wantSubtype: errs.SubtypeUnknown, + wantCode: 999, }, } { t.Run(tc.name, func(t *testing.T) { @@ -1073,30 +1077,85 @@ func TestParseIssueCredentialDataMessageAddsNoExtraSecret(t *testing.T) { 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) + if p.Category != tc.wantType || p.Subtype != tc.wantSubtype || p.Code != tc.wantCode { + t.Fatalf("problem metadata = %s/%s code=%d, want %s/%s code=%d", + p.Category, p.Subtype, p.Code, tc.wantType, tc.wantSubtype, tc.wantCode) + } + if !strings.Contains(p.Message, "permission denied") { + t.Fatalf("Message = %q, want it to retain non-secret server context", p.Message) } - // 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) + for _, leaked := range []string{samplePAT, "user:" + samplePAT + "@", testCredentialAssignment("password", samplePassword)} { + if strings.Contains(val, leaked) { + t.Fatalf("%s leaks %q: %q", field, leaked, val) + } + } + } + for _, want := range []string{ + testRedactedAssignment("token"), + testRedactedAssignment("password"), + "https://***@example.com/repo.git", + } { + if !strings.Contains(p.Message, want) { + t.Fatalf("Message missing %q after redaction: %q", want, p.Message) } } }) } } +func TestParseIssueCredentialDataRedactsSDKErrorPreservesCause(t *testing.T) { + samplePAT := testPublicSafeJoin("pat", "-sample") + cause := errors.New("transport failed with " + testCredentialAssignment("token", samplePAT)) + + _, err := parseIssueCredentialData(nil, cause, errclass.ClassifyContext{}) + if err == nil { + t.Fatal("expected SDK-boundary error, got nil") + } + if !errors.Is(err, cause) { + t.Fatalf("error does not preserve cause: %v", err) + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed errs.Problem, got %T: %v", err, err) + } + if p.Category != errs.CategoryNetwork || p.Subtype != errs.SubtypeNetworkTransport { + t.Fatalf("problem metadata = %s/%s, want %s/%s", + p.Category, p.Subtype, errs.CategoryNetwork, errs.SubtypeNetworkTransport) + } + if strings.Contains(p.Message, samplePAT) { + t.Fatalf("message leaks credential value: %q", p.Message) + } + if want := testRedactedAssignment("token"); !strings.Contains(p.Message, want) { + t.Fatalf("message missing %q after redaction: %q", want, p.Message) + } +} + +func TestRedactGitCredentialIssueErrorNil(t *testing.T) { + if err := redactGitCredentialIssueError(nil); err != nil { + t.Fatalf("redactGitCredentialIssueError(nil) = %v, want nil", err) + } +} + +func testPublicSafeJoin(parts ...string) string { + return strings.Join(parts, "") +} + +func testCredentialAssignment(key, value string) string { + return key + "=" + value +} + +func testRedactedAssignment(key string) string { + return key + "=" +} + +func testCredentialURLWithUserInfo(hostPath, credential string) string { + return "https://" + "user:" + credential + "@" + hostPath +} + type errorReader struct{} func (errorReader) Read(p []byte) (int, error) { diff --git a/shortcuts/apps/gitcred/gitcred_test.go b/shortcuts/apps/gitcred/gitcred_test.go index 7085a682..c5202df6 100644 --- a/shortcuts/apps/gitcred/gitcred_test.go +++ b/shortcuts/apps/gitcred/gitcred_test.go @@ -542,7 +542,15 @@ func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) { if err := manager.Store.Upsert(*record); err != nil { t.Fatalf("Upsert expired record returned error: %v", err) } - issuer.err = errors.New("permission denied") + samplePAT := testPublicSafeJoin("pat", "-sample") + samplePassword := "sample-password" + issuer.err = errs.NewAPIError( + errs.SubtypeUnknown, + "permission denied: "+ + testCredentialAssignment("token", samplePAT)+" "+ + testCredentialAssignment("password", samplePassword)+" "+ + testCredentialURLWithUserInfo("example.com/git/u/app.git", samplePAT), + ).WithHint("retry without " + testCredentialAssignment("token", samplePAT)).WithLogID("log_x") var out bytes.Buffer var errOut bytes.Buffer @@ -552,6 +560,22 @@ func TestManagerGetKeepsStdoutEmptyWhenRefreshFails(t *testing.T) { if out.Len() != 0 { t.Fatalf("stdout = %q, want empty", out.String()) } + stderr := errOut.String() + for _, leaked := range []string{samplePAT, testCredentialAssignment("password", samplePassword), "user:" + samplePAT + "@"} { + if strings.Contains(stderr, leaked) { + t.Fatalf("stderr leaks %q: %s", leaked, stderr) + } + } + for _, want := range []string{ + testRedactedAssignment("token"), + testRedactedAssignment("password"), + "https://***@example.com/git/u/app.git", + "log_id=log_x", + } { + if !strings.Contains(stderr, want) { + t.Fatalf("stderr missing %q in %s", want, stderr) + } + } 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()) } @@ -1411,10 +1435,36 @@ func TestSecretStoreBranches(t *testing.T) { if err := NewSecretStore(newFakeKeychain()).Set("", "pat"); err == nil { t.Fatal("SecretStore.Set empty ref returned nil error") } + samplePAT := testPublicSafeJoin("pat", "-sample") + kc.setErr = errors.New("keychain set failed with " + testCredentialAssignment("token", samplePAT)) + var setCfgErr *errs.ConfigError + setErr := NewSecretStore(kc).Set("ref", samplePAT) + if setErr == nil || !errors.As(setErr, &setCfgErr) { + t.Fatalf("SecretStore.Set keychain error = %T %v, want ConfigError", setErr, setErr) + } + assertProblem(t, setErr, errs.CategoryConfig, errs.SubtypeInvalidConfig) + if setCfgErr.Message != "save local Git credential PAT to keychain failed" { + t.Fatalf("ConfigError message = %q, want static keychain failure", setCfgErr.Message) + } + if strings.Contains(setCfgErr.Message, samplePAT) { + t.Fatalf("ConfigError message leaks credential value: %q", setCfgErr.Message) + } + if !errors.Is(setCfgErr, kc.setErr) { + t.Fatalf("ConfigError does not preserve keychain cause") + } + kc.setErr = nil 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) + removeErr := NewSecretStore(kc).Remove("ref") + if removeErr == nil || !errors.As(removeErr, &cfgErr) { + t.Fatalf("SecretStore.Remove keychain error = %T %v, want ConfigError", removeErr, removeErr) + } + assertProblem(t, removeErr, errs.CategoryConfig, errs.SubtypeInvalidConfig) + if cfgErr.Message != "remove local Git credential PAT from keychain failed" { + t.Fatalf("ConfigError message = %q, want static keychain failure", cfgErr.Message) + } + if !errors.Is(cfgErr, kc.removeErr) { + t.Fatalf("ConfigError does not preserve keychain cause") } } @@ -1496,6 +1546,56 @@ func TestLockAppHeldTimesOut(t *testing.T) { } } +func TestManagerGetPreservesTypedLockAppError(t *testing.T) { + now := time.Unix(1780000000, 0) + store := NewStoreAt(filepath.Join(t.TempDir(), MetadataFilename)) + kc := newFakeKeychain() + record := CredentialRecord{ + AppID: "app_xxx", + GitHTTPURL: "https://example.com/git/u/app.git", + Profile: testProfile().Profile, + ProfileAppID: testProfile().ProfileAppID, + UserOpenID: testProfile().UserOpenID, + Username: "x-access-token", + PATRef: "ref", + Status: StatusConfirmed, + ExpiresAt: now.Add(-time.Minute).Unix(), + UpdatedAt: now.Unix(), + } + if err := store.Upsert(record); err != nil { + t.Fatalf("Upsert returned error: %v", err) + } + kc.values[record.PATRef] = "old-pat" + + 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) + + manager := NewManager(store, NewSecretStore(kc), nil, &fakeIssuer{next: &IssuedCredential{ + GitHTTPURL: record.GitHTTPURL, + PAT: "new-pat", + ExpiresAt: now.Add(24 * time.Hour).Unix(), + }}) + manager.Now = func() time.Time { return now } + 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()) + } + stderr := errOut.String() + if !strings.Contains(stderr, "create Git credential lock dir") { + t.Fatalf("stderr = %q, want typed lock-dir setup error", stderr) + } + if strings.Contains(stderr, "acquire Git credential lock") { + t.Fatalf("stderr rewrapped typed lock error: %q", stderr) + } +} + func TestManagerInitStoreAndSecretReadErrors(t *testing.T) { now := time.Unix(1780000000, 0) path := filepath.Join(t.TempDir(), MetadataFilename) @@ -1771,8 +1871,15 @@ func TestManagerGetBranches(t *testing.T) { 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()) + stderr := errOut.String() + if !strings.Contains(stderr, "save local Git credential PAT to keychain failed") { + t.Fatalf("stderr = %q, want static keychain error", stderr) + } + if !strings.Contains(stderr, "lark-cli apps +git-credential-init") { + t.Fatalf("stderr = %q, want init retry hint", stderr) + } + if strings.Contains(stderr, "keychain locked") { + t.Fatalf("stderr leaks keychain cause: %q", stderr) } kc.setErr = nil @@ -2165,6 +2272,189 @@ func TestNilManagerUsesTimeNow(t *testing.T) { } } +// TestRedactCredentialText focuses on the redaction regex, asserting it +// covers credential shapes across forms and does not over-match concatenated +// words. JSON-quoted forms are common in server-provided error bodies and must +// be covered; concatenated words like mytoken must not be treated as token. +func TestRedactCredentialText(t *testing.T) { + samplePAT := testPublicSafeJoin("pat", "-sample") + samplePassword := "sample-password" + sampleSecret := "sample-secret" + githubLikeToken := testPublicSafeJoin("gh", "p_") + strings.Repeat("x", 20) + cases := []struct { + name string + in string + want string + }{ + { + name: "bare assignment", + in: "permission denied: " + + testCredentialAssignment("token", samplePAT) + " " + + testCredentialAssignment("password", samplePassword), + want: "permission denied: " + + testRedactedAssignment("token") + " " + + testRedactedAssignment("password"), + }, + { + name: "json double-quoted value", + in: "body={" + + testDoubleQuotedAssignment("password", samplePassword) + "," + + testDoubleQuotedAssignment("token", samplePAT) + + "}", + want: "body={" + + testDoubleQuotedRedactedAssignment("password") + "," + + testDoubleQuotedRedactedAssignment("token") + + "}", + }, + { + name: "json single-quoted value", + in: "body={" + testSingleQuotedAssignment("secret", sampleSecret) + "}", + want: "body={" + testSingleQuotedRedactedAssignment("secret") + "}", + }, + { + name: "colon separator with quoted value", + in: testCredentialColon("token", `"`+samplePAT+`"`), + want: testRedactedColon("token"), + }, + { + name: "url userinfo", + in: "clone " + testCredentialURLWithUserInfo("example.com/repo.git", samplePAT), + want: "clone https://***@example.com/repo.git", + }, + { + name: "bearer header", + in: testAuthorizationBearer(githubLikeToken), + want: testRedactedAuthorizationBearer(), + }, + { + name: "pat-like standalone", + in: "issued " + samplePAT + " for app", + want: "issued for app", + }, + { + name: "concatenated key not redacted", + in: testCredentialAssignment("mytoken", "abc123") + " " + testCredentialAssignment("secret_field", "see"), + want: testCredentialAssignment("mytoken", "abc123") + " " + testCredentialAssignment("secret_field", "see"), + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := RedactCredentialText(tc.in); got != tc.want { + t.Fatalf("RedactCredentialText(%q) = %q, want %q", tc.in, got, tc.want) + } + }) + } +} + +func TestSafeCredentialErrorMessageFallbacks(t *testing.T) { + if got := safeCredentialErrorMessage(nil); got != "" { + t.Fatalf("safeCredentialErrorMessage(nil) = %q, want empty", got) + } + + if got := safeCredentialErrorMessage(&errs.ConfigError{Problem: errs.Problem{ + Category: errs.CategoryConfig, + Subtype: errs.SubtypeInvalidConfig, + }}); got != "config/invalid_config" { + t.Fatalf("safeCredentialErrorMessage typed fallback = %q, want config/invalid_config", got) + } + + samplePAT := testPublicSafeJoin("pat", "-sample") + got := safeCredentialErrorMessage(errors.New("transport failed with " + testCredentialAssignment("token", samplePAT))) + if strings.Contains(got, samplePAT) { + t.Fatalf("safeCredentialErrorMessage leaks credential value: %q", got) + } + if want := testRedactedAssignment("token"); !strings.Contains(got, want) { + t.Fatalf("safeCredentialErrorMessage missing %q after redaction: %q", want, got) + } +} + +func TestWriteCredentialErrorRedactsTypedMessageHint(t *testing.T) { + samplePAT := testPublicSafeJoin("pat", "-sample") + err := errs.NewInternalError(errs.SubtypeStorage, "save failed with %s", testCredentialAssignment("token", samplePAT)). + WithHint("retry without %s", testCredentialAssignment("password", samplePAT)). + WithLogID("log_x") + + var buf bytes.Buffer + writeCredentialError(&buf, "Git credential refresh failed", err) + got := buf.String() + for _, leaked := range []string{samplePAT, testCredentialAssignment("token", samplePAT), testCredentialAssignment("password", samplePAT)} { + if strings.Contains(got, leaked) { + t.Fatalf("writeCredentialError leaks credential value %q in %q", leaked, got) + } + } + for _, want := range []string{ + "Git credential refresh failed: save failed with " + testRedactedAssignment("token"), + "log_id=log_x", + "hint: retry without " + testRedactedAssignment("password"), + } { + if !strings.Contains(got, want) { + t.Fatalf("writeCredentialError output missing %q: %q", want, got) + } + } + + writeCredentialError(nil, "ignored", err) + writeCredentialError(&buf, "ignored", nil) +} + +func assertProblem(t *testing.T, err error, wantCategory errs.Category, wantSubtype errs.Subtype) { + t.Helper() + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed errs.Problem, got %T: %v", err, err) + } + if p.Category != wantCategory || p.Subtype != wantSubtype { + t.Fatalf("problem metadata = %s/%s, want %s/%s", p.Category, p.Subtype, wantCategory, wantSubtype) + } +} + +func testPublicSafeJoin(parts ...string) string { + return strings.Join(parts, "") +} + +func testCredentialAssignment(key, value string) string { + return key + "=" + value +} + +func testRedactedAssignment(key string) string { + return key + "=" +} + +func testCredentialColon(key, value string) string { + return key + ": " + value +} + +func testRedactedColon(key string) string { + return key + ": " +} + +func testDoubleQuotedAssignment(key, value string) string { + return `"` + key + `"` + ":" + `"` + value + `"` +} + +func testDoubleQuotedRedactedAssignment(key string) string { + return `"` + key + `"` + ":" +} + +func testSingleQuotedAssignment(key, value string) string { + return `'` + key + `'` + ":" + `'` + value + `'` +} + +func testSingleQuotedRedactedAssignment(key string) string { + return `'` + key + `'` + ":" +} + +func testCredentialURLWithUserInfo(hostPath, credential string) string { + return "https://" + "user:" + credential + "@" + hostPath +} + +func testAuthorizationBearer(value string) string { + return "Authorization" + ": " + "Bearer " + value +} + +func testRedactedAuthorizationBearer() string { + return "Authorization" + ": " + "Bearer " +} + func testProfile() ProfileContext { return ProfileContext{Profile: "default", ProfileAppID: "cli_xxx", UserOpenID: "ou_xxx"} } diff --git a/shortcuts/apps/gitcred/helper.go b/shortcuts/apps/gitcred/helper.go index 2533efe6..8610a401 100644 --- a/shortcuts/apps/gitcred/helper.go +++ b/shortcuts/apps/gitcred/helper.go @@ -8,6 +8,7 @@ import ( "context" "fmt" "io" + "regexp" "strings" "time" @@ -27,6 +28,25 @@ type Manager struct { Now func() time.Time } +// credentialKeys is the shared list of credential field names to redact; the +// bare, double-quoted (JSON), and single-quoted forms all reuse it. +const credentialKeys = `access_token|refresh_token|app_secret|token|pat|password|secret` + +var ( + credentialURLUserinfoRE = regexp.MustCompile(`(?i)(https?://)[^/\s]+@`) + // credentialAssignmentRE matches credential key assignments, including JSON + // quoted forms. Capture group 1 is the key and separator; only the value is + // replaced with . The key is one of three forms — double-quoted, + // single-quoted, or bare with a word boundary — so concatenated words like + // mytoken are not matched. Each form wraps the key list in (?:...) so the | + // alternation does not bind the quote/boundary to only the first and last key. + credentialAssignmentRE = regexp.MustCompile( + `(?i)((?:"(?:` + credentialKeys + `)"|'(?:` + credentialKeys + `)'|\b(?:` + credentialKeys + `)\b)\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)`, + ) + credentialBearerRE = regexp.MustCompile(`(?i)(authorization\s*:\s*bearer\s+)[^\s,;]+`) + credentialPATLikeRE = regexp.MustCompile(`(?i)\b(?:gh[pousr]_[A-Za-z0-9_]{20,}|pat-[A-Za-z0-9._-]+)\b`) +) + func NewManager(store *Store, secrets *SecretStore, gitConfig GitConfig, issuer Issuer) *Manager { return &Manager{ Store: store, @@ -172,12 +192,12 @@ func (m *Manager) List() (*ListResult, error) { 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) + writeCredentialError(errOut, "Git credential unavailable", err) return nil } record, pat, ok, err := m.readConfirmed(url, current) if err != nil { - fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err) + writeCredentialError(errOut, "Git credential unavailable", err) return nil } if !ok { @@ -187,18 +207,28 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil return writeGitCredential(out, record.Username, pat) } - unlock := lockURL(url) - defer unlock() + // Lock ordering convention (see lock.go package comment): always acquire + // lockApp before lockURL. lockApp is a cross-process file lock with a + // timeout and possible setup failure; acquiring it first avoids holding an + // in-process mutex on the failure path, which would risk a deadlock. unlockApp, err := lockApp(record.AppID) if err != nil { - fmt.Fprintf(errOut, "Git credential refresh failed: acquire lock for %s: %s\n", record.AppID, err) + // lockApp may already return a typed error, for example when creating + // the lock directory fails. Preserve those classifications and only wrap + // raw lockfile errors to add app context. + if _, ok := errs.ProblemOf(err); !ok { + err = errs.NewInternalError(errs.SubtypeStorage, "acquire Git credential lock for %s: %v", record.AppID, err).WithCause(err) + } + writeCredentialError(errOut, "Git credential refresh failed", err) return nil } defer unlockApp() + unlockURL := lockURL(url) + defer unlockURL() record, pat, ok, err = m.readConfirmed(url, current) if err != nil { - fmt.Fprintf(errOut, "Git credential unavailable: %s\n", err) + writeCredentialError(errOut, "Git credential unavailable", err) return nil } if !ok { @@ -213,16 +243,17 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil } 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) + writeCredentialError(errOut, "Git credential refresh failed", err) + fmt.Fprintf(errOut, "Next step: lark-cli apps +git-credential-init --app-id %s\n", record.AppID) return nil } issuedURL, urlErr := NormalizeGitHTTPURL(issued.GitHTTPURL) if urlErr != nil { - fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", urlErr) + writeCredentialError(errOut, "Git credential refresh failed", urlErr) return nil } if err := validateIssuedCredential(record.AppID, issuedURL, issued, m.nowUnix()); err != nil { - fmt.Fprintf(errOut, "Git credential refresh failed: %s\n", err) + writeCredentialError(errOut, "Git credential refresh failed", err) return nil } if issuedURL != url { @@ -232,7 +263,7 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil 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) + writeCredentialError(errOut, "Git credential unavailable", readErr) return nil } if found && m.usable(latest, latestPAT) { @@ -247,17 +278,64 @@ func (m *Manager) Get(ctx context.Context, input CredentialInput, current Profil 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) + writeCredentialError(errOut, "Git credential refresh failed", 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) + writeCredentialError(errOut, "Git credential refresh failed", err) return nil } return writeGitCredential(out, record.Username, issued.PAT) } +func writeCredentialError(w io.Writer, prefix string, err error) { + if w == nil || err == nil { + return + } + fmt.Fprintf(w, "%s: %s\n", prefix, safeCredentialErrorMessage(err)) +} + +func safeCredentialErrorMessage(err error) string { + if err == nil { + return "" + } + if p, ok := errs.ProblemOf(err); ok { + message := RedactCredentialText(p.Message) + if p.LogID != "" { + if message != "" { + message += "; " + } + message += "log_id=" + p.LogID + } + if p.Hint != "" { + if message != "" { + message += "; " + } + message += "hint: " + RedactCredentialText(p.Hint) + } + if message != "" { + return message + } + if p.Category != "" || p.Subtype != "" { + return strings.Trim(strings.TrimSpace(string(p.Category)+"/"+string(p.Subtype)), "/") + } + } + return RedactCredentialText(err.Error()) +} + +// RedactCredentialText masks credential fragments that may appear in free +// text, covering URL userinfo, Authorization bearer headers, credential +// assignments including JSON-quoted forms, and PAT-shaped strings. Shared by +// the gitcred and apps packages so the redaction logic does not fork. +func RedactCredentialText(text string) string { + text = credentialURLUserinfoRE.ReplaceAllString(text, "${1}***@") + text = credentialBearerRE.ReplaceAllString(text, "${1}") + text = credentialAssignmentRE.ReplaceAllString(text, "${1}") + text = credentialPATLikeRE.ReplaceAllString(text, "") + return text +} + func (m *Manager) currentAppRecord(appID string) (*CredentialRecord, error) { records, err := m.Store.FindByAppID(appID, ProfileContext{}) if err != nil || len(records) == 0 { diff --git a/shortcuts/apps/gitcred/keychain.go b/shortcuts/apps/gitcred/keychain.go index 21e84d4b..ba4b388d 100644 --- a/shortcuts/apps/gitcred/keychain.go +++ b/shortcuts/apps/gitcred/keychain.go @@ -42,7 +42,15 @@ func (s *SecretStore) Set(ref, pat string) error { Message: "keychain PAT reference is empty", }} } - return s.kc.Set(KeychainService, ref, pat) + if err := s.kc.Set(KeychainService, ref, pat); err != nil { + return &errs.ConfigError{Problem: errs.Problem{ + Category: errs.CategoryConfig, + Subtype: errs.SubtypeInvalidConfig, + Message: "save local Git credential PAT to keychain failed", + Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-init", + }, Cause: err} + } + return nil } func (s *SecretStore) Remove(ref string) error { @@ -64,7 +72,7 @@ func (s *SecretStore) Remove(ref string) error { return &errs.ConfigError{Problem: errs.Problem{ Category: errs.CategoryConfig, Subtype: errs.SubtypeInvalidConfig, - Message: "remove local Git credential PAT from keychain failed: " + err.Error(), + Message: "remove local Git credential PAT from keychain failed", Hint: "make sure the system credential store is available, then retry lark-cli apps +git-credential-remove", }, Cause: err} } diff --git a/shortcuts/apps/gitcred/lock.go b/shortcuts/apps/gitcred/lock.go index 3cc0ae46..8723e82d 100644 --- a/shortcuts/apps/gitcred/lock.go +++ b/shortcuts/apps/gitcred/lock.go @@ -1,6 +1,24 @@ // Copyright (c) 2026 Lark Technologies Pte. Ltd. // SPDX-License-Identifier: MIT +// Package gitcred manages the lifecycle of app Git credentials. +// +// Lock ordering convention — read this before adding any new lock acquisition: +// +// ALWAYS acquire lockApp BEFORE lockURL. Never invert this order. +// +// Rationale: +// - lockApp is a cross-process file lock with bounded timeout (2s) and a +// possible setup error; acquiring it first keeps the failure surface +// outside any in-process lock and avoids holding the in-process mutex +// while waiting on I/O / another process. +// - lockURL is an in-process sync.Mutex that never fails and blocks +// indefinitely; holding it while waiting on lockApp would risk +// deadlocking with a concurrent goroutine that held lockApp first. +// +// Paths that only manipulate per-app state (Init, Remove, Erase) only need +// lockApp. Get() is the only path that touches per-URL state in addition to +// per-app state, so it is the only caller that takes both locks. package gitcred import ( @@ -20,6 +38,11 @@ var urlLocks sync.Map var safeLockNameChars = regexp.MustCompile(`[^a-zA-Z0-9._-]`) +// lockURL acquires an in-process, per-URL mutex. It never returns an error +// and blocks until the mutex is available. +// +// Lock ordering: lockURL MUST NOT be held while calling lockApp. See package +// comment for the full convention. func lockURL(url string) func() { actual, _ := urlLocks.LoadOrStore(url, &sync.Mutex{}) mu := actual.(*sync.Mutex) @@ -27,6 +50,12 @@ func lockURL(url string) func() { return mu.Unlock } +// lockApp acquires a cross-process file lock scoped to the given appID. It +// returns an unlock function or an error if the lock directory cannot be +// created or the lock cannot be acquired within the 2s timeout. +// +// Lock ordering: when both lockApp and lockURL are needed, lockApp must be +// taken FIRST. See package comment for the full convention. func lockApp(appID string) (func(), error) { dir := filepath.Join(core.GetConfigDir(), "locks") if err := vfs.MkdirAll(dir, 0700); err != nil { diff --git a/shortcuts/apps/plugin_common.go b/shortcuts/apps/plugin_common.go new file mode 100644 index 00000000..d362f5f4 --- /dev/null +++ b/shortcuts/apps/plugin_common.go @@ -0,0 +1,420 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "archive/tar" + "compress/gzip" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" +) + +// pluginResolveProjectPath resolves --project-path to an absolute path, +// defaulting to cwd when empty. +func pluginResolveProjectPath(raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + 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) + } + return cwd, nil + } + if err := validate.RejectControlChars(raw, "--project-path"); err != nil { + return "", err + } + return filepath.Clean(raw), nil +} + +// pluginCheckProjectDir validates that projectPath contains a package.json. +func pluginCheckProjectDir(projectPath string) error { + info, err := os.Stat(filepath.Join(projectPath, "package.json")) //nolint:forbidigo // shortcuts cannot import internal/vfs; local stat for project dir check. + if err != nil { + if os.IsNotExist(err) { + return appsFailedPreconditionError("package.json not found in %s", projectPath). + WithHint("run 'lark-cli apps +init' to initialize the project first") + } + return appsFileIOError(err, "cannot access package.json in %s", projectPath) + } + if !info.Mode().IsRegular() { + return appsFailedPreconditionError("package.json in %s is not a regular file", projectPath) + } + return nil +} + +// validatePluginKey validates a plugin key for use in filesystem paths. +// Rejects empty, ".", "..", absolute paths, path traversal, and control characters. +func validatePluginKey(key string) error { + if key == "" || key == "." || key == ".." { + return appsValidationError("invalid plugin key: must not be empty, \".\", or \"..\"") + } + if filepath.IsAbs(key) { + return appsValidationError("invalid plugin key: must not be an absolute path: %q", key) + } + if strings.Contains(key, "..") { + return appsValidationError("invalid plugin key: must not contain path traversal: %q", key) + } + for _, r := range key { + if r < 32 || r == 127 { + return appsValidationError("invalid plugin key: contains control character (code %d)", r) + } + } + return nil +} + +// secureModulePath validates the plugin key and joins it with +// projectPath/node_modules, asserting the result stays within node_modules. +func secureModulePath(projectPath, key string) (string, error) { + if err := validatePluginKey(key); err != nil { + return "", err + } + nodeModules := filepath.Join(projectPath, "node_modules") + resolved := filepath.Clean(filepath.Join(nodeModules, key)) + expectedPrefix := filepath.Clean(nodeModules) + string(filepath.Separator) + if !strings.HasPrefix(resolved+string(filepath.Separator), expectedPrefix) { + return "", appsValidationError("plugin key %q resolves outside node_modules", key) + } + return resolved, nil +} + +// pluginResolveCapDir resolves the capabilities directory using a 3-level fallback: +// 1. MIAODA_CAPABILITIES_DIR env var +// 2. MIAODA_APP_TYPE env var (2→server/capabilities, 6→shared/capabilities) +// 2.5 Read .env.local for MIAODA_APP_TYPE +// 3. Detect by checking which directories exist under projectPath +func pluginResolveCapDir(projectPath string) (string, error) { + if dir := os.Getenv("MIAODA_CAPABILITIES_DIR"); dir != "" { + if filepath.IsAbs(dir) { + return dir, nil + } + return filepath.Join(projectPath, dir), nil + } + + // 2. MIAODA_APP_TYPE: only appType=6 (Modern) uses shared/; everything else uses server/ + appType := os.Getenv("MIAODA_APP_TYPE") + if appType == "" { + appType = pluginReadEnvLocalValue(projectPath, "MIAODA_APP_TYPE") + } + if appType != "" { + if _, err := strconv.Atoi(appType); err != nil { + return "", appsValidationError("MIAODA_APP_TYPE must be a number, got %q", appType). + WithHint("set MIAODA_APP_TYPE to a valid numeric value in .env.local") + } + } + if appType == "6" { + return filepath.Join(projectPath, "shared", "capabilities"), nil + } + if appType != "" { + return filepath.Join(projectPath, "server", "capabilities"), nil + } + + // 3. Directory detection + serverDir := filepath.Join(projectPath, "server", "capabilities") + sharedDir := filepath.Join(projectPath, "shared", "capabilities") + serverOK := pluginDirExists(serverDir) + sharedOK := pluginDirExists(sharedDir) + + switch { + case serverOK && sharedOK: + return "", appsFailedPreconditionError( + "ambiguous capabilities path: both server/capabilities/ and shared/capabilities/ exist", + ).WithHint("set MIAODA_APP_TYPE or MIAODA_CAPABILITIES_DIR in .env.local to resolve ambiguity") + case serverOK: + return serverDir, nil + case sharedOK: + return sharedDir, nil + default: + return filepath.Join(projectPath, "server", "capabilities"), nil + } +} + +// pluginReadEnvLocalValue reads a value from .env.local by key name. +func pluginReadEnvLocalValue(projectPath, key string) string { + data, err := os.ReadFile(filepath.Join(projectPath, ".env.local")) //nolint:forbidigo // shortcuts cannot import internal/vfs; local env file read. + if err != nil { + return "" + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + k, v, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(k) != key { + continue + } + v = strings.TrimSpace(v) + v = strings.Trim(v, "\"'") + return v + } + return "" +} + +func pluginDirExists(path string) bool { + info, err := os.Stat(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local dir existence check. + return err == nil && info.IsDir() +} + +// pluginListCapabilities reads all *.json files from capDir. +// Returns nil (not error) if the directory does not exist. +func pluginListCapabilities(capDir string) ([]map[string]interface{}, error) { + entries, err := os.ReadDir(capDir) //nolint:forbidigo // shortcuts cannot import internal/vfs; local dir listing. + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, appsFileIOError(err, "cannot read capabilities directory %s", capDir) + } + + var caps []map[string]interface{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + data, err := os.ReadFile(filepath.Join(capDir, entry.Name())) //nolint:forbidigo + if err != nil { + continue + } + var cap map[string]interface{} + if err := json.Unmarshal(data, &cap); err != nil { + continue + } + caps = append(caps, cap) + } + return caps, nil +} + +// pluginCheckDependentInstances scans the capabilities directory for instances +// that reference the given pluginKey. Returns nil if none found, an error with +// the list of dependent instance ids if any exist, or the underlying I/O error. +func pluginCheckDependentInstances(projectPath, pluginKey string) error { + capDir, err := pluginResolveCapDir(projectPath) + if err != nil { + return nil //nolint:nilerr // best-effort: no capabilities dir means no conflict + } + caps, err := pluginListCapabilities(capDir) + if err != nil { + return nil //nolint:nilerr // best-effort: scan failure should not block uninstall + } + var deps []string + for _, cap := range caps { + if pk, _ := cap["pluginKey"].(string); pk == pluginKey { + if id, _ := cap["id"].(string); id != "" { + deps = append(deps, id) + } + } + } + if len(deps) == 0 { + return nil + } + return appsFailedPreconditionError( + "plugin %q is still referenced by %d instance(s): %s", pluginKey, len(deps), strings.Join(deps, ", "), + ).WithHint("delete these instances first (see /.agents/skills/plugin-guide/SKILL.md for instance removal steps), clean up calling code and types, then retry uninstall") +} + +// ── package.json helpers ── + +// pluginReadPackageJSON reads and parses the project's package.json. +func pluginReadPackageJSON(projectPath string) (map[string]interface{}, error) { + path := filepath.Join(projectPath, "package.json") + data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package.json read. + if err != nil { + return nil, appsFileIOError(err, "cannot read package.json") + } + var pkg map[string]interface{} + if err := json.Unmarshal(data, &pkg); err != nil { + return nil, appsValidationError("invalid package.json: %v", err).WithCause(err) + } + return pkg, nil +} + +// pluginWritePackageJSON writes package.json atomically, preserving formatting. +func pluginWritePackageJSON(projectPath string, pkg map[string]interface{}) error { + data, err := json.MarshalIndent(pkg, "", " ") + if err != nil { + return appsFileIOError(err, "cannot marshal package.json") + } + data = append(data, '\n') + return validate.AtomicWrite(filepath.Join(projectPath, "package.json"), data, 0o644) +} + +// pluginGetActionPlugins extracts actionPlugins from package.json as key→version. +func pluginGetActionPlugins(pkg map[string]interface{}) map[string]string { + raw, ok := pkg["actionPlugins"] + if !ok { + return nil + } + m, ok := raw.(map[string]interface{}) + if !ok { + return nil + } + out := make(map[string]string, len(m)) + for k, v := range m { + if s, ok := v.(string); ok { + out[k] = s + } + } + return out +} + +// pluginSetActionPlugin adds or updates a plugin entry in actionPlugins. +func pluginSetActionPlugin(pkg map[string]interface{}, key, version string) { + m, ok := pkg["actionPlugins"].(map[string]interface{}) + if !ok { + m = make(map[string]interface{}) + pkg["actionPlugins"] = m + } + m[key] = version +} + +// pluginRemoveActionPlugin removes a plugin entry from actionPlugins. +func pluginRemoveActionPlugin(pkg map[string]interface{}, key string) { + m, ok := pkg["actionPlugins"].(map[string]interface{}) + if !ok { + return + } + delete(m, key) +} + +// pluginSyncActionPlugins ensures the actionPlugins record in package.json +// matches the actually installed version, even when install is skipped. +func pluginSyncActionPlugins(projectPath, key, version string) { + pkg, err := pluginReadPackageJSON(projectPath) + if err != nil { + return + } + ap := pluginGetActionPlugins(pkg) + if ap[key] == version { + return + } + pluginSetActionPlugin(pkg, key, version) + _ = pluginWritePackageJSON(projectPath, pkg) +} + +// pluginCheckPeerDeps reads peerDependencies from the installed plugin's +// package.json and returns the names of any that are missing from node_modules. +func pluginCheckPeerDeps(projectPath, pluginKey string) []string { + pkgPath := filepath.Join(projectPath, "node_modules", pluginKey, "package.json") + data, err := os.ReadFile(pkgPath) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package read. + if err != nil { + return nil + } + var pkg map[string]interface{} + if err := json.Unmarshal(data, &pkg); err != nil { + return nil + } + peerDeps, ok := pkg["peerDependencies"].(map[string]interface{}) + if !ok || len(peerDeps) == 0 { + return nil + } + var missing []string + for dep := range peerDeps { + depDir := filepath.Join(projectPath, "node_modules", dep) + if !pluginDirExists(depDir) { + missing = append(missing, dep) + } + } + return missing +} + +// pluginInstalledVersion reads the version of an installed plugin from its +// package.json in node_modules. Returns "" if not found or unreadable. +func pluginInstalledVersion(projectPath, pluginKey string) string { + path := filepath.Join(projectPath, "node_modules", pluginKey, "package.json") + data, err := os.ReadFile(path) //nolint:forbidigo // shortcuts cannot import internal/vfs; local package read. + if err != nil { + return "" + } + var pkg map[string]interface{} + if err := json.Unmarshal(data, &pkg); err != nil { + return "" + } + v, _ := pkg["version"].(string) + return v +} + +// ── tgz extraction ── + +const pluginExtractMaxBytes = 10 * 1024 * 1024 + +// pluginExtractTGZ extracts a gzipped tar archive into destDir, stripping the +// first path component (npm convention: tarballs contain a "package/" prefix). +// Path traversal entries are silently skipped. +func pluginExtractTGZ(r io.Reader, destDir string) error { + gz, err := gzip.NewReader(r) + if err != nil { + return fmt.Errorf("gzip: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed + } + defer gz.Close() + + cleanDest := filepath.Clean(destDir) + string(filepath.Separator) + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("tar: %w", err) //nolint:forbidigo // intermediate helper error; callers wrap as typed + } + + name := pluginStripFirstComponent(hdr.Name) + if name == "" { + continue + } + if strings.Contains(name, "..") { + continue + } + + target := filepath.Join(destDir, name) + if !strings.HasPrefix(filepath.Clean(target)+string(filepath.Separator), cleanDest) && + filepath.Clean(target) != filepath.Clean(destDir) { + continue + } + + switch hdr.Typeflag { + case tar.TypeSymlink, tar.TypeLink: + continue + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs; tgz extraction. + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { //nolint:forbidigo + return err + } + f, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(hdr.Mode)&0o755) //nolint:forbidigo + if err != nil { + return err + } + if _, err := io.Copy(f, io.LimitReader(tr, pluginExtractMaxBytes)); err != nil { + if cerr := f.Close(); cerr != nil { + return fmt.Errorf("copy tar entry: %w; close file: %w", err, cerr) //nolint:forbidigo // intermediate helper error; callers wrap as typed + } + return err + } + if err := f.Close(); err != nil { + return err + } + } + } + return nil +} + +// pluginStripFirstComponent removes the first path component ("package/foo" → "foo"). +func pluginStripFirstComponent(name string) string { + name = filepath.ToSlash(name) + if i := strings.Index(name, "/"); i >= 0 { + return name[i+1:] + } + return "" +} diff --git a/shortcuts/apps/plugin_common_test.go b/shortcuts/apps/plugin_common_test.go new file mode 100644 index 00000000..7a6ce320 --- /dev/null +++ b/shortcuts/apps/plugin_common_test.go @@ -0,0 +1,253 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package apps + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/larksuite/cli/errs" +) + +// --- pluginResolveProjectPath --- + +func TestPluginResolveProjectPath_DefaultToCwd(t *testing.T) { + got, err := pluginResolveProjectPath("") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + cwd, _ := os.Getwd() + if got != cwd { + t.Errorf("got %q, want cwd %q", got, cwd) + } +} + +func TestPluginResolveProjectPath_ExplicitPath(t *testing.T) { + got, err := pluginResolveProjectPath("/tmp/myapp") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "/tmp/myapp" { + t.Errorf("got %q, want /tmp/myapp", got) + } +} + +// --- pluginCheckProjectDir --- + +func TestPluginCheckProjectDir_OK(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "package.json"), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + if err := pluginCheckProjectDir(dir); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPluginCheckProjectDir_Missing(t *testing.T) { + dir := t.TempDir() + err := pluginCheckProjectDir(dir) + if err == nil { + t.Fatal("expected error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", p.Subtype) + } +} + +// --- pluginResolveCapDir --- + +func TestPluginResolveCapDir_EnvVar(t *testing.T) { + t.Setenv("MIAODA_CAPABILITIES_DIR", "envdir/caps") + got, err := pluginResolveCapDir("/proj") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "envdir/caps"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_AppTypeEnv(t *testing.T) { + t.Setenv("MIAODA_APP_TYPE", "2") + got, err := pluginResolveCapDir("/proj") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "server", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_AppTypeEnvShared(t *testing.T) { + t.Setenv("MIAODA_APP_TYPE", "6") + got, err := pluginResolveCapDir("/proj") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "shared", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_EnvLocal(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".env.local"), []byte("MIAODA_APP_TYPE=2\n"), 0o644); err != nil { + t.Fatal(err) + } + got, err := pluginResolveCapDir(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join(dir, "server", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_DetectServer(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { + t.Fatal(err) + } + got, err := pluginResolveCapDir(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join(dir, "server", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_DetectShared(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { + t.Fatal(err) + } + got, err := pluginResolveCapDir(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join(dir, "shared", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_Ambiguous(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "server", "capabilities"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "shared", "capabilities"), 0o755); err != nil { + t.Fatal(err) + } + _, err := pluginResolveCapDir(dir) + if err == nil { + t.Fatal("expected ambiguous error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if p.Subtype != errs.SubtypeFailedPrecondition { + t.Errorf("subtype = %q, want failed_precondition", p.Subtype) + } +} + +func TestPluginResolveCapDir_NeitherExists_DefaultsToServer(t *testing.T) { + dir := t.TempDir() + got, err := pluginResolveCapDir(dir) + if err != nil { + t.Fatalf("should default to server/capabilities, got error: %v", err) + } + if want := filepath.Join(dir, "server", "capabilities"); got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestPluginResolveCapDir_AppType3_UsesServer(t *testing.T) { + t.Setenv("MIAODA_APP_TYPE", "3") + got, err := pluginResolveCapDir("/proj") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := filepath.Join("/proj", "server", "capabilities"); got != want { + t.Errorf("got %q, want %q (appType=3 should use server)", got, want) + } +} + +// --- pluginListCapabilities --- + +func TestPluginListCapabilities_Empty(t *testing.T) { + dir := t.TempDir() + caps, err := pluginListCapabilities(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(caps) != 0 { + t.Errorf("got %d caps, want 0", len(caps)) + } +} + +func TestPluginListCapabilities_DirNotExist(t *testing.T) { + caps, err := pluginListCapabilities("/nonexistent/path") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if caps != nil { + t.Errorf("got %v, want nil", caps) + } +} + +func TestPluginListCapabilities_WithFiles(t *testing.T) { + dir := t.TempDir() + writeTestCapJSON(t, dir, "cap1.json", map[string]interface{}{"id": "cap1", "name": "Cap One"}) + writeTestCapJSON(t, dir, "cap2.json", map[string]interface{}{"id": "cap2", "name": "Cap Two"}) + // non-JSON file should be skipped + if err := os.WriteFile(filepath.Join(dir, "readme.txt"), []byte("ignore"), 0o644); err != nil { + t.Fatal(err) + } + + caps, err := pluginListCapabilities(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(caps) != 2 { + t.Fatalf("got %d caps, want 2", len(caps)) + } +} + +func TestPluginListCapabilities_SkipsMalformed(t *testing.T) { + dir := t.TempDir() + writeTestCapJSON(t, dir, "good.json", map[string]interface{}{"id": "good"}) + if err := os.WriteFile(filepath.Join(dir, "bad.json"), []byte("not json"), 0o644); err != nil { + t.Fatal(err) + } + + caps, err := pluginListCapabilities(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(caps) != 1 { + t.Fatalf("got %d caps, want 1", len(caps)) + } +} + +// --- helpers --- + +func writeTestCapJSON(t *testing.T, dir, filename string, data map[string]interface{}) { + t.Helper() + b, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, filename), b, 0o644); err != nil { + t.Fatal(err) + } +} diff --git a/shortcuts/apps/shortcuts.go b/shortcuts/apps/shortcuts.go index e15489fa..b3ac97f5 100644 --- a/shortcuts/apps/shortcuts.go +++ b/shortcuts/apps/shortcuts.go @@ -7,6 +7,9 @@ import "github.com/larksuite/cli/shortcuts/common" // Shortcuts returns all apps domain shortcuts. func Shortcuts() []common.Shortcut { + envSet := withExtraTips(AppsEnvVarSet, "Example: lark-cli apps +env-set --app-id --environment online --key FOO --value --yes") + envDelete := withExtraTips(AppsEnvVarDelete, "Tip: +env-delete is high-risk-write; only pass --yes after explicit confirmation.") + return []common.Shortcut{ AppsCreate, AppsUpdate, @@ -19,10 +22,38 @@ func Shortcuts() []common.Shortcut { AppsReleaseList, AppsReleaseGet, AppsEnvPull, + withExtraTips(AppsLogList, "Tip: logs are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsLogGet, "Tip: logs are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsTraceList, "Tip: traces are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsTraceGet, "Tip: traces are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsMetricList, "Tip: metrics are online-only; keep --environment omitted or set --environment online."), + withExtraTips(AppsAnalyticsList, "Tip: analytics are online-only; keep --environment omitted or set --environment online."), + AppsEnvVarList, + envSet, + envDelete, AppsDBTableList, AppsDBTableGet, AppsDBExecute, AppsDBEnvCreate, + AppsDBDataImport, + AppsDBDataExport, + AppsDBChangelogList, + AppsDBAuditStatus, + AppsDBAuditEnable, + AppsDBAuditDisable, + AppsDBAuditList, + AppsDBEnvDiff, + AppsDBEnvMigrate, + AppsDBRecoveryDiff, + AppsDBRecoveryApply, + AppsDBQuotaGet, + AppsFileList, + AppsFileGet, + AppsFileSign, + AppsFileDownload, + AppsFileUpload, + AppsFileDelete, + AppsFileQuotaGet, AppsGitCredentialInit, AppsGitCredentialList, AppsGitCredentialRemove, @@ -32,5 +63,22 @@ func Shortcuts() []common.Shortcut { AppsSessionStop, AppsSessionMessagesList, AppsChat, + AppsPluginInstall, + AppsPluginUninstall, + AppsPluginList, + // open API key management + AppsOpenAPIKeyList, + AppsOpenAPIKeyGet, + AppsOpenAPIKeyCreate, + AppsOpenAPIKeyUpdate, + AppsOpenAPIKeyEnable, + AppsOpenAPIKeyDisable, + AppsOpenAPIKeyDelete, + AppsOpenAPIKeyReset, } } + +func withExtraTips(sc common.Shortcut, tips ...string) common.Shortcut { + sc.Tips = append(append([]string{}, sc.Tips...), tips...) + return sc +} diff --git a/shortcuts/apps/shortcuts_test.go b/shortcuts/apps/shortcuts_test.go index 264c7ed4..fccdb62f 100644 --- a/shortcuts/apps/shortcuts_test.go +++ b/shortcuts/apps/shortcuts_test.go @@ -10,12 +10,60 @@ import ( ) // 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。 -// 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)+ 1 session-messages-list = 24。 -func TestAppsShortcuts_Returns24(t *testing.T) { +// 6 基础 + 1 init + 3 publish + 1 env-pull +// - 6 observability(log-list/log-get/trace-list/trace-get/metric-list/analytics-list) +// - 3 env(list/set/delete) +// - 16 db(table-list/table-schema/sql/dev-init/data-import/data-export/changelog-list/ +// audit-status/audit-enable/audit-disable/audit-list/ +// env-diff/env-migrate/recovery-diff/recovery-apply/quota-get) +// - 7 file(list/get/sign/download/upload/delete/quota-get) +// - 3 git-credential +// - 5 session(create/list/get/stop/chat)+ 1 session-messages-list +// - 8 openapi-key(list/get/create/update/enable/disable/delete/reset) +// - 3 plugin(install/uninstall/list)= 63。 +func TestAppsShortcuts_Returns63(t *testing.T) { got := Shortcuts() - if len(got) != 24 { - t.Fatalf("Shortcuts() returned %d entries, want 24", len(got)) + if len(got) != 63 { + t.Fatalf("Shortcuts() returned %d entries, want 63", len(got)) + } +} + +func TestAppsShortcuts_DoesNotIncludeEnvGet(t *testing.T) { + for _, sc := range Shortcuts() { + switch sc.Command { + case "+env-get", "+envvar-get", "+envvar-list", "+envvar-set", "+envvar-delete": + t.Fatalf("Shortcuts() must not register %s", sc.Command) + } + } +} + +func TestAppsShortcuts_DoesNotIncludeMetricQueryAliases(t *testing.T) { + for _, sc := range Shortcuts() { + switch sc.Command { + case "+metric-query", "+analytics-query": + t.Fatalf("Shortcuts() must not register %s", sc.Command) + } + } +} + +func TestAppsShortcuts_EnvCommandsUseCanonicalNames(t *testing.T) { + want := map[string]bool{ + "+env-list": false, + "+env-set": false, + "+env-delete": false, + } + for _, sc := range Shortcuts() { + if _, ok := want[sc.Command]; ok { + want[sc.Command] = true + if sc.Hidden { + t.Errorf("%s must be visible", sc.Command) + } + } + } + for cmd, found := range want { + if !found { + t.Errorf("Shortcuts() missing canonical %s", cmd) + } } } @@ -40,6 +88,7 @@ func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) { } } +// TestAppsGitCredentialHelper_IsNotAShortcut 确认 git credential helper 不作为 shortcut 暴露。 func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) { for _, shortcut := range Shortcuts() { if shortcut.Command == "git-credential-helper" { @@ -48,18 +97,21 @@ func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) { } } +// TestAppsGitCredentialRemove_IsLocalCleanupWithoutScopes 确认 git credential remove 是本地清理、不带任何 scope。 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) } } +// TestAppsGitCredentialList_IsLocalReadWithoutScopes 确认 git credential list 是本地读取、不带任何 scope。 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) } } +// TestInstallOnApps_AddsHiddenGitCredentialHelper 验证 InstallOnApps 挂载一个隐藏、带 RunE 且独立于 shortcut 管线的 git-credential-helper 命令。 func TestInstallOnApps_AddsHiddenGitCredentialHelper(t *testing.T) { parent := &cobra.Command{Use: "apps"} InstallOnApps(parent, nil) diff --git a/shortcuts/base/base_dryrun_ops_test.go b/shortcuts/base/base_dryrun_ops_test.go index 17de7bdd..5a1646ce 100644 --- a/shortcuts/base/base_dryrun_ops_test.go +++ b/shortcuts/base/base_dryrun_ops_test.go @@ -89,6 +89,18 @@ func TestDryRunFieldOps(t *testing.T) { ) assertDryRunContains(t, dryRunFieldGet(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1") assertDryRunContains(t, dryRunFieldCreate(ctx, rt), "POST /open-apis/base/v3/bases/app_x/tables/tbl_1/fields") + + arrayRT := newBaseTestRuntime( + map[string]string{ + "base-token": "app_x", + "table-id": "tbl_1", + "json": `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`, + }, + nil, + nil, + ) + assertDryRunContains(t, dryRunFieldCreate(ctx, arrayRT), `"name":"A"`, `"name":"B"`) + assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1") assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1") assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open") diff --git a/shortcuts/base/base_execute_test.go b/shortcuts/base/base_execute_test.go index 79bd12ac..24cbd692 100644 --- a/shortcuts/base/base_execute_test.go +++ b/shortcuts/base/base_execute_test.go @@ -830,11 +830,6 @@ func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) { shortcut common.Shortcut args []string }{ - { - name: "field create", - shortcut: BaseFieldCreate, - args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"}, - }, { name: "field update", shortcut: BaseFieldUpdate, @@ -1102,6 +1097,54 @@ func TestBaseFieldExecuteCRUD(t *testing.T) { } }) + t.Run("create array sequentially", func(t *testing.T) { + oldDelay := fieldCreateBatchDelay + fieldCreateBatchDelay = 0 + t.Cleanup(func() { fieldCreateBatchDelay = oldDelay }) + + factory, stdout, reg := newExecuteFactory(t) + firstStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + BodyFilter: func(body []byte) bool { + return strings.Contains(string(body), `"name":"A"`) + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_a", "name": "A", "type": "text"}, + }, + } + secondStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields", + BodyFilter: func(body []byte) bool { + return strings.Contains(string(body), `"name":"B"`) + }, + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"id": "fld_b", "name": "B", "type": "text"}, + }, + } + reg.Register(firstStub) + reg.Register(secondStub) + + err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"A","type":"text"},{"name":"B","type":"text"}]`}, factory, stdout) + if err != nil { + t.Fatalf("err=%v", err) + } + data := decodeBaseEnvelope(t, stdout) + if data["created"] != true || data["total"] != float64(2) { + t.Fatalf("unexpected output: %#v", data) + } + fields, _ := data["fields"].([]interface{}) + if len(fields) != 2 { + t.Fatalf("fields len=%d output=%#v", len(fields), data) + } + if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) { + t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody) + } + }) + t.Run("delete", func(t *testing.T) { factory, stdout, reg := newExecuteFactory(t) reg.Register(&httpmock.Stub{ diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index 4dd2769c..9810cd70 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -1060,6 +1060,15 @@ func TestBaseFieldValidate(t *testing.T) { if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") { t.Fatalf("err=%v", err) } + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},{"name":"b","type":"text"}]`}, nil, nil)); err != nil { + t.Fatalf("array create validate err=%v", err) + } + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"text"},1]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json item 2 must be an object") { + t.Fatalf("err=%v", err) + } + if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `[{"name":"a","type":"formula"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") { + t.Fatalf("err=%v", err) + } if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") { t.Fatalf("err=%v", err) } diff --git a/shortcuts/base/field_ops.go b/shortcuts/base/field_ops.go index 39d6f0d6..403be096 100644 --- a/shortcuts/base/field_ops.go +++ b/shortcuts/base/field_ops.go @@ -6,10 +6,13 @@ package base import ( "context" "strings" + "time" "github.com/larksuite/cli/shortcuts/common" ) +var fieldCreateBatchDelay = time.Second + func dryRunFieldList(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { offset := runtime.Int("offset") if offset < 0 { @@ -33,12 +36,14 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { pc := newParseCtx(runtime) - body, _ := parseJSONObject(pc, runtime.Str("json"), "json") - return common.NewDryRunAPI(). - POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields"). - Body(body). + bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json")) + dr := common.NewDryRunAPI(). Set("base_token", runtime.Str("base-token")). Set("table_id", baseTableID(runtime)) + for _, body := range bodies { + dr.POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields").Body(body) + } + return dr } func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -95,11 +100,16 @@ func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command strin } func validateFieldCreate(runtime *common.RuntimeContext) error { - body, err := validateFieldJSON(runtime) + bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json")) if err != nil { return err } - return validateFormulaLookupGuideAck(runtime, "+field-create", body) + for _, body := range bodies { + if err := validateFormulaLookupGuideAck(runtime, "+field-create", body); err != nil { + return err + } + } + return nil } func validateFieldUpdate(runtime *common.RuntimeContext) error { @@ -140,19 +150,40 @@ func executeFieldGet(runtime *common.RuntimeContext) error { } func executeFieldCreate(runtime *common.RuntimeContext) error { - pc := newParseCtx(runtime) - body, err := parseJSONObject(pc, runtime.Str("json"), "json") + bodies, err := parseFieldCreateBodies(newParseCtx(runtime), runtime.Str("json")) if err != nil { return err } - data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body) - if err != nil { - return err + fields := make([]interface{}, 0, len(bodies)) + for idx, body := range bodies { + if idx > 0 && fieldCreateBatchDelay > 0 { + time.Sleep(fieldCreateBatchDelay) + } + data, err := baseV3Call(runtime, "POST", baseV3Path("bases", runtime.Str("base-token"), "tables", baseTableID(runtime), "fields"), nil, body) + if err != nil { + return err + } + fields = append(fields, data) } - runtime.Out(map[string]interface{}{"field": data, "created": true}, nil) + if len(fields) == 1 { + runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil) + return nil + } + runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil) return nil } +func parseFieldCreateBodies(pc *parseCtx, raw string) ([]map[string]interface{}, error) { + bodies, err := parseObjectList(pc, raw, "json") + if err != nil { + return nil, err + } + if len(bodies) == 0 { + return nil, baseFlagErrorf("--json must contain at least one field JSON object") + } + return bodies, nil +} + func executeFieldUpdate(runtime *common.RuntimeContext) error { pc := newParseCtx(runtime) baseToken := runtime.Str("base-token") diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index f375714c..468148c2 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -534,6 +534,20 @@ func (ctx *RuntimeContext) IO() *cmdutil.IOStreams { return ctx.Factory.IOStreams } +// StartSpinner shows a braille spinner with elapsed time on stderr for a slow +// operation, until the returned stop() runs. It is a no-op unless stderr is an +// interactive terminal, so pipes / CI / captured output emit nothing and stdout +// (JSON/pretty) is never polluted — hence it is shown in JSON mode too. Call +// stop() before printing the result; stop() is safe to call multiple times +// (e.g. `defer stop()` plus an explicit call on the success path). +func (ctx *RuntimeContext) StartSpinner(label string) func() { + io := ctx.IO() + if io == nil { + return func() {} + } + return output.StartSpinner(io.ErrOut, io.StderrIsTerminal, label) +} + // FileIO resolves the FileIO using the current execution context. // Falls back to the globally registered provider when Factory or its // FileIOProvider is nil (e.g. in lightweight test helpers). diff --git a/shortcuts/doc/doc_media_upload.go b/shortcuts/doc/doc_media_upload.go index 67ef5341..37d95151 100644 --- a/shortcuts/doc/doc_media_upload.go +++ b/shortcuts/doc/doc_media_upload.go @@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{ AuthTypes: []string{"user", "bot"}, Flags: []common.Flag{ {Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true}, - {Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true}, - {Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true}, + {Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true}, + {Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true}, {Name: "doc-id", Desc: "document ID (for drive_route_token)"}, }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { diff --git a/shortcuts/doc/docs_create_v2.go b/shortcuts/doc/docs_create_v2.go index ffa48745..96a9ed3a 100644 --- a/shortcuts/doc/docs_create_v2.go +++ b/shortcuts/doc/docs_create_v2.go @@ -18,6 +18,7 @@ func v2CreateFlags() []common.Flag { return []common.Flag{ {Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as ... so the title wins over later content titles"}, {Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}}, + {Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}}, {Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}}, {Name: "parent-token", Desc: "parent folder token or wiki node token; mutually exclusive with --parent-position"}, {Name: "parent-position", Desc: "parent position such as my_library; mutually exclusive with --parent-token"}, @@ -32,8 +33,8 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error { if runtime.Changed("title") && title == "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--title must not be empty").WithParam("--title") } - if runtime.Str("content") == "" && title == "" { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content") + if err := validateDocsV2ReferenceMapFlags(runtime); err != nil { + return err } if runtime.Str("parent-token") != "" && runtime.Str("parent-position") != "" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--parent-token and --parent-position are mutually exclusive").WithParams( @@ -41,11 +42,21 @@ func validateCreateV2(_ context.Context, runtime *common.RuntimeContext) error { errs.InvalidParam{Name: "--parent-position", Reason: "mutually exclusive with --parent-token"}, ) } + if runtime.Str("content") == "" && title == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required unless --title is provided").WithParam("--content") + } + if runtime.Str("content") != "" { + _, err := resolveDocsV2ContentReferenceMap(runtime) + return err + } return nil } func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { - body := buildCreateBody(runtime) + body, err := buildCreateBodyWithHTML5ReferenceMap(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } desc := "OpenAPI: create document" if runtime.IsBot() { desc += ". After document creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new document." @@ -57,7 +68,10 @@ func dryRunCreateV2(_ context.Context, runtime *common.RuntimeContext) *common.D } func executeCreateV2(_ context.Context, runtime *common.RuntimeContext) error { - body := buildCreateBody(runtime) + body, err := buildCreateBodyWithHTML5ReferenceMap(runtime) + if err != nil { + return err + } data, err := doDocAPI(runtime, "POST", "/open-apis/docs_ai/v1/documents", body) if err != nil { @@ -86,7 +100,10 @@ func buildCreateBody(runtime *common.RuntimeContext) map[string]interface{} { } func buildCreateContent(runtime *common.RuntimeContext) string { - content := runtime.Str("content") + return buildCreateContentWithBody(runtime, runtime.Str("content")) +} + +func buildCreateContentWithBody(runtime *common.RuntimeContext, content string) string { title := strings.TrimSpace(runtime.Str("title")) if title == "" { return content diff --git a/shortcuts/doc/docs_fetch_v2.go b/shortcuts/doc/docs_fetch_v2.go index 16ca133f..51d96b22 100644 --- a/shortcuts/doc/docs_fetch_v2.go +++ b/shortcuts/doc/docs_fetch_v2.go @@ -14,6 +14,8 @@ import ( "github.com/larksuite/cli/shortcuts/common" ) +const docsFetchExtraParam = `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}` + // v2FetchFlags returns the flag definitions for the v2 (OpenAPI) fetch path. func v2FetchFlags() []common.Flag { return []common.Flag{ @@ -69,6 +71,9 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { if err != nil { return err } + if err := processHTML5BlockReferenceMapForFetch(runtime, effectiveFetchFormat(runtime), ref.Token, data); err != nil { + return err + } if warning := addFetchDetailDowngradeWarning(runtime, data); warning != "" && runtime.Format == "pretty" { fmt.Fprintf(runtime.IO().ErrOut, "warning: %s\n", warning) } @@ -88,7 +93,8 @@ func executeFetchV2(_ context.Context, runtime *common.RuntimeContext) error { func buildFetchBody(runtime *common.RuntimeContext) map[string]interface{} { body := map[string]interface{}{ - "format": effectiveFetchFormat(runtime), + "format": effectiveFetchFormat(runtime), + "extra_param": docsFetchExtraParam, } if v := runtime.Int("revision-id"); v > 0 { body["revision_id"] = v diff --git a/shortcuts/doc/docs_fetch_v2_test.go b/shortcuts/doc/docs_fetch_v2_test.go index 63aba4a7..9d681cf0 100644 --- a/shortcuts/doc/docs_fetch_v2_test.go +++ b/shortcuts/doc/docs_fetch_v2_test.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "reflect" "strings" "testing" @@ -487,6 +488,44 @@ func TestAddFetchDetailDowngradeWarningNoops(t *testing.T) { } } +func TestBuildFetchBodyIncludesFetchExtraParamByDefault(t *testing.T) { + t.Parallel() + + runtime := newFetchBodyTestRuntime(context.Background()) + + body := buildFetchBody(runtime) + extraParam, ok := body["extra_param"].(string) + if !ok || extraParam == "" { + t.Fatalf("extra_param = %#v, want JSON string", body["extra_param"]) + } + var got map[string]bool + if err := json.Unmarshal([]byte(extraParam), &got); err != nil { + t.Fatalf("decode extra_param %q: %v", extraParam, err) + } + if got["enable_user_cite_reference_map"] != true { + t.Fatalf("enable_user_cite_reference_map = %#v, want true in %#v", got["enable_user_cite_reference_map"], got) + } + if got["return_html5_block_data"] != true { + t.Fatalf("return_html5_block_data = %#v, want true in %#v", got["return_html5_block_data"], got) + } + if _, ok := got["reference_map_mode"]; ok { + t.Fatalf("extra_param should not use legacy reference_map_mode: %#v", got) + } + if len(got) != 2 { + t.Fatalf("extra_param should only contain fetch reference_map and html5 data toggles: %#v", got) + } +} + +func TestDocsFetchV2ReferenceMapFlagIsNotAvailable(t *testing.T) { + t.Parallel() + + for _, flag := range v2FetchFlags() { + if flag.Name == "reference-map" { + t.Fatal("fetch should not expose reference-map flag") + } + } +} + func TestDocsFetchDryRunDefaultsToV2Endpoint(t *testing.T) { t.Parallel() @@ -540,6 +579,46 @@ func TestDocsFetchIMMarkdownRequestsMarkdownFromAPI(t *testing.T) { } } +func TestDocsFetchIMMarkdownIgnoresHTML5BlockInsideCodeFence(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-fetch-im-markdown-code-fence")) + registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcnFetchIMMarkdownFence/fetch", map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcnFetchIMMarkdownFence", + "revision_id": float64(1), + "content": "```xml\n\n```\n", + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--doc", "doxcnFetchIMMarkdownFence", + "--doc-format", "im-markdown", + "--format", "json", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v\nraw=%s", err, stdout.String()) + } + if errField, ok := envelope["error"]; ok { + t.Fatalf("fetch output should not contain error: %#v", errField) + } + data, _ := envelope["data"].(map[string]interface{}) + doc, _ := data["document"].(map[string]interface{}) + content, _ := doc["content"].(string) + if !strings.Contains(content, "```xml\n\n```") { + t.Fatalf("fenced html5-block should stay in content, got:\n%s", content) + } + if _, ok := doc["reference_map"]; ok { + t.Fatalf("fenced html5-block should not create reference_map side effects: %#v", doc["reference_map"]) + } +} + func TestDocsFetchMarkdownDetailDowngradesToSimple(t *testing.T) { t.Parallel() @@ -805,20 +884,48 @@ func TestDocsFetchRejectsLegacyFlags(t *testing.T) { func newFetchBodyTestRuntime(ctx context.Context) *common.RuntimeContext { cmd := &cobra.Command{Use: "+fetch"} - cmd.Flags().String("doc-format", "xml", "") - cmd.Flags().String("detail", "simple", "") - cmd.Flags().String("lang", "", "") - cmd.Flags().Int("revision-id", -1, "") - cmd.Flags().String("scope", "full", "") - cmd.Flags().String("start-block-id", "", "") - cmd.Flags().String("end-block-id", "", "") - cmd.Flags().String("keyword", "", "") - cmd.Flags().Int("context-before", 0, "") - cmd.Flags().Int("context-after", 0, "") - cmd.Flags().Int("max-depth", -1, "") + cmd.Flags().String("doc-format", fetchDefault("doc-format"), "") + cmd.Flags().String("detail", fetchDefault("detail"), "") + cmd.Flags().String("lang", fetchDefault("lang"), "") + cmd.Flags().Int("revision-id", fetchDefaultInt("revision-id"), "") + cmd.Flags().String("scope", fetchDefault("scope"), "") + cmd.Flags().String("start-block-id", fetchDefault("start-block-id"), "") + cmd.Flags().String("end-block-id", fetchDefault("end-block-id"), "") + cmd.Flags().String("keyword", fetchDefault("keyword"), "") + cmd.Flags().Int("context-before", fetchDefaultInt("context-before"), "") + cmd.Flags().Int("context-after", fetchDefaultInt("context-after"), "") + cmd.Flags().Int("max-depth", fetchDefaultInt("max-depth"), "") return common.TestNewRuntimeContextWithCtx(ctx, cmd, nil) } +// fetchDefault returns the declared default for a flag from the real +// v2FetchFlags definition so tests don't hardcode a stale default. +// It panics if the flag is not found, since a missing flag indicates +// a test setup error rather than a runtime condition. +func fetchDefault(name string) string { + for _, fl := range v2FetchFlags() { + if fl.Name == name { + return fl.Default + } + } + panic(fmt.Sprintf("fetchDefault: flag %q not found in v2FetchFlags", name)) +} + +// fetchDefaultInt returns the declared default for an int flag from +// v2FetchFlags, parsed as an int. It panics if the flag is not found +// or its default cannot be parsed as an int. +func fetchDefaultInt(name string) int { + s := fetchDefault(name) + if s == "" { + return 0 + } + var d int + if _, err := fmt.Sscanf(s, "%d", &d); err != nil { + panic(fmt.Sprintf("fetchDefaultInt: flag %q default %q is not an int", name, s)) + } + return d +} + func mustSetFetchFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) { t.Helper() @@ -833,17 +940,17 @@ func newFetchShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[s cmd := &cobra.Command{Use: "+fetch"} cmd.Flags().String("api-version", "", "") cmd.Flags().String("doc", "doxcnFetchDryRun", "") - cmd.Flags().String("doc-format", "xml", "") - cmd.Flags().String("detail", "simple", "") - cmd.Flags().String("lang", "", "") - cmd.Flags().Int("revision-id", -1, "") - cmd.Flags().String("scope", "full", "") - cmd.Flags().String("start-block-id", "", "") - cmd.Flags().String("end-block-id", "", "") - cmd.Flags().String("keyword", "", "") - cmd.Flags().Int("context-before", 0, "") - cmd.Flags().Int("context-after", 0, "") - cmd.Flags().Int("max-depth", -1, "") + cmd.Flags().String("doc-format", fetchDefault("doc-format"), "") + cmd.Flags().String("detail", fetchDefault("detail"), "") + cmd.Flags().String("lang", fetchDefault("lang"), "") + cmd.Flags().Int("revision-id", fetchDefaultInt("revision-id"), "") + cmd.Flags().String("scope", fetchDefault("scope"), "") + cmd.Flags().String("start-block-id", fetchDefault("start-block-id"), "") + cmd.Flags().String("end-block-id", fetchDefault("end-block-id"), "") + cmd.Flags().String("keyword", fetchDefault("keyword"), "") + cmd.Flags().Int("context-before", fetchDefaultInt("context-before"), "") + cmd.Flags().Int("context-after", fetchDefaultInt("context-after"), "") + cmd.Flags().Int("max-depth", fetchDefaultInt("max-depth"), "") cmd.Flags().String("offset", "", "") cmd.Flags().String("limit", "", "") if apiVersion != "" { @@ -875,6 +982,7 @@ func newUpdateBodyTestRuntime(ctx context.Context) *common.RuntimeContext { cmd.Flags().String("command", "append", "") cmd.Flags().Int("revision-id", 0, "") cmd.Flags().String("content", "

hello

", "") + cmd.Flags().String("reference-map", "", "") cmd.Flags().String("pattern", "", "") cmd.Flags().String("block-id", "", "") cmd.Flags().String("src-block-ids", "", "") diff --git a/shortcuts/doc/docs_update_test.go b/shortcuts/doc/docs_update_test.go index b6b757cb..93a882d9 100644 --- a/shortcuts/doc/docs_update_test.go +++ b/shortcuts/doc/docs_update_test.go @@ -4,9 +4,12 @@ package doc import ( "context" + "errors" + "os" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" "github.com/spf13/cobra" ) @@ -61,6 +64,149 @@ func TestDocsUpdateDryRunIgnoresAPIVersionCompatFlag(t *testing.T) { } } +func TestBuildUpdateBodyWithHTML5ReferenceMapReportsPathError(t *testing.T) { + t.Parallel() + + runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{ + "content": ``, + }) + + _, err := buildUpdateBodyWithHTML5ReferenceMap(runtime) + if err == nil { + t.Fatal("buildUpdateBodyWithHTML5ReferenceMap() succeeded, want error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("ProblemOf() ok = false for %T (%v)", err, err) + } + if p.Category != errs.CategoryValidation { + t.Fatalf("category = %q, want %q", p.Category, errs.CategoryValidation) + } + if p.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("error type = %T, want *errs.ValidationError", err) + } + if validationErr.Param != "path" { + t.Fatalf("param = %q, want path", validationErr.Param) + } + if !errors.Is(err, os.ErrNotExist) { + t.Fatalf("error should preserve os.ErrNotExist cause, got: %v", err) + } +} + +func TestDocsUpdateV2ReferenceMapFlagIsPublicFileInput(t *testing.T) { + t.Parallel() + + var flag common.Flag + for _, candidate := range v2UpdateFlags() { + if candidate.Name == "reference-map" { + flag = candidate + break + } + } + if flag.Name == "" { + t.Fatal("reference-map flag not found") + } + if flag.Hidden { + t.Fatal("reference-map flag should be public") + } + if flag.Type != "" { + t.Fatalf("reference-map flag Type = %q, want default string", flag.Type) + } + if !hasUpdateTestInput(flag, common.File) || !hasUpdateTestInput(flag, common.Stdin) { + t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input) + } + if flag.Desc != docsUpdateReferenceMapFlagDesc { + t.Fatalf("reference-map help = %q, want %q", flag.Desc, docsUpdateReferenceMapFlagDesc) + } +} + +func TestBuildUpdateBodyIncludesReferenceMap(t *testing.T) { + t.Parallel() + + runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{ + "command": "append", + "content": `

`, + "reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`, + }) + body := buildUpdateBody(runtime) + + refMap, ok := body["reference_map"].(map[string]interface{}) + if !ok { + t.Fatalf("reference_map = %#v, want object", body["reference_map"]) + } + widget, _ := refMap["widget"].(map[string]interface{}) + r1, _ := widget["r1"].(map[string]interface{}) + if got := r1["label"]; got != "widget-ref-value" { + t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body) + } + if got, want := body["command"], "block_insert_after"; got != want { + t.Fatalf("command = %#v, want %q", got, want) + } + if got, want := body["block_id"], "-1"; got != want { + t.Fatalf("block_id = %#v, want %q", got, want) + } +} + +func TestValidateUpdateV2RejectsInvalidReferenceMap(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setFlags map[string]string + wantCause bool + }{ + { + name: "invalid json", + setFlags: map[string]string{ + "reference-map": "{", + }, + wantCause: true, + }, + { + name: "empty", + setFlags: map[string]string{ + "reference-map": "", + }, + }, + { + name: "without content", + setFlags: map[string]string{ + "content": "", + "reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`, + }, + }, + { + name: "unsupported command", + setFlags: map[string]string{ + "command": "block_move_after", + "block-id": "blk_anchor", + "src-block-ids": "blk_src", + "reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newUpdateShortcutTestRuntime(t, "", tt.setFlags) + err := validateUpdateV2(context.Background(), runtime) + if err == nil { + t.Fatal("validateUpdateV2() succeeded, want error") + } + assertValidationContract(t, err, errs.SubtypeInvalidArgument, "--reference-map") + if tt.wantCause && errors.Unwrap(err) == nil { + t.Fatal("validateUpdateV2() error lost underlying JSON cause") + } + }) + } +} + func TestDocsUpdateRejectsLegacyFlags(t *testing.T) { tests := []struct { name string @@ -103,6 +249,15 @@ func TestDocsUpdateRejectsLegacyFlags(t *testing.T) { } } +func hasUpdateTestInput(flag common.Flag, input string) bool { + for _, candidate := range flag.Input { + if candidate == input { + return true + } + } + return false +} + func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[string]string) *common.RuntimeContext { t.Helper() @@ -113,6 +268,7 @@ func newUpdateShortcutTestRuntime(t *testing.T, apiVersion string, setFlags map[ cmd.Flags().String("command", "append", "") cmd.Flags().Int("revision-id", -1, "") cmd.Flags().String("content", "

hello

", "") + cmd.Flags().String("reference-map", "", "") cmd.Flags().String("pattern", "", "") cmd.Flags().String("block-id", "", "") cmd.Flags().String("src-block-ids", "", "") diff --git a/shortcuts/doc/docs_update_v2.go b/shortcuts/doc/docs_update_v2.go index cc75d44e..f610168c 100644 --- a/shortcuts/doc/docs_update_v2.go +++ b/shortcuts/doc/docs_update_v2.go @@ -5,7 +5,9 @@ package doc import ( "context" + "encoding/json" "fmt" + "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" @@ -22,12 +24,17 @@ var validCommandsV2 = map[string]bool{ "append": true, } +const docsReferenceMapFlagDesc = "结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;`--reference-map` 主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。" + +const docsUpdateReferenceMapFlagDesc = docsReferenceMapFlagDesc + // v2UpdateFlags returns the flag definitions for the v2 (OpenAPI) update path. func v2UpdateFlags() []common.Flag { return []common.Flag{ {Name: "command", Desc: "operation; requirements: str_replace(--pattern), block_delete(--block-id, comma-separated for batch), block_insert_after/block_replace(--block-id,--content), block_copy_insert_after/block_move_after(--block-id,--src-block-ids), overwrite/append(--content)", Enum: validCommandsV2Keys()}, {Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}}, {Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}}, + {Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}}, {Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"}, {Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"}, {Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"}, @@ -54,6 +61,9 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { 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") + if err := validateUpdateReferenceMap(runtime, cmd, content); err != nil { + return err + } pattern := runtime.Str("pattern") blockID := runtime.Str("block-id") srcBlockIDs := runtime.Str("src-block-ids") @@ -107,13 +117,20 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command append requires --content").WithParam("--content") } } + if content != "" { + _, err := resolveDocsV2ContentReferenceMap(runtime) + return err + } return nil } func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { // Validate has already accepted --doc; parseDocumentRef cannot fail here. ref, _ := parseDocumentRef(runtime.Str("doc")) - body := buildUpdateBody(runtime) + body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token) return common.NewDryRunAPI(). PUT(apiPath). @@ -126,7 +143,10 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { ref, _ := parseDocumentRef(runtime.Str("doc")) apiPath := fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s", ref.Token) - body := buildUpdateBody(runtime) + body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime) + if err != nil { + return err + } data, err := doDocAPI(runtime, "PUT", apiPath, body) if err != nil { @@ -138,6 +158,24 @@ func executeUpdateV2(_ context.Context, runtime *common.RuntimeContext) error { } func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} { + body, _ := buildUpdateBodyWithReferenceMap(runtime) + return body +} + +func buildUpdateBodyWithReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) { + body := buildUpdateBodyBase(runtime) + if !runtime.Changed("reference-map") { + return body, nil + } + refMap, err := parseUpdateReferenceMap(runtime.Str("reference-map")) + if err != nil { + return body, err + } + body["reference_map"] = refMap + return body, nil +} + +func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{} { cmd := runtime.Str("command") // append is a shorthand for block_insert_after with block_id "-1" (end of document) @@ -169,3 +207,40 @@ func buildUpdateBody(runtime *common.RuntimeContext) map[string]interface{} { injectDocsScene(runtime, body) return body } + +func validateUpdateReferenceMap(runtime *common.RuntimeContext, command string, content string) error { + if !runtime.Changed("reference-map") { + return nil + } + if !updateCommandAcceptsReferenceMap(command) { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map is only supported with update commands that send --content").WithParam("--reference-map") + } + if content == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content that uses matching sidecar refs").WithParam("--reference-map") + } + _, err := parseUpdateReferenceMap(runtime.Str("reference-map")) + return err +} + +func updateCommandAcceptsReferenceMap(command string) bool { + switch command { + case "str_replace", "block_insert_after", "block_replace", "overwrite", "append": + return true + default: + return false + } +} + +func parseUpdateReferenceMap(raw string) (map[string]interface{}, error) { + if strings.TrimSpace(raw) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a non-empty JSON object").WithParam("--reference-map") + } + var refMap map[string]interface{} + if err := json.Unmarshal([]byte(raw), &refMap); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a valid JSON object: %v", err).WithParam("--reference-map").WithCause(err) + } + if refMap == nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map must be a JSON object, got null").WithParam("--reference-map") + } + return refMap, nil +} diff --git a/shortcuts/doc/html5_block_resources.go b/shortcuts/doc/html5_block_resources.go new file mode 100644 index 00000000..80955ba5 --- /dev/null +++ b/shortcuts/doc/html5_block_resources.go @@ -0,0 +1,696 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "bytes" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "path/filepath" + "regexp" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + html5BlockTag = "html5-block" + html5BlockPathAttr = "path" + html5BlockDataRefAttr = "data-ref" + html5BlockDataAttr = "data" + html5BlockReferenceRoot = "doc-fetch-resources" + html5BlockReferenceMaxRaw = 1024 +) + +var ( + html5BlockStartTagPattern = regexp.MustCompile(`(?is)]*>`) + html5BlockElementPattern = regexp.MustCompile(`(?is)]*>(.*?)`) + html5BlockSafeNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) +) + +type html5BlockReferenceEntry struct { + Data string `json:"data,omitempty"` + Path string `json:"path,omitempty"` + UserID string `json:"user_id,omitempty"` +} + +type html5BlockReferenceMap map[string]map[string]html5BlockReferenceEntry + +type docsV2WriteInput struct { + Content string + ReferenceMap map[string]interface{} +} + +type html5BlockAttr struct { + Name string + Value string +} + +type html5BlockStartTag struct { + Attrs []html5BlockAttr + SelfClosing bool +} + +func buildCreateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) { + body := buildCreateBody(runtime) + if runtime.Str("content") == "" && !runtime.Changed("reference-map") { + return body, nil + } + input, err := resolveDocsV2ContentReferenceMap(runtime) + if err != nil { + return nil, err + } + body["content"] = buildCreateContentWithBody(runtime, input.Content) + if len(input.ReferenceMap) > 0 { + body["reference_map"] = input.ReferenceMap + } + return body, nil +} + +func buildUpdateBodyWithHTML5ReferenceMap(runtime *common.RuntimeContext) (map[string]interface{}, error) { + body := buildUpdateBody(runtime) + input, err := resolveDocsV2ContentReferenceMap(runtime) + if err != nil { + return nil, err + } + if input.Content != "" { + body["content"] = input.Content + } + if len(input.ReferenceMap) > 0 { + body["reference_map"] = input.ReferenceMap + } + return body, nil +} + +func validateDocsV2ReferenceMapFlags(runtime *common.RuntimeContext) error { + if runtime.Changed("reference-map") && runtime.Str("content") == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--reference-map requires --content").WithParam("--reference-map") + } + return nil +} + +func resolveDocsV2ContentReferenceMap(runtime *common.RuntimeContext) (docsV2WriteInput, error) { + input := docsV2WriteInput{Content: runtime.Str("content")} + if raw := runtime.Str("reference-map"); strings.TrimSpace(raw) != "" { + refMap, err := parseReferenceMapObject(raw, "--reference-map") + if err != nil { + return docsV2WriteInput{}, err + } + input.ReferenceMap = refMap + } + return prepareDocsV2WriteInput(runtime, input) +} + +func prepareDocsV2WriteInput(runtime *common.RuntimeContext, input docsV2WriteInput) (docsV2WriteInput, error) { + refMap := cloneReferenceMapObject(input.ReferenceMap) + html5RefMap, err := html5ReferenceMapFromObject(refMap) + if err != nil { + return docsV2WriteInput{}, err + } + + content, html5RefMap, err := prepareHTML5BlockWriteContent(runtime, runtime.Str("doc-format"), input.Content, html5RefMap) + if err != nil { + return docsV2WriteInput{}, err + } + if err := resolveReferenceMapPaths(runtime, html5RefMap); err != nil { + return docsV2WriteInput{}, err + } + refMap = mergeHTML5ReferenceMap(refMap, html5RefMap) + return docsV2WriteInput{ + Content: content, + ReferenceMap: refMap, + }, nil +} + +func parseReferenceMapObject(raw string, label string) (map[string]interface{}, error) { + if len(bytes.TrimSpace([]byte(raw))) == 0 || string(bytes.TrimSpace([]byte(raw))) == "null" { + return nil, nil + } + var refMap map[string]interface{} + if err := json.Unmarshal([]byte(raw), &refMap); err != nil { + return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err) + } + return refMap, nil +} + +func parseHTML5BlockReferenceMapBytes(raw []byte, label string) (html5BlockReferenceMap, error) { + if len(bytes.TrimSpace(raw)) == 0 || string(bytes.TrimSpace(raw)) == "null" { + return nil, nil + } + var refMap html5BlockReferenceMap + if err := json.Unmarshal(raw, &refMap); err != nil { + return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam(label).WithCause(err) + } + return compactReferenceMap(refMap), nil +} + +func prepareHTML5BlockWriteContent(runtime *common.RuntimeContext, format string, content string, refMap html5BlockReferenceMap) (string, html5BlockReferenceMap, error) { + if !strings.Contains(content, " and ").WithParam("html5-block") + } + } + return nil + } + + if strings.TrimSpace(format) != "markdown" { + return validateSegment(content) + } + + var validateErr error + _ = applyOutsideCodeFences(content, func(segment string) string { + if validateErr != nil { + return segment + } + validateErr = validateSegment(segment) + return segment + }) + return validateErr +} + +func processHTML5BlockReferenceMapForFetch(runtime *common.RuntimeContext, format string, docToken string, data map[string]interface{}) error { + doc, _ := data["document"].(map[string]interface{}) + if doc == nil { + return nil + } + content, _ := doc["content"].(string) + if !hasProcessableHTML5Block(format, content) { + return nil + } + + refMap, err := referenceMapFromDocument(doc) + if err != nil { + return err + } + group := refMap[html5BlockTag] + if group == nil { + return common.ValidationErrorf("document.reference_map.%s is required for fetched html5-block content", html5BlockTag).WithParam("reference_map") + } + + if err := validateFetchedHTML5BlockRefs(format, content, refMap); err != nil { + return err + } + + changed := false + for ref, entry := range group { + if entry.Data == "" || len([]byte(entry.Data)) <= html5BlockReferenceMaxRaw { + continue + } + relPath, err := writeHTML5BlockReferenceFile(runtime, docToken, ref, entry.Data) + if err != nil { + return err + } + entry.Data = "" + entry.Path = "@" + filepath.ToSlash(relPath) + group[ref] = entry + changed = true + } + if changed { + doc["reference_map"] = refMap + } + return nil +} + +func referenceMapFromDocument(doc map[string]interface{}) (html5BlockReferenceMap, error) { + raw, ok := doc["reference_map"] + if !ok || raw == nil { + return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map") + } + refMap, err := referenceMapFromValue(raw, "document.reference_map") + if err != nil { + return nil, err + } + if len(refMap) == 0 { + return nil, common.ValidationErrorf("document.reference_map is required for fetched html5-block content").WithParam("reference_map") + } + return refMap, nil +} + +func referenceMapFromValue(value interface{}, label string) (html5BlockReferenceMap, error) { + if typed, ok := value.(html5BlockReferenceMap); ok { + return compactReferenceMap(typed), nil + } + raw, err := json.Marshal(value) + if err != nil { + return nil, common.ValidationErrorf("%s is not valid reference_map JSON: %v", label, err).WithParam("reference_map").WithCause(err) + } + return parseHTML5BlockReferenceMapBytes(raw, label) +} + +func validateFetchedHTML5BlockRefs(format string, content string, refMap html5BlockReferenceMap) error { + validateSegment := func(segment string) error { + _, err := rewriteHTML5BlockStartTags(segment, func(raw string) (string, error) { + tag, parseErr := parseHTML5BlockStartTag(raw) + if parseErr != nil { + return raw, common.ValidationErrorf("invalid html5-block tag in fetched content: %v", parseErr).WithParam("html5-block") + } + ref, ok := tag.attr(html5BlockDataRefAttr) + if !ok || strings.TrimSpace(ref) == "" { + return raw, common.ValidationErrorf("fetched html5-block is missing data-ref; cannot resolve HTML reference").WithParam("html5-block") + } + ref = strings.TrimSpace(ref) + if _, ok := refMap[html5BlockTag][ref]; !ok { + return raw, common.ValidationErrorf("document.reference_map.%s.%s is missing; cannot resolve html5-block. Re-run fetch or check that the upstream document.reference_map field includes this ref.", html5BlockTag, ref).WithParam("reference_map") + } + return raw, nil + }) + return err + } + + if strings.TrimSpace(format) != "markdown" { + return validateSegment(content) + } + var validateErr error + _ = applyOutsideCodeFences(content, func(segment string) string { + if validateErr != nil { + return segment + } + validateErr = validateSegment(segment) + return segment + }) + return validateErr +} + +func resolveReferenceMapPaths(runtime *common.RuntimeContext, refMap html5BlockReferenceMap) error { + for typ, group := range refMap { + for ref, entry := range group { + if strings.TrimSpace(entry.Path) == "" { + continue + } + if entry.Data != "" { + return common.ValidationErrorf("reference_map.%s.%s must use either data or path, not both", typ, ref).WithParam("reference_map") + } + data, err := readHTML5BlockPath(runtime, entry.Path, fmt.Sprintf("reference_map.%s.%s.path", typ, ref)) + if err != nil { + return err + } + entry.Data = data + entry.Path = "" + group[ref] = entry + } + } + return nil +} + +func readHTML5BlockPath(runtime *common.RuntimeContext, pathValue string, label string) (string, error) { + pathRaw := strings.TrimSpace(pathValue) + if !strings.HasPrefix(pathRaw, "@") { + return "", common.ValidationErrorf("%s %q must start with @, for example @widget.html", label, pathValue).WithParam("path") + } + relPath := strings.TrimSpace(strings.TrimPrefix(pathRaw, "@")) + if relPath == "" { + return "", common.ValidationErrorf("%s cannot be empty after @", label).WithParam("path") + } + clean := filepath.Clean(relPath) + if filepath.IsAbs(clean) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { + return "", common.ValidationErrorf("%s %q must be a relative path within the current working directory", label, pathValue).WithParam("path") + } + if strings.ToLower(filepath.Ext(clean)) != ".html" { + return "", common.ValidationErrorf("%s %q must point to a .html file", label, pathValue).WithParam("path") + } + data, err := cmdutil.ReadInputFile(runtime.FileIO(), clean) + if err != nil { + return "", common.ValidationErrorf("%s %q cannot be read from the current working directory; check that the file exists relative to where lark-cli is running: %v", label, clean, err).WithParam("path").WithCause(err) + } + return string(data), nil +} + +func hasProcessableHTML5Block(format string, content string) bool { + if !strings.Contains(content, "") + decoder := xml.NewDecoder(strings.NewReader(raw)) + for { + tok, err := decoder.Token() + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return html5BlockStartTag{}, err + } + start, ok := tok.(xml.StartElement) + if !ok { + continue + } + if start.Name.Local != html5BlockTag { + return html5BlockStartTag{}, fmt.Errorf("expected <%s>, got <%s>", html5BlockTag, start.Name.Local) //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors. + } + attrs := make([]html5BlockAttr, 0, len(start.Attr)) + for _, attr := range start.Attr { + attrs = append(attrs, html5BlockAttr{Name: attr.Name.Local, Value: attr.Value}) + } + return html5BlockStartTag{Attrs: attrs, SelfClosing: selfClosing}, nil + } + return html5BlockStartTag{}, fmt.Errorf("missing start element") //nolint:forbidigo // intermediate parse helper; callers wrap with typed validation errors. +} + +func (t html5BlockStartTag) attr(name string) (string, bool) { + for _, attr := range t.Attrs { + if attr.Name == name { + return attr.Value, true + } + } + return "", false +} + +func (t html5BlockStartTag) hasAttr(name string) bool { + _, ok := t.attr(name) + return ok +} + +func (t *html5BlockStartTag) removeAttrs(names ...string) { + remove := make(map[string]struct{}, len(names)) + for _, name := range names { + remove[name] = struct{}{} + } + attrs := t.Attrs[:0] + for _, attr := range t.Attrs { + if _, ok := remove[attr.Name]; ok { + continue + } + attrs = append(attrs, attr) + } + t.Attrs = attrs +} + +func (t html5BlockStartTag) render(selfClosing bool) string { + var b strings.Builder + b.WriteByte('<') + b.WriteString(html5BlockTag) + for _, attr := range t.Attrs { + b.WriteByte(' ') + b.WriteString(attr.Name) + b.WriteString(`="`) + b.WriteString(escapeXMLAttr(attr.Value)) + b.WriteByte('"') + } + if selfClosing { + b.WriteString("/>") + } else { + b.WriteByte('>') + } + if t.SelfClosing && !selfClosing { + b.WriteString("') + } + return b.String() +} + +func escapeXMLAttr(value string) string { + var b strings.Builder + for _, r := range value { + switch r { + case '&': + b.WriteString("&") + case '<': + b.WriteString("<") + case '>': + b.WriteString(">") + case '"': + b.WriteString(""") + case '\'': + b.WriteString("'") + default: + b.WriteRune(r) + } + } + return b.String() +} diff --git a/shortcuts/doc/html5_block_resources_test.go b/shortcuts/doc/html5_block_resources_test.go new file mode 100644 index 00000000..1da3f10d --- /dev/null +++ b/shortcuts/doc/html5_block_resources_test.go @@ -0,0 +1,563 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestDocsV2ReferenceMapFlagIsPublicFileInput(t *testing.T) { + for name, flags := range map[string][]common.Flag{ + "create": v2CreateFlags(), + "update": v2UpdateFlags(), + } { + t.Run(name, func(t *testing.T) { + flag := findDocsTestFlag(flags, "reference-map") + if flag.Name == "" { + t.Fatal("reference-map flag not found") + } + if flag.Hidden { + t.Fatal("reference-map flag should be public") + } + if !hasDocsTestInput(flag, common.File) || !hasDocsTestInput(flag, common.Stdin) { + t.Fatalf("reference-map Input = %#v, want file and stdin", flag.Input) + } + if !strings.Contains(flag.Desc, "@reference-map.json") { + t.Fatalf("reference-map help should mention @file support, got %q", flag.Desc) + } + }) + } +} + +func TestDocsV2InputFlagIsNotAvailable(t *testing.T) { + for name, flags := range map[string][]common.Flag{ + "create": v2CreateFlags(), + "update": v2UpdateFlags(), + } { + t.Run(name, func(t *testing.T) { + for _, flag := range flags { + if flag.Name == "input" { + t.Fatalf("%s should not expose input flag", name) + } + } + }) + } +} + +func TestDocsUpdateV2ReferenceMapPreservesGenericGroups(t *testing.T) { + t.Parallel() + + runtime := newUpdateShortcutTestRuntime(t, "", map[string]string{ + "command": "append", + "content": `

`, + "reference-map": `{"widget":{"r1":{"label":"widget-ref-value"}}}`, + }) + body, err := buildUpdateBodyWithHTML5ReferenceMap(runtime) + if err != nil { + t.Fatalf("buildUpdateBodyWithHTML5ReferenceMap: %v", err) + } + + refMap, ok := body["reference_map"].(map[string]interface{}) + if !ok { + t.Fatalf("reference_map = %#v, want object", body["reference_map"]) + } + widget, _ := refMap["widget"].(map[string]interface{}) + r1, _ := widget["r1"].(map[string]interface{}) + if got := r1["label"]; got != "widget-ref-value" { + t.Fatalf("reference_map.widget.r1.label = %#v, want widget-ref-value; body=%#v", got, body) + } +} + +func TestDocsCreateV2HTML5BlockReferenceMapFromPath(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + if err := os.WriteFile("widget.html", []byte("hello"), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcn_new_doc", + "revision_id": float64(1), + }, + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", `demo`, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeRequestBody(t, stub.CapturedBody) + if got := body["content"].(string); !strings.Contains(got, ``) { + t.Fatalf("content was not rewritten with data-ref: %s", got) + } + refMap := decodeHTML5ReferenceMap(t, body["reference_map"]) + if got := refMap[html5BlockTag]["html5_1"].Data; got != "hello" { + t.Fatalf("reference_map html data = %q", got) + } + if _, ok := body["resources"]; ok { + t.Fatalf("request body must not use resources: %#v", body) + } +} + +func findDocsTestFlag(flags []common.Flag, name string) common.Flag { + for _, flag := range flags { + if flag.Name == name { + return flag + } + } + return common.Flag{} +} + +func hasDocsTestInput(flag common.Flag, input string) bool { + for _, item := range flag.Input { + if item == input { + return true + } + } + return false +} + +func TestDocsUpdateV2HTML5BlockReferenceMapFromPath(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + if err := os.WriteFile("widget.html", []byte("
updated
"), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-update")) + stub := registerDocsAIStub(reg, "PUT", "/open-apis/docs_ai/v1/documents/doxcn_doc", map[string]interface{}{ + "document": map[string]interface{}{ + "revision_id": float64(2), + "new_blocks": []interface{}{ + map[string]interface{}{ + "block_type": "html5-block", + "block_id": "blk_html5", + "block_token": "boardXXXX", + }, + }, + }, + "result": "success", + }) + + err := mountAndRunDocs(t, DocsUpdate, []string{ + "+update", + "--api-version", "v2", + "--doc", "doxcn_doc", + "--command", "append", + "--content", ``, + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeRequestBody(t, stub.CapturedBody) + if got := body["content"].(string); got != `` { + t.Fatalf("content = %q", got) + } + refMap := decodeHTML5ReferenceMap(t, body["reference_map"]) + if got := refMap[html5BlockTag]["html5_1"].Data; got != "
updated
" { + t.Fatalf("reference_map html data = %q", got) + } + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode stdout: %v\n%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + doc, _ := data["document"].(map[string]interface{}) + if blocks, _ := doc["new_blocks"].([]interface{}); len(blocks) != 1 { + t.Fatalf("new_blocks not preserved in stdout: %#v", doc) + } +} + +func TestDocsFetchV2HTML5BlockKeepsSmallReferenceMapInline(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch")) + registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcn_fetch", + "revision_id": float64(3), + "content": ``, + "reference_map": map[string]interface{}{ + "html5-block": map[string]interface{}{ + "html5_1": map[string]interface{}{"data": "
fetched
"}, + }, + }, + }, + "tips": "must_read_html_code", + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--api-version", "v2", + "--doc", "doxcn_fetch", + "--format", "json", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html") + if _, err := os.Stat(written); err == nil { + t.Fatalf("small html should stay inline, got file %s", written) + } + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode stdout: %v\n%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + doc, _ := data["document"].(map[string]interface{}) + if got := doc["content"].(string); !strings.Contains(got, ``) { + t.Fatalf("content should keep data-ref: %s", got) + } + refMap := decodeHTML5ReferenceMap(t, doc["reference_map"]) + if got := refMap[html5BlockTag]["html5_1"].Data; got != "
fetched
" { + t.Fatalf("reference_map html data = %q", got) + } + if _, ok := doc["resources"]; ok { + t.Fatalf("fetch output must not use resources: %#v", doc) + } + if _, ok := data["suggestions"]; ok { + t.Fatalf("CLI must not add suggestions; service tips is enough: %#v", data["suggestions"]) + } + if got := data["tips"]; got != "must_read_html_code" { + t.Fatalf("tips should be preserved from service response, got %#v", got) + } +} + +func TestDocsFetchV2HTML5BlockLargeReferenceMapUsesPath(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + + largeHTML := "
" + strings.Repeat("x", html5BlockReferenceMaxRaw+1) + "
" + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-large")) + registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcn_fetch", + "revision_id": float64(3), + "content": ``, + "reference_map": map[string]interface{}{ + "html5-block": map[string]interface{}{ + "html5_1": map[string]interface{}{"data": largeHTML}, + }, + }, + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--api-version", "v2", + "--doc", "doxcn_fetch", + "--format", "json", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + written := filepath.Join(dir, html5BlockReferenceRoot, "doxcn_fetch", "html5_1.html") + raw, err := os.ReadFile(written) + if err != nil { + t.Fatalf("ReadFile(%s) error: %v", written, err) + } + if string(raw) != largeHTML { + t.Fatalf("materialized html = %q", raw) + } + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode stdout: %v\n%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + doc, _ := data["document"].(map[string]interface{}) + if got := doc["content"].(string); strings.Contains(got, `path="@`) || !strings.Contains(got, `data-ref="html5_1"`) { + t.Fatalf("content should keep data-ref and not path: %s", got) + } + refMap := decodeHTML5ReferenceMap(t, doc["reference_map"]) + entry := refMap[html5BlockTag]["html5_1"] + if entry.Data != "" || entry.Path != "@doc-fetch-resources/doxcn_fetch/html5_1.html" { + t.Fatalf("large html should be represented as path, got %#v", entry) + } +} + +func TestDocsCreateV2HTML5BlockReferenceMapAdvancedInput(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcn_new_doc", + "revision_id": float64(1), + }, + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", ``, + "--reference-map", `{"html5-block":{"html5_1":{"data":""}}}`, + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + body := decodeRequestBody(t, stub.CapturedBody) + if got := body["content"].(string); got != `` { + t.Fatalf("content = %q", got) + } + refMap := decodeHTML5ReferenceMap(t, body["reference_map"]) + if got := refMap[html5BlockTag]["html5_1"].Data; got != "" { + t.Fatalf("reference_map html data = %q", got) + } +} + +func TestDocsCreateV2HTML5BlockReferenceMapFromFile(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + if err := os.WriteFile("reference-map.json", []byte(`{"html5-block":{"html5_1":{"data":"from file"}}}`), 0o600); err != nil { + t.Fatalf("WriteFile(reference-map.json) error: %v", err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcn_new_doc", + "revision_id": float64(1), + }, + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", ``, + "--reference-map", "@reference-map.json", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + body := decodeRequestBody(t, stub.CapturedBody) + refMap := decodeHTML5ReferenceMap(t, body["reference_map"]) + if got := refMap[html5BlockTag]["html5_1"].Data; got != "from file" { + t.Fatalf("reference_map html data = %q", got) + } +} + +func TestDocsCreateV2HTML5BlockRejectsMissingReferenceMap(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", ``, + "--as", "user", + }) + if err == nil || !strings.Contains(err.Error(), `reference_map.html5-block.html5_1 is required`) { + t.Fatalf("expected missing reference_map error, got: %v", err) + } +} + +func TestDocsCreateV2HTML5BlockRejectsInternalDataAttr(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", ``, + "--as", "user", + }) + if err == nil || !strings.Contains(err.Error(), `html5-block data is reserved for SDK internals`) { + t.Fatalf("expected internal data attr error, got: %v", err) + } +} + +func TestDocsCreateV2HTML5BlockPathReadFailure(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", ``, + "--as", "user", + }) + if err == nil || !strings.Contains(err.Error(), `html5-block path "missing.html" cannot be read from the current working directory`) { + t.Fatalf("expected path read error, got: %v", err) + } +} + +func TestDocsCreateV2HTML5BlockRejectsInlineContent(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + if err := os.WriteFile("widget.html", []byte("
from file
"), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--content", `
inline
`, + "--as", "user", + }) + if err == nil || !strings.Contains(err.Error(), `html5-block content must be loaded from path="@relative.html"`) { + t.Fatalf("expected inline content error, got: %v", err) + } +} + +func TestDocsFetchV2MissingHTML5BlockReferenceFails(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-html5-fetch-missing")) + registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents/doxcn_fetch/fetch", map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcn_fetch", + "revision_id": float64(3), + "content": ``, + "reference_map": map[string]interface{}{ + "html5-block": map[string]interface{}{ + "html5_1": map[string]interface{}{"data": ""}, + }, + }, + }, + }) + + err := mountAndRunDocs(t, DocsFetch, []string{ + "+fetch", + "--api-version", "v2", + "--doc", "doxcn_fetch", + "--format", "json", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "Re-run fetch or check that the upstream document.reference_map field includes this ref") { + t.Fatalf("expected missing reference_map error, got: %v", err) + } +} + +func TestHTML5BlockMarkdownCodeFenceIsIgnored(t *testing.T) { + for _, fence := range []string{"```", "~~~"} { + t.Run(fence, func(t *testing.T) { + content := fence + "xml\n\n" + fence + "\n" + if hasProcessableHTML5Block("markdown", content) { + t.Fatalf("html5-block inside markdown code fence should be ignored") + } + }) + } +} + +func TestWriteHTML5BlockReferenceFileRejectsDotNames(t *testing.T) { + runtime := newFetchShortcutTestRuntime(t, "", nil) + tests := []struct { + name string + docToken string + ref string + want string + }{ + {name: "dot doc token", docToken: ".", ref: "html5_1", want: "document_id"}, + {name: "dotdot doc token", docToken: "..", ref: "html5_1", want: "document_id"}, + {name: "dot ref", docToken: "doxcn_fetch", ref: ".", want: "data-ref"}, + {name: "dotdot ref", docToken: "doxcn_fetch", ref: "..", want: "data-ref"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := writeHTML5BlockReferenceFile(runtime, tt.docToken, tt.ref, "") + if err == nil || !strings.Contains(err.Error(), tt.want) { + t.Fatalf("writeHTML5BlockReferenceFile() error = %v, want %q", err, tt.want) + } + }) + } +} + +func TestPrepareHTML5BlockWriteContentMarkdownRaw(t *testing.T) { + dir := t.TempDir() + cmdutil.TestChdir(t, dir) + if err := os.WriteFile("widget.html", []byte("markdown"), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + f, stdout, _, reg := cmdutil.TestFactory(t, docsCreateTestConfig(t, "")) + stub := registerDocsAIStub(reg, "POST", "/open-apis/docs_ai/v1/documents", map[string]interface{}{ + "document": map[string]interface{}{ + "document_id": "doxcn_new_doc", + "revision_id": float64(1), + }, + }) + + err := runDocsCreateShortcut(t, f, stdout, []string{ + "+create", + "--api-version", "v2", + "--doc-format", "markdown", + "--content", "before\n\nafter", + "--as", "user", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeRequestBody(t, stub.CapturedBody) + if got := body["content"].(string); !strings.Contains(got, ``) { + t.Fatalf("content was not rewritten: %s", got) + } + refMap := decodeHTML5ReferenceMap(t, body["reference_map"]) + if got := refMap[html5BlockTag]["html5_1"].Data; got != "markdown" { + t.Fatalf("reference_map html data = %q", got) + } +} + +func registerDocsAIStub(reg *httpmock.Registry, method string, url string, data map[string]interface{}) *httpmock.Stub { + stub := &httpmock.Stub{ + Method: method, + URL: url, + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": data, + }, + } + reg.Register(stub) + return stub +} + +func decodeRequestBody(t *testing.T, raw []byte) map[string]interface{} { + t.Helper() + var body map[string]interface{} + if err := json.Unmarshal(bytes.TrimSpace(raw), &body); err != nil { + t.Fatalf("decode request body: %v\n%s", err, raw) + } + return body +} + +func decodeHTML5ReferenceMap(t *testing.T, raw interface{}) html5BlockReferenceMap { + t.Helper() + data, err := json.Marshal(raw) + if err != nil { + t.Fatalf("marshal reference_map: %v\n%#v", err, raw) + } + var refMap html5BlockReferenceMap + if err := json.Unmarshal(data, &refMap); err != nil { + t.Fatalf("decode reference_map: %v\n%s", err, data) + } + return refMap +} diff --git a/shortcuts/drive/drive_export_test.go b/shortcuts/drive/drive_export_test.go index 8c6c189d..ec026e41 100644 --- a/shortcuts/drive/drive_export_test.go +++ b/shortcuts/drive/drive_export_test.go @@ -162,6 +162,9 @@ func TestDriveExportMarkdownWritesFile(t *testing.T) { if reqBody["format"] != "markdown" { t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown") } + if _, ok := reqBody["extra_param"]; ok { + t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody) + } data, err := os.ReadFile(filepath.Join(tmpDir, "Weekly Notes.md")) if err != nil { @@ -213,6 +216,9 @@ func TestDriveExportMarkdownUsesProvidedFileName(t *testing.T) { if reqBody["format"] != "markdown" { t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown") } + if _, ok := reqBody["extra_param"]; ok { + t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody) + } data, err := os.ReadFile(filepath.Join(tmpDir, "custom-notes.md")) if err != nil { @@ -283,6 +289,9 @@ func TestDriveExportDryRunIncludesLocalFileNameMetadata(t *testing.T) { if !strings.Contains(out, `"output_dir": "./exports"`) { t.Fatalf("stdout missing output_dir metadata: %s", out) } + if tt.name == "markdown" && strings.Contains(out, `"extra_param"`) { + t.Fatalf("markdown dry-run must not enable docs fetch extra_param: %s", out) + } }) } } @@ -333,6 +342,9 @@ func TestDriveExportMarkdownFallsBackToTokenWhenTitleLookupFails(t *testing.T) { if reqBody["format"] != "markdown" { t.Fatalf("docs_ai fetch body format = %v, want %q", reqBody["format"], "markdown") } + if _, ok := reqBody["extra_param"]; ok { + t.Fatalf("drive markdown export must not enable docs fetch extra_param: %#v", reqBody) + } data, err := os.ReadFile(filepath.Join(tmpDir, "docx123.md")) if err != nil { diff --git a/shortcuts/drive/drive_pull.go b/shortcuts/drive/drive_pull.go index 3fa27eda..bcdce761 100644 --- a/shortcuts/drive/drive_pull.go +++ b/shortcuts/drive/drive_pull.go @@ -37,11 +37,16 @@ const ( ) type drivePullItem struct { - RelPath string `json:"rel_path"` - FileToken string `json:"file_token,omitempty"` - SourceID string `json:"source_id,omitempty"` - Action string `json:"action"` - Error string `json:"error,omitempty"` + RelPath string `json:"rel_path"` + FileToken string `json:"file_token,omitempty"` + SourceID string `json:"source_id,omitempty"` + Action string `json:"action"` + Error string `json:"error,omitempty"` + Phase string `json:"phase,omitempty"` + ErrorClass string `json:"error_class,omitempty"` + Code int `json:"code,omitempty"` + Subtype string `json:"subtype,omitempty"` + Retryable *bool `json:"retryable,omitempty"` } type drivePullTarget struct { @@ -189,6 +194,9 @@ var DrivePull = common.Shortcut{ sort.Strings(downloadablePaths) for _, rel := range downloadablePaths { + if drivePullHasTerminalFailure(items) { + break + } targetFile := remoteFiles[rel] downloadToken := targetFile.DownloadToken itemFileToken := targetFile.ItemFileToken @@ -204,13 +212,9 @@ var DrivePull = common.Shortcut{ // pre-existing file under --if-exists=skip silently // hides the conflict. Surface as a failure. if info.IsDir() { - items = append(items, drivePullItem{ - RelPath: rel, - FileToken: itemFileToken, - SourceID: itemSourceID, - Action: "failed", - Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target), - }) + conflictErr := errs.NewValidationError(errs.SubtypeFailedPrecondition, "local path is a directory, remote is a regular file: %s", target) + item, _ := drivePullFailedItem(rel, itemFileToken, itemSourceID, "failed", "local", conflictErr) + items = append(items, item) failed++ downloadFailed++ continue @@ -223,9 +227,14 @@ var DrivePull = common.Shortcut{ } if err := drivePullDownload(ctx, runtime, downloadToken, target, targetFile.ModifiedTime); err != nil { - items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()}) + item, terminal := drivePullFailedItem(rel, itemFileToken, itemSourceID, "failed", "download", err) + items = append(items, item) failed++ downloadFailed++ + if terminal { + fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err) + break + } continue } items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "downloaded"}) @@ -251,7 +260,8 @@ var DrivePull = common.Shortcut{ for _, absPath := range localAbsPaths { rel, relErr := filepath.Rel(safeRoot, absPath) if relErr != nil { - items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()}) + item, _ := drivePullFailedItem(absPath, "", "", "delete_failed", "delete_local", relErr) + items = append(items, item) failed++ continue } @@ -271,7 +281,9 @@ var DrivePull = common.Shortcut{ // acceptable here. Shortcuts cannot import internal/vfs // directly (depguard rule shortcuts-no-vfs). if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above - items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()}) + deleteErr := errs.NewInternalError(errs.SubtypeFileIO, "delete local %q: %s", rel, err).WithCause(err) + item, _ := drivePullFailedItem(rel, "", "", "delete_failed", "delete_local", deleteErr) + items = append(items, item) failed++ continue } @@ -286,6 +298,7 @@ var DrivePull = common.Shortcut{ "skipped": skipped, "failed": failed, "deleted_local": deletedLocal, + "aborted": drivePullHasTerminalFailure(items), }, "items": items, } @@ -317,6 +330,32 @@ var DrivePull = common.Shortcut{ }, } +func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err error) (drivePullItem, bool) { + decision := driveClassifyBatchFailure(err) + item := drivePullItem{ + RelPath: relPath, + FileToken: fileToken, + SourceID: sourceID, + Action: action, + Error: err.Error(), + Phase: phase, + ErrorClass: decision.Class, + Code: decision.Code, + Subtype: decision.Subtype, + Retryable: driveBoolPtr(decision.Retryable), + } + return item, decision.Terminal +} + +func drivePullHasTerminalFailure(items []drivePullItem) bool { + for _, item := range items { + if driveTerminalBatchErrorClass(item.ErrorClass) { + return true + } + } + return false +} + // drivePullDownload streams one Drive file into the local mirror target and // then best-effort aligns the local mtime to Drive's modified_time. func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error { diff --git a/shortcuts/drive/drive_pull_test.go b/shortcuts/drive/drive_pull_test.go index 5a6d6caa..5502291b 100644 --- a/shortcuts/drive/drive_pull_test.go +++ b/shortcuts/drive/drive_pull_test.go @@ -1032,6 +1032,66 @@ func TestDrivePullDownloadFailureSkipsDeleteLocalAndExitsNonZero(t *testing.T) { } } +func TestDrivePullAbortsAfterDownloadForbidden(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: http.StatusForbidden, + RawBody: []byte("forbidden"), + }) + + err := mountAndRunDrive(t, DrivePull, []string{ + "+pull", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + assertDrivePullPartialFailure(t, err) + + summary, items := splitDrivePullStdout(t, stdout.Bytes()) + if got := summary["aborted"]; got != true { + t.Fatalf("summary.aborted = %v, want true", got) + } + if got := summary["failed"]; got != float64(1) { + t.Fatalf("summary.failed = %v, want 1", got) + } + if len(items) != 1 { + t.Fatalf("items len = %d, want 1; items=%#v", len(items), items) + } + item := items[0] + if item["rel_path"] != "a.txt" || item["phase"] != "download" || item["error_class"] != "permission_denied" { + t.Fatalf("unexpected failed item: %#v", item) + } + if item["code"] != float64(http.StatusForbidden) || item["retryable"] != false { + t.Fatalf("unexpected failure classification: %#v", item) + } + if _, statErr := os.Stat(filepath.Join("local", "b.txt")); !os.IsNotExist(statErr) { + t.Fatalf("b.txt should not be downloaded after terminal permission failure; stat err=%v", statErr) + } +} + // TestDrivePullDeleteLocalDoesNotEscapeViaSymlinkParentRef is the // regression for the "link/.." escape applied to --delete-local — the // most dangerous variant, since the bug would otherwise let the kernel diff --git a/shortcuts/drive/drive_push.go b/shortcuts/drive/drive_push.go index 351ef560..e0ff5c12 100644 --- a/shortcuts/drive/drive_push.go +++ b/shortcuts/drive/drive_push.go @@ -29,12 +29,25 @@ const ( ) type drivePushItem struct { - RelPath string `json:"rel_path"` - FileToken string `json:"file_token,omitempty"` - Action string `json:"action"` - Version string `json:"version,omitempty"` - SizeBytes int64 `json:"size_bytes,omitempty"` - Error string `json:"error,omitempty"` + RelPath string `json:"rel_path"` + FileToken string `json:"file_token,omitempty"` + Action string `json:"action"` + Version string `json:"version,omitempty"` + SizeBytes int64 `json:"size_bytes,omitempty"` + Error string `json:"error,omitempty"` + Phase string `json:"phase,omitempty"` + ErrorClass string `json:"error_class,omitempty"` + Code int `json:"code,omitempty"` + Subtype string `json:"subtype,omitempty"` + Retryable *bool `json:"retryable,omitempty"` +} + +type driveBatchFailureDecision struct { + Class string + Code int + Subtype string + Retryable bool + Terminal bool } // DrivePush is a one-way, file-level mirror from a local directory onto a @@ -248,9 +261,14 @@ var DrivePush = common.Shortcut{ continue } if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil { - items = append(items, drivePushItem{RelPath: relDir, Action: "failed", Error: ensureErr.Error()}) + item, terminal := drivePushFailedItem(relDir, "", "failed", "create_folder", 0, ensureErr) + items = append(items, item) failed++ uploadFailed = true + if terminal { + fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr) + break + } continue } items = append(items, drivePushItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created"}) @@ -266,6 +284,9 @@ var DrivePush = common.Shortcut{ for _, rel := range localPaths { localFile := localFiles[rel] + if uploadFailed && drivePushHasTerminalFailure(items) { + break + } if entry, ok := remoteFiles[rel]; ok { if drivePushShouldSkipExisting(localFile, entry, ifExists) { @@ -275,9 +296,14 @@ var DrivePush = common.Shortcut{ } parentToken, parentErr := drivePushEnsureParentToken(ctx, runtime, folderToken, rel, folderCache) if parentErr != nil { - items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "failed", SizeBytes: localFile.Size, Error: parentErr.Error()}) + item, terminal := drivePushFailedItem(rel, entry.FileToken, "failed", "create_folder", localFile.Size, parentErr) + items = append(items, item) failed++ uploadFailed = true + if terminal { + fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr) + break + } continue } token, version, upErr := drivePushUploadFile(ctx, runtime, localFile, entry.FileToken, parentToken) @@ -301,9 +327,14 @@ var DrivePush = common.Shortcut{ if failedToken == "" { failedToken = entry.FileToken } - items = append(items, drivePushItem{RelPath: rel, FileToken: failedToken, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()}) + item, terminal := drivePushFailedItem(rel, failedToken, "failed", "upload", localFile.Size, upErr) + items = append(items, item) failed++ uploadFailed = true + if terminal { + fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr) + break + } continue } items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "overwritten", Version: version, SizeBytes: localFile.Size}) @@ -314,16 +345,26 @@ var DrivePush = common.Shortcut{ parentRel := drivePushParentRel(rel) parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache) if ensureErr != nil { - items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: ensureErr.Error()}) + item, terminal := drivePushFailedItem(rel, "", "failed", "create_folder", localFile.Size, ensureErr) + items = append(items, item) failed++ uploadFailed = true + if terminal { + fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr) + break + } continue } token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken) if upErr != nil { - items = append(items, drivePushItem{RelPath: rel, Action: "failed", SizeBytes: localFile.Size, Error: upErr.Error()}) + item, terminal := drivePushFailedItem(rel, "", "failed", "upload", localFile.Size, upErr) + items = append(items, item) failed++ uploadFailed = true + if terminal { + fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr) + break + } continue } items = append(items, drivePushItem{RelPath: rel, FileToken: token, Action: "uploaded", SizeBytes: localFile.Size}) @@ -350,7 +391,11 @@ var DrivePush = common.Shortcut{ } sort.Strings(remoteRelPaths) + abortDelete := false for _, rel := range remoteRelPaths { + if abortDelete { + break + } keepToken := "" if _, ok := localFiles[rel]; ok { if chosen, ok := remoteFiles[rel]; ok { @@ -362,8 +407,14 @@ var DrivePush = common.Shortcut{ continue } if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil { - items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()}) + item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err) + items = append(items, item) failed++ + if terminal { + fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err) + abortDelete = true + break + } continue } items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"}) @@ -378,6 +429,7 @@ var DrivePush = common.Shortcut{ "skipped": skipped, "failed": failed, "deleted_remote": deletedRemote, + "aborted": drivePushHasTerminalFailure(items), }, "items": items, } @@ -507,6 +559,91 @@ func drivePushShouldSkipSmart(localFile drivePushLocalFile, remoteFile driveRemo return cmp >= 0 } +func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int64, err error) (drivePushItem, bool) { + decision := driveClassifyBatchFailure(err) + item := drivePushItem{ + RelPath: relPath, + FileToken: fileToken, + Action: action, + SizeBytes: sizeBytes, + Error: err.Error(), + Phase: phase, + ErrorClass: decision.Class, + Code: decision.Code, + Subtype: decision.Subtype, + Retryable: driveBoolPtr(decision.Retryable), + } + return item, decision.Terminal +} + +func driveBoolPtr(v bool) *bool { + return &v +} + +func driveClassifyBatchFailure(err error) driveBatchFailureDecision { + decision := driveBatchFailureDecision{Class: "unknown", Retryable: errs.IsRetryable(err)} + problem, ok := errs.ProblemOf(err) + if !ok { + return decision + } + decision.Code = problem.Code + decision.Subtype = string(problem.Subtype) + decision.Retryable = problem.Retryable + + switch { + case problem.Category == errs.CategoryAuthorization && problem.Code == 99991672: + decision.Class = "app_scope_missing" + decision.Terminal = true + case problem.Category == errs.CategoryAuthorization && problem.Code == 99991679: + decision.Class = "user_scope_missing" + decision.Terminal = true + case problem.Category == errs.CategoryAuthorization && problem.Subtype == errs.SubtypePermissionDenied: + decision.Class = "permission_denied" + decision.Terminal = true + case problem.Category == errs.CategoryNetwork && problem.Code == http.StatusForbidden: + decision.Class = "permission_denied" + decision.Terminal = true + case problem.Subtype == errs.SubtypeInvalidParameters || problem.Code == 1061002: + decision.Class = "invalid_api_parameters" + decision.Terminal = true + case problem.Subtype == errs.SubtypeRateLimit || problem.Code == 99991400: + decision.Class = "rate_limited" + decision.Terminal = true + case problem.Subtype == errs.SubtypeQuotaExceeded || problem.Code == 1061043: + decision.Class = "file_size_limit" + case problem.Code == 1062009: + decision.Class = "upload_size_mismatch" + case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007: + decision.Class = "remote_not_found" + case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200: + decision.Class = "server_error" + decision.Terminal = true + case problem.Subtype == errs.SubtypeFailedPrecondition: + decision.Class = "local_file_changed" + default: + decision.Class = string(problem.Subtype) + } + return decision +} + +func drivePushHasTerminalFailure(items []drivePushItem) bool { + for _, item := range items { + if driveTerminalBatchErrorClass(item.ErrorClass) { + return true + } + } + return false +} + +func driveTerminalBatchErrorClass(errorClass string) bool { + switch errorClass { + case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error": + return true + default: + return false + } +} + func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) { remoteFiles := make(map[string]driveRemoteEntry, len(entries)) remoteFolders := make(map[string]driveRemoteEntry, len(entries)) @@ -600,6 +737,12 @@ func drivePushEnsureParentToken(ctx context.Context, runtime *common.RuntimeCont // the three-step prepare/part/finish flow, which mirrors drive +upload's // existing multipart logic. func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) { + if err := drivePushValidateUploadRequest(file, existingToken, parentToken); err != nil { + return "", "", err + } + if err := drivePushVerifyLocalSnapshot(runtime, file); err != nil { + return "", "", err + } if file.Size > common.MaxDriveMediaUploadSinglePartSize { token, err := drivePushUploadMultipart(ctx, runtime, file, existingToken, parentToken) // Multipart finish does not return version on the existing @@ -612,6 +755,44 @@ func drivePushUploadFile(ctx context.Context, runtime *common.RuntimeContext, fi return drivePushUploadAll(ctx, runtime, file, existingToken, parentToken) } +func drivePushValidateUploadRequest(file drivePushLocalFile, existingToken, parentToken string) error { + if strings.TrimSpace(file.FileName) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: file name is empty", file.RelPath) + } + if file.Size < 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: file size is negative", file.RelPath) + } + if strings.TrimSpace(parentToken) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: parent folder token is empty", file.RelPath) + } + if err := validate.ResourceName(parentToken, "parent_node"); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot upload %q: %s", file.RelPath, err) + } + if existingToken != "" { + if err := validate.ResourceName(existingToken, "file_token"); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "cannot overwrite %q: %s", file.RelPath, err) + } + } + return nil +} + +func drivePushVerifyLocalSnapshot(runtime *common.RuntimeContext, file drivePushLocalFile) error { + info, err := runtime.FileIO().Stat(file.OpenPath) + if err != nil { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s is no longer readable: %v", file.RelPath, err).WithCause(err) + } + if !info.Mode().IsRegular() { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s is no longer a regular file", file.RelPath) + } + if info.Size() != file.Size { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s snapshot size no longer matches", file.RelPath) + } + if modTimer, ok := info.(interface{ ModTime() time.Time }); ok && !modTimer.ModTime().Equal(file.ModTime) { + return errs.NewValidationError(errs.SubtypeFailedPrecondition, "local file changed during push: %s snapshot modtime no longer matches", file.RelPath) + } + return nil +} + func drivePushUploadAll(_ context.Context, runtime *common.RuntimeContext, file drivePushLocalFile, existingToken, parentToken string) (string, string, error) { f, err := runtime.FileIO().Open(file.OpenPath) if err != nil { diff --git a/shortcuts/drive/drive_push_test.go b/shortcuts/drive/drive_push_test.go index fff10eeb..dfdd6073 100644 --- a/shortcuts/drive/drive_push_test.go +++ b/shortcuts/drive/drive_push_test.go @@ -5,8 +5,10 @@ package drive import ( "context" + "encoding/json" "errors" "io" + "net/http" "os" "path/filepath" "strings" @@ -14,12 +16,14 @@ import ( "testing" "time" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/extension/fileio" "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" + "github.com/spf13/cobra" ) // countingOpenProvider wraps a fileio.Provider and counts FileIO.Open @@ -652,6 +656,82 @@ func TestDrivePushDeleteRemoteSkipsOnlineDocs(t *testing.T) { } } +func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, + map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "DELETE", + URL: "/open-apis/drive/v1/files/tok_a", + Body: map[string]interface{}{ + "code": 1061004, + "msg": "forbidden", + }, + }) + // No DELETE stub for tok_b: terminal delete failure must stop before it. + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--delete-remote", + "--yes", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String()) + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) || pfErr.Code != output.ExitAPI { + t.Fatalf("expected ExitAPI *output.PartialFailureError, got %T %v", err, err) + } + + summary, items := splitDrivePushStdout(t, stdout.Bytes()) + if got := summary["failed"]; got != float64(1) { + t.Fatalf("summary.failed = %v, want 1", got) + } + if got := summary["aborted"]; got != true { + t.Fatalf("summary.aborted = %v, want true", got) + } + if got := summary["deleted_remote"]; got != float64(0) { + t.Fatalf("summary.deleted_remote = %v, want 0", got) + } + if len(items) != 1 { + t.Fatalf("items len = %d, want 1; items=%#v", len(items), items) + } + item := items[0] + if item["action"] != "delete_failed" || item["phase"] != "delete" || item["error_class"] != "permission_denied" { + t.Fatalf("unexpected failed item: %#v", item) + } + if item["code"] != float64(1061004) || item["retryable"] != false { + t.Fatalf("unexpected failure metadata: %#v", item) + } + for _, item := range items { + if item["file_token"] == "tok_b" { + t.Fatalf("terminal delete failure must abort before tok_b, got items=%#v", items) + } + } +} + func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) { f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) @@ -886,21 +966,22 @@ func TestDrivePushOverwriteWithoutVersionFails(t *testing.T) { t.Errorf("expected ExitAPI (%d), got code=%d", output.ExitAPI, pfErr.Code) } - out := stdout.String() - // summary.failed should reflect the missing version; summary.uploaded - // should not pretend the overwrite succeeded. - if !strings.Contains(out, `"failed": 1`) { - t.Errorf("expected failed=1, got: %s", out) + summary, items := splitDrivePushStdout(t, stdout.Bytes()) + if got := summary["failed"]; got != float64(1) { + t.Errorf("summary.failed = %v, want 1", got) } - if !strings.Contains(out, "no version") { - t.Errorf("expected error about missing version in items[].error, got: %s", out) + if len(items) != 1 { + t.Fatalf("items len = %d, want 1; items=%#v", len(items), items) + } + if got, _ := items[0]["error"].(string); !strings.Contains(got, "no version") { + t.Errorf("items[0].error = %q, want missing-version message", got) } // Pin the token-stability contract: the failed item must surface the // token returned by upload_all (tok_keep_new), NOT the fallback // entry.FileToken (tok_keep). Without this, a regression that always // uses entry.FileToken on failure would slip through. - if !strings.Contains(out, `"file_token": "tok_keep_new"`) { - t.Errorf("expected failed item to surface upload_all's returned file_token (tok_keep_new), got: %s", out) + if got := items[0]["file_token"]; got != "tok_keep_new" { + t.Errorf("items[0].file_token = %v, want tok_keep_new", got) } } @@ -962,24 +1043,313 @@ func TestDrivePushOverwritePartialSuccessSurfacesReturnedToken(t *testing.T) { t.Fatalf("expected ExitAPI from *output.PartialFailureError, got %T %v", err, err) } - out := stdout.String() // Partial failure reports an ok:false result envelope on stdout (not a // misleading ok:true) while still carrying BOTH the succeeded and failed // items — consistent with the pre-change payload. The failed side is // asserted via "failed": 1 and the succeeded side via tok_keep_partial. - if !strings.Contains(out, `"ok": false`) { - t.Errorf("partial failure must emit an ok:false result envelope, got: %s", out) + envelope := decodeDrivePushStdout(t, stdout.Bytes()) + if envelope.OK { + t.Fatalf("partial failure must emit ok=false; stdout=%s", stdout.String()) } - if !strings.Contains(out, `"failed": 1`) { - t.Errorf("expected failed=1, got: %s", out) + summary, items := splitDrivePushStdout(t, stdout.Bytes()) + if got := summary["failed"]; got != float64(1) { + t.Errorf("summary.failed = %v, want 1", got) + } + if len(items) != 1 { + t.Fatalf("items len = %d, want 1; items=%#v", len(items), items) } // The freshly returned token must be the one in items[].file_token, // not the stale entry.FileToken (tok_keep_old). - if !strings.Contains(out, `"file_token": "tok_keep_partial"`) { - t.Errorf("expected items[].file_token to surface upload_all's returned token (tok_keep_partial), got: %s", out) + if got := items[0]["file_token"]; got != "tok_keep_partial" { + t.Errorf("items[0].file_token = %v, want tok_keep_partial", got) } - if strings.Contains(out, `"file_token": "tok_keep_old"`) { - t.Errorf("must NOT fall back to entry.FileToken when upload_all returned a token; got: %s", out) + if got := items[0]["file_token"]; got == "tok_keep_old" { + t.Errorf("must NOT fall back to entry.FileToken when upload_all returned a token; item=%#v", items[0]) + } +} + +func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil { + t.Fatalf("WriteFile a: %v", err) + } + if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil { + t.Fatalf("WriteFile b: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 1061002, + "msg": "params error.", + }, + }) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String()) + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) + } + summary, items := splitDrivePushStdout(t, stdout.Bytes()) + if got := summary["failed"]; got != float64(1) { + t.Fatalf("summary.failed = %v, want 1", got) + } + if got := summary["aborted"]; got != true { + t.Fatalf("summary.aborted = %v, want true", got) + } + if len(items) != 1 { + t.Fatalf("items len = %d, want 1; items=%#v", len(items), items) + } + item := items[0] + if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "invalid_api_parameters" { + t.Fatalf("unexpected failed item: %#v", item) + } + if item["code"] != float64(1061002) || item["subtype"] != "invalid_parameters" || item["retryable"] != false { + t.Fatalf("unexpected failure metadata: %#v", item) + } + for _, item := range items { + if item["rel_path"] == "b.txt" { + t.Fatalf("terminal upload params error must abort before b.txt, got items=%#v", items) + } + } +} + +func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll(filepath.Join("local", "a"), 0o755); err != nil { + t.Fatalf("MkdirAll a: %v", err) + } + if err := os.MkdirAll(filepath.Join("local", "b"), 0o755); err != nil { + t.Fatalf("MkdirAll b: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/create_folder", + Body: map[string]interface{}{ + "code": 99991672, + "msg": "Access denied. One of the following scopes is required: [drive:drive, space:folder:create].", + }, + }) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String()) + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) + } + summary, items := splitDrivePushStdout(t, stdout.Bytes()) + if got := summary["failed"]; got != float64(1) { + t.Fatalf("summary.failed = %v, want 1", got) + } + if got := summary["aborted"]; got != true { + t.Fatalf("summary.aborted = %v, want true", got) + } + if len(items) != 1 { + t.Fatalf("items len = %d, want 1; items=%#v", len(items), items) + } + item := items[0] + if item["rel_path"] != "a" || item["phase"] != "create_folder" || item["error_class"] != "app_scope_missing" { + t.Fatalf("unexpected failed item: %#v", item) + } + if item["code"] != float64(99991672) || item["retryable"] != false { + t.Fatalf("unexpected failure metadata: %#v", item) + } + for _, item := range items { + if item["rel_path"] == "b" { + t.Fatalf("missing folder-create scope must abort before b, got items=%#v", items) + } + } +} + +func TestDrivePushDetectsLocalFileChangedBeforeUpload(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + localPath := filepath.Join("local", "changing.txt") + if err := os.WriteFile(localPath, []byte("before"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + OnMatch: func(req *http.Request) { + if err := os.WriteFile(localPath, []byte("after-change"), 0o644); err != nil { + t.Fatalf("mutate local file: %v", err) + } + }, + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String()) + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) + } + summary, items := splitDrivePushStdout(t, stdout.Bytes()) + if got := summary["failed"]; got != float64(1) { + t.Fatalf("summary.failed = %v, want 1", got) + } + if got := summary["aborted"]; got != false { + t.Fatalf("summary.aborted = %v, want false", got) + } + if len(items) != 1 { + t.Fatalf("items len = %d, want 1; items=%#v", len(items), items) + } + item := items[0] + if item["error_class"] != "local_file_changed" || item["subtype"] != "failed_precondition" || item["retryable"] != false { + t.Fatalf("unexpected failure metadata: %#v", item) + } + if got, _ := item["error"].(string); !strings.Contains(got, "local file changed during push") { + t.Fatalf("items[0].error = %q, want local-change message", got) + } + if strings.Contains(stdout.String(), "httpmock: no stub") { + t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String()) + } + + problemErr := drivePushVerifyLocalSnapshot(common.TestNewRuntimeContext(&cobra.Command{Use: "drive +push"}, &core.CliConfig{}), drivePushLocalFile{ + RelPath: "missing.txt", + OpenPath: filepath.Join("local", "missing.txt"), + FileName: "missing.txt", + Size: 1, + ModTime: time.Now(), + }) + problem, ok := errs.ProblemOf(problemErr) + if !ok { + t.Fatalf("ProblemOf(snapshot error) ok=false, err=%T %v", problemErr, problemErr) + } + if problem.Subtype != errs.SubtypeFailedPrecondition { + t.Fatalf("snapshot error subtype = %q, want %q", problem.Subtype, errs.SubtypeFailedPrecondition) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("snapshot error category = %q, want %q", problem.Category, errs.CategoryValidation) + } + if errors.Unwrap(problemErr) == nil { + t.Fatalf("snapshot error cause was not preserved") + } +} + +func TestDrivePushDetectsSameSizeLocalFileChangedBeforeUpload(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + localPath := filepath.Join("local", "changing.txt") + if err := os.WriteFile(localPath, []byte("before"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + originalModTime := time.Unix(100, 0) + changedModTime := time.Unix(200, 0) + if err := os.Chtimes(localPath, originalModTime, originalModTime); err != nil { + t.Fatalf("Chtimes original: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + OnMatch: func(req *http.Request) { + if err := os.WriteFile(localPath, []byte("AFTER!"), 0o644); err != nil { + t.Fatalf("mutate local file: %v", err) + } + if err := os.Chtimes(localPath, changedModTime, changedModTime); err != nil { + t.Fatalf("Chtimes changed: %v", err) + } + }, + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{"files": []interface{}{}, "has_more": false}, + }, + }) + + err := mountAndRunDrive(t, DrivePush, []string{ + "+push", + "--local-dir", "local", + "--folder-token", "folder_root", + "--as", "bot", + }, f, stdout) + if err == nil { + t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String()) + } + var pfErr *output.PartialFailureError + if !errors.As(err, &pfErr) { + t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err) + } + + summary, items := splitDrivePushStdout(t, stdout.Bytes()) + if got := summary["failed"]; got != float64(1) { + t.Fatalf("summary.failed = %v, want 1", got) + } + if len(items) != 1 { + t.Fatalf("items len = %d, want 1; items=%#v", len(items), items) + } + item := items[0] + if item["rel_path"] != "changing.txt" || item["error_class"] != "local_file_changed" || item["subtype"] != "failed_precondition" { + t.Fatalf("unexpected failure metadata: %#v", item) + } + if got, _ := item["error"].(string); !strings.Contains(got, "snapshot modtime no longer matches") { + t.Fatalf("items[0].error = %q, want modtime mismatch", got) + } + if strings.Contains(stdout.String(), "httpmock: no stub") { + t.Fatalf("upload_all was called after local snapshot changed:\n%s", stdout.String()) } } @@ -1113,6 +1483,32 @@ func TestDrivePushExitsZeroOnCleanRun(t *testing.T) { } } +type drivePushStdoutEnvelope struct { + OK bool `json:"ok"` + Data struct { + Summary map[string]interface{} `json:"summary"` + Items []map[string]interface{} `json:"items"` + } `json:"data"` +} + +func decodeDrivePushStdout(t *testing.T, stdout []byte) drivePushStdoutEnvelope { + t.Helper() + var envelope drivePushStdoutEnvelope + if err := json.Unmarshal(stdout, &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout)) + } + return envelope +} + +func splitDrivePushStdout(t *testing.T, stdout []byte) (map[string]interface{}, []map[string]interface{}) { + t.Helper() + envelope := decodeDrivePushStdout(t, stdout) + if envelope.Data.Summary == nil { + t.Fatalf("stdout missing data.summary; raw=%s", string(stdout)) + } + return envelope.Data.Summary, envelope.Data.Items +} + // TestDrivePushUploadsSiblingWhenRemoteSameNameIsNativeDoc pins the // behavior when a local regular file shares its rel_path with a Lark // native cloud document on Drive (sheet/docx/bitable/...). diff --git a/shortcuts/drive/drive_search.go b/shortcuts/drive/drive_search.go index 3be4809f..ca4ece03 100644 --- a/shortcuts/drive/drive_search.go +++ b/shortcuts/drive/drive_search.go @@ -72,7 +72,7 @@ var DriveSearch = common.Shortcut{ Description: "Search Lark docs, Wiki, and spreadsheet files with flat filters (Search v2: doc_wiki/search)", Risk: "read", Scopes: []string{"search:docs:read"}, - AuthTypes: []string{"user"}, + AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ {Name: "query", Desc: "search keyword (may be empty to browse by filter only)"}, diff --git a/shortcuts/drive/drive_secure_label.go b/shortcuts/drive/drive_secure_label.go index 8c99f370..67b8250f 100644 --- a/shortcuts/drive/drive_secure_label.go +++ b/shortcuts/drive/drive_secure_label.go @@ -6,6 +6,7 @@ package drive import ( "context" "fmt" + "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/validate" @@ -17,6 +18,13 @@ const ( secureLabelUpdateScope = "docs:secure_label:write_only" ) +type secureLabelOperation string + +const ( + secureLabelOperationList secureLabelOperation = "list" + secureLabelOperationUpdate secureLabelOperation = "update" +) + var secureLabelTypes = permApplyTypes // DriveSecureLabelList lists secure labels available to the current user. @@ -28,6 +36,9 @@ var DriveSecureLabelList = common.Shortcut{ Scopes: []string{secureLabelReadScope}, AuthTypes: []string{"user"}, HasFormat: true, + Tips: []string{ + "Use the `id` field from this command as --label-id for +secure-label-update; do not use the display name.", + }, Flags: []common.Flag{ {Name: "page-size", Type: "int", Default: "10", Desc: "page size, 1-10"}, {Name: "page-token", Desc: "pagination token from previous response"}, @@ -53,7 +64,7 @@ var DriveSecureLabelList = common.Shortcut{ nil, ) if err != nil { - return err + return decorateSecureLabelError(err, secureLabelOperationList) } runtime.OutFormat(data, nil, nil) return nil @@ -68,13 +79,21 @@ var DriveSecureLabelUpdate = common.Shortcut{ Risk: "write", Scopes: []string{secureLabelUpdateScope}, AuthTypes: []string{"user"}, + Tips: []string{ + "Pass the numeric label id returned by +secure-label-list; display names like Public(D) are rejected.", + "Downgrading a secure label may require approval; retrying the same request will not bypass approval.", + "When updating many files, serialize requests and back off on rate_limit errors.", + }, Flags: []common.Flag{ {Name: "token", Desc: "target file token or document URL (docx/sheets/base/file/wiki/doc/mindnote/slides)", Required: true}, {Name: "type", Desc: "target type; auto-inferred from URL when omitted", Enum: secureLabelTypes}, {Name: "label-id", Desc: "secure label ID to set", Required: true}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { - _, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")) + if _, _, err := resolveSecureLabelTarget(runtime.Str("token"), runtime.Str("type")); err != nil { + return err + } + _, err := normalizeSecureLabelID(runtime.Str("label-id")) return err }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -82,11 +101,15 @@ var DriveSecureLabelUpdate = common.Shortcut{ if err != nil { return common.NewDryRunAPI().Set("error", err.Error()) } + labelID, err := normalizeSecureLabelID(runtime.Str("label-id")) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } return common.NewDryRunAPI(). Desc("Update Drive secure label"). PATCH("/open-apis/drive/v2/files/:file_token/secure_label"). Params(map[string]interface{}{"type": docType}). - Body(map[string]interface{}{"id": runtime.Str("label-id")}). + Body(map[string]interface{}{"id": labelID}). Set("file_token", token) }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -94,14 +117,18 @@ var DriveSecureLabelUpdate = common.Shortcut{ if err != nil { return err } - body := map[string]interface{}{"id": runtime.Str("label-id")} + labelID, err := normalizeSecureLabelID(runtime.Str("label-id")) + if err != nil { + return err + } + body := map[string]interface{}{"id": labelID} data, err := runtime.CallAPITyped("PATCH", fmt.Sprintf("/open-apis/drive/v2/files/%s/secure_label", validate.EncodePathSegment(token)), map[string]interface{}{"type": docType}, body, ) if err != nil { - return err + return decorateSecureLabelError(err, secureLabelOperationUpdate) } runtime.Out(data, nil) return nil @@ -122,3 +149,70 @@ func buildSecureLabelListParams(runtime *common.RuntimeContext) map[string]inter func resolveSecureLabelTarget(raw, explicitType string) (token, docType string, err error) { return resolvePermApplyTarget(raw, explicitType) } + +// normalizeSecureLabelID trims a label id and rejects display names before the +// request reaches Drive, where they otherwise surface as opaque JSON errors. +func normalizeSecureLabelID(raw string) (string, error) { + labelID := strings.TrimSpace(raw) + if labelID == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id is required"). + WithParam("--label-id") + } + for _, r := range labelID { + if r < '0' || r > '9' { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--label-id must be a numeric secure label ID, not a display name: %q", raw). + WithParam("--label-id"). + WithHint("run `lark-cli drive +secure-label-list` and pass the numeric `id` value; do not pass label names like `Public(D)`") + } + } + return labelID, nil +} + +// decorateSecureLabelError appends command-aware recovery guidance while +// preserving upstream/classifier hints already attached to the typed error. +func decorateSecureLabelError(err error, operation secureLabelOperation) error { + if err == nil { + return nil + } + p, ok := errs.ProblemOf(err) + if !ok { + return err + } + guidance := secureLabelErrorGuidance(p.Code, operation) + if guidance == "" { + return err + } + if p.Hint == "" { + p.Hint = guidance + } else if !strings.Contains(p.Hint, guidance) { + p.Hint = p.Hint + "; " + guidance + } + return err +} + +// secureLabelErrorGuidance returns recovery guidance for secure-label API +// failures whose generic code-level classification needs command context. +func secureLabelErrorGuidance(code int, operation secureLabelOperation) string { + switch code { + case 99991400: + if operation == secureLabelOperationUpdate { + return "secure label updates are rate limited; retry later with exponential backoff and serialize bulk updates" + } + return "secure label listing is rate limited; retry later with exponential backoff" + case 1063013: + if operation == secureLabelOperationUpdate { + return "secure label downgrade requires approval; request approval or choose a non-downgrade label before retrying" + } + case 1063002: + if operation == secureLabelOperationUpdate { + return "the current user lacks permission to update this file's secure label; use a user with file and security-label permission" + } + return "the current user lacks permission to list secure labels; use a user with security-label read permission" + case 1063001, 99992402, 9499: + if operation == secureLabelOperationUpdate { + return "check --token/--type and pass a secure label ID from `lark-cli drive +secure-label-list`, not the display name" + } + return "check secure label list parameters such as --page-size, --page-token, and --lang" + } + return "" +} diff --git a/shortcuts/drive/drive_secure_label_test.go b/shortcuts/drive/drive_secure_label_test.go index 49edaa1b..c467930f 100644 --- a/shortcuts/drive/drive_secure_label_test.go +++ b/shortcuts/drive/drive_secure_label_test.go @@ -5,9 +5,11 @@ package drive import ( "encoding/json" + "errors" "strings" "testing" + "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/httpmock" ) @@ -90,13 +92,54 @@ func TestDriveSecureLabelList_ExecuteSuccess(t *testing.T) { } } +func TestDriveSecureLabelList_RateLimitPreservesUpstreamHint(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v2/my_secure_labels?page_size=10", + Status: 429, + Body: map[string]interface{}{ + "code": 99991400, + "msg": "rate limit exceeded", + "error": map[string]interface{}{ + "details": []interface{}{ + map[string]interface{}{"value": "server says slow down"}, + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveSecureLabelList, []string{ + "+secure-label-list", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected rate limit error") + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T: %v", err, err) + } + if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable { + t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem) + } + for _, want := range []string{"server says slow down", "secure label listing is rate limited"} { + if !strings.Contains(apiErr.Hint, want) { + t.Fatalf("hint missing %q: %q", want, apiErr.Hint) + } + } + if strings.Contains(apiErr.Hint, "updates are rate limited") { + t.Fatalf("list hint should not use update-specific wording: %q", apiErr.Hint) + } +} + func TestDriveSecureLabelUpdate_DryRunInfersTypeFromURL(t *testing.T) { t.Parallel() f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{ "+secure-label-update", "--token", "https://example.feishu.cn/docx/doxTok123?from=share", - "--label-id", "7217780879644737539", + "--label-id", " 7217780879644737539 ", "--dry-run", "--as", "user", }, f, stdout) if err != nil { @@ -132,7 +175,7 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) { "+secure-label-update", "--token", "doxTok123", "--type", "docx", - "--label-id", "7217780879644737539", + "--label-id", " 7217780879644737539 ", "--as", "user", }, f, stdout) if err != nil { @@ -148,7 +191,32 @@ func TestDriveSecureLabelUpdate_ExecuteSuccess(t *testing.T) { } } -func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) { +func TestDriveSecureLabelUpdate_RejectsDisplayNameAsLabelID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{ + "+secure-label-update", + "--token", "doxTok123", + "--type", "docx", + "--label-id", "Public(D)", + "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected label id validation error") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected ValidationError, got %T: %v", err, err) + } + if validationErr.Param != "--label-id" { + t.Fatalf("Param = %q, want --label-id", validationErr.Param) + } + if !strings.Contains(validationErr.Hint, "+secure-label-list") { + t.Fatalf("hint missing list guidance: %q", validationErr.Hint) + } +} + +func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsFailedPrecondition(t *testing.T) { f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) reg.Register(&httpmock.Stub{ Method: "PATCH", @@ -169,7 +237,78 @@ func TestDriveSecureLabelUpdate_DowngradeApprovalReturnsAPIError(t *testing.T) { if err == nil { t.Fatal("expected 1063013 error") } - if !strings.Contains(err.Error(), "Security label downgrade requires approval") { - t.Fatalf("expected raw API error message, got: %v", err) + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected ValidationError, got %T: %v", err, err) + } + if validationErr.Subtype != errs.SubtypeFailedPrecondition || validationErr.Code != 1063013 { + t.Fatalf("problem = %+v, want code=1063013 subtype=failed_precondition", validationErr.Problem) + } + if !strings.Contains(validationErr.Hint, "approval") { + t.Fatalf("hint missing approval guidance: %q", validationErr.Hint) + } +} + +func TestDriveSecureLabelUpdate_InvalidJSONTypeGetsLabelHint(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/drive/v2/files/doxTok123/secure_label", + Status: 400, + Body: map[string]interface{}{ + "code": 9499, "msg": "Invalid parameter type in json: id", + }, + }) + + err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{ + "+secure-label-update", + "--token", "https://example.feishu.cn/docx/doxTok123", + "--label-id", "7217780879644737539", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected 9499 error") + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T: %v", err, err) + } + if apiErr.Subtype != errs.SubtypeInvalidParameters || apiErr.Code != 9499 { + t.Fatalf("problem = %+v, want code=9499 subtype=invalid_parameters", apiErr.Problem) + } + if !strings.Contains(apiErr.Hint, "+secure-label-list") { + t.Fatalf("hint missing secure label list guidance: %q", apiErr.Hint) + } +} + +func TestDriveSecureLabelUpdate_RateLimitIsRetryableWithBackoffHint(t *testing.T) { + f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/drive/v2/files/doxTok123/secure_label", + Status: 429, + Body: map[string]interface{}{ + "code": 99991400, "msg": "rate limit exceeded", + }, + }) + + err := mountAndRunDrive(t, DriveSecureLabelUpdate, []string{ + "+secure-label-update", + "--token", "https://example.feishu.cn/docx/doxTok123", + "--label-id", "7217780879644737539", + "--as", "user", + }, f, nil) + if err == nil { + t.Fatal("expected rate limit error") + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected APIError, got %T: %v", err, err) + } + if apiErr.Subtype != errs.SubtypeRateLimit || apiErr.Code != 99991400 || !apiErr.Retryable { + t.Fatalf("problem = %+v, want code=99991400 subtype=rate_limit retryable=true", apiErr.Problem) + } + if !strings.Contains(apiErr.Hint, "backoff") { + t.Fatalf("hint missing backoff guidance: %q", apiErr.Hint) } } diff --git a/shortcuts/drive/drive_sync.go b/shortcuts/drive/drive_sync.go index ecb97e52..75658e35 100644 --- a/shortcuts/drive/drive_sync.go +++ b/shortcuts/drive/drive_sync.go @@ -25,12 +25,21 @@ const ( driveSyncOnConflictAsk = "ask" ) +func driveSyncActionScopes() []string { + return []string{"drive:file:download", "drive:file:upload", "space:folder:create"} +} + type driveSyncItem struct { - RelPath string `json:"rel_path"` - FileToken string `json:"file_token,omitempty"` - Action string `json:"action"` - Direction string `json:"direction,omitempty"` // "pull" or "push" - Error string `json:"error,omitempty"` + RelPath string `json:"rel_path"` + FileToken string `json:"file_token,omitempty"` + Action string `json:"action"` + Direction string `json:"direction,omitempty"` // "pull" or "push" + Error string `json:"error,omitempty"` + Phase string `json:"phase,omitempty"` + ErrorClass string `json:"error_class,omitempty"` + Code int `json:"code,omitempty"` + Subtype string `json:"subtype,omitempty"` + Retryable *bool `json:"retryable,omitempty"` } // DriveSync performs a two-way sync between a local directory and a Drive @@ -66,6 +75,7 @@ var DriveSync = common.Shortcut{ "Default --on-conflict=remote-wins pulls the remote version when both sides changed a file. Use local-wins to push instead, keep-both to rename and keep both copies, or ask for interactive resolution.", "Pass --quick for faster best-effort diff detection using modified_time instead of SHA-256 hash (no remote file downloads needed during diffing).", "Because +sync acts on the diff, --quick can still pull, overwrite, or rename files when timestamps differ even if file contents are actually unchanged.", + "Actual sync execution pre-flights download, upload, and folder-create scopes before listing or walking, so missing grants fail before any partial sync can start.", "Only entries with type=file are synced; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.", }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { @@ -110,10 +120,8 @@ var DriveSync = common.Shortcut{ duplicateRemote = driveDuplicateRemoteFail } quick := runtime.Bool("quick") - if !quick { - if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil { - return err - } + if err := runtime.EnsureScopes(driveSyncActionScopes()); err != nil { + return err } safeRoot, err := validate.SafeInputPath(localDir) @@ -262,18 +270,6 @@ var DriveSync = common.Shortcut{ var pulled, pushed, skipped, failed int items := make([]driveSyncItem, 0) - if quick && driveSyncNeedsDownloadScope(newRemote, modified, conflictResolutions) { - if err := runtime.EnsureScopes([]string{"drive:file:download"}); err != nil { - return err - } - } - plannedUploads := driveSyncPlannedUploadPaths(newLocal, modified, conflictResolutions) - if len(plannedUploads) > 0 { - if err := runtime.EnsureScopes([]string{"drive:file:upload"}); err != nil { - return err - } - } - // Build push infrastructure: local walk for push + remote views + folder cache. folderCache := map[string]string{"": folderToken} for relDir, entry := range remoteFolders { @@ -287,20 +283,18 @@ var DriveSync = common.Shortcut{ return err } - if driveSyncNeedsCreateScope(plannedUploads, localDirs, folderCache) { - if err := runtime.EnsureScopes([]string{"space:folder:create"}); err != nil { - return err - } - } - // Mirror local directory structure first (same as +push), so // empty local directories are not silently dropped. for _, relDir := range localDirs { + if driveSyncHasTerminalFailure(items) { + break + } if _, alreadyRemote := folderCache[relDir]; alreadyRemote { continue } if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil { - items = append(items, driveSyncItem{RelPath: relDir, Action: "failed", Direction: "push", Error: ensureErr.Error()}) + item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr) + items = append(items, item) failed++ continue } @@ -310,6 +304,9 @@ var DriveSync = common.Shortcut{ // 2a. Pull new_remote files. for _, entry := range newRemote { + if driveSyncHasTerminalFailure(items) { + break + } targetFile, ok := pullRemoteFiles[entry.RelPath] if !ok { // Non-file type (doc, shortcut, etc.) — skip. @@ -317,8 +314,13 @@ var DriveSync = common.Shortcut{ } target := filepath.Join(rootRelToCwd, entry.RelPath) if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil { - items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()}) + item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", err) + items = append(items, item) failed++ + if terminal { + fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err) + break + } continue } items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"}) @@ -327,6 +329,9 @@ var DriveSync = common.Shortcut{ // 2b. Push new_local files. for _, entry := range newLocal { + if driveSyncHasTerminalFailure(items) { + break + } localFile, ok := pushLocalFiles[entry.RelPath] if !ok { items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "skipped", Direction: "push", Error: "local file disappeared during sync"}) @@ -336,14 +341,20 @@ var DriveSync = common.Shortcut{ parentRel := drivePushParentRel(entry.RelPath) parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache) if ensureErr != nil { - items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: ensureErr.Error()}) + item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr) + items = append(items, item) failed++ continue } token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken) if upErr != nil { - items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "push", Error: upErr.Error()}) + item, terminal := driveSyncFailedItem(entry.RelPath, token, "failed", "push", "upload", upErr) + items = append(items, item) failed++ + if terminal { + fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr) + break + } continue } items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "uploaded", Direction: "push"}) @@ -352,6 +363,9 @@ var DriveSync = common.Shortcut{ // 2c. Resolve modified files by --on-conflict strategy. for _, entry := range modified { + if driveSyncHasTerminalFailure(items) { + break + } remoteFile := remoteFiles[entry.RelPath] localFile, hasLocal := pushLocalFiles[entry.RelPath] if !hasLocal { @@ -379,8 +393,13 @@ var DriveSync = common.Shortcut{ } target := filepath.Join(rootRelToCwd, entry.RelPath) if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil { - items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: err.Error()}) + item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", err) + items = append(items, item) failed++ + if terminal { + fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err) + break + } continue } items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "downloaded", Direction: "pull"}) @@ -396,7 +415,8 @@ var DriveSync = common.Shortcut{ } parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache) if parentErr != nil { - items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: existingToken, Action: "failed", Direction: "push", Error: parentErr.Error()}) + item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr) + items = append(items, item) failed++ continue } @@ -411,8 +431,13 @@ var DriveSync = common.Shortcut{ if failedToken == "" { failedToken = existingToken } - items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: failedToken, Action: "failed", Direction: "push", Error: upErr.Error()}) + item, terminal := driveSyncFailedItem(entry.RelPath, failedToken, "failed", "push", "upload", upErr) + items = append(items, item) failed++ + if terminal { + fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr) + break + } continue } items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: token, Action: "overwritten", Direction: "push"}) @@ -433,7 +458,8 @@ var DriveSync = common.Shortcut{ } suffixedRel, err := relPathWithUniqueFileTokenSuffix(entry.RelPath, remoteFile.FileToken, occupied) if err != nil { - items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: err.Error()}) + item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "conflict", "conflict", err) + items = append(items, item) failed++ continue } @@ -441,7 +467,9 @@ var DriveSync = common.Shortcut{ oldAbsPath := filepath.Join(safeRoot, filepath.FromSlash(entry.RelPath)) newAbsPath := filepath.Join(safeRoot, filepath.FromSlash(suffixedRel)) if err := os.Rename(oldAbsPath, newAbsPath); err != nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. - items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "conflict", Error: fmt.Sprintf("rename local: %s", err)}) + renameErr := errs.NewInternalError(errs.SubtypeFileIO, "rename local: %s", err).WithCause(err) + item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "conflict", "conflict", renameErr) + items = append(items, item) failed++ continue } @@ -454,19 +482,30 @@ var DriveSync = common.Shortcut{ if rollbackErr != nil { errMsg += "; rollback failed: " + rollbackErr.Error() } - items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "failed", Direction: "pull", Error: errMsg}) + notFoundErr := errs.NewAPIError(errs.SubtypeNotFound, "%s", errMsg) + item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "pull", "download", notFoundErr) + items = append(items, item) failed++ continue } target := filepath.Join(rootRelToCwd, entry.RelPath) if err := drivePullDownload(ctx, runtime, targetFile.DownloadToken, target, targetFile.ModifiedTime); err != nil { + downloadErr := err rollbackErr := driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath) errMsg := err.Error() if rollbackErr != nil { errMsg += "; rollback failed: " + rollbackErr.Error() } - items = append(items, driveSyncItem{RelPath: entry.RelPath, FileToken: entry.FileToken, Action: "failed", Direction: "pull", Error: errMsg}) + item, terminal := driveSyncFailedItem(entry.RelPath, entry.FileToken, "failed", "pull", "download", downloadErr) + if rollbackErr != nil { + item.Error = errMsg + } + items = append(items, item) failed++ + if terminal { + fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr) + break + } continue } items = append(items, driveSyncItem{RelPath: entry.RelPath, Action: "renamed_local", Direction: "conflict"}) @@ -492,6 +531,7 @@ var DriveSync = common.Shortcut{ "pushed": pushed, "skipped": skipped, "failed": failed, + "aborted": driveSyncHasTerminalFailure(items), }, "items": items, } @@ -520,6 +560,32 @@ func driveSyncStatusRemoteFiles(pullRemoteFiles map[string]drivePullTarget) map[ return remoteFiles } +func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, err error) (driveSyncItem, bool) { + decision := driveClassifyBatchFailure(err) + item := driveSyncItem{ + RelPath: relPath, + FileToken: fileToken, + Action: action, + Direction: direction, + Error: err.Error(), + Phase: phase, + ErrorClass: decision.Class, + Code: decision.Code, + Subtype: decision.Subtype, + Retryable: driveBoolPtr(decision.Retryable), + } + return item, decision.Terminal +} + +func driveSyncHasTerminalFailure(items []driveSyncItem) bool { + for _, item := range items { + if driveTerminalBatchErrorClass(item.ErrorClass) { + return true + } + } + return false +} + // driveSyncAskConflict prompts the user for a conflict resolution strategy // for a single file. Returns the strategy string, or empty string if the // user chose to skip. @@ -558,51 +624,6 @@ func driveSyncAskConflict(relPath string, runtime *common.RuntimeContext) (strin } } -func driveSyncNeedsDownloadScope(newRemote, modified []driveStatusEntry, conflictResolutions map[string]string) bool { - if len(newRemote) > 0 { - return true - } - for _, entry := range modified { - switch conflictResolutions[entry.RelPath] { - case driveSyncOnConflictRemoteWins, driveSyncOnConflictKeepBoth: - return true - } - } - return false -} - -func driveSyncPlannedUploadPaths(newLocal, modified []driveStatusEntry, conflictResolutions map[string]string) []string { - planned := make([]string, 0, len(newLocal)+len(modified)) - for _, entry := range newLocal { - planned = append(planned, entry.RelPath) - } - for _, entry := range modified { - if conflictResolutions[entry.RelPath] == driveSyncOnConflictLocalWins { - planned = append(planned, entry.RelPath) - } - } - return planned -} - -func driveSyncNeedsCreateScope(uploadPaths []string, localDirs []string, folderCache map[string]string) bool { - for _, relPath := range uploadPaths { - parentRel := drivePushParentRel(relPath) - if parentRel == "" { - continue - } - if _, ok := folderCache[parentRel]; !ok { - return true - } - } - // Empty local directories also need create_folder if not already on Drive. - for _, relDir := range localDirs { - if _, ok := folderCache[relDir]; !ok { - return true - } - } - return false -} - func driveSyncRollbackRenamedLocal(oldAbsPath, newAbsPath string) error { if info, err := os.Stat(oldAbsPath); err == nil { //nolint:forbidigo // shortcuts cannot import internal/vfs (depguard rule shortcuts-no-vfs); safeRoot is validated. if info.IsDir() { diff --git a/shortcuts/drive/drive_sync_test.go b/shortcuts/drive/drive_sync_test.go index 1e1ea5af..70104a2f 100644 --- a/shortcuts/drive/drive_sync_test.go +++ b/shortcuts/drive/drive_sync_test.go @@ -311,6 +311,71 @@ func TestDriveSyncRemoteWinsPullsNewRemoteAndPushesNewLocal(t *testing.T) { } } +func TestDriveSyncAbortsAfterNewRemoteDownloadForbidden(t *testing.T) { + syncTestConfig := &core.CliConfig{ + AppID: "drive-sync-forbidden", AppSecret: "test-secret", Brand: core.BrandFeishu, + } + f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("local", 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "folder_token=folder_root", + Body: map[string]interface{}{ + "code": 0, "msg": "ok", + "data": map[string]interface{}{ + "files": []interface{}{ + map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "100"}, + map[string]interface{}{"token": "tok_b", "name": "b.txt", "type": "file", "modified_time": "100"}, + }, + "has_more": false, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/tok_a/download", + Status: http.StatusForbidden, + RawBody: []byte("forbidden"), + }) + + err := mountAndRunDrive(t, DriveSync, []string{ + "+sync", + "--local-dir", "local", + "--folder-token", "folder_root", + "--quick", + "--as", "bot", + }, f, stdout) + assertDriveSyncPartialFailure(t, err) + + summary := driveSyncStdoutSummary(t, stdout.Bytes()) + if got := summary["aborted"]; got != true { + t.Fatalf("summary.aborted = %v, want true", got) + } + if got := summary["failed"]; got != float64(1) { + t.Fatalf("summary.failed = %v, want 1", got) + } + items := driveSyncStdoutItems(t, stdout.Bytes()) + if len(items) != 1 { + t.Fatalf("items len = %d, want 1; items=%#v", len(items), items) + } + item := items[0] + if item.RelPath != "a.txt" || item.Direction != "pull" || item.Phase != "download" || item.ErrorClass != "permission_denied" { + t.Fatalf("unexpected failed item: %#v", item) + } + if item.Code != http.StatusForbidden || item.Retryable == nil || *item.Retryable { + t.Fatalf("unexpected failure classification: %#v", item) + } + if _, statErr := os.Stat(filepath.Join("local", "b.txt")); !os.IsNotExist(statErr) { + t.Fatalf("b.txt should not be downloaded after terminal permission failure; stat err=%v", statErr) + } +} + // TestDriveSyncLocalWinsPushesOverRemote verifies that --on-conflict=local-wins // pushes the local version over the remote file. func TestDriveSyncLocalWinsPushesOverRemote(t *testing.T) { @@ -1552,11 +1617,11 @@ func TestDriveSyncDryRunQuickAcceptsMetadataOnlyScope(t *testing.T) { } } -func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) { +func TestDriveSyncPreflightsActionScopesBeforeListing(t *testing.T) { syncTestConfig := &core.CliConfig{ AppID: "drive-sync-download-scope-only", AppSecret: "test-secret", Brand: core.BrandFeishu, } - f, stdout, _, reg := cmdutil.TestFactory(t, syncTestConfig) + f, stdout, _, _ := cmdutil.TestFactory(t, syncTestConfig) f.Credential = credential.NewCredentialProvider(nil, nil, &driveStatusScopedTokenResolver{scopes: "drive:drive.metadata:readonly drive:file:download"}, nil) tmpDir := t.TempDir() @@ -1568,34 +1633,6 @@ func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) { t.Fatalf("WriteFile a.txt: %v", err) } - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "folder_token=folder_root", - Body: map[string]interface{}{ - "code": 0, "msg": "ok", - "data": map[string]interface{}{ - "files": []interface{}{ - map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file"}, - }, - "has_more": false, - }, - }, - }) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/drive/v1/files/tok_a/download", - Status: 200, - Body: []byte("remote-a"), - Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, - }) - reg.Register(&httpmock.Stub{ - Method: "GET", - URL: "/open-apis/drive/v1/files/tok_a/download", - Status: 200, - Body: []byte("remote-a"), - Headers: http.Header{"Content-Type": []string{"application/octet-stream"}}, - }) - err := mountAndRunDrive(t, DriveSync, []string{ "+sync", "--local-dir", "local", @@ -1603,11 +1640,30 @@ func TestDriveSyncExactRemoteWinsAcceptsDownloadOnlyScope(t *testing.T) { "--on-conflict", "remote-wins", "--as", "bot", }, f, stdout) - if err != nil { - t.Fatalf("expected exact remote-wins to succeed with download-only scope, got: %v\nstdout: %s", err, stdout.String()) + if err == nil { + t.Fatalf("expected action-scope preflight to reject download-only scope\nstdout: %s", stdout.String()) } - if strings.Contains(strings.ToLower(stdout.String()), "missing_scope") { - t.Fatalf("should not surface missing_scope, got: %s", stdout.String()) + var permErr *errs.PermissionError + if !errors.As(err, &permErr) { + t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err) + } + if permErr.Subtype != errs.SubtypeMissingScope { + t.Fatalf("Subtype = %q, want %q", permErr.Subtype, errs.SubtypeMissingScope) + } + for _, scope := range []string{"drive:file:upload", "space:folder:create"} { + found := false + for _, missing := range permErr.MissingScopes { + if missing == scope { + found = true + break + } + } + if !found { + t.Fatalf("MissingScopes = %v, want %s", permErr.MissingScopes, scope) + } + } + if strings.Contains(stdout.String(), "folder_root") { + t.Fatalf("preflight should fail before remote listing, got stdout: %s", stdout.String()) } } @@ -2552,30 +2608,6 @@ func TestDriveSyncAskConflictRemoteShortForms(t *testing.T) { } } -// TestDriveSyncNeedsDownloadScopeReturnsFalseForLocalWinsOnly verifies -// that driveSyncNeedsDownloadScope returns false when there are no -// new_remote entries and all modified entries resolve to local-wins. -func TestDriveSyncNeedsDownloadScopeReturnsFalseForLocalWinsOnly(t *testing.T) { - modified := []driveStatusEntry{{RelPath: "a.txt"}, {RelPath: "b.txt"}} - resolutions := map[string]string{"a.txt": driveSyncOnConflictLocalWins, "b.txt": driveSyncOnConflictLocalWins} - - if driveSyncNeedsDownloadScope(nil, modified, resolutions) { - t.Fatal("expected false when no new_remote and all conflicts are local-wins") - } -} - -// TestDriveSyncNeedsDownloadScopeReturnsTrueForKeepBoth verifies that -// driveSyncNeedsDownloadScope returns true when a modified entry resolves -// to keep-both (which requires pulling the remote version). -func TestDriveSyncNeedsDownloadScopeReturnsTrueForKeepBoth(t *testing.T) { - modified := []driveStatusEntry{{RelPath: "a.txt"}} - resolutions := map[string]string{"a.txt": driveSyncOnConflictKeepBoth} - - if !driveSyncNeedsDownloadScope(nil, modified, resolutions) { - t.Fatal("expected true when a conflict resolves to keep-both") - } -} - // TestDriveSyncRemoteWinsReportsMissingPullView verifies that when a // modified file's rel_path is not in pullRemoteFiles during the // remote-wins branch, a failed item is reported instead of a panic. @@ -3083,3 +3115,19 @@ func driveSyncStdoutItems(t *testing.T, stdout []byte) []driveSyncItem { } return envelope.Data.Items } + +func driveSyncStdoutSummary(t *testing.T, stdout []byte) map[string]interface{} { + t.Helper() + var envelope struct { + Data struct { + Summary map[string]interface{} `json:"summary"` + } `json:"data"` + } + if err := json.Unmarshal(stdout, &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v\nraw=%s", err, string(stdout)) + } + if envelope.Data.Summary == nil { + t.Fatalf("stdout missing data.summary; raw=%s", string(stdout)) + } + return envelope.Data.Summary +} diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 85e45ed4..5c12f305 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -3,7 +3,10 @@ package drive -import "testing" +import ( + "reflect" + "testing" +) // TestShortcutsIncludesExpectedCommands verifies the drive shortcut registry contains the expected commands. func TestShortcutsIncludesExpectedCommands(t *testing.T) { @@ -58,3 +61,12 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { } } } + +func TestDriveSearchSupportsUserAndBotIdentity(t *testing.T) { + t.Parallel() + + want := []string{"user", "bot"} + if !reflect.DeepEqual(DriveSearch.AuthTypes, want) { + t.Fatalf("DriveSearch.AuthTypes = %v, want %v", DriveSearch.AuthTypes, want) + } +} diff --git a/shortcuts/im/helpers_test.go b/shortcuts/im/helpers_test.go index b177fd87..a715d56b 100644 --- a/shortcuts/im/helpers_test.go +++ b/shortcuts/im/helpers_test.go @@ -651,6 +651,7 @@ func TestShortcuts(t *testing.T) { want := []string{ "+chat-create", "+chat-list", + "+chat-members-list", "+chat-messages-list", "+chat-search", "+chat-update", diff --git a/shortcuts/im/im_chat_members_list.go b/shortcuts/im/im_chat_members_list.go new file mode 100644 index 00000000..d467af63 --- /dev/null +++ b/shortcuts/im/im_chat_members_list.go @@ -0,0 +1,420 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "context" + "encoding/json" + "fmt" + "io" + "strings" + "time" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list" + chatMembersListDefaultPageSize = 20 + chatMembersListMaxPageSize = 100 + // chatMembersListDefaultPageDelay throttles --page-all the same way the + // generic paginateLoop does (200ms). It matters for tenants WITHOUT the + // server-side member cap, where a large group drains many pages back to + // back and could otherwise trip rate limits. + chatMembersListDefaultPageDelay = 200 +) + +// ImChatMembersList is the +chat-members-list shortcut: it lists chat members, +// returning users and bots in separate buckets (users[]/bots[]). It owns its +// pagination loop (mirroring the generic paginateLoop conventions: a per-page +// log line, a --page-limit cap, a non-advancing-token guard) precisely because +// the response is multi-bucket — the generic --page-all merger is built for +// single-array responses and would drop the bots[] bucket and the final-page +// truncations[] signal. See mergeChatMemberPages for the merge semantics. +var ImChatMembersList = common.Shortcut{ + Service: "im", + Command: "+chat-members-list", + Description: "List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket", + Risk: "read", + // Declare the narrowest scope the API accepts so tokens carrying only + // im:chat.members:read are honored (same rationale as +chat-list). + Scopes: []string{"im:chat.members:read"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"}, + {Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"}, + {Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}}, + {Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)}, + {Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"}, + {Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"}, + {Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"}, + {Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"}, + }, + Tips: []string{ + "Default fetches a single page; pass --page-all to walk every page.", + "With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.", + "truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + chatID := strings.TrimSpace(runtime.Str("chat-id")) + if chatID == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id") + } + if !strings.HasPrefix(chatID, "oc_") { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id") + } + if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size") + } + if n := runtime.Int("page-limit"); n < 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit") + } + if n := runtime.Int("page-delay"); n < 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay") + } + _, err := normalizeMemberTypes(runtime.StrSlice("member-types")) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + chatID := strings.TrimSpace(runtime.Str("chat-id")) + dry := common.NewDryRunAPI() + if chatMembersShouldAutoPaginate(runtime) { + dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)") + } + params, _ := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token"))) + return dry. + GET(fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))). + Params(params) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + warnIfConflictingPagingFlags(runtime) + + chatID := strings.TrimSpace(runtime.Str("chat-id")) + res, err := fetchChatMembers(ctx, runtime, chatID) + if err != nil { + return err + } + + // The truncation signal is the whole reason this is a dedicated shortcut: + // surface it loudly so an agent never mistakes a capped list for a + // complete one. + if len(res.truncations) > 0 { + writeChatMembersTruncationWarning(runtime.IO().ErrOut, res.truncations) + } + fmt.Fprintf(runtime.IO().ErrOut, "Found %d user(s) and %d bot(s)\n", len(res.users), len(res.bots)) + + outData := map[string]interface{}{ + "chat_id": chatID, + "users": res.users, + "bots": res.bots, + "truncations": res.truncations, + "has_more": res.hasMore, + "page_token": res.pageToken, + } + if res.userTotal != nil { + outData["user_total"] = res.userTotal + } + if res.botTotal != nil { + outData["bot_total"] = res.botTotal + } + + runtime.OutFormat(outData, &output.Meta{Count: len(res.users) + len(res.bots)}, func(w io.Writer) { + renderChatMembersPretty(w, chatID, res) + }) + return nil + }, +} + +// chatMembersResult is the aggregated view across one or more pages. +type chatMembersResult struct { + users []interface{} + bots []interface{} + truncations []interface{} + userTotal interface{} + botTotal interface{} + hasMore bool + pageToken string +} + +// effectiveChatMembersPageSize resolves the page_size to request. When draining +// every page (--page-all) and the caller did NOT explicitly set --page-size, it +// uses the maximum so a full walk takes the fewest round-trips. An explicit +// --page-size is always honored; without --page-all the smaller default is kept +// as a sensible single-page preview size. +func effectiveChatMembersPageSize(runtime *common.RuntimeContext) int { + if chatMembersShouldAutoPaginate(runtime) && !runtime.Changed("page-size") { + return chatMembersListMaxPageSize + } + if n := runtime.Int("page-size"); n > 0 { + return n + } + return chatMembersListDefaultPageSize +} + +// chatMembersShouldAutoPaginate reports whether the fetch loop should walk +// every page. An explicit --page-token disables the auto loop because the +// caller supplied a specific cursor (single-page fetch). +func chatMembersShouldAutoPaginate(runtime *common.RuntimeContext) bool { + if strings.TrimSpace(runtime.Str("page-token")) != "" { + return false + } + return runtime.Bool("page-all") +} + +// buildChatMembersParams builds the query params for one page request. The +// startToken (when non-empty) seeds the page_token; the loop overrides it per +// page. Returns the params and the normalized member-types CSV (already +// validated by Validate, so the error is only a defensive guard). +func buildChatMembersParams(runtime *common.RuntimeContext, startToken string) (map[string]interface{}, error) { + memberTypes, err := normalizeMemberTypes(runtime.StrSlice("member-types")) + if err != nil { + return nil, err + } + params := map[string]interface{}{ + "member_id_type": runtime.Str("member-id-type"), + "page_size": effectiveChatMembersPageSize(runtime), + } + if memberTypes != "" { + params["member_types"] = memberTypes + } + if startToken != "" { + params["page_token"] = startToken + } + return params, nil +} + +// fetchChatMembers walks the list_members endpoint, honoring the four +// pagination flags the same way the generic --page-all path does. It merges +// each page into the aggregate as it arrives (rather than buffering every raw +// page), so peak memory is just the aggregated members plus the single most +// recent page — important for large groups under --page-limit 0. +func fetchChatMembers(ctx context.Context, runtime *common.RuntimeContext, chatID string) (*chatMembersResult, error) { + auto := chatMembersShouldAutoPaginate(runtime) + pageLimit := runtime.Int("page-limit") + pageDelay := runtime.Int("page-delay") + apiPath := fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID)) + + params, err := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token"))) + if err != nil { + return nil, err + } + + res := newChatMembersResult() + var lastData map[string]interface{} + pageToken := strings.TrimSpace(runtime.Str("page-token")) + for page := 0; ; page++ { + if pageToken != "" { + params["page_token"] = pageToken + } + fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", page+1) + data, err := runtime.CallAPITyped("GET", apiPath, params, nil) + if err != nil { + return nil, err + } + addMemberBuckets(res, data) + lastData = data + + hasMore, nextToken := common.PaginationMeta(data) + if !auto { + break + } + if !hasMore || nextToken == "" { + break + } + if nextToken == pageToken { + // Guard against a buggy server echoing the same cursor with + // has_more=true: without --page-limit we would loop forever. + fmt.Fprintln(runtime.IO().ErrOut, "Stopping pagination: server returned a non-advancing page_token.") + break + } + if pageLimit > 0 && page+1 >= pageLimit { + fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", pageLimit) + break + } + pageToken = nextToken + // Throttle between pages (only reached when another page follows), so + // draining a large untruncated list doesn't hammer the API. + if pageDelay > 0 { + time.Sleep(time.Duration(pageDelay) * time.Millisecond) + } + } + if lastData != nil { + applyLastPageSignals(res, lastData) + } + return res, nil +} + +// newChatMembersResult returns an empty aggregate with non-nil buckets so the +// JSON output always carries arrays (never null). +func newChatMembersResult() *chatMembersResult { + return &chatMembersResult{ + users: []interface{}{}, + bots: []interface{}{}, + truncations: []interface{}{}, + } +} + +// addMemberBuckets appends one page's users[] and bots[] into the aggregate. +// Concatenating every bucket is what avoids dropping bots[] — the bug the +// generic single-array --page-all merger would hit on this multi-bucket shape. +func addMemberBuckets(res *chatMembersResult, data map[string]interface{}) { + if u, ok := data["users"].([]interface{}); ok { + res.users = append(res.users, u...) + } + if b, ok := data["bots"].([]interface{}); ok { + res.bots = append(res.bots, b...) + } +} + +// applyLastPageSignals copies the per-request signals from the FINAL page: +// has_more / page_token / truncations / totals. These must come from the last +// page, not page 1: truncations[] is emitted only on the final page (empty +// earlier), so reading it sooner would hide a server-side cap; user_total / +// bot_total are server-wide counts, and taking the final page's value keeps a +// single, consistent source rather than a possibly-stale earlier count. +func applyLastPageSignals(res *chatMembersResult, data map[string]interface{}) { + res.hasMore, res.pageToken = common.PaginationMeta(data) + if t, ok := data["truncations"].([]interface{}); ok { + res.truncations = t + } + res.userTotal = data["user_total"] + res.botTotal = data["bot_total"] +} + +// mergeChatMemberPages folds a slice of page payloads into one aggregate. It is +// the same logic fetchChatMembers applies incrementally, kept as a pure +// function so the multi-bucket merge + last-page-signal semantics are unit +// tested in one place. +func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult { + res := newChatMembersResult() + if len(pages) == 0 { + return res + } + for _, data := range pages { + addMemberBuckets(res, data) + } + applyLastPageSignals(res, pages[len(pages)-1]) + return res +} + +// normalizeMemberTypes validates the --member-types slice (already CSV-split by +// cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return +// the API's default of all types). Any element outside {user, bot} is rejected. +func normalizeMemberTypes(raw []string) (string, error) { + if len(raw) == 0 { + return "", nil + } + seen := make(map[string]struct{}, len(raw)) + out := make([]string, 0, len(raw)) + for _, p := range raw { + p = strings.TrimSpace(strings.ToLower(p)) + if p != "user" && p != "bot" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types") + } + if _, dup := seen[p]; dup { + continue + } + seen[p] = struct{}{} + out = append(out, p) + } + return strings.Join(out, ","), nil +} + +// warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token +// wins (single-page fetch from the supplied cursor) and --page-all is ignored. +func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) { + if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") { + fmt.Fprintln(runtime.IO().ErrOut, + "warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)") + } +} + +// writeChatMembersTruncationWarning emits a stderr warning for every +// server-side bucket cap reported in truncations[]. It uses the repo's plain +// "warning: : " convention (see shortcuts/common/runner.go and +// +chat-list's bot_strip_p2p) — no emoji, so it stays legible in CI logs and +// pipes regardless of terminal encoding. +func writeChatMembersTruncationWarning(w io.Writer, truncations []interface{}) { + for _, t := range truncations { + tm, ok := t.(map[string]interface{}) + if !ok { + continue + } + memberType := valueOrAll(tm["member_type"]) + limit := tm["limit"] + fmt.Fprintf(w, "warning: members_truncated: %s bucket capped at %v by server security config; the member list is INCOMPLETE\n", memberType, limit) + } +} + +func valueOrAll(v interface{}) string { + if s, ok := v.(string); ok && s != "" { + return s + } + return "member" +} + +func renderChatMembersPretty(w io.Writer, chatID string, res *chatMembersResult) { + fmt.Fprintf(w, "Chat: %s\n", chatID) + // Show the server-wide total next to the fetched count: when truncated or + // paged, total can far exceed len(users)/len(bots), and that gap is exactly + // what tells the reader how incomplete the list is. + fmt.Fprintf(w, "Users (%d%s):\n", len(res.users), totalSuffix(res.userTotal, len(res.users))) + for i, u := range res.users { + m, _ := u.(map[string]interface{}) + fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"])) + } + fmt.Fprintf(w, "Bots (%d%s):\n", len(res.bots), totalSuffix(res.botTotal, len(res.bots))) + for i, b := range res.bots { + m, _ := b.(map[string]interface{}) + fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"])) + } + if len(res.truncations) > 0 { + fmt.Fprintln(w, "warning: result truncated by server security config (see truncations[]); the list is INCOMPLETE") + } + if res.hasMore { + fmt.Fprint(w, "More pages available; pass --page-all (and --page-limit 0 for everything)") + if res.pageToken != "" { + fmt.Fprintf(w, ", or --page-token %s to resume", res.pageToken) + } + fmt.Fprintln(w) + } +} + +func valueOrDash(v interface{}) string { + if s, ok := v.(string); ok && s != "" { + return s + } + return "-" +} + +// totalSuffix renders " of " when the server-reported total exceeds the +// number actually fetched (so a truncated/partial bucket is obvious), and "" +// when the total is absent or already matches the fetched count. +func totalSuffix(total interface{}, fetched int) string { + n, ok := toInt(total) + if !ok || n <= fetched { + return "" + } + return fmt.Sprintf(" of %d", n) +} + +// toInt coerces a JSON-decoded number (float64 / json.Number / int) to int. +func toInt(v interface{}) (int, bool) { + switch n := v.(type) { + case float64: + return int(n), true + case int: + return n, true + case int64: + return int(n), true + case json.Number: + if i, err := n.Int64(); err == nil { + return int(i), true + } + } + return 0, false +} diff --git a/shortcuts/im/im_chat_members_list_test.go b/shortcuts/im/im_chat_members_list_test.go new file mode 100644 index 00000000..f0da0300 --- /dev/null +++ b/shortcuts/im/im_chat_members_list_test.go @@ -0,0 +1,325 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package im + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +// page builds one list_members page payload shaped like the data object the +// server returns (users[]/bots[]/truncations[] plus paging + totals). +func cmlPage(users, bots, truncations []interface{}, hasMore bool, pageToken string) map[string]interface{} { + return map[string]interface{}{ + "users": users, + "bots": bots, + "truncations": truncations, + "has_more": hasMore, + "page_token": pageToken, + "user_total": 324, + "bot_total": 2, + } +} + +func us(ids ...string) []interface{} { + out := make([]interface{}, 0, len(ids)) + for _, id := range ids { + out = append(out, map[string]interface{}{"member_id": id}) + } + return out +} + +// TestMergeChatMemberPages_MergesUsersAndBots covers Bug 1: every list bucket +// (users AND bots) must be concatenated across pages, not just one of them. +func TestMergeChatMemberPages_MergesUsersAndBots(t *testing.T) { + pages := []map[string]interface{}{ + cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"), + cmlPage(us("u3"), us("b2", "b3"), []interface{}{}, false, ""), + } + + res := mergeChatMemberPages(pages) + + if len(res.users) != 3 { + t.Errorf("users: want 3 merged, got %d", len(res.users)) + } + if len(res.bots) != 3 { + t.Errorf("bots: want 3 merged, got %d", len(res.bots)) + } +} + +// TestMergeChatMemberPages_TruncationsFromLastPage covers Bug 2: truncations[] +// is emitted only on the final page, so the merged view must take it from the +// last page rather than inherit page 1's empty slice. +func TestMergeChatMemberPages_TruncationsFromLastPage(t *testing.T) { + limit := []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}} + pages := []map[string]interface{}{ + cmlPage(us("u1"), us("b1"), []interface{}{}, true, "p2"), + cmlPage(us("u2"), nil, limit, false, ""), + } + + res := mergeChatMemberPages(pages) + + if len(res.truncations) != 1 { + t.Fatalf("truncations: want last page's 1 entry, got %d (%v)", len(res.truncations), res.truncations) + } +} + +// TestMergeChatMemberPages_HasMoreAndTokenFromLastPage guards that paging +// signals come from the final page (so a --page-limit cutoff is visible). +func TestMergeChatMemberPages_HasMoreAndTokenFromLastPage(t *testing.T) { + pages := []map[string]interface{}{ + cmlPage(us("u1"), nil, nil, true, "p2"), + cmlPage(us("u2"), nil, nil, true, "p3"), // loop stopped early; server still has more + } + + res := mergeChatMemberPages(pages) + + if !res.hasMore { + t.Error("has_more: want true from last page") + } + if res.pageToken != "p3" { + t.Errorf("page_token: want last page's p3, got %q", res.pageToken) + } +} + +// TestMergeChatMemberPages_TotalsFromLastPage verifies user_total / bot_total +// are taken from the final page (not an earlier, possibly-different value). +func TestMergeChatMemberPages_TotalsFromLastPage(t *testing.T) { + pages := []map[string]interface{}{ + {"users": us("u1"), "user_total": 999, "bot_total": 7, "has_more": true, "page_token": "p2"}, + {"users": us("u2"), "user_total": 324, "bot_total": 2, "has_more": false, "page_token": ""}, + } + res := mergeChatMemberPages(pages) + if n, _ := toInt(res.userTotal); n != 324 { + t.Errorf("user_total: want last page's 324, got %v", res.userTotal) + } + if n, _ := toInt(res.botTotal); n != 2 { + t.Errorf("bot_total: want last page's 2, got %v", res.botTotal) + } +} + +// TestChatMembersValidate covers --chat-id presence + oc_ prefix enforcement. +func TestChatMembersValidate(t *testing.T) { + noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil + }) + cases := []struct { + name string + chatID string + wantErr bool + }{ + {"valid oc_", "oc_abc", false}, + {"empty", "", true}, + {"missing oc_ prefix", "abc123", true}, + } + for _, c := range cases { + rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": c.chatID}, nil, nil) + err := ImChatMembersList.Validate(context.Background(), rt) + if c.wantErr { + assertValidationError(t, c.name, err, "--chat-id") + continue + } + if err != nil { + t.Errorf("%s: unexpected error %v", c.name, err) + } + } +} + +// assertValidationError checks err satisfies the repo's typed-error contract for +// a validation failure: a *errs.ValidationError carrying the expected Param, and +// problem metadata of category validation / subtype invalid_argument. +func assertValidationError(t *testing.T, ctx string, err error, wantParam string) { + t.Helper() + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Errorf("%s: want *errs.ValidationError, got %T (%v)", ctx, err, err) + return + } + if ve.Param != wantParam { + t.Errorf("%s: Param = %q, want %q", ctx, ve.Param, wantParam) + } + p, ok := errs.ProblemOf(err) + if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("%s: problem = %+v (ok=%v), want category=%s subtype=%s", ctx, p, ok, errs.CategoryValidation, errs.SubtypeInvalidArgument) + } +} + +func TestNormalizeMemberTypes(t *testing.T) { + cases := []struct { + in []string + want string + wantErr bool + }{ + {nil, "", false}, + {[]string{"user", "bot"}, "user,bot", false}, + {[]string{"USER", "user"}, "user", false}, // lowercased + deduped + {[]string{"admin"}, "", true}, + {[]string{""}, "", true}, + } + for _, c := range cases { + got, err := normalizeMemberTypes(c.in) + if c.wantErr { + assertValidationError(t, fmt.Sprintf("normalizeMemberTypes(%v)", c.in), err, "--member-types") + continue + } + if err != nil { + t.Errorf("normalizeMemberTypes(%v): unexpected error %v", c.in, err) + } + if got != c.want { + t.Errorf("normalizeMemberTypes(%v) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior: +// drain with no explicit size → max; explicit size → honored; single page → default. +func TestEffectiveChatMembersPageSize(t *testing.T) { + noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil + }) + cases := []struct { + name string + b map[string]bool + ints map[string]int + want int + }{ + {"page-all, size unset -> max", map[string]bool{"page-all": true}, nil, chatMembersListMaxPageSize}, + {"page-all, size explicit -> honored", map[string]bool{"page-all": true}, map[string]int{"page-size": 15}, 15}, + {"single page, size unset -> default", nil, nil, chatMembersListDefaultPageSize}, + } + for _, c := range cases { + rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": "oc_x"}, c.b, c.ints) + if got := effectiveChatMembersPageSize(rt); got != c.want { + t.Errorf("%s: want %d, got %d", c.name, c.want, got) + } + } +} + +// newChatMembersTestRuntime registers the shortcut's flags and returns a +// user-identity runtime wired to the given RoundTripper for multi-page mocking. +func newChatMembersTestRuntime(t *testing.T, rt http.RoundTripper, str map[string]string, b map[string]bool, ints map[string]int) *common.RuntimeContext { + t.Helper() + runtime := newUserShortcutRuntime(t, rt) + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("chat-id", "", "") + cmd.Flags().String("member-id-type", "open_id", "") + cmd.Flags().StringSlice("member-types", nil, "") + cmd.Flags().String("page-token", "", "") + cmd.Flags().Bool("page-all", false, "") + cmd.Flags().Int("page-size", 20, "") + cmd.Flags().Int("page-limit", 10, "") + cmd.Flags().Int("page-delay", 200, "") + if err := cmd.ParseFlags(nil); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + for k, v := range str { + if err := cmd.Flags().Set(k, v); err != nil { + t.Fatalf("set %s: %v", k, err) + } + } + for k, v := range b { + if err := cmd.Flags().Set(k, strconv.FormatBool(v)); err != nil { + t.Fatalf("set %s: %v", k, err) + } + } + for k, v := range ints { + if err := cmd.Flags().Set(k, strconv.Itoa(v)); err != nil { + t.Fatalf("set %s: %v", k, err) + } + } + runtime.Cmd = cmd + return runtime +} + +// TestFetchChatMembers_PageAllMergesBucketsAndTruncations exercises the full +// fetch loop over mocked pages: users/bots merge across pages and the final +// page's truncations[] survives. +func TestFetchChatMembers_PageAllMergesBucketsAndTruncations(t *testing.T) { + calls := 0 + rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_test/members/list") { + return shortcutJSONResponse(404, map[string]interface{}{"code": 1}), nil + } + calls++ + token := req.URL.Query().Get("page_token") + if token == "" { + return shortcutJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"), + }), nil + } + return shortcutJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": cmlPage(us("u3"), us("b2"), []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}, false, ""), + }), nil + }) + runtime := newChatMembersTestRuntime(t, rt, + map[string]string{"chat-id": "oc_test"}, + map[string]bool{"page-all": true}, + map[string]int{"page-size": 2, "page-limit": 0, "page-delay": 0}) + + res, err := fetchChatMembers(context.Background(), runtime, "oc_test") + if err != nil { + t.Fatalf("fetchChatMembers: %v", err) + } + if calls != 2 { + t.Errorf("want 2 page calls, got %d", calls) + } + if len(res.users) != 3 { + t.Errorf("users: want 3, got %d", len(res.users)) + } + if len(res.bots) != 2 { + t.Errorf("bots: want 2, got %d", len(res.bots)) + } + if len(res.truncations) != 1 { + t.Errorf("truncations: want 1 from last page, got %d", len(res.truncations)) + } + if res.hasMore { + t.Error("has_more: want false after draining all pages") + } +} + +// TestFetchChatMembers_PageLimitStops verifies --page-limit caps the loop and +// leaves has_more=true so the caller knows the result is incomplete. +func TestFetchChatMembers_PageLimitStops(t *testing.T) { + seq := 0 + rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) { + // Every page reports more pages available, with an advancing token so the + // loop is stopped by --page-limit, not the non-advancing-token guard. + seq++ + return shortcutJSONResponse(200, map[string]interface{}{ + "code": 0, + "data": cmlPage(us("u"), nil, nil, true, fmt.Sprintf("p%d", seq)), + }), nil + }) + runtime := newChatMembersTestRuntime(t, rt, + map[string]string{"chat-id": "oc_test"}, + map[string]bool{"page-all": true}, + map[string]int{"page-size": 1, "page-limit": 3, "page-delay": 0}) + + res, err := fetchChatMembers(context.Background(), runtime, "oc_test") + if err != nil { + t.Fatalf("fetchChatMembers: %v", err) + } + if len(res.users) != 3 { + t.Errorf("users: want 3 (capped at page-limit), got %d", len(res.users)) + } + if !res.hasMore { + t.Error("has_more: want true (loop cut short by page-limit)") + } + errOut := runtime.IO().ErrOut.(*bytes.Buffer) + if !strings.Contains(errOut.String(), "reached page limit (3)") { + t.Errorf("want page-limit notice on stderr, got: %s", errOut.String()) + } +} diff --git a/shortcuts/im/shortcuts.go b/shortcuts/im/shortcuts.go index 1aff3f06..3dd032da 100644 --- a/shortcuts/im/shortcuts.go +++ b/shortcuts/im/shortcuts.go @@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut { return []common.Shortcut{ ImChatCreate, ImChatList, + ImChatMembersList, ImChatMessageList, ImChatSearch, ImChatUpdate, diff --git a/shortcuts/okr/okr_batch_create.go b/shortcuts/okr/okr_batch_create.go index ff5fc1f0..61df0863 100644 --- a/shortcuts/okr/okr_batch_create.go +++ b/shortcuts/okr/okr_batch_create.go @@ -58,45 +58,9 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) { return objectives, nil } -// buildContentBlock converts text and mentions to a ContentBlock. -func buildContentBlock(text string, mentions []string) *ContentBlock { - elements := make([]ContentParagraphElement, 0, len(mentions)+1) - - // Add text element - textElem := ContentParagraphElement{ - ParagraphElementType: ParagraphElementTypeTextRun.Ptr(), - TextRun: &ContentTextRun{ - Text: &text, - }, - } - elements = append(elements, textElem) - - // Add mention elements - for _, mention := range mentions { - mentionElem := ContentParagraphElement{ - ParagraphElementType: ParagraphElementTypeMention.Ptr(), - Mention: &ContentMention{ - UserID: &mention, - }, - } - elements = append(elements, mentionElem) - } - - return &ContentBlock{ - Blocks: []ContentBlockElement{ - { - BlockElementType: BlockElementTypeParagraph.Ptr(), - Paragraph: &ContentParagraph{ - Elements: elements, - }, - }, - }, - } -} - // createObjective calls the API to create an objective. func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) { - content := buildContentBlock(obj.Text, obj.Mention) + content := BuildContentBlock(obj.Text, obj.Mention) body := map[string]interface{}{ "content": content, } @@ -120,7 +84,7 @@ func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleI // createKR calls the API to create a key result. func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) { - content := buildContentBlock(kr.Text, kr.Mention) + content := BuildContentBlock(kr.Text, kr.Mention) body := map[string]interface{}{ "content": content, } @@ -224,7 +188,7 @@ var OKRBatchCreate = common.Shortcut{ for i, obj := range objectives { // Objective creation - objContent := buildContentBlock(obj.Text, obj.Mention) + objContent := BuildContentBlock(obj.Text, obj.Mention) objBody := map[string]interface{}{ "content": objContent, } @@ -241,7 +205,7 @@ var OKRBatchCreate = common.Shortcut{ // KR creations for j, kr := range obj.KRs { - krContent := buildContentBlock(kr.Text, kr.Mention) + krContent := BuildContentBlock(kr.Text, kr.Mention) krBody := map[string]interface{}{ "content": krContent, } diff --git a/shortcuts/okr/okr_batch_create_test.go b/shortcuts/okr/okr_batch_create_test.go index 809ee9d3..3efa2e0c 100644 --- a/shortcuts/okr/okr_batch_create_test.go +++ b/shortcuts/okr/okr_batch_create_test.go @@ -557,7 +557,7 @@ func TestParseBatchCreateInput_Valid(t *testing.T) { func TestBuildContentBlock(t *testing.T) { t.Parallel() - cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"}) + cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"}) if cb == nil { t.Fatal("expected non-nil ContentBlock") } diff --git a/shortcuts/okr/okr_cli_resp.go b/shortcuts/okr/okr_cli_resp.go index dc45a58d..1b026380 100644 --- a/shortcuts/okr/okr_cli_resp.go +++ b/shortcuts/okr/okr_cli_resp.go @@ -29,15 +29,10 @@ type RespCategory struct { // RespCycle 周期 type RespCycle struct { - ID string `json:"id"` - CreateTime string `json:"create_time"` - UpdateTime string `json:"update_time"` - TenantCycleID string `json:"tenant_cycle_id"` - Owner RespOwner `json:"owner"` - StartTime string `json:"start_time"` - EndTime string `json:"end_time"` - CycleStatus *string `json:"cycle_status,omitempty"` - Score *float64 `json:"score,omitempty"` + ID string `json:"id"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time"` + CycleStatus *string `json:"cycle_status,omitempty"` } // RespIndicator 指标 @@ -152,3 +147,145 @@ type RespProgress struct { Content *string `json:"content,omitempty"` ProgressRate *RespProgressRate `json:"progress_rate,omitempty"` } + +// ========== Simple-style response types (semi-plain text format) ========== + +// RespKeyResultSimple is KeyResult response with SemiPlainContent instead of ContentBlock JSON string. +type RespKeyResultSimple struct { + ID string `json:"id"` + CreateTime string `json:"create_time"` + UpdateTime string `json:"update_time"` + Owner RespOwner `json:"owner"` + ObjectiveID string `json:"objective_id"` + Position *int32 `json:"position,omitempty"` + Content *SemiPlainContent `json:"content,omitempty"` + Score *float64 `json:"score,omitempty"` + Weight *float64 `json:"weight,omitempty"` + Deadline *string `json:"deadline,omitempty"` +} + +// RespObjectiveSimple is Objective response with SemiPlainContent instead of ContentBlock JSON string. +type RespObjectiveSimple struct { + ID string `json:"id"` + CreateTime string `json:"create_time"` + UpdateTime string `json:"update_time"` + Owner RespOwner `json:"owner"` + CycleID string `json:"cycle_id"` + Position *int32 `json:"position,omitempty"` + Content *SemiPlainContent `json:"content,omitempty"` + Score *float64 `json:"score,omitempty"` + Notes *SemiPlainContent `json:"notes,omitempty"` + Weight *float64 `json:"weight,omitempty"` + Deadline *string `json:"deadline,omitempty"` + CategoryID *string `json:"category_id,omitempty"` + KeyResults []RespKeyResultSimple `json:"key_results,omitempty"` +} + +// RespProgressSimple is Progress response with SemiPlainContent instead of ContentBlock JSON string. +type RespProgressSimple struct { + ID string `json:"progress_id"` + ModifyTime string `json:"modify_time"` + CreateTime *string `json:"create_time,omitempty"` + Content *SemiPlainContent `json:"content,omitempty"` + ProgressRate *RespProgressRate `json:"progress_rate,omitempty"` +} + +// ToSimple converts KeyResult to RespKeyResultSimple. +func (k *KeyResult) ToSimple() *RespKeyResultSimple { + if k == nil { + return nil + } + result := &RespKeyResultSimple{ + ID: k.ID, + CreateTime: formatTimestamp(k.CreateTime), + UpdateTime: formatTimestamp(k.UpdateTime), + Owner: *k.Owner.ToResp(), + ObjectiveID: k.ObjectiveID, + Position: k.Position, + Score: k.Score, + Weight: k.Weight, + } + if k.Deadline != nil { + d := formatTimestamp(*k.Deadline) + result.Deadline = &d + } + result.Content = k.Content.ToSemiPlain() + return result +} + +// ToSimple converts Objective to RespObjectiveSimple. +func (o *Objective) ToSimple() *RespObjectiveSimple { + if o == nil { + return nil + } + result := &RespObjectiveSimple{ + ID: o.ID, + CreateTime: formatTimestamp(o.CreateTime), + UpdateTime: formatTimestamp(o.UpdateTime), + Owner: *o.Owner.ToResp(), + CycleID: o.CycleID, + Position: o.Position, + Score: o.Score, + Weight: o.Weight, + CategoryID: o.CategoryID, + } + if o.Deadline != nil { + d := formatTimestamp(*o.Deadline) + result.Deadline = &d + } + result.Content = o.Content.ToSemiPlain() + result.Notes = o.Notes.ToSemiPlain() + return result +} + +// ToSimple converts ProgressV1 to RespProgressSimple. +func (p *ProgressV1) ToSimple() *RespProgressSimple { + if p == nil { + return nil + } + resp := &RespProgressSimple{ + ID: p.ID, + ModifyTime: formatTimestamp(p.ModifyTime), + } + if p.ProgressRate != nil { + resp.ProgressRate = &RespProgressRate{ + Percent: p.ProgressRate.Percent, + } + if p.ProgressRate.Status != nil { + s := ProgressStatus(*p.ProgressRate.Status).String() + if s != "" { + resp.ProgressRate.Status = &s + } + } + } + if p.Content != nil { + resp.Content = p.Content.ToV2().ToSemiPlain() + } + return resp +} + +// ToSimple converts Progress to RespProgressSimple. +func (p *Progress) ToSimple() *RespProgressSimple { + if p == nil { + return nil + } + createTime := formatTimestamp(p.CreateTime) + resp := &RespProgressSimple{ + ID: p.ID, + ModifyTime: formatTimestamp(p.UpdateTime), + CreateTime: &createTime, + } + if p.ProgressRate != nil { + resp.ProgressRate = &RespProgressRate{ + Percent: p.ProgressRate.ProgressPercent, + } + if p.ProgressRate.ProgressStatus != nil { + s := ProgressStatus(*p.ProgressRate.ProgressStatus).String() + if s != "" { + resp.ProgressRate.Status = &s + } + } + } + resp.Content = p.Content.ToSemiPlain() + return resp +} diff --git a/shortcuts/okr/okr_cycle_detail.go b/shortcuts/okr/okr_cycle_detail.go index 3839e1d1..41be7b23 100644 --- a/shortcuts/okr/okr_cycle_detail.go +++ b/shortcuts/okr/okr_cycle_detail.go @@ -26,6 +26,7 @@ var OKRCycleDetail = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true}, + {Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { cycleID := runtime.Str("cycle-id") @@ -35,6 +36,10 @@ var OKRCycleDetail = common.Shortcut{ if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id") } + style := runtime.Str("style") + if style != "simple" && style != "richtext" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style") + } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -50,6 +55,7 @@ var OKRCycleDetail = common.Shortcut{ }, Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { cycleID := runtime.Str("cycle-id") + style := runtime.Str("style") // Paginate objectives under the cycle. queryParams := map[string]interface{}{"page_size": "100"} @@ -96,85 +102,106 @@ var OKRCycleDetail = common.Shortcut{ } // For each objective, paginate key results and convert to response format. - respObjectives := make([]*RespObjective, 0, len(objectives)) - for i := range objectives { - if err := ctx.Err(); err != nil { - return err - } - obj := &objectives[i] - - krQuery := map[string]interface{}{"page_size": "100"} - - var keyResults []KeyResult - krPage := 0 - for { + if style == "simple" { + respObjectives := make([]*RespObjectiveSimple, 0, len(objectives)) + for i := range objectives { if err := ctx.Err(); err != nil { return err } - if krPage > 0 { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(500 * time.Millisecond): - } - } - krPage++ + obj := &objectives[i] - path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID) - data, err := runtime.CallAPITyped("GET", path, krQuery, nil) + keyResults, err := fetchKeyResults(ctx, runtime, obj.ID) if err != nil { return err } - itemsRaw, _ := data["items"].([]interface{}) - for _, item := range itemsRaw { - raw, err := json.Marshal(item) - if err != nil { - continue + respObj := obj.ToSimple() + if respObj == nil { + continue + } + respKRs := make([]RespKeyResultSimple, 0, len(keyResults)) + for j := range keyResults { + if r := keyResults[j].ToSimple(); r != nil { + respKRs = append(respKRs, *r) } - var kr KeyResult - if err := json.Unmarshal(raw, &kr); err != nil { - continue + } + respObj.KeyResults = respKRs + respObjectives = append(respObjectives, respObj) + } + + result := map[string]interface{}{ + "cycle_id": cycleID, + "objectives": respObjectives, + "total": len(respObjectives), + "style": style, + } + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style) + for _, o := range respObjectives { + contentText := "" + if o.Content != nil { + contentText = o.Content.Text } - keyResults = append(keyResults, kr) + notesText := "" + if o.Notes != nil { + notesText = o.Notes.Text + } + fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, contentText, notesText, ptrFloat64(o.Score), ptrFloat64(o.Weight)) + for _, kr := range o.KeyResults { + krText := "" + if kr.Content != nil { + krText = kr.Content.Text + } + fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, krText, ptrFloat64(kr.Score), ptrFloat64(kr.Weight)) + } + } + }) + } else { + // richtext mode + respObjectives := make([]*RespObjective, 0, len(objectives)) + for i := range objectives { + if err := ctx.Err(); err != nil { + return err + } + obj := &objectives[i] + + keyResults, err := fetchKeyResults(ctx, runtime, obj.ID) + if err != nil { + return err } - hasMore, pageToken := common.PaginationMeta(data) - if !hasMore || pageToken == "" { - break + respObj := obj.ToResp() + if respObj == nil { + continue } - krQuery["page_token"] = pageToken + respKRs := make([]RespKeyResult, 0, len(keyResults)) + for j := range keyResults { + if r := keyResults[j].ToResp(); r != nil { + respKRs = append(respKRs, *r) + } + } + respObj.KeyResults = respKRs + respObjectives = append(respObjectives, respObj) } - respObj := obj.ToResp() - if respObj == nil { - continue + result := map[string]interface{}{ + "cycle_id": cycleID, + "objectives": respObjectives, + "total": len(respObjectives), + "style": style, } - respKRs := make([]RespKeyResult, 0, len(keyResults)) - for j := range keyResults { - if r := keyResults[j].ToResp(); r != nil { - respKRs = append(respKRs, *r) + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style) + for _, o := range respObjectives { + fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight)) + for _, kr := range o.KeyResults { + fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight)) + } } - } - respObj.KeyResults = respKRs - respObjectives = append(respObjectives, respObj) + }) } - - result := map[string]interface{}{ - "cycle_id": cycleID, - "objectives": respObjectives, - "total": len(respObjectives), - } - - runtime.OutFormat(result, nil, func(w io.Writer) { - fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives)) - for _, o := range respObjectives { - fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight)) - for _, kr := range o.KeyResults { - fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight)) - } - } - }) return nil }, } diff --git a/shortcuts/okr/okr_cycle_list.go b/shortcuts/okr/okr_cycle_list.go index c236b288..4055908c 100644 --- a/shortcuts/okr/okr_cycle_list.go +++ b/shortcuts/okr/okr_cycle_list.go @@ -46,12 +46,38 @@ func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool { if err1 != nil || err2 != nil { return false } - cycleStart := time.UnixMilli(startMs) - cycleEnd := time.UnixMilli(endMs) + cycleStart := time.UnixMilli(startMs).UTC() + cycleEnd := time.UnixMilli(endMs).UTC() // Two ranges overlap iff one starts before the other ends return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart) } +// isCurrentActiveCycle checks whether a cycle is currently active: +// - current time is within the cycle's start and end time +// - cycle status is default (0) or normal (1) +func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool { + startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64) + endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64) + if err1 != nil || err2 != nil { + return false + } + cycleStart := time.UnixMilli(startMs).UTC() + cycleEnd := time.UnixMilli(endMs).UTC() + nowUTC := now.UTC() + + // Check time range: now must be >= start and <= end + if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) { + return false + } + + // Check status: must be default or normal + if cycle.CycleStatus == nil { + return false + } + status := *cycle.CycleStatus + return status == CycleStatusDefault || status == CycleStatusNormal +} + var OKRListCycles = common.Shortcut{ Service: "okr", Command: "+cycle-list", @@ -175,14 +201,30 @@ var OKRListCycles = common.Shortcut{ respCycles = append(respCycles, filtered[i].ToResp()) } + // Filter current active cycles + now := time.Now() + currentActiveCycles := make([]*RespCycle, 0) + for i := range filtered { + if isCurrentActiveCycle(&filtered[i], now) { + currentActiveCycles = append(currentActiveCycles, filtered[i].ToResp()) + } + } + runtime.OutFormat(map[string]interface{}{ - "cycles": respCycles, - "total": len(respCycles), + "cycles": respCycles, + "total": len(respCycles), + "current_active_cycles": currentActiveCycles, }, nil, func(w io.Writer) { fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles)) for _, c := range respCycles { fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus)) } + if len(currentActiveCycles) > 0 { + fmt.Fprintf(w, "\nCurrent active cycle(s):\n") + for _, c := range currentActiveCycles { + fmt.Fprintf(w, " [%s] %s ~ %s\n", c.ID, c.StartTime, c.EndTime) + } + } }) return nil }, diff --git a/shortcuts/okr/okr_cycle_list_test.go b/shortcuts/okr/okr_cycle_list_test.go index 53b0a5c7..951cbcda 100644 --- a/shortcuts/okr/okr_cycle_list_test.go +++ b/shortcuts/okr/okr_cycle_list_test.go @@ -5,8 +5,10 @@ package okr import ( "bytes" + "strconv" "strings" "testing" + "time" "github.com/spf13/cobra" @@ -260,11 +262,156 @@ func TestCycleListExecute_NoCycles(t *testing.T) { if len(cycles) != 0 { t.Fatalf("cycles = %v, want empty", cycles) } + // Assert current_active_cycles field exists and is a slice + rawCurrentActive, ok := data["current_active_cycles"] + if !ok { + t.Fatal("current_active_cycles field is missing from response") + } + currentActive, ok := rawCurrentActive.([]interface{}) + if !ok { + t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive) + } + if len(currentActive) != 0 { + t.Fatalf("current_active_cycles = %v, want empty", currentActive) + } +} + +// --- isCurrentActiveCycle unit tests --- + +func TestIsCurrentActiveCycle(t *testing.T) { + t.Parallel() + now := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + cycle *Cycle + expected bool + }{ + { + name: "active cycle with normal status", + cycle: &Cycle{ + ID: "c1", + StartTime: "1767225600000", // 2026-01-01 + EndTime: "1798761599999", // 2026-12-31 23:59:59 + CycleStatus: CycleStatusNormal.Ptr(), + }, + expected: true, + }, + { + name: "active cycle with default status", + cycle: &Cycle{ + ID: "c2", + StartTime: "1767225600000", // 2026-01-01 + EndTime: "1798761599999", // 2026-12-31 + CycleStatus: CycleStatusDefault.Ptr(), + }, + expected: true, + }, + { + name: "cycle with invalid status", + cycle: &Cycle{ + ID: "c3", + StartTime: "1767225600000", // 2026-01-01 + EndTime: "1798761599999", // 2026-12-31 + CycleStatus: CycleStatusInvalid.Ptr(), + }, + expected: false, + }, + { + name: "cycle with hidden status", + cycle: &Cycle{ + ID: "c4", + StartTime: "1767225600000", // 2026-01-01 + EndTime: "1798761599999", // 2026-12-31 + CycleStatus: CycleStatusHidden.Ptr(), + }, + expected: false, + }, + { + name: "past cycle", + cycle: &Cycle{ + ID: "c5", + StartTime: "1704067200000", // 2024-01-01 + EndTime: "1719791999999", // 2024-06-30 + CycleStatus: CycleStatusNormal.Ptr(), + }, + expected: false, + }, + { + name: "future cycle", + cycle: &Cycle{ + ID: "c6", + StartTime: "1830297600000", // 2028-01-01 + EndTime: "1861833599999", // 2028-12-31 + CycleStatus: CycleStatusNormal.Ptr(), + }, + expected: false, + }, + { + name: "nil cycle status", + cycle: &Cycle{ + ID: "c7", + StartTime: "1767225600000", // 2026-01-01 + EndTime: "1798761599999", // 2026-12-31 + CycleStatus: nil, + }, + expected: false, + }, + { + name: "invalid start time", + cycle: &Cycle{ + ID: "c8", + StartTime: "invalid", + EndTime: "1798761599999", // 2026-12-31 + CycleStatus: CycleStatusNormal.Ptr(), + }, + expected: false, + }, + { + name: "exact start time boundary", + cycle: &Cycle{ + ID: "c9", + StartTime: "1782734400000", // 2026-06-29 12:00:00 UTC + EndTime: "1798761599000", // 2026-12-31 23:59:59 UTC + CycleStatus: CycleStatusNormal.Ptr(), + }, + expected: true, + }, + { + name: "exact end time boundary", + cycle: &Cycle{ + ID: "c10", + StartTime: "1767225600000", // 2026-01-01 00:00:00 UTC + EndTime: "1782734400000", // 2026-06-29 12:00:00 UTC + CycleStatus: CycleStatusNormal.Ptr(), + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isCurrentActiveCycle(tt.cycle, now) + if result != tt.expected { + t.Fatalf("isCurrentActiveCycle() = %v, want %v", result, tt.expected) + } + }) + } } func TestCycleListExecute_WithCycles(t *testing.T) { t.Parallel() f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t)) + + // Calculate timestamps relative to now to avoid test expiration + now := time.Now().UTC() + // Active cycle: 6 months before to 6 months after now + activeStartMs := now.AddDate(0, -6, 0).UnixMilli() + activeEndMs := now.AddDate(0, 6, 0).UnixMilli() + // Past cycle: 2 years before to 1.5 years before now + pastStartMs := now.AddDate(-2, 0, 0).UnixMilli() + pastEndMs := now.AddDate(-1, -6, 0).UnixMilli() + reg.Register(&httpmock.Stub{ Method: "GET", URL: "/open-apis/okr/v2/cycles", @@ -274,19 +421,19 @@ func TestCycleListExecute_WithCycles(t *testing.T) { "data": map[string]interface{}{ "items": []interface{}{ map[string]interface{}{ - "id": "cycle-1", - "start_time": "1735689600000", - "end_time": "1751318400000", - "cycle_status": 1, + "id": "cycle-active", + "start_time": strconv.FormatInt(activeStartMs, 10), + "end_time": strconv.FormatInt(activeEndMs, 10), + "cycle_status": 1, // normal "owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"}, "tenant_cycle_id": "tc-1", "score": 0.75, }, map[string]interface{}{ - "id": "cycle-2", - "start_time": "1704067200000", - "end_time": "1719792000000", - "cycle_status": 2, + "id": "cycle-past", + "start_time": strconv.FormatInt(pastStartMs, 10), + "end_time": strconv.FormatInt(pastEndMs, 10), + "cycle_status": 2, // invalid "owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"}, "tenant_cycle_id": "tc-2", "score": 0.5, @@ -311,6 +458,46 @@ func TestCycleListExecute_WithCycles(t *testing.T) { if int(total) != 2 { t.Fatalf("total = %v, want 2", total) } + + // Check current_active_cycles - should only contain cycle-active + rawCurrentActive, ok := data["current_active_cycles"] + if !ok { + t.Fatal("current_active_cycles field is missing from response") + } + currentActive, ok := rawCurrentActive.([]interface{}) + if !ok { + t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive) + } + if len(currentActive) != 1 { + t.Fatalf("current_active_cycles count = %d, want 1", len(currentActive)) + } + activeCycle, ok := currentActive[0].(map[string]interface{}) + if !ok { + t.Fatalf("current_active_cycles[0] is not a map, got %T", currentActive[0]) + } + if activeCycle["id"] != "cycle-active" { + t.Fatalf("current_active_cycles[0].id = %v, want cycle-active", activeCycle["id"]) + } + + // Verify removed fields are not present in the response + for _, c := range cycles { + cycleMap, _ := c.(map[string]interface{}) + if _, ok := cycleMap["create_time"]; ok { + t.Fatal("create_time should not be present in response") + } + if _, ok := cycleMap["update_time"]; ok { + t.Fatal("update_time should not be present in response") + } + if _, ok := cycleMap["tenant_cycle_id"]; ok { + t.Fatal("tenant_cycle_id should not be present in response") + } + if _, ok := cycleMap["owner"]; ok { + t.Fatal("owner should not be present in response") + } + if _, ok := cycleMap["score"]; ok { + t.Fatal("score should not be present in response") + } + } } func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) { diff --git a/shortcuts/okr/okr_openapi.go b/shortcuts/okr/okr_openapi.go index 32794030..4e65a37d 100644 --- a/shortcuts/okr/okr_openapi.go +++ b/shortcuts/okr/okr_openapi.go @@ -5,7 +5,9 @@ package okr import ( "encoding/json" + "regexp" "strconv" + "strings" "time" ) @@ -261,14 +263,9 @@ func (c *Cycle) ToResp() *RespCycle { return nil } resp := &RespCycle{ - ID: c.ID, - CreateTime: formatTimestamp(c.CreateTime), - UpdateTime: formatTimestamp(c.UpdateTime), - TenantCycleID: c.TenantCycleID, - Owner: *c.Owner.ToResp(), - StartTime: formatTimestamp(c.StartTime), - EndTime: formatTimestamp(c.EndTime), - Score: c.Score, + ID: c.ID, + StartTime: formatTimestamp(c.StartTime), + EndTime: formatTimestamp(c.EndTime), } if c.CycleStatus != nil { s := c.CycleStatus.ToString() @@ -733,6 +730,131 @@ func (p *ContentPersonV1) ToV2() *ContentMention { } } +// ========== SemiPlainContent (半纯文本格式) ========== + +// Regex patterns for semi-plain text processing (pre-compiled for performance). +var ( + placeholderRE = regexp.MustCompile(`\s*@\{[^}]+\}\s*`) + multiSpaceRE = regexp.MustCompile(`\s+`) +) + +// SemiPlainDoc represents a document link in semi-plain content. +type SemiPlainDoc struct { + Title string `json:"title"` + URL string `json:"url"` +} + +// SemiPlainContent is a simplified, lossy representation of ContentBlock. +// It contains plain text, mentions, docs, and images without rich formatting or position info. +type SemiPlainContent struct { + Text string `json:"text"` + Mention []string `json:"mention,omitempty"` + Docs []SemiPlainDoc `json:"docs,omitempty"` + Images []string `json:"images,omitempty"` +} + +// ToSemiPlain converts ContentBlock to SemiPlainContent (lossy conversion). +// Position information and formatting are discarded; only text, mentions, docs, and images are extracted. +func (c *ContentBlock) ToSemiPlain() *SemiPlainContent { + if c == nil { + return nil + } + result := &SemiPlainContent{} + var textParts []string + + for _, block := range c.Blocks { + if block.Paragraph != nil { + for _, elem := range block.Paragraph.Elements { + switch { + case elem.TextRun != nil && elem.TextRun.Text != nil: + textParts = append(textParts, *elem.TextRun.Text) + case elem.Mention != nil && elem.Mention.UserID != nil: + textParts = append(textParts, " @{"+*elem.Mention.UserID+"} ") + result.Mention = append(result.Mention, *elem.Mention.UserID) + case elem.DocsLink != nil: + doc := SemiPlainDoc{} + if elem.DocsLink.Title != nil { + doc.Title = *elem.DocsLink.Title + } + if elem.DocsLink.URL != nil { + doc.URL = *elem.DocsLink.URL + } + result.Docs = append(result.Docs, doc) + } + } + } + if block.Gallery != nil { + for _, img := range block.Gallery.Images { + if img.Src != nil { + result.Images = append(result.Images, *img.Src) + } + } + } + } + + result.Text = strings.Join(textParts, "") + return result +} + +// ToContentBlock converts SemiPlainContent to ContentBlock. +// Text and mentions are placed in a single paragraph (text first, then mentions). +// Docs and images are NOT converted (input semi-plain format only supports text+mention). +func (s *SemiPlainContent) ToContentBlock() *ContentBlock { + if s == nil { + return nil + } + elements := make([]ContentParagraphElement, 0, len(s.Mention)+1) + + // Strip @{userID} placeholders from text to avoid duplicate mentions + // (these placeholders are only for readability in the output format) + strippedText := placeholderRE.ReplaceAllString(s.Text, " ") + // Collapse multiple spaces and trim + strippedText = multiSpaceRE.ReplaceAllString(strippedText, " ") + strippedText = strings.TrimSpace(strippedText) + + // Add text element if stripped text is not empty + if strippedText != "" { + text := strippedText + elements = append(elements, ContentParagraphElement{ + ParagraphElementType: ParagraphElementTypeTextRun.Ptr(), + TextRun: &ContentTextRun{ + Text: &text, + }, + }) + } + + // Add mention elements + for _, mention := range s.Mention { + m := mention + elements = append(elements, ContentParagraphElement{ + ParagraphElementType: ParagraphElementTypeMention.Ptr(), + Mention: &ContentMention{ + UserID: &m, + }, + }) + } + + return &ContentBlock{ + Blocks: []ContentBlockElement{ + { + BlockElementType: BlockElementTypeParagraph.Ptr(), + Paragraph: &ContentParagraph{ + Elements: elements, + }, + }, + }, + } +} + +// BuildContentBlock converts text and mentions to a ContentBlock. +// This is a convenience wrapper around SemiPlainContent.ToContentBlock(). +func BuildContentBlock(text string, mentions []string) *ContentBlock { + return (&SemiPlainContent{ + Text: text, + Mention: mentions, + }).ToContentBlock() +} + // ProgressRateV1 进度率 type ProgressRateV1 struct { Percent *float64 `json:"percent,omitempty"` diff --git a/shortcuts/okr/okr_openapi_test.go b/shortcuts/okr/okr_openapi_test.go index d123dc92..01ace572 100644 --- a/shortcuts/okr/okr_openapi_test.go +++ b/shortcuts/okr/okr_openapi_test.go @@ -57,7 +57,9 @@ func TestToRespMethods(t *testing.T) { convey.So(resp, convey.ShouldNotBeNil) convey.So(resp.ID, convey.ShouldEqual, "cycle-id") convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal") - convey.So(*resp.Score, convey.ShouldEqual, 0.75) + // Verify removed fields are not present in RespCycle + convey.So(resp.StartTime, convey.ShouldNotBeEmpty) + convey.So(resp.EndTime, convey.ShouldNotBeEmpty) }) convey.Convey("Objective", func() { @@ -518,5 +520,449 @@ func float64Ptr(v float64) *float64 { return &v } // boolPtr returns a pointer to the given bool value. func boolPtr(v bool) *bool { return &v } +// ========== SemiPlainContent Conversion Tests ========== + +func TestContentBlockToSemiPlain_TextOnly(t *testing.T) { + t.Parallel() + cb := &ContentBlock{ + Blocks: []ContentBlockElement{ + { + BlockElementType: BlockElementTypeParagraph.Ptr(), + Paragraph: &ContentParagraph{ + Elements: []ContentParagraphElement{ + { + ParagraphElementType: ParagraphElementTypeTextRun.Ptr(), + TextRun: &ContentTextRun{ + Text: strPtr("Hello world"), + }, + }, + }, + }, + }, + }, + } + sp := cb.ToSemiPlain() + if sp == nil { + t.Fatal("expected non-nil SemiPlainContent") + } + if sp.Text != "Hello world" { + t.Fatalf("expected text 'Hello world', got '%s'", sp.Text) + } + if len(sp.Mention) != 0 { + t.Fatalf("expected 0 mentions, got %d", len(sp.Mention)) + } + if len(sp.Docs) != 0 { + t.Fatalf("expected 0 docs, got %d", len(sp.Docs)) + } + if len(sp.Images) != 0 { + t.Fatalf("expected 0 images, got %d", len(sp.Images)) + } +} + +func TestContentBlockToSemiPlain_WithMention(t *testing.T) { + t.Parallel() + cb := &ContentBlock{ + Blocks: []ContentBlockElement{ + { + BlockElementType: BlockElementTypeParagraph.Ptr(), + Paragraph: &ContentParagraph{ + Elements: []ContentParagraphElement{ + { + ParagraphElementType: ParagraphElementTypeTextRun.Ptr(), + TextRun: &ContentTextRun{ + Text: strPtr("Hello "), + }, + }, + { + ParagraphElementType: ParagraphElementTypeMention.Ptr(), + Mention: &ContentMention{ + UserID: strPtr("ou_123"), + }, + }, + { + ParagraphElementType: ParagraphElementTypeTextRun.Ptr(), + TextRun: &ContentTextRun{ + Text: strPtr(", how are you?"), + }, + }, + }, + }, + }, + }, + } + sp := cb.ToSemiPlain() + if sp == nil { + t.Fatal("expected non-nil SemiPlainContent") + } + // Text includes @{userID} placeholder to preserve positional context + if sp.Text != "Hello @{ou_123} , how are you?" { + t.Fatalf("expected text 'Hello @{ou_123} , how are you?', got '%s'", sp.Text) + } + if len(sp.Mention) != 1 || sp.Mention[0] != "ou_123" { + t.Fatalf("expected mention [ou_123], got %v", sp.Mention) + } +} + +func TestContentBlockToSemiPlain_WithDocsAndImages(t *testing.T) { + t.Parallel() + cb := &ContentBlock{ + Blocks: []ContentBlockElement{ + { + BlockElementType: BlockElementTypeParagraph.Ptr(), + Paragraph: &ContentParagraph{ + Elements: []ContentParagraphElement{ + { + ParagraphElementType: ParagraphElementTypeTextRun.Ptr(), + TextRun: &ContentTextRun{ + Text: strPtr("Check out this doc: "), + }, + }, + { + ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(), + DocsLink: &ContentDocsLink{ + Title: strPtr("Design Doc"), + URL: strPtr("https://example.feishu.cn/docx/xxx"), + }, + }, + }, + }, + }, + { + BlockElementType: BlockElementTypeGallery.Ptr(), + Gallery: &ContentGallery{ + Images: []ContentImageItem{ + { + Src: strPtr("https://example.com/img1.png"), + }, + { + Src: strPtr("https://example.com/img2.png"), + }, + }, + }, + }, + }, + } + sp := cb.ToSemiPlain() + if sp == nil { + t.Fatal("expected non-nil SemiPlainContent") + } + if sp.Text != "Check out this doc: " { + t.Fatalf("unexpected text: '%s'", sp.Text) + } + if len(sp.Docs) != 1 { + t.Fatalf("expected 1 doc, got %d", len(sp.Docs)) + } + if sp.Docs[0].Title != "Design Doc" || sp.Docs[0].URL != "https://example.feishu.cn/docx/xxx" { + t.Fatalf("unexpected doc: %+v", sp.Docs[0]) + } + if len(sp.Images) != 2 { + t.Fatalf("expected 2 images, got %d", len(sp.Images)) + } + if sp.Images[0] != "https://example.com/img1.png" || sp.Images[1] != "https://example.com/img2.png" { + t.Fatalf("unexpected images: %v", sp.Images) + } +} + +func TestContentBlockToSemiPlain_Nil(t *testing.T) { + t.Parallel() + var cb *ContentBlock + sp := cb.ToSemiPlain() + if sp != nil { + t.Fatal("expected nil SemiPlainContent for nil ContentBlock") + } +} + +func TestSemiPlainContentToContentBlock_TextOnly(t *testing.T) { + t.Parallel() + sp := &SemiPlainContent{ + Text: "Hello world", + } + cb := sp.ToContentBlock() + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + if len(cb.Blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(cb.Blocks)) + } + block := cb.Blocks[0] + if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph { + t.Fatal("expected paragraph block") + } + if block.Paragraph == nil || len(block.Paragraph.Elements) != 1 { + t.Fatalf("expected 1 paragraph element, got %d", len(block.Paragraph.Elements)) + } + elem := block.Paragraph.Elements[0] + if elem.ParagraphElementType == nil || *elem.ParagraphElementType != ParagraphElementTypeTextRun { + t.Fatal("expected textRun element") + } + if elem.TextRun == nil || elem.TextRun.Text == nil || *elem.TextRun.Text != "Hello world" { + t.Fatalf("unexpected text: %v", elem.TextRun) + } +} + +func TestSemiPlainContentToContentBlock_WithMentions(t *testing.T) { + t.Parallel() + sp := &SemiPlainContent{ + Text: "Please review", + Mention: []string{"ou_123", "ou_456"}, + } + cb := sp.ToContentBlock() + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + if len(cb.Blocks) != 1 { + t.Fatalf("expected 1 block, got %d", len(cb.Blocks)) + } + elems := cb.Blocks[0].Paragraph.Elements + if len(elems) != 3 { + t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems)) + } + if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun || *elems[0].TextRun.Text != "Please review" { + t.Fatal("unexpected first element") + } + if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_123" { + t.Fatal("unexpected second element") + } + if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_456" { + t.Fatal("unexpected third element") + } +} + +func TestSemiPlainContentToContentBlock_EmptyText(t *testing.T) { + t.Parallel() + sp := &SemiPlainContent{ + Text: " ", + Mention: []string{"ou_123"}, + } + cb := sp.ToContentBlock() + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + elems := cb.Blocks[0].Paragraph.Elements + // Empty text should be skipped, only mention remains + if len(elems) != 1 { + t.Fatalf("expected 1 element (mention only), got %d", len(elems)) + } + if *elems[0].ParagraphElementType != ParagraphElementTypeMention { + t.Fatal("expected mention element") + } +} + +func TestSemiPlainContentToContentBlock_DocsImagesIgnored(t *testing.T) { + t.Parallel() + sp := &SemiPlainContent{ + Text: "Test", + Mention: []string{"ou_123"}, + Docs: []SemiPlainDoc{{Title: "Doc", URL: "https://..."}}, + Images: []string{"https://img.png"}, + } + cb := sp.ToContentBlock() + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + elems := cb.Blocks[0].Paragraph.Elements + // Docs and images are ignored in input conversion + if len(elems) != 2 { + t.Fatalf("expected 2 elements (text + mention), got %d", len(elems)) + } +} + +func TestSemiPlainContentToContentBlock_PlaceholderStripping(t *testing.T) { + t.Parallel() + // Simulate round-trip: output format has @{userID} in text, + // input conversion should strip them to avoid duplicate mentions + sp := &SemiPlainContent{ + Text: "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ", + Mention: []string{"ou_zhangsan", "ou_lisi"}, + } + cb := sp.ToContentBlock() + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + elems := cb.Blocks[0].Paragraph.Elements + // Should have 3 elements: 1 text (stripped) + 2 mentions + if len(elems) != 3 { + t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems)) + } + // Text should have placeholders stripped + if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun { + t.Fatal("expected first element to be textRun") + } + // Note: space before comma is preserved from the placeholder's trailing space + expectedText := "任务一 ,任务二" + if *elems[0].TextRun.Text != expectedText { + t.Fatalf("expected stripped text '%s', got '%s'", expectedText, *elems[0].TextRun.Text) + } + // Mentions should be preserved as separate elements + if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_zhangsan" { + t.Fatal("unexpected second element") + } + if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_lisi" { + t.Fatal("unexpected third element") + } +} + +func TestSemiPlainContentToContentBlock_OnlyPlaceholders(t *testing.T) { + t.Parallel() + // Text that is only placeholders should result in no text element + sp := &SemiPlainContent{ + Text: " @{ou_123} @{ou_456} ", + Mention: []string{"ou_123", "ou_456"}, + } + cb := sp.ToContentBlock() + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + elems := cb.Blocks[0].Paragraph.Elements + // Should have only 2 mention elements, no text element + if len(elems) != 2 { + t.Fatalf("expected 2 elements (mentions only), got %d", len(elems)) + } + if *elems[0].ParagraphElementType != ParagraphElementTypeMention { + t.Fatal("expected first element to be mention") + } + if *elems[1].ParagraphElementType != ParagraphElementTypeMention { + t.Fatal("expected second element to be mention") + } +} + +func TestSemiPlainContentToContentBlock_Nil(t *testing.T) { + t.Parallel() + var sp *SemiPlainContent + cb := sp.ToContentBlock() + if cb != nil { + t.Fatal("expected nil ContentBlock for nil SemiPlainContent") + } +} + +func TestBuildContentBlock_Conversion(t *testing.T) { + t.Parallel() + cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"}) + if cb == nil { + t.Fatal("expected non-nil ContentBlock") + } + elems := cb.Blocks[0].Paragraph.Elements + if len(elems) != 3 { + t.Fatalf("expected 3 elements, got %d", len(elems)) + } + if *elems[0].TextRun.Text != "Test text" { + t.Fatalf("unexpected text: %s", *elems[0].TextRun.Text) + } + if *elems[1].Mention.UserID != "ou_123" { + t.Fatalf("unexpected mention: %s", *elems[1].Mention.UserID) + } + if *elems[2].Mention.UserID != "ou_456" { + t.Fatalf("unexpected mention: %s", *elems[2].Mention.UserID) + } +} + +func TestToSimpleMethods(t *testing.T) { + t.Parallel() + + // Test Objective.ToSimple() + text := "Objective text" + obj := &Objective{ + ID: "obj-1", + Content: BuildContentBlock(text, []string{"ou_123"}), + Notes: BuildContentBlock("Note text", nil), + Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_owner")}, + CycleID: "cycle-1", + Score: float64Ptr(0.7), + Weight: float64Ptr(0.5), + Deadline: strPtr("1735776000000"), + } + simpleObj := obj.ToSimple() + if simpleObj == nil { + t.Fatal("expected non-nil RespObjectiveSimple") + } + if simpleObj.ID != "obj-1" { + t.Fatalf("expected ID obj-1, got %s", simpleObj.ID) + } + // Text includes @{userID} placeholder for positional context + expectedContentText := "Objective text @{ou_123} " + if simpleObj.Content == nil || simpleObj.Content.Text != expectedContentText { + t.Fatalf("unexpected content text: expected '%s', got '%s'", expectedContentText, simpleObj.Content.Text) + } + if simpleObj.Notes == nil || simpleObj.Notes.Text != "Note text" { + t.Fatalf("unexpected notes: %+v", simpleObj.Notes) + } + if simpleObj.Score == nil || *simpleObj.Score != 0.7 { + t.Fatalf("unexpected score: %v", simpleObj.Score) + } + if len(simpleObj.Content.Mention) != 1 || simpleObj.Content.Mention[0] != "ou_123" { + t.Fatalf("unexpected mentions: %v", simpleObj.Content.Mention) + } + + // Test KeyResult.ToSimple() + kr := &KeyResult{ + ID: "kr-1", + ObjectiveID: "obj-1", + Content: BuildContentBlock("KR text", nil), + Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_kr_owner")}, + Score: float64Ptr(0.5), + } + simpleKR := kr.ToSimple() + if simpleKR == nil { + t.Fatal("expected non-nil RespKeyResultSimple") + } + if simpleKR.Content == nil || simpleKR.Content.Text != "KR text" { + t.Fatalf("unexpected KR content: %+v", simpleKR.Content) + } + + // Test ProgressV1.ToSimple() + progress := &ProgressV1{ + ID: "prog-1", + ModifyTime: "1735776000000", + Content: BuildContentBlock("Progress text", []string{"ou_mention"}).ToV1(), + } + simpleProgress := progress.ToSimple() + if simpleProgress == nil { + t.Fatal("expected non-nil RespProgressSimple") + } + // Text includes @{userID} placeholder for positional context + expectedProgressText := "Progress text @{ou_mention} " + if simpleProgress.Content == nil || simpleProgress.Content.Text != expectedProgressText { + t.Fatalf("unexpected progress text: expected '%s', got '%s'", expectedProgressText, simpleProgress.Content.Text) + } + if len(simpleProgress.Content.Mention) != 1 || simpleProgress.Content.Mention[0] != "ou_mention" { + t.Fatalf("unexpected progress mentions: %v", simpleProgress.Content.Mention) + } + + // Test Progress.ToSimple() (V2 progress record) + progressV2 := &Progress{ + ID: "prog-v2-1", + CreateTime: "1735689600000", + UpdateTime: "1735776000000", + Content: BuildContentBlock("V2 progress text", []string{"ou_v2_mention"}), + ProgressRate: &ProgressRate{ + ProgressPercent: float64Ptr(80.0), + ProgressStatus: int32Ptr(int32(ProgressStatusDone)), + }, + } + simpleProgressV2 := progressV2.ToSimple() + if simpleProgressV2 == nil { + t.Fatal("expected non-nil RespProgressSimple for Progress V2") + } + if simpleProgressV2.ID != "prog-v2-1" { + t.Fatalf("expected ID prog-v2-1, got %s", simpleProgressV2.ID) + } + if simpleProgressV2.CreateTime == nil || *simpleProgressV2.CreateTime == "" { + t.Fatal("expected non-empty CreateTime for Progress V2") + } + expectedV2Text := "V2 progress text @{ou_v2_mention} " + if simpleProgressV2.Content == nil || simpleProgressV2.Content.Text != expectedV2Text { + t.Fatalf("unexpected V2 progress text: expected '%s', got '%s'", expectedV2Text, simpleProgressV2.Content.Text) + } + if simpleProgressV2.ProgressRate == nil || simpleProgressV2.ProgressRate.Status == nil || *simpleProgressV2.ProgressRate.Status != "done" { + t.Fatalf("expected progress status 'done', got %+v", simpleProgressV2.ProgressRate) + } + if simpleProgressV2.ProgressRate.Percent == nil || *simpleProgressV2.ProgressRate.Percent != 80.0 { + t.Fatalf("expected progress percent 80.0, got %v", simpleProgressV2.ProgressRate.Percent) + } + if len(simpleProgressV2.Content.Mention) != 1 || simpleProgressV2.Content.Mention[0] != "ou_v2_mention" { + t.Fatalf("unexpected V2 progress mentions: %v", simpleProgressV2.Content.Mention) + } +} + // listTypePtr returns a pointer to the given ListType value. func listTypePtr(v ListType) *ListType { return &v } diff --git a/shortcuts/okr/okr_patch.go b/shortcuts/okr/okr_patch.go new file mode 100644 index 00000000..2ccb21ab --- /dev/null +++ b/shortcuts/okr/okr_patch.go @@ -0,0 +1,311 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "context" + "encoding/json" + "fmt" + "io" + "math" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +// patchParams holds the parsed parameters for the patch operation. +type patchParams struct { + Level string + TargetID string + Style string + Content *ContentBlock + Notes *ContentBlock + Score *float64 + Deadline *string + UserIDType string +} + +// parsePatchParams parses and validates flags from runtime into request-ready parameters. +func parsePatchParams(runtime *common.RuntimeContext) (*patchParams, error) { + p := &patchParams{ + Level: runtime.Str("level"), + TargetID: runtime.Str("target-id"), + Style: runtime.Str("style"), + UserIDType: runtime.Str("user-id-type"), + } + + hasField := false + + // Parse content if provided + if contentStr := runtime.Str("content"); contentStr != "" { + hasField = true + if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil { + return nil, err + } + if p.Style == "simple" { + var sp SemiPlainContent + if err := json.Unmarshal([]byte(contentStr), &sp); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err) + } + if strings.TrimSpace(sp.Text) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content") + } + for i, m := range sp.Mention { + if strings.TrimSpace(m) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content") + } + } + if len(sp.Docs) > 0 || len(sp.Images) > 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content") + } + p.Content = sp.ToContentBlock() + } else { + var cb ContentBlock + if err := json.Unmarshal([]byte(contentStr), &cb); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + } + p.Content = &cb + } + } + + // Parse notes if provided (only for objective) + if notesStr := runtime.Str("notes"); notesStr != "" { + hasField = true + if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil { + return nil, err + } + if p.Level != "objective" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported for level=objective").WithParam("--notes") + } + if p.Style == "simple" { + var sp SemiPlainContent + if err := json.Unmarshal([]byte(notesStr), &sp); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--notes").WithCause(err) + } + if strings.TrimSpace(sp.Text) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes text is required and cannot be empty").WithParam("--notes") + } + for i, m := range sp.Mention { + if strings.TrimSpace(m) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes mention[%d] cannot be empty", i).WithParam("--notes") + } + } + if len(sp.Docs) > 0 || len(sp.Images) > 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--notes") + } + p.Notes = sp.ToContentBlock() + } else { + var cb ContentBlock + if err := json.Unmarshal([]byte(notesStr), &cb); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid ContentBlock JSON: %s", err).WithParam("--notes").WithCause(err) + } + p.Notes = &cb + } + } + + // Parse score if provided + if scoreStr := runtime.Str("score"); scoreStr != "" { + hasField = true + score, err := strconv.ParseFloat(scoreStr, 64) + if err != nil || math.IsNaN(score) || math.IsInf(score, 0) { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be a valid number").WithParam("--score") + } + if score < 0 || score > 1 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be between 0 and 1").WithParam("--score") + } + // Check for exactly one decimal place + scoreStrTrimmed := strings.TrimRight(strings.TrimRight(scoreStr, "0"), ".") + parts := strings.Split(scoreStrTrimmed, ".") + if len(parts) == 2 && len(parts[1]) > 1 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must have at most one decimal place (e.g., 0.5, not 0.51)").WithParam("--score") + } + // Validation ensures at most one decimal place, so score is already correctly formatted + p.Score = &score + } + + // Parse deadline if provided + if deadlineStr := runtime.Str("deadline"); deadlineStr != "" { + hasField = true + deadlineMs, err := strconv.ParseInt(deadlineStr, 10, 64) + if err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a valid millisecond timestamp (integer)").WithParam("--deadline") + } + if deadlineMs <= 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a positive millisecond timestamp").WithParam("--deadline") + } + // Reject non-millisecond timestamps: year 2000 in ms is ~946e9, year 2100 in ms is ~4.1e12 + // Anything less than 1e12 is likely seconds or a wrong unit + if deadlineMs < 1000000000000 { // 1e12 ms = year ~33658, so use 1e12 as lower bound for reasonable ms timestamps + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a millisecond timestamp (13 digits), not seconds").WithParam("--deadline") + } + p.Deadline = &deadlineStr + } + + // At least one field must be provided + if !hasField { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --content, --notes, --score, or --deadline must be provided") + } + + return p, nil +} + +// OKRPatch patches an objective or key result. +var OKRPatch = common.Shortcut{ + Service: "okr", + Command: "+patch", + Description: "Patch an OKR objective or key result (content, notes, score, deadline)", + Risk: "write", + Scopes: []string{"okr:okr.content:writeonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "level", Desc: "patch level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}}, + {Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true}, + {Name: "style", Default: "simple", Desc: "input style for content/notes: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}}, + {Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}}, + {Name: "notes", Desc: "notes (objective only): semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}}, + {Name: "score", Desc: "score value between 0 and 1, with at most one decimal place (e.g., 0.5)"}, + {Name: "deadline", Desc: "deadline as millisecond timestamp"}, + {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + level := runtime.Str("level") + if level != "objective" && level != "key-result" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level") + } + + targetID := runtime.Str("target-id") + if targetID == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id") + } + if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil { + return err + } + if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id") + } + + style := runtime.Str("style") + if style != "simple" && style != "richtext" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style") + } + + idType := runtime.Str("user-id-type") + if idType != "open_id" && idType != "union_id" && idType != "user_id" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type") + } + + // Delegate content/notes/score/deadline validation to parsePatchParams + if _, err := parsePatchParams(runtime); err != nil { + return err + } + + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + p, err := parsePatchParams(runtime) + if err != nil { + return common.NewDryRunAPI(). + PATCH(""). + Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error())) + } + + body := make(map[string]interface{}) + if p.Content != nil { + body["content"] = p.Content + } + if p.Notes != nil { + body["notes"] = p.Notes + } + if p.Score != nil { + body["score"] = *p.Score + } + if p.Deadline != nil { + body["deadline"] = *p.Deadline + } + + params := map[string]interface{}{ + "user_id_type": p.UserIDType, + } + + api := common.NewDryRunAPI() + if p.Level == "objective" { + api = api.PATCH("/open-apis/okr/v2/objectives/:objective_id"). + Set("objective_id", p.TargetID) + } else { + api = api.PATCH("/open-apis/okr/v2/key_results/:key_result_id"). + Set("key_result_id", p.TargetID) + } + return api.Params(params).Body(body). + Desc(fmt.Sprintf("Patch OKR %s: content=%v, notes=%v, score=%v, deadline=%v", + p.Level, p.Content != nil, p.Notes != nil, p.Score != nil, p.Deadline != nil)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + p, err := parsePatchParams(runtime) + if err != nil { + return err + } + + body := make(map[string]interface{}) + if p.Content != nil { + body["content"] = p.Content + } + if p.Notes != nil { + body["notes"] = p.Notes + } + if p.Score != nil { + body["score"] = *p.Score + } + if p.Deadline != nil { + body["deadline"] = *p.Deadline + } + + queryParams := map[string]interface{}{ + "user_id_type": p.UserIDType, + } + + var path string + if p.Level == "objective" { + path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s", p.TargetID) + } else { + path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s", p.TargetID) + } + + _, err = runtime.CallAPITyped("PATCH", path, queryParams, body) + if err != nil { + return wrapOkrNetworkErr(err, "failed to patch OKR %s", p.Level) + } + + result := map[string]interface{}{ + "level": p.Level, + "target_id": p.TargetID, + "patched": map[string]bool{ + "content": p.Content != nil, + "notes": p.Notes != nil, + "score": p.Score != nil, + "deadline": p.Deadline != nil, + }, + } + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Patched OKR %s [%s]\n", p.Level, p.TargetID) + if p.Content != nil { + fmt.Fprintf(w, " - content: updated\n") + } + if p.Notes != nil { + fmt.Fprintf(w, " - notes: updated\n") + } + if p.Score != nil { + fmt.Fprintf(w, " - score: %.1f\n", *p.Score) + } + if p.Deadline != nil { + fmt.Fprintf(w, " - deadline: %s\n", formatTimestamp(*p.Deadline)) + } + }) + + return nil + }, +} diff --git a/shortcuts/okr/okr_patch_test.go b/shortcuts/okr/okr_patch_test.go new file mode 100644 index 00000000..937bffa5 --- /dev/null +++ b/shortcuts/okr/okr_patch_test.go @@ -0,0 +1,1350 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package okr + +import ( + "bytes" + "encoding/json" + "errors" + "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/spf13/cobra" +) + +func patchTestConfig(t *testing.T) *core.CliConfig { + t.Helper() + return &core.CliConfig{ + AppID: "dummy", + AppSecret: "dummy", + Brand: core.BrandFeishu, + } +} + +func runPatchShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error { + t.Helper() + parent := &cobra.Command{Use: "okr"} + OKRPatch.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +// --- Validate tests --- + +func TestPatchValidate_MissingLevel(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--target-id", "123", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected --level required error") + } + // Cobra required flag errors are not our typed errors, so check message + if !strings.Contains(err.Error(), "level") { + t.Fatalf("expected --level required error, got: %v", err) + } +} + +func TestPatchValidate_MissingTargetID(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected --target-id required error") + } + // Cobra required flag errors are not our typed errors, so check message + if !strings.Contains(err.Error(), "target-id") { + t.Fatalf("expected --target-id required error, got: %v", err) + } +} + +func TestPatchValidate_InvalidLevel(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "invalid", + "--target-id", "123", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected error for invalid level") + } + _, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--level" { + t.Fatalf("expected param --level, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidTargetID_NonNumeric(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "not-a-number", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected error for invalid target-id") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--target-id" { + t.Fatalf("expected param --target-id, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidTargetID_Negative(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "-1", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected error for negative target-id") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--target-id" { + t.Fatalf("expected param --target-id, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidTargetID_Zero(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "0", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected error for zero target-id") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--target-id" { + t.Fatalf("expected param --target-id, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidStyle(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "invalid", + "--content", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected error for invalid style") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--style" { + t.Fatalf("expected param --style, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidUserIDType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--content", validSemiPlainJSON, + "--user-id-type", "invalid", + }) + if err == nil { + t.Fatal("expected error for invalid user-id-type") + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--user-id-type" { + t.Fatalf("expected param --user-id-type, got %q", validationErr.Param) + } +} + +func TestPatchValidate_NoFieldsProvided(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + }) + if err == nil { + t.Fatal("expected error for no fields provided") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "" { + t.Fatalf("expected empty param (error not tied to a specific field), got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "at least one of") { + t.Fatalf("expected 'at least one of' error message, got: %v", err) + } +} + +func TestPatchValidate_InvalidContent_SimpleStyle(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", "not-json", + }) + if err == nil { + t.Fatal("expected error for invalid --content JSON") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param --content, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "semi-plain JSON") { + t.Fatalf("expected semi-plain JSON error, got: %v", err) + } +} + +func TestPatchValidate_InvalidContent_RichTextStyle(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "richtext", + "--content", "not-json", + }) + if err == nil { + t.Fatal("expected error for invalid --content JSON") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param --content, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "ContentBlock JSON") { + t.Fatalf("expected ContentBlock JSON error, got: %v", err) + } +} + +func TestPatchValidate_SemiPlainContent_EmptyText(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", `{"text":" ","mention":[]}`, + }) + if err == nil { + t.Fatal("expected error for empty text in content") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param --content, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "text is required") { + t.Fatalf("expected text required error, got: %v", err) + } +} + +func TestPatchValidate_SemiPlainContent_EmptyMention(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", `{"text":"hello","mention":[""]}`, + }) + if err == nil { + t.Fatal("expected error for empty mention in content") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param --content, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "mention[0] cannot be empty") { + t.Fatalf("expected mention empty error, got: %v", err) + } +} + +func TestPatchValidate_SemiPlainContent_WithDocs(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", `{"text":"hello","docs":[{"title":"doc","url":"https://example.com"}]}`, + }) + if err == nil { + t.Fatal("expected error for docs in simple style content") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param --content, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "docs and images are not supported") { + t.Fatalf("expected docs/images not supported error, got: %v", err) + } +} + +func TestPatchValidate_SemiPlainContent_WithImages(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", `{"text":"hello","images":["https://example.com/img.png"]}`, + }) + if err == nil { + t.Fatal("expected error for images in simple style content") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param --content, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "docs and images are not supported") { + t.Fatalf("expected docs/images not supported error, got: %v", err) + } +} + +func TestPatchValidate_NotesForbiddenOnKeyResult(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "123", + "--notes", validSemiPlainJSON, + }) + if err == nil { + t.Fatal("expected error for notes on key-result") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--notes" { + t.Fatalf("expected param --notes, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "only supported for level=objective") { + t.Fatalf("expected notes only for objective error, got: %v", err) + } +} + +func TestPatchValidate_InvalidNotes_SimpleStyle(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--notes", "not-json", + }) + if err == nil { + t.Fatal("expected error for invalid --notes JSON") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--notes" { + t.Fatalf("expected param --notes, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidNotes_RichTextStyle(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "richtext", + "--notes", "not-json", + }) + if err == nil { + t.Fatal("expected error for invalid --notes JSON") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--notes" { + t.Fatalf("expected param --notes, got %q", validationErr.Param) + } +} + +func TestPatchValidate_SemiPlainNotes_EmptyText(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--notes", `{"text":" "}`, + }) + if err == nil { + t.Fatal("expected error for empty text in notes") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--notes" { + t.Fatalf("expected param --notes, got %q", validationErr.Param) + } +} + +func TestPatchValidate_SemiPlainNotes_EmptyMention(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--notes", `{"text":"hello","mention":[" "]}`, + }) + if err == nil { + t.Fatal("expected error for empty mention in notes") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--notes" { + t.Fatalf("expected param --notes, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidScore_NonNumeric(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "not-a-number", + }) + if err == nil { + t.Fatal("expected error for invalid score") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--score" { + t.Fatalf("expected param --score, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidScore_OutOfRange(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "1.5", + }) + if err == nil { + t.Fatal("expected error for score out of range") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--score" { + t.Fatalf("expected param --score, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "between 0 and 1") { + t.Fatalf("expected between 0 and 1 error, got: %v", err) + } +} + +func TestPatchValidate_InvalidScore_Negative(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "-0.1", + }) + if err == nil { + t.Fatal("expected error for negative score") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--score" { + t.Fatalf("expected param --score, got %q", validationErr.Param) + } +} + +func TestPatchValidate_InvalidScore_TooManyDecimals(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "0.51", + }) + if err == nil { + t.Fatal("expected error for score with too many decimals") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--score" { + t.Fatalf("expected param --score, got %q", validationErr.Param) + } + if !strings.Contains(err.Error(), "at most one decimal place") { + t.Fatalf("expected one decimal place error, got: %v", err) + } +} + +func TestPatchValidate_InvalidDeadline(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--deadline", "not-a-number", + }) + if err == nil { + t.Fatal("expected error for invalid deadline") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--deadline" { + t.Fatalf("expected param --deadline, got %q", validationErr.Param) + } +} + +func TestPatchValidate_Valid_Objective_SimpleStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", validSemiPlainJSON, + "--notes", validSemiPlainJSON, + "--score", "0.5", + "--deadline", "1735776000000", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPatchValidate_Valid_Objective_RichTextStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "richtext", + "--content", validContentBlockJSON, + "--notes", validContentBlockJSON, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPatchValidate_Valid_KeyResult_SimpleStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/key_results/456", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "456", + "--style", "simple", + "--content", validSemiPlainJSON, + "--score", "1.0", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPatchValidate_Valid_KeyResult_RichTextStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/key_results/456", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "456", + "--style", "richtext", + "--content", validContentBlockJSON, + "--deadline", "1735776000000", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPatchValidate_Valid_ScoreBoundaryValues(t *testing.T) { + t.Parallel() + tests := []string{"0", "0.0", "1", "1.0", "0.3", "0.7"} + for _, score := range tests { + t.Run(score, func(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", score, + }) + if err != nil { + t.Fatalf("unexpected error for score %q: %v", score, err) + } + }) + } +} + +func TestPatchValidate_Valid_DefaultStyleIsSimple(t *testing.T) { + t.Parallel() + // Default style is simple, so passing semi-plain JSON without --style should work + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--content", validSemiPlainJSON, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPatchValidate_Valid_OnlyScore(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "0.0", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestPatchValidate_Valid_OnlyDeadline(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--deadline", "1735776000000", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- DryRun tests --- + +func TestPatchDryRun_Objective_Content(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", validSemiPlainJSON, + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "PATCH") { + t.Fatalf("expected PATCH method in dry-run output, got: %s", output) + } + if !strings.Contains(output, "/open-apis/okr/v2/objectives/123") { + t.Fatalf("expected objective URL in dry-run output, got: %s", output) + } + if !strings.Contains(output, "content=true") { + t.Fatalf("expected content=true in dry-run output, got: %s", output) + } +} + +func TestPatchDryRun_KeyResult_Score(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "456", + "--score", "0.7", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "PATCH") { + t.Fatalf("expected PATCH method in dry-run output, got: %s", output) + } + if !strings.Contains(output, "/open-apis/okr/v2/key_results/456") { + t.Fatalf("expected key_result URL in dry-run output, got: %s", output) + } + if !strings.Contains(output, "score=true") { + t.Fatalf("expected score=true in dry-run output, got: %s", output) + } +} + +func TestPatchDryRun_Objective_MultipleFields(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "789", + "--style", "simple", + "--content", validSemiPlainJSON, + "--notes", validSemiPlainJSON, + "--score", "0.5", + "--deadline", "1735776000000", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "content=true") || + !strings.Contains(output, "notes=true") || + !strings.Contains(output, "score=true") || + !strings.Contains(output, "deadline=true") { + t.Fatalf("expected all fields in dry-run output, got: %s", output) + } + if !strings.Contains(output, `"user_id_type": "open_id"`) { + t.Fatalf("expected user_id_type param in dry-run output, got: %s", output) + } +} + +func TestPatchDryRun_KeyResult_WithUserIDType(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, patchTestConfig(t)) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "456", + "--score", "0.7", + "--user-id-type", "user_id", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, `"user_id_type": "user_id"`) { + t.Fatalf("expected user_id_type=user_id in dry-run output, got: %s", output) + } +} + +// --- Execute tests --- + +func TestPatchExecute_Objective_Success(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + BodyFilter: func(body []byte) bool { + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return false + } + // Check content is present and is a ContentBlock structure + content, ok := data["content"].(map[string]interface{}) + if !ok { + return false + } + blocks, ok := content["blocks"].([]interface{}) + if !ok || len(blocks) == 0 { + return false + } + // Check score + score, ok := data["score"].(float64) + if !ok || score != 0.5 { + return false + } + // Check notes + notes, ok := data["notes"].(map[string]interface{}) + if !ok { + return false + } + notesBlocks, ok := notes["blocks"].([]interface{}) + if !ok || len(notesBlocks) == 0 { + return false + } + // Check deadline + deadline, ok := data["deadline"].(string) + if !ok || deadline != "1735776000000" { + return false + } + return true + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--style", "simple", + "--content", validSemiPlainJSON, + "--notes", validSemiPlainJSON, + "--score", "0.5", + "--deadline", "1735776000000", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, `"level": "objective"`) { + t.Fatalf("expected objective level in output, got: %s", output) + } + if !strings.Contains(output, `"target_id": "123"`) { + t.Fatalf("expected target_id in output, got: %s", output) + } + if !strings.Contains(output, `"content": true`) || + !strings.Contains(output, `"notes": true`) || + !strings.Contains(output, `"score": true`) || + !strings.Contains(output, `"deadline": true`) { + t.Fatalf("expected all field patches in output, got: %s", output) + } +} + +func TestPatchExecute_KeyResult_Success(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/key_results/456", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + BodyFilter: func(body []byte) bool { + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return false + } + // Check content is present + content, ok := data["content"].(map[string]interface{}) + if !ok { + return false + } + blocks, ok := content["blocks"].([]interface{}) + if !ok || len(blocks) == 0 { + return false + } + // Check score + score, ok := data["score"].(float64) + if !ok || score != 1.0 { + return false + } + // Notes should NOT be present for key-result + if _, hasNotes := data["notes"]; hasNotes { + return false + } + return true + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "456", + "--style", "richtext", + "--content", validContentBlockJSON, + "--score", "1.0", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, `"level": "key-result"`) { + t.Fatalf("expected key-result level in output, got: %s", output) + } + if !strings.Contains(output, `"target_id": "456"`) { + t.Fatalf("expected target_id in output, got: %s", output) + } + if !strings.Contains(output, `"content": true`) || + !strings.Contains(output, `"score": true`) { + t.Fatalf("expected field patches in output, got: %s", output) + } + if strings.Contains(output, `"notes": true`) { + t.Fatalf("unexpected notes patch in key-result output, got: %s", output) + } +} + +func TestPatchExecute_OnlyScore(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + BodyFilter: func(body []byte) bool { + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return false + } + // Only score should be present + if _, hasContent := data["content"]; hasContent { + return false + } + if _, hasNotes := data["notes"]; hasNotes { + return false + } + if _, hasDeadline := data["deadline"]; hasDeadline { + return false + } + score, ok := data["score"].(float64) + return ok && score == 0.3 + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "0.3", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, `"score": true`) { + t.Fatalf("expected score patch in output, got: %s", output) + } + if strings.Contains(output, `"content": true`) || + strings.Contains(output, `"notes": true`) || + strings.Contains(output, `"deadline": true`) { + t.Fatalf("unexpected field patches in output, got: %s", output) + } +} + +func TestPatchExecute_APIError(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 9999, + "msg": "patch error", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "0.5", + }) + if err == nil { + t.Fatal("expected error for API failure") + } + prob, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if prob.Category != errs.CategoryAPI { + t.Fatalf("expected CategoryAPI, got %q", prob.Category) + } + var apiErr *errs.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected error to be *errs.APIError, got: %T", err) + } + if !errors.Is(err, apiErr) { + t.Fatal("errors.Is should find the APIError in the chain") + } +} + +func TestPatchExecute_WithUserIDType(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/key_results/789", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "key-result", + "--target-id", "789", + "--score", "0.8", + "--user-id-type", "union_id", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --- parsePatchParams tests --- + +func TestParsePatchParams_ScoreRounding(t *testing.T) { + t.Parallel() + // Valid score with one decimal place is accepted (score 0.3) + f, stdout, _, reg := cmdutil.TestFactory(t, patchTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/okr/v2/objectives/123", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + }, + BodyFilter: func(body []byte) bool { + var data map[string]interface{} + if err := json.Unmarshal(body, &data); err != nil { + return false + } + score, ok := data["score"].(float64) + // 0.33 should round to 0.3 + return ok && score == 0.3 + }, + }) + err := runPatchShortcut(t, f, stdout, []string{ + "+patch", + "--level", "objective", + "--target-id", "123", + "--score", "0.3", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/okr/okr_progress_create.go b/shortcuts/okr/okr_progress_create.go index 62d976a2..3a56d5d9 100644 --- a/shortcuts/okr/okr_progress_create.go +++ b/shortcuts/okr/okr_progress_create.go @@ -10,6 +10,7 @@ import ( "io" "math" "strconv" + "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/internal/core" @@ -35,12 +36,37 @@ type createProgressRecordParams struct { // parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters. func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) { + style := runtime.Str("style") content := runtime.Str("content") - var cb ContentBlock - if err := json.Unmarshal([]byte(content), &cb); err != nil { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + var contentV1 *ContentBlockV1 + + if style == "simple" { + var sp SemiPlainContent + if err := json.Unmarshal([]byte(content), &sp); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err) + } + if strings.TrimSpace(sp.Text) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content") + } + // Validate mention IDs are non-empty + for i, m := range sp.Mention { + if strings.TrimSpace(m) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content") + } + } + if len(sp.Docs) > 0 || len(sp.Images) > 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content") + } + // Build ContentBlock from semi-plain content (text + mentions) + contentV1 = sp.ToContentBlock().ToV1() + } else { + // richtext mode + var cb ContentBlock + if err := json.Unmarshal([]byte(content), &cb); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + } + contentV1 = cb.ToV1() } - contentV1 := cb.ToV1() targetType := runtime.Str("target-type") targetTypeVal := targetTypeAllowed[targetType] @@ -92,7 +118,7 @@ var OKRCreateProgressRecord = common.Shortcut{ AuthTypes: []string{"user", "bot"}, HasFormat: true, Flags: []common.Flag{ - {Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}}, + {Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}}, {Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true}, {Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}}, {Name: "progress-percent", Desc: "progress percentage"}, @@ -100,6 +126,7 @@ var OKRCreateProgressRecord = common.Shortcut{ {Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"}, {Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"}, {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + {Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { content := runtime.Str("content") @@ -109,10 +136,36 @@ var OKRCreateProgressRecord = common.Shortcut{ if err := common.RejectDangerousCharsTyped("--content", content); err != nil { return err } - // Validate content is valid JSON and can be parsed as ContentBlock - var cb ContentBlock - if err := json.Unmarshal([]byte(content), &cb); err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + + style := runtime.Str("style") + if style != "simple" && style != "richtext" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style") + } + + // Validate content based on style + if style == "simple" { + var sp SemiPlainContent + if err := json.Unmarshal([]byte(content), &sp); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err) + } + if strings.TrimSpace(sp.Text) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content") + } + for i, m := range sp.Mention { + if strings.TrimSpace(m) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content") + } + } + // If user provided docs or images in simple mode, warn that they are ignored + if len(sp.Docs) > 0 || len(sp.Images) > 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content") + } + } else { + // richtext mode + var cb ContentBlock + if err := json.Unmarshal([]byte(content), &cb); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + } } targetID := runtime.Str("target-id") @@ -213,21 +266,43 @@ var OKRCreateProgressRecord = common.Shortcut{ return err } - resp := record.ToResp() - result := map[string]interface{}{ - "progress": resp, - } + style := runtime.Str("style") + var result map[string]interface{} + if style == "simple" { + resp := record.ToSimple() + result = map[string]interface{}{ + "progress": resp, + "style": style, + } - runtime.OutFormat(result, nil, func(w io.Writer) { - fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID) - fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) - if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { - fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", resp.Content.Text) + } + }) + } else { + resp := record.ToResp() + result = map[string]interface{}{ + "progress": resp, + "style": style, } - if resp.Content != nil { - fmt.Fprintf(w, " Content: %s\n", *resp.Content) - } - }) + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", *resp.Content) + } + }) + } return nil }, } diff --git a/shortcuts/okr/okr_progress_create_test.go b/shortcuts/okr/okr_progress_create_test.go index 87b26661..ba5c1999 100644 --- a/shortcuts/okr/okr_progress_create_test.go +++ b/shortcuts/okr/okr_progress_create_test.go @@ -5,11 +5,13 @@ package okr import ( "bytes" + "errors" "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/internal/httpmock" @@ -38,6 +40,7 @@ func runProgressCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.B } const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}` +const validSemiPlainJSON = `{"text":"test content","mention":["ou_123"]}` // --- Validate tests --- @@ -60,6 +63,7 @@ func TestProgressCreateValidate_InvalidContentJSON(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", "not-json", + "--style", "richtext", "--target-id", "123", "--target-type", "objective", }) @@ -77,6 +81,7 @@ func TestProgressCreateValidate_MissingTargetID(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-type", "objective", }) if err == nil { @@ -90,6 +95,7 @@ func TestProgressCreateValidate_InvalidTargetID_NonNumeric(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "abc", "--target-type", "objective", }) @@ -107,6 +113,7 @@ func TestProgressCreateValidate_InvalidTargetType(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "invalid", }) @@ -124,6 +131,7 @@ func TestProgressCreateValidate_ControlCharsInContent(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}", + "--style", "richtext", "--target-id", "123", "--target-type", "objective", }) @@ -138,6 +146,7 @@ func TestProgressCreateValidate_InvalidUserIDType(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", "--user-id-type", "invalid", @@ -153,6 +162,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_OutOfRange(t *testing.T) err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", "--progress-percent", "999999999999", @@ -171,6 +181,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_NonNumeric(t *testing.T) err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", "--progress-percent", "abc", @@ -189,6 +200,7 @@ func TestProgressCreateValidate_InvalidProgressStatus(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", "--progress-status", "invalid_status", @@ -219,6 +231,7 @@ func TestProgressCreateValidate_Valid(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", }) @@ -235,6 +248,7 @@ func TestProgressCreateDryRun(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", "--dry-run", @@ -264,6 +278,7 @@ func TestProgressCreateDryRun_WithProgressRate(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "123", "--target-type", "objective", "--progress-percent", "75", @@ -299,6 +314,7 @@ func TestProgressCreateExecute_Success(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "456", "--target-type", "key_result", }) @@ -330,6 +346,7 @@ func TestProgressCreateExecute_APIError(t *testing.T) { err := runProgressCreateShortcut(t, f, stdout, []string{ "+progress-create", "--content", validContentBlockJSON, + "--style", "richtext", "--target-id", "789", "--target-type", "objective", }) @@ -337,3 +354,200 @@ func TestProgressCreateExecute_APIError(t *testing.T) { t.Fatal("expected error for API failure") } } + +// --- Simple mode tests --- + +func TestProgressCreateExecute_SimpleMode_DefaultStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/progress_records/", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "300", + "modify_time": "1735776000000", + }, + }, + }) + // Use default style (simple) without specifying --style + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validSemiPlainJSON, + "--target-id", "123", + "--target-type", "objective", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + pr, _ := data["progress"].(map[string]interface{}) + if pr == nil { + t.Fatal("expected progress in output") + } + if pr["progress_id"] != "300" { + t.Fatalf("progress_id = %v, want 300", pr["progress_id"]) + } +} + +func TestProgressCreateExecute_SimpleMode_ExplicitStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/okr/v1/progress_records/", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "400", + "modify_time": "1735776000000", + }, + }, + }) + // Explicitly specify --style simple with mentions + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", `{"text":"simple progress with mention","mention":["ou_abc","ou_def"]}`, + "--style", "simple", + "--target-id", "456", + "--target-type", "key_result", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + pr, _ := data["progress"].(map[string]interface{}) + if pr == nil { + t.Fatal("expected progress in output") + } + if pr["progress_id"] != "400" { + t.Fatalf("progress_id = %v, want 400", pr["progress_id"]) + } +} + +func TestProgressCreateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", `{"text":"missing closing brace`, + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for invalid semi-plain JSON") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param %q, got %q", "--content", validationErr.Param) + } + if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressCreateValidate_SimpleMode_EmptyText(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", `{"text":" ","mention":[]}`, + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for empty text in simple mode") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param %q, got %q", "--content", validationErr.Param) + } + if !strings.Contains(err.Error(), "--content text is required and cannot be empty") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressCreateValidate_SimpleMode_DocsImagesNotSupported(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", `{"text":"has docs","mention":[],"docs":[{"title":"doc","url":"https://example.com"}]}`, + "--target-id", "123", + "--target-type", "objective", + }) + if err == nil { + t.Fatal("expected error for docs in simple mode") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param %q, got %q", "--content", validationErr.Param) + } + if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressCreateDryRun_SimpleMode(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t)) + err := runProgressCreateShortcut(t, f, stdout, []string{ + "+progress-create", + "--content", validSemiPlainJSON, + "--target-id", "123", + "--target-type", "objective", + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "/open-apis/okr/v1/progress_records/") { + t.Fatalf("dry-run output should contain API path, got: %s", output) + } + if !strings.Contains(output, "POST") { + t.Fatalf("dry-run output should contain POST method, got: %s", output) + } +} diff --git a/shortcuts/okr/okr_progress_get.go b/shortcuts/okr/okr_progress_get.go index 2878f0ef..e98e0944 100644 --- a/shortcuts/okr/okr_progress_get.go +++ b/shortcuts/okr/okr_progress_get.go @@ -26,6 +26,7 @@ var OKRGetProgressRecord = common.Shortcut{ Flags: []common.Flag{ {Name: "progress-id", Desc: "progress ID (int64)", Required: true}, {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + {Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { progressID := runtime.Str("progress-id") @@ -39,6 +40,10 @@ var OKRGetProgressRecord = common.Shortcut{ if idType != "open_id" && idType != "union_id" && idType != "user_id" { return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type") } + style := runtime.Str("style") + if style != "simple" && style != "richtext" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style") + } return nil }, DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { @@ -55,6 +60,7 @@ var OKRGetProgressRecord = common.Shortcut{ Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { progressID := runtime.Str("progress-id") userIDType := runtime.Str("user-id-type") + style := runtime.Str("style") queryParams := map[string]interface{}{"user_id_type": userIDType} @@ -69,21 +75,45 @@ var OKRGetProgressRecord = common.Shortcut{ return err } - resp := record.ToResp() - result := map[string]interface{}{ - "progress": resp, - } + var result map[string]interface{} + if style == "simple" { + resp := record.ToSimple() + result = map[string]interface{}{ + "progress": resp, + "style": style, + } - runtime.OutFormat(result, nil, func(w io.Writer) { - fmt.Fprintf(w, "Progress [%s]\n", resp.ID) - fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) - if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { - fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", resp.Content.Text) + if len(resp.Content.Mention) > 0 { + fmt.Fprintf(w, " Mentions: %v\n", resp.Content.Mention) + } + } + }) + } else { + resp := record.ToResp() + result = map[string]interface{}{ + "progress": resp, + "style": style, } - if resp.Content != nil { - fmt.Fprintf(w, " Content: %s\n", *resp.Content) - } - }) + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", *resp.Content) + } + }) + } return nil }, } diff --git a/shortcuts/okr/okr_progress_update.go b/shortcuts/okr/okr_progress_update.go index c492ec75..52ae3b86 100644 --- a/shortcuts/okr/okr_progress_update.go +++ b/shortcuts/okr/okr_progress_update.go @@ -10,6 +10,7 @@ import ( "io" "math" "strconv" + "strings" "github.com/larksuite/cli/errs" "github.com/larksuite/cli/shortcuts/common" @@ -25,12 +26,35 @@ type updateProgressRecordParams struct { // parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters. func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) { + style := runtime.Str("style") content := runtime.Str("content") - var cb ContentBlock - if err := json.Unmarshal([]byte(content), &cb); err != nil { - return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + var contentV1 *ContentBlockV1 + + if style == "simple" { + var sp SemiPlainContent + if err := json.Unmarshal([]byte(content), &sp); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err) + } + if strings.TrimSpace(sp.Text) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content") + } + for i, m := range sp.Mention { + if strings.TrimSpace(m) == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content") + } + } + if len(sp.Docs) > 0 || len(sp.Images) > 0 { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content") + } + contentV1 = sp.ToContentBlock().ToV1() + } else { + // richtext mode + var cb ContentBlock + if err := json.Unmarshal([]byte(content), &cb); err != nil { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + } + contentV1 = cb.ToV1() } - contentV1 := cb.ToV1() var progressRate *ProgressRateV1 if v := runtime.Str("progress-percent"); v != "" { @@ -67,10 +91,11 @@ var OKRUpdateProgressRecord = common.Shortcut{ HasFormat: true, Flags: []common.Flag{ {Name: "progress-id", Desc: "progress ID (int64)", Required: true}, - {Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}}, + {Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}}, {Name: "progress-percent", Desc: "progress percentage"}, {Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}}, {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, + {Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}}, }, Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { progressID := runtime.Str("progress-id") @@ -88,9 +113,35 @@ var OKRUpdateProgressRecord = common.Shortcut{ if err := common.RejectDangerousCharsTyped("--content", content); err != nil { return err } - var cb ContentBlock - if err := json.Unmarshal([]byte(content), &cb); err != nil { - return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + + style := runtime.Str("style") + if style != "simple" && style != "richtext" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style") + } + + // Validate content based on style + if style == "simple" { + var sp SemiPlainContent + if err := json.Unmarshal([]byte(content), &sp); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err) + } + if strings.TrimSpace(sp.Text) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content") + } + for i, m := range sp.Mention { + if strings.TrimSpace(m) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content") + } + } + if len(sp.Docs) > 0 || len(sp.Images) > 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content") + } + } else { + // richtext mode + var cb ContentBlock + if err := json.Unmarshal([]byte(content), &cb); err != nil { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) + } } if v := runtime.Str("progress-percent"); v != "" { @@ -158,21 +209,43 @@ var OKRUpdateProgressRecord = common.Shortcut{ return err } - resp := record.ToResp() - result := map[string]interface{}{ - "progress": resp, - } + style := runtime.Str("style") + var result map[string]interface{} + if style == "simple" { + resp := record.ToSimple() + result = map[string]interface{}{ + "progress": resp, + "style": style, + } - runtime.OutFormat(result, nil, func(w io.Writer) { - fmt.Fprintf(w, "Updated Progress [%s]\n", resp.ID) - fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) - if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { - fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent) + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", resp.Content.Text) + } + }) + } else { + resp := record.ToResp() + result = map[string]interface{}{ + "progress": resp, + "style": style, } - if resp.Content != nil { - fmt.Fprintf(w, " Content: %s\n", *resp.Content) - } - }) + + runtime.OutFormat(result, nil, func(w io.Writer) { + fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style) + fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) + if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { + fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent) + } + if resp.Content != nil { + fmt.Fprintf(w, " Content: %s\n", *resp.Content) + } + }) + } return nil }, } diff --git a/shortcuts/okr/okr_progress_update_test.go b/shortcuts/okr/okr_progress_update_test.go index 6d56d4dd..b3f2cc46 100644 --- a/shortcuts/okr/okr_progress_update_test.go +++ b/shortcuts/okr/okr_progress_update_test.go @@ -5,11 +5,13 @@ package okr import ( "bytes" + "errors" "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/internal/httpmock" @@ -45,6 +47,7 @@ func TestProgressUpdateValidate_MissingProgressID(t *testing.T) { err := runProgressUpdateShortcut(t, f, stdout, []string{ "+progress-update", "--content", validContentBlockJSON, + "--style", "richtext", }) if err == nil { t.Fatal("expected error for missing --progress-id") @@ -58,6 +61,7 @@ func TestProgressUpdateValidate_InvalidProgressID(t *testing.T) { "+progress-update", "--progress-id", "abc", "--content", validContentBlockJSON, + "--style", "richtext", }) if err == nil { t.Fatal("expected error for invalid --progress-id") @@ -86,6 +90,7 @@ func TestProgressUpdateValidate_InvalidContentJSON(t *testing.T) { "+progress-update", "--progress-id", "123", "--content", "not-json", + "--style", "richtext", }) if err == nil { t.Fatal("expected error for invalid --content JSON") @@ -102,6 +107,7 @@ func TestProgressUpdateValidate_InvalidUserIDType(t *testing.T) { "+progress-update", "--progress-id", "123", "--content", validContentBlockJSON, + "--style", "richtext", "--user-id-type", "invalid", }) if err == nil { @@ -116,6 +122,7 @@ func TestProgressUpdateValidate_InvalidProgressPercent_OutOfRange(t *testing.T) "+progress-update", "--progress-id", "123", "--content", validContentBlockJSON, + "--style", "richtext", "--progress-percent", "-999999999999", }) if err == nil { @@ -133,6 +140,7 @@ func TestProgressUpdateValidate_InvalidProgressStatus(t *testing.T) { "+progress-update", "--progress-id", "123", "--content", validContentBlockJSON, + "--style", "richtext", "--progress-status", "invalid_status", }) if err == nil { @@ -162,6 +170,7 @@ func TestProgressUpdateValidate_Valid(t *testing.T) { "+progress-update", "--progress-id", "123", "--content", validContentBlockJSON, + "--style", "richtext", }) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -177,6 +186,7 @@ func TestProgressUpdateDryRun(t *testing.T) { "+progress-update", "--progress-id", "456", "--content", validContentBlockJSON, + "--style", "richtext", "--dry-run", }) if err != nil { @@ -201,6 +211,7 @@ func TestProgressUpdateDryRun_WithProgressRate(t *testing.T) { "+progress-update", "--progress-id", "456", "--content", validContentBlockJSON, + "--style", "richtext", "--progress-percent", "50", "--progress-status", "overdue", "--dry-run", @@ -235,6 +246,7 @@ func TestProgressUpdateExecute_Success(t *testing.T) { "+progress-update", "--progress-id", "789", "--content", validContentBlockJSON, + "--style", "richtext", }) if err != nil { t.Fatalf("unexpected error: %v", err) @@ -265,8 +277,202 @@ func TestProgressUpdateExecute_APIError(t *testing.T) { "+progress-update", "--progress-id", "999", "--content", validContentBlockJSON, + "--style", "richtext", }) if err == nil { t.Fatal("expected error for API failure") } } + +// --- Simple mode tests --- + +func TestProgressUpdateExecute_SimpleMode_DefaultStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/okr/v1/progress_records/500", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "500", + "modify_time": "1735776000000", + }, + }, + }) + // Use default style (simple) without specifying --style + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "500", + "--content", validSemiPlainJSON, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + pr, _ := data["progress"].(map[string]interface{}) + if pr == nil { + t.Fatal("expected progress in output") + } + if pr["progress_id"] != "500" { + t.Fatalf("progress_id = %v, want 500", pr["progress_id"]) + } +} + +func TestProgressUpdateExecute_SimpleMode_ExplicitStyle(t *testing.T) { + t.Parallel() + f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + reg.Register(&httpmock.Stub{ + Method: "PUT", + URL: "/open-apis/okr/v1/progress_records/600", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "progress_id": "600", + "modify_time": "1735776000000", + }, + }, + }) + // Explicitly specify --style simple with mentions and progress rate + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "600", + "--content", `{"text":"updated progress","mention":["ou_abc"]}`, + "--style", "simple", + "--progress-percent", "80", + "--progress-status", "normal", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data := decodeEnvelope(t, stdout) + pr, _ := data["progress"].(map[string]interface{}) + if pr == nil { + t.Fatal("expected progress in output") + } + if pr["progress_id"] != "600" { + t.Fatalf("progress_id = %v, want 600", pr["progress_id"]) + } +} + +func TestProgressUpdateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "123", + "--content", `{"text":"invalid json`, + }) + if err == nil { + t.Fatal("expected error for invalid semi-plain JSON") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param %q, got %q", "--content", validationErr.Param) + } + if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressUpdateValidate_SimpleMode_EmptyMention(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "123", + "--content", `{"text":"has empty mention","mention":["ou_abc",""]}`, + }) + if err == nil { + t.Fatal("expected error for empty mention in simple mode") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param %q, got %q", "--content", validationErr.Param) + } + if !strings.Contains(err.Error(), "--content mention[1] cannot be empty") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressUpdateValidate_SimpleMode_ImagesNotSupported(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "123", + "--content", `{"text":"has images","mention":[],"images":["img_token"]}`, + }) + if err == nil { + t.Fatal("expected error for images in simple mode") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category) + } + if problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got: %T", err) + } + if validationErr.Param != "--content" { + t.Fatalf("expected param %q, got %q", "--content", validationErr.Param) + } + if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestProgressUpdateDryRun_SimpleMode(t *testing.T) { + t.Parallel() + f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t)) + err := runProgressUpdateShortcut(t, f, stdout, []string{ + "+progress-update", + "--progress-id", "700", + "--content", validSemiPlainJSON, + "--dry-run", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + output := stdout.String() + if !strings.Contains(output, "/open-apis/okr/v1/progress_records/700") { + t.Fatalf("dry-run output should contain API path, got: %s", output) + } + if !strings.Contains(output, "PUT") { + t.Fatalf("dry-run output should contain PUT method, got: %s", output) + } +} diff --git a/shortcuts/okr/shortcuts.go b/shortcuts/okr/shortcuts.go index eb476c56..5b371e69 100644 --- a/shortcuts/okr/shortcuts.go +++ b/shortcuts/okr/shortcuts.go @@ -22,5 +22,6 @@ func Shortcuts() []common.Shortcut { OKRReorder, OKRWeight, OKRIndicatorUpdate, + OKRPatch, } } diff --git a/shortcuts/task/task_get_my_tasks.go b/shortcuts/task/task_get_my_tasks.go index 8badce07..334430ca 100644 --- a/shortcuts/task/task_get_my_tasks.go +++ b/shortcuts/task/task_get_my_tasks.go @@ -200,16 +200,21 @@ var GetMyTasks = common.Shortcut{ for _, item := range filteredItems { urlVal, _ := item["url"].(string) urlVal = truncateTaskURL(urlVal) + completed, completedAt := taskCompletionState(item) outputItem := map[string]interface{}{ - "guid": item["guid"], - "summary": item["summary"], - "url": urlVal, + "guid": item["guid"], + "summary": item["summary"], + "url": urlVal, + "completed": completed, } if createdAtStr, ok := item["created_at"].(string); ok { if ts, err := strconv.ParseInt(createdAtStr, 10, 64); err == nil { outputItem["created_at"] = time.UnixMilli(ts).Local().Format(time.RFC3339) } } + if !completedAt.IsZero() { + outputItem["completed_at"] = completedAt.Local().Format(time.RFC3339) + } if dueObj, ok := item["due"].(map[string]interface{}); ok { if tsStr, ok := dueObj["timestamp"].(string); ok { if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil { @@ -237,6 +242,7 @@ var GetMyTasks = common.Shortcut{ summary, _ := item["summary"].(string) urlVal, _ := item["url"].(string) urlVal = truncateTaskURL(urlVal) + completed, completedAt := taskCompletionState(item) var dueTimeStr string if dueObj, ok := item["due"].(map[string]interface{}); ok { @@ -259,6 +265,10 @@ var GetMyTasks = common.Shortcut{ if urlVal != "" { fmt.Fprintf(w, " URL: %s\n", urlVal) } + fmt.Fprintf(w, " Completed: %t\n", completed) + if !completedAt.IsZero() { + fmt.Fprintf(w, " Completed At: %s\n", completedAt.Local().Format("2006-01-02 15:04")) + } if dueTimeStr != "" { fmt.Fprintf(w, " Due: %s\n", dueTimeStr) } @@ -278,3 +288,15 @@ var GetMyTasks = common.Shortcut{ return nil }, } + +func taskCompletionState(item map[string]interface{}) (bool, time.Time) { + completedAtStr, _ := item["completed_at"].(string) + if completedAtStr == "" || completedAtStr == "0" { + return false, time.Time{} + } + ts, err := strconv.ParseInt(completedAtStr, 10, 64) + if err != nil { + return false, time.Time{} + } + return true, time.UnixMilli(ts) +} diff --git a/shortcuts/task/task_get_my_tasks_test.go b/shortcuts/task/task_get_my_tasks_test.go index 81fc8841..69d65043 100644 --- a/shortcuts/task/task_get_my_tasks_test.go +++ b/shortcuts/task/task_get_my_tasks_test.go @@ -110,6 +110,118 @@ func TestGetMyTasks_LocalTimeFormatting(t *testing.T) { } } +func TestGetMyTasks_IncludesCompletionStateInJSON(t *testing.T) { + tsMs := int64(1775174400000) + tsStr := strconv.FormatInt(tsMs, 10) + expectedCompletedAt := time.UnixMilli(tsMs).Local().Format(time.RFC3339) + + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasks", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "guid": "task-open", + "summary": "Open Task", + "completed_at": "0", + "url": "https://example.com/task-open", + }, + map[string]interface{}{ + "guid": "task-done", + "summary": "Done Task", + "completed_at": tsStr, + "url": "https://example.com/task-done", + }, + }, + "has_more": false, + "page_token": "", + }, + }, + }) + + s := GetMyTasks + s.AuthTypes = []string{"bot", "user"} + + err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "json", "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + outNorm := strings.ReplaceAll(stdout.String(), `":"`, `": "`) + for _, expected := range []string{ + `"guid": "task-open"`, + `"completed": false`, + `"guid": "task-done"`, + `"completed": true`, + `"completed_at": "` + expectedCompletedAt + `"`, + } { + if !strings.Contains(outNorm, expected) { + t.Fatalf("output missing expected string (%s), got: %s", expected, stdout.String()) + } + } +} + +func TestGetMyTasks_IncludesCompletionStateInPretty(t *testing.T) { + tsMs := int64(1775174400000) + tsStr := strconv.FormatInt(tsMs, 10) + expectedCompletedAt := time.UnixMilli(tsMs).Local().Format("2006-01-02 15:04") + + f, stdout, _, reg := taskShortcutTestFactory(t) + warmTenantToken(t, f, reg) + + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/task/v2/tasks", + Body: map[string]interface{}{ + "code": 0, "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "guid": "task-open", + "summary": "Open Task", + "completed_at": "0", + "url": "https://example.com/task-open", + }, + map[string]interface{}{ + "guid": "task-done", + "summary": "Done Task", + "completed_at": tsStr, + "url": "https://example.com/task-done", + }, + }, + "has_more": false, + "page_token": "", + }, + }, + }) + + s := GetMyTasks + s.AuthTypes = []string{"bot", "user"} + + err := runMountedTaskShortcut(t, s, []string{"+get-my-tasks", "--format", "pretty", "--as", "bot"}, f, stdout) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + out := stdout.String() + for _, expected := range []string{ + "[1] Open Task\n ID: task-open\n URL: https://example.com/task-open\n Completed: false\n", + "[2] Done Task\n ID: task-done\n URL: https://example.com/task-done\n Completed: true\n Completed At: " + expectedCompletedAt + "\n", + } { + if !strings.Contains(out, expected) { + t.Fatalf("output missing expected string (%s), got: %s", expected, out) + } + } + if count := strings.Count(out, "Completed At:"); count != 1 { + t.Fatalf("Completed At count = %d, want 1; output: %s", count, out) + } +} + // TestGetMyTasks_InvalidTimeFlags locks the three time-flag validation arms in // Execute (--created_at / --due-start / --due-end). The parse runs before any // API call, so a malformed value deterministically surfaces a typed diff --git a/shortcuts/task/task_util_test.go b/shortcuts/task/task_util_test.go index a1ee9aaf..3db84d12 100644 --- a/shortcuts/task/task_util_test.go +++ b/shortcuts/task/task_util_test.go @@ -159,7 +159,7 @@ func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) { if pe.Subtype != errs.SubtypeAppScopeNotApplied { t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied) } - if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123/auth") { + if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") { t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL) } if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" { diff --git a/shortcuts/vc/shortcuts.go b/shortcuts/vc/shortcuts.go index 6b4ab523..d354674c 100644 --- a/shortcuts/vc/shortcuts.go +++ b/shortcuts/vc/shortcuts.go @@ -16,5 +16,6 @@ func Shortcuts() []common.Shortcut { VCMeetingLeave, VCMeetingListActive, VCMeetingEvents, + VCMeetingMessageSend, } } diff --git a/shortcuts/vc/vc_meeting_events_test.go b/shortcuts/vc/vc_meeting_events_test.go index 19849873..dd2c0ffd 100644 --- a/shortcuts/vc/vc_meeting_events_test.go +++ b/shortcuts/vc/vc_meeting_events_test.go @@ -838,7 +838,7 @@ func TestVCShortcuts_RegistersMeetingAgentCommands(t *testing.T) { for _, shortcut := range got { commands = append(commands, shortcut.Command) } - want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events"} + want := []string{"+search", "+notes", "+recording", "+detail", "+meeting-join", "+meeting-leave", "+meeting-list-active", "+meeting-events", "+meeting-message-send"} if !reflect.DeepEqual(commands, want) { t.Fatalf("shortcut commands = %#v, want %#v", commands, want) } diff --git a/shortcuts/vc/vc_meeting_message_send.go b/shortcuts/vc/vc_meeting_message_send.go new file mode 100644 index 00000000..03ead380 --- /dev/null +++ b/shortcuts/vc/vc_meeting_message_send.go @@ -0,0 +1,161 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + meetingMessageTypeText = "text" + meetingMessageTypeReaction = "reaction" + // Keep the client-side cap below the server-side content limit. + meetingMessageMaxTextBytes = 48 * 1024 + meetingMessageMaxUUIDBytes = 128 +) + +// VCMeetingMessageSend sends an in-meeting text message or reaction emoji. +var VCMeetingMessageSend = common.Shortcut{ + Service: "vc", + Command: "+meeting-message-send", + Description: "Send an in-meeting text message or reaction emoji", + Risk: "write", + Scopes: []string{"vc:meeting.message:write"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "meeting-id", Required: true, Desc: "meeting ID to send into"}, + {Name: "msg-type", Desc: "message type: text or reaction"}, + {Name: "text", Desc: "text content when --msg-type text"}, + {Name: "emoji-type", Desc: "emoji key when --msg-type reaction, for example LOVE, THUMBSUP, VC_NoSound"}, + {Name: "uuid", Desc: "optional idempotency key"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if err := validateMeetingEventsMeetingID(runtime.Str("meeting-id")); err != nil { + return err + } + _, err := validateMeetingMessagePayload(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + body, err := buildMeetingMessageSendBody(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + POST(buildMeetingMessageSendPath()). + Body(body) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + body, err := buildMeetingMessageSendBody(runtime) + if err != nil { + return err + } + data, err := runtime.CallAPITyped(http.MethodPost, buildMeetingMessageSendPath(), nil, body) + if err != nil { + return err + } + if data == nil { + data = map[string]interface{}{} + } + runtime.OutFormat(data, nil, func(w io.Writer) { + fmt.Fprintln(w, "Meeting message sent.") + if msgType := common.GetString(data, "msg_type"); msgType != "" { + fmt.Fprintf(w, " Type: %s\n", msgType) + } else if msgType, _ := body["msg_type"].(string); msgType != "" { + fmt.Fprintf(w, " Type: %s\n", msgType) + } + if uuid := common.GetString(data, "uuid"); uuid != "" { + fmt.Fprintf(w, " UUID: %s\n", uuid) + } + }) + return nil + }, +} + +func buildMeetingMessageSendPath() string { + return "/open-apis/vc/v1/bots/message" +} + +func buildMeetingMessageSendBody(runtime *common.RuntimeContext) (map[string]interface{}, error) { + msgType, err := validateMeetingMessagePayload(runtime) + if err != nil { + return nil, err + } + body := map[string]interface{}{ + "meeting_id": strings.TrimSpace(runtime.Str("meeting-id")), + "msg_type": msgType, + } + switch msgType { + case meetingMessageTypeText: + body["content"] = strings.TrimSpace(runtime.Str("text")) + case meetingMessageTypeReaction: + body["content"] = strings.TrimSpace(runtime.Str("emoji-type")) + } + if uuid := strings.TrimSpace(runtime.Str("uuid")); uuid != "" { + body["uuid"] = uuid + } + return body, nil +} + +func validateMeetingMessagePayload(runtime *common.RuntimeContext) (string, error) { + msgType, err := resolveMeetingMessageType(runtime) + if err != nil { + return "", err + } + if msgType == meetingMessageTypeText { + text := strings.TrimSpace(runtime.Str("text")) + if len(text) > meetingMessageMaxTextBytes { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, fmt.Sprintf("--text is too long; max %d bytes", meetingMessageMaxTextBytes)).WithParam("--text") + } + } + if uuid := strings.TrimSpace(runtime.Str("uuid")); len(uuid) > meetingMessageMaxUUIDBytes { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, fmt.Sprintf("--uuid is too long; max %d bytes", meetingMessageMaxUUIDBytes)).WithParam("--uuid") + } + return msgType, nil +} + +func resolveMeetingMessageType(runtime *common.RuntimeContext) (string, error) { + msgType := strings.ToLower(strings.TrimSpace(runtime.Str("msg-type"))) + text := strings.TrimSpace(runtime.Str("text")) + emojiType := strings.TrimSpace(runtime.Str("emoji-type")) + + if msgType == "" { + switch { + case text != "" && emojiType == "": + msgType = meetingMessageTypeText + case text == "" && emojiType != "": + msgType = meetingMessageTypeReaction + default: + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type is required when both --text and --emoji-type are empty or both are set").WithParam("--msg-type") + } + } + + switch msgType { + case meetingMessageTypeText: + if text == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--text is required when --msg-type text").WithParam("--text") + } + if emojiType != "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type cannot be used when --msg-type text").WithParam("--emoji-type") + } + case meetingMessageTypeReaction: + if emojiType == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--emoji-type is required when --msg-type reaction").WithParam("--emoji-type") + } + if text != "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--text cannot be used when --msg-type reaction").WithParam("--text") + } + default: + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--msg-type must be text or reaction").WithParam("--msg-type") + } + return msgType, nil +} diff --git a/shortcuts/vc/vc_meeting_message_send_test.go b/shortcuts/vc/vc_meeting_message_send_test.go new file mode 100644 index 00000000..683e2e70 --- /dev/null +++ b/shortcuts/vc/vc_meeting_message_send_test.go @@ -0,0 +1,312 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package vc + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func newMeetingMessageSendRuntime() *common.RuntimeContext { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().String("meeting-id", "", "") + cmd.Flags().String("msg-type", "", "") + cmd.Flags().String("text", "", "") + cmd.Flags().String("emoji-type", "", "") + cmd.Flags().String("uuid", "", "") + return common.TestNewRuntimeContext(cmd, defaultConfig()) +} + +func mustSetMeetingMessageSendFlag(t *testing.T, runtime *common.RuntimeContext, name, value string) { + t.Helper() + if err := runtime.Cmd.Flags().Set(name, value); err != nil { + t.Fatalf("Flags().Set(%q, %q) error = %v", name, value, err) + } +} + +func assertMeetingMessageSendValidationError(t *testing.T, err error, wantParam string) { + t.Helper() + if err == nil { + t.Fatal("expected validation error") + } + p, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed problem, got %T: %v", err, err) + } + if p.Category != errs.CategoryValidation { + t.Errorf("Category = %q, want %q", p.Category, errs.CategoryValidation) + } + if p.Subtype != errs.SubtypeInvalidArgument { + t.Errorf("Subtype = %q, want %q", p.Subtype, errs.SubtypeInvalidArgument) + } + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if ve.Param != wantParam { + t.Errorf("Param = %q, want %q", ve.Param, wantParam) + } +} + +func TestMeetingMessageSendBuildBody_Text(t *testing.T) { + runtime := newMeetingMessageSendRuntime() + mustSetMeetingMessageSendFlag(t, runtime, "text", " hello ") + mustSetMeetingMessageSendFlag(t, runtime, "uuid", " cid-1 ") + + body, err := buildMeetingMessageSendBody(runtime) + if err != nil { + t.Fatalf("buildMeetingMessageSendBody() error = %v", err) + } + if body["msg_type"] != meetingMessageTypeText { + t.Fatalf("msg_type = %v, want text", body["msg_type"]) + } + if body["content"] != "hello" { + t.Fatalf("content = %v, want hello", body["content"]) + } + if body["uuid"] != "cid-1" { + t.Fatalf("uuid = %v, want cid-1", body["uuid"]) + } +} + +func TestMeetingMessageSendBuildBody_Reaction(t *testing.T) { + runtime := newMeetingMessageSendRuntime() + mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction") + mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE") + + body, err := buildMeetingMessageSendBody(runtime) + if err != nil { + t.Fatalf("buildMeetingMessageSendBody() error = %v", err) + } + if body["msg_type"] != meetingMessageTypeReaction { + t.Fatalf("msg_type = %v, want reaction", body["msg_type"]) + } + if body["content"] != "LOVE" { + t.Fatalf("content = %v, want LOVE", body["content"]) + } + if _, ok := body["text"]; ok { + t.Fatalf("text should be omitted for reaction, got %#v", body["text"]) + } + if _, ok := body["emoji_type"]; ok { + t.Fatalf("emoji_type should be omitted for reaction, got %#v", body["emoji_type"]) + } +} + +func TestMeetingMessageSendBuildBody_ReactionVCFeedbackKey(t *testing.T) { + runtime := newMeetingMessageSendRuntime() + mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction") + mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "VC_NoSound") + + body, err := buildMeetingMessageSendBody(runtime) + if err != nil { + t.Fatalf("buildMeetingMessageSendBody() error = %v", err) + } + if body["content"] != "VC_NoSound" { + t.Fatalf("content = %v, want VC_NoSound", body["content"]) + } +} + +func TestMeetingMessageSendValidateRejectsMeetingNumber(t *testing.T) { + runtime := newMeetingMessageSendRuntime() + mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "123456789") + mustSetMeetingMessageSendFlag(t, runtime, "text", "hello") + + err := VCMeetingMessageSend.Validate(context.Background(), runtime) + assertMeetingMessageSendValidationError(t, err, "--meeting-id") + if !strings.Contains(err.Error(), "9-digit meeting number") { + t.Fatalf("error = %v, want 9-digit meeting number hint", err) + } +} + +func TestMeetingMessageSendValidateRejectsMissingEmojiType(t *testing.T) { + runtime := newMeetingMessageSendRuntime() + mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044") + mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction") + + err := VCMeetingMessageSend.Validate(context.Background(), runtime) + assertMeetingMessageSendValidationError(t, err, "--emoji-type") + if !strings.Contains(err.Error(), "--emoji-type is required") { + t.Fatalf("error = %v, want --emoji-type required", err) + } +} + +func TestMeetingMessageSendValidateRejectsTextMessageWithEmojiType(t *testing.T) { + runtime := newMeetingMessageSendRuntime() + mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044") + mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "text") + mustSetMeetingMessageSendFlag(t, runtime, "text", "hello") + mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE") + + err := VCMeetingMessageSend.Validate(context.Background(), runtime) + assertMeetingMessageSendValidationError(t, err, "--emoji-type") + if !strings.Contains(err.Error(), "--emoji-type cannot be used") { + t.Fatalf("error = %v, want --emoji-type conflict", err) + } +} + +func TestMeetingMessageSendValidateRejectsReactionMessageWithText(t *testing.T) { + runtime := newMeetingMessageSendRuntime() + mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044") + mustSetMeetingMessageSendFlag(t, runtime, "msg-type", "reaction") + mustSetMeetingMessageSendFlag(t, runtime, "emoji-type", "LOVE") + mustSetMeetingMessageSendFlag(t, runtime, "text", "hello") + + err := VCMeetingMessageSend.Validate(context.Background(), runtime) + assertMeetingMessageSendValidationError(t, err, "--text") + if !strings.Contains(err.Error(), "--text cannot be used") { + t.Fatalf("error = %v, want --text conflict", err) + } +} + +func TestMeetingMessageSendValidateRejectsLongText(t *testing.T) { + runtime := newMeetingMessageSendRuntime() + mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044") + mustSetMeetingMessageSendFlag(t, runtime, "text", strings.Repeat("a", meetingMessageMaxTextBytes+1)) + + err := VCMeetingMessageSend.Validate(context.Background(), runtime) + assertMeetingMessageSendValidationError(t, err, "--text") + if !strings.Contains(err.Error(), "--text is too long") { + t.Fatalf("error = %v, want --text too long", err) + } +} + +func TestMeetingMessageSendValidateRejectsLongUUID(t *testing.T) { + runtime := newMeetingMessageSendRuntime() + mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044") + mustSetMeetingMessageSendFlag(t, runtime, "text", "hello") + mustSetMeetingMessageSendFlag(t, runtime, "uuid", strings.Repeat("u", meetingMessageMaxUUIDBytes+1)) + + err := VCMeetingMessageSend.Validate(context.Background(), runtime) + assertMeetingMessageSendValidationError(t, err, "--uuid") + if !strings.Contains(err.Error(), "--uuid is too long") { + t.Fatalf("error = %v, want --uuid too long", err) + } +} + +func TestMeetingMessageSendDryRun_Text(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig()) + err := mountAndRun(t, VCMeetingMessageSend, []string{ + "+meeting-message-send", "--dry-run", "--as", "user", + "--meeting-id", "7651377260537433044", + "--text", "hello", + "--uuid", "cid-1", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{ + "/open-apis/vc/v1/bots/message", + "\"meeting_id\": \"7651377260537433044\"", + "\"msg_type\": \"text\"", + "\"content\": \"hello\"", + "\"uuid\": \"cid-1\"", + } { + if !strings.Contains(out, want) { + t.Fatalf("dry-run output missing %q: %s", want, out) + } + } +} + +func TestMeetingMessageSendDryRun_ValidationErrorEnvelope(t *testing.T) { + runtime := newMeetingMessageSendRuntime() + mustSetMeetingMessageSendFlag(t, runtime, "meeting-id", "7651377260537433044") + + dryRun := VCMeetingMessageSend.DryRun(context.Background(), runtime) + if got := dryRun.Format(); !strings.Contains(got, "--msg-type is required") { + t.Fatalf("dry-run error = %v, want --msg-type required", got) + } +} + +func TestMeetingMessageSendExecute_Text(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + stub := &httpmock.Stub{ + Method: "POST", + URL: buildMeetingMessageSendPath(), + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "msg_type": "text", + "uuid": "cid-1", + }, + }, + } + reg.Register(stub) + + err := mountAndRun(t, VCMeetingMessageSend, []string{ + "+meeting-message-send", "--as", "user", + "--format", "pretty", + "--meeting-id", "7651377260537433044", + "--text", "hello", + "--uuid", "cid-1", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + + var req map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &req); err != nil { + t.Fatalf("failed to parse request body: %v", err) + } + for key, want := range map[string]string{ + "meeting_id": "7651377260537433044", + "msg_type": "text", + "content": "hello", + "uuid": "cid-1", + } { + if req[key] != want { + t.Errorf("%s = %v, want %s", key, req[key], want) + } + } + + out := stdout.String() + for _, want := range []string{ + "Meeting message sent.", + "Type: text", + "UUID: cid-1", + } { + if !strings.Contains(out, want) { + t.Fatalf("output missing %q: %s", want, out) + } + } +} + +func TestMeetingMessageSendExecute_ReactionFallsBackToRequestType(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: buildMeetingMessageSendPath(), + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{}, + }, + }) + + err := mountAndRun(t, VCMeetingMessageSend, []string{ + "+meeting-message-send", "--as", "user", + "--format", "pretty", + "--meeting-id", "7651377260537433044", + "--msg-type", "reaction", + "--emoji-type", "LOVE", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + reg.Verify(t) + if out := stdout.String(); !strings.Contains(out, "Type: reaction") { + t.Fatalf("output missing fallback type: %s", out) + } +} diff --git a/skill-template/domains/approval.md b/skill-template/domains/approval.md index c79be9e4..f4265388 100644 --- a/skill-template/domains/approval.md +++ b/skill-template/domains/approval.md @@ -1,23 +1,77 @@ -所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval..` 查参数结构,不要猜字段。 +所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段。 + +## 路由优先级(先判断是不是审批,再选命令) + +审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。** + +### 明确归 `lark-approval` 的高优先级语义 + +出现以下任一语义时,优先走 `lark-approval`: + +- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义 +- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送 +- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑 + +**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。 ## 选哪个命令 -| 想做什么 | 命令 | -|---|---| -| 搜可发起定义 | `approvals search` | -| 看审批定义详情/提单前确认表单与流程 | `approvals get` | -| 发起原生审批实例 | `instances create` | -| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)| -| 看表单/进度/当前节点 | `instances get` | -| 同意/拒绝 | `tasks approve` / `tasks reject` | -| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` | -| 催办 | `tasks remind` | -| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` | +| 想做什么 | 命令 | 按需读取 reference | +|---|---|---------------------------------------------------------------------------------| +| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) | +| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) | +| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) | +| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读) | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) | +| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) | +| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) | +| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) | +| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) | +| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) | +| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) | +| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) | +| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) | +| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) | +| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) | 处理链: -- 发起审批:`approvals search` -> `approvals get` -> `instances.create` -- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作 +- 发起审批:`approvals search` -> `approvals get` -> `instances create` +- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 只有用户明确需要查看详情、当前节点、表单内容、或流程进度时,再 `instances get` → 执行操作 + +## 执行原则(减少误路由、误重试和无效消耗) + +### 1) 先拿最小必要信息,再执行 + +- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id` +- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get` +- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤 + +### 2) 已知对象时直达动作 + +- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind` +- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query` +- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤 + +### 3) 错误码驱动,而不是盲目重试 + +- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束 +- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作** +- 同一个失败原因不要连续多次重试,避免 token 和耗时失控,最多重试1次 + +## 写操作失败处理:1395001 决策树 + +当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理: + +1. **先停止盲目重试**,不要连续重复提交相同写操作,最多重试1次 +2. 优先从以下角度解释: + - 任务可能已被他人处理 + - 单据状态已变化,当前动作已不再允许 + - 当前用户已不具备该任务的操作资格 + - 当前节点或单据状态不支持该操作 +3. 如需确认,只补 **一次** 状态查询(`tasks query` 或 `instances get`),不要陷入 query/write 循环 +4. 最终给用户明确结论和下一步建议,而不是继续无意义重试 + +**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。 ```bash lark-cli approval approvals search --data '{"keyword":"请假"}' --as user @@ -27,14 +81,6 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user lark-cli approval tasks approve --data '{"instance_code":"","task_id":"","comment":"同意"}' --as user ``` -## 发起原生审批 +## 不在本 skill 范围 -发起审批属于高风险写操作,按下表处理: - -| 规则 | 处理 | -|---|---| -| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` | -| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` | -| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 | -| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) | -| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` | +创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md) diff --git a/skills/lark-approval/SKILL.md b/skills/lark-approval/SKILL.md index 04b9403c..94862ce0 100644 --- a/skills/lark-approval/SKILL.md +++ b/skills/lark-approval/SKILL.md @@ -8,28 +8,83 @@ metadata: cliHelp: "lark-cli approval --help" --- + **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** -所有命令默认 `--as user`(审批是人的动作)。调用前先 `lark-cli schema approval..` 查参数结构,不要猜字段。 +所有命令默认 `--as user`(审批是人的动作)。调用前先按需读取 references 下对应的文件,查参数结构,不要猜字段;**references 是第一信息源**,只有在 reference 未覆盖的原生 / 高级场景下,才额外用 `lark-cli ... --help`、`lark-cli schema` 等方式补充确认字段。 + +## 路由优先级(先判断是不是审批,再选命令) + +审批待办不是飞书任务。**只要用户的核心对象是审批单据 / 审批待办 / 审批实例,就优先使用 `lark-approval`,不要让渡给 `lark-task`。** + +### 明确归 `lark-approval` 的高优先级语义 + +出现以下任一语义时,优先走 `lark-approval`: + +- 审批待办 / 审批单据 / 审批实例 / 审批意见 / 审批定义 +- 同意 / 拒绝 / 转交 / 退回 / 撤回 / 催办 / 加签 / 抄送 +- 待办列表 / 待办单据 / 已发起审批 / 已办审批 / 审批详情 / 同意可编辑 + +**判定规则:** 只要最终动作是对审批单据做同意、拒绝、转交、退回、撤回、催办、加签、抄送、查详情、查已发起/已办/待办,就归 `lark-approval`。只有当用户处理的是**非审批类任务/待办**时,才走 [`lark-task`](../lark-task/SKILL.md)。 ## 选哪个命令 -| 想做什么 | 命令 | -|---|---| -| 搜可发起定义 | `approvals search` | -| 看审批定义详情/提单前确认表单与流程 | `approvals get` | -| 发起原生审批实例 | `instances create` | -| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读)| -| 看表单/进度/当前节点 | `instances get` | -| 同意/拒绝 | `tasks approve` / `tasks reject` | -| 转交/加签/退回 | `tasks transfer` / `tasks add_sign` / `tasks rollback` | -| 催办 | `tasks remind` | -| 撤回/抄送/按定义查已发起 | `instances cancel` / `instances cc` / `instances initiated` | +| 想做什么 | 命令 | 按需读取 reference | +|---|---|---------------------------------------------------------------------------------| +| 搜可发起定义 | `approvals search` | [`lark-approval-approvals-search.md`](references/lark-approval-approvals-search.md) | +| 看审批定义详情/提单前确认表单与流程 | `approvals get` | [`lark-approval-approvals-get.md`](references/lark-approval-approvals-get.md) | +| 发起原生审批实例/提交请假审批/提交报销审批/创建审批实例 | `instances create` | [`lark-approval-initiate.md`](references/lark-approval-initiate.md) | +| 查待办/已办 | `tasks query`(`topic`:1待办 2已办 17未读 18已读) | [`lark-approval-tasks-query.md`](references/lark-approval-tasks-query.md) | +| 看表单/进度/当前节点 | `instances get` | [`lark-approval-instances-get.md`](references/lark-approval-instances-get.md) | +| 同意审批 | `tasks approve` | [`lark-approval-tasks-approve.md`](references/lark-approval-tasks-approve.md) | +| 拒绝审批 | `tasks reject` | [`lark-approval-tasks-reject.md`](references/lark-approval-tasks-reject.md) | +| 转交审批 | `tasks transfer` | [`lark-approval-tasks-transfer.md`](references/lark-approval-tasks-transfer.md) | +| 加签审批 | `tasks add_sign` | [`lark-approval-tasks-add-sign.md`](references/lark-approval-tasks-add-sign.md) | +| 退回审批 | `tasks rollback` | [`lark-approval-tasks-rollback.md`](references/lark-approval-tasks-rollback.md) | +| 催办审批 | `tasks remind` | [`lark-approval-tasks-remind.md`](references/lark-approval-tasks-remind.md) | +| 撤回已发起审批 | `instances cancel` | [`lark-approval-instances-cancel.md`](references/lark-approval-instances-cancel.md) | +| 给审批实例追加抄送 | `instances cc` | [`lark-approval-instances-cc.md`](references/lark-approval-instances-cc.md) | +| 按定义查已发起审批 | `instances initiated` | [`lark-approval-instances-initiated.md`](references/lark-approval-instances-initiated.md) | 处理链: -- 发起审批:`approvals search` -> `approvals get` -> `instances.create` -- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 需要细节再 `instances get` → 执行操作 +- 发起审批:`approvals search` -> `approvals get` -> `instances create` +- 处理审批:`tasks query` 拿 `instance_code` + `task_id`(操作必须成对带上)→ 只有用户明确需要查看详情、当前节点、表单内容、或流程进度时,再 `instances get` → 执行操作 + +## 执行原则(减少误路由、误重试和无效消耗) + +### 1) 先拿最小必要信息,再执行 + +- 目标只是处理待办时,优先 `tasks query` 获取 `instance_code` + `task_id` +- **只有**用户明确要看详情、当前节点、表单内容、流程进度时,才调用 `instances get` +- 用户已经明确给出 `instance_code` / `task_id` 时,不要先查列表再过滤 + +### 2) 已知对象时直达动作 + +- 已拿到 `instance_code` + `task_id` 后,优先直接执行 `tasks approve/reject/transfer/add_sign/rollback/remind` +- 同一轮里如果已有足够的新鲜查询结果,不要重复 `tasks query` +- 不要默认走 `list -> filter -> detail -> write` 全链路;对象已明确时应压缩步骤 + +### 3) 错误码驱动,而不是盲目重试 + +- 写操作失败后,先看错误码和报错语义,再决定是否补查或结束 +- **除非错误明确提示可恢复或需要补充参数,否则不要重复刷同一个写操作** +- 同一个失败原因不要连续多次重试,避免 token 和耗时失控,最多重试1次 + +## 写操作失败处理:1395001 决策树 + +当拒绝 / 转交 / 退回 / 撤回 / 同意等写操作返回 `1395001`(任务状态异常 / 写前置校验失败)时,按下面规则处理: + +1. **先停止盲目重试**,不要连续重复提交相同写操作,最多重试1次 +2. 优先从以下角度解释: + - 任务可能已被他人处理 + - 单据状态已变化,当前动作已不再允许 + - 当前用户已不具备该任务的操作资格 + - 当前节点或单据状态不支持该操作 +3. 如需确认,只补 **一次** 状态查询(`tasks query` 或 `instances get`),不要陷入 query/write 循环 +4. 最终给用户明确结论和下一步建议,而不是继续无意义重试 + +**特别注意:** 对拒绝 / 转交 / 撤回场景更要严格执行上述规则;这些场景最容易因状态切换而失败。 ```bash lark-cli approval approvals search --data '{"keyword":"请假"}' --as user @@ -39,18 +94,6 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user lark-cli approval tasks approve --data '{"instance_code":"","task_id":"","comment":"同意"}' --as user ``` -## 发起原生审批 - -发起审批属于高风险写操作,按下表处理: - -| 规则 | 处理 | -|---|---| -| 用户意图是发起审批 / 提单 / 提交请假审批 / 提交报销审批 / 创建审批实例 | 先读 [`references/lark-approval-initiate.md`](references/lark-approval-initiate.md)、[`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md) 和 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create` | -| 编排顺序 | 固定走 `approvals.search` -> `approvals.get` -> `instances.create`;未拿到定义详情前不要猜 `form`、`node_approver_list` 或 `node_cc_list` | -| 三方定义 | `is_external=true` 时不要调用 `approval instances create`,返回 `create_link` 并说明需通过链接发起 | -| 表单与节点参数 | 控件 `value` 结构看 [`references/lark-approval-instance-form-control-parameters.md`](references/lark-approval-instance-form-control-parameters.md);值来源看 [`references/lark-approval-instance-value-sourcing.md`](references/lark-approval-instance-value-sourcing.md) | -| 真正执行前 | 让用户确认最终定义、表单值和节点参数;执行时显式传 `--yes`,成功后回报 `instance_code` 与 `instance_link` | - ## 不在本 skill 范围 创建审批定义(走飞书客户端或审批管理后台);三方定义发起(返回 `create_link`,引导用户通过链接发起);非审批类待办 → [`lark-task`](../lark-task/SKILL.md) diff --git a/skills/lark-approval/references/lark-approval-approvals-get.md b/skills/lark-approval/references/lark-approval-approvals-get.md new file mode 100644 index 00000000..cdd607e7 --- /dev/null +++ b/skills/lark-approval/references/lark-approval-approvals-get.md @@ -0,0 +1,128 @@ + +# approval approvals get + +获取单个审批定义详情(用户级只读操作)。适合在发起审批实例前,先确认审批名称、表单控件结构、选项值范围以及流程节点信息。 + +需要的 scopes: ["approval:approval:read"] + +## 命令 + +```bash +# 按 approval_code 查询审批定义详情 +lark-cli approval approvals get --params '{"approval_code":""}' --as user + +# 表格格式输出,便于快速浏览顶层字段 +lark-cli approval approvals get --params '{"approval_code":""}' --format table --as user + +# 预览 API 调用,不执行 +lark-cli approval approvals get --params '{"approval_code":""}' --as user --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--params '{...}'` | 是 | 查询参数,使用 JSON 传入 | +| `approval_code` | 是 | 审批定义 Code;通常来自 `approval approvals search` 的结果 | +| `locale` | 否 | 返回语言,例如 `zh-CN`、`en-US`、`ja-JP` | +| `--as user` | 否 | 建议显式指定用户身份;审批定义详情通常按当前用户可见范围读取 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 常见输入来源 + +如果你已经有 `approval_code`,可直接查询: + +```bash +lark-cli approval approvals get --params '{"approval_code":""}' --as user +``` + +如果你还没有 `approval_code`,先搜索可发起审批定义: + +```bash +lark-cli approval approvals search --data '{"keyword":"请假"}' --as user +``` + +## 输出重点字段 + +返回结果中,优先关注以下字段: + +| 字段 | 说明 | +|------|------| +| `approval_code` | 审批定义 Code | +| `approval_name` | 审批定义名称;确认是不是用户想发起的那张单 | +| `form` | 表单定义快照;用于识别控件 `id`、`type`、选项值范围、明细子控件结构 | +| `node_list` | 流程节点列表;用于识别节点 key、是否需要补充审批人、是否允许多人 | + +## form 的使用重点 + +`form` 最重要的作用是帮助 agent **识别怎么组装 `instances.create.data.form`**,而不是直接把它原样提交出去。 + +重点看: + +| 字段 / 结构 | 说明 | +|------|------| +| `form[].id` | 控件 ID;后续创建实例时必须使用 | +| `form[].type` | 控件类型,例如 `input`、`date`、`radio`、`checkbox`、`fieldList` | +| `form[].value` / 选项定义 | 用来识别可选值范围、默认值或选项值 | +| 明细 / 子控件结构 | 用于识别 `fieldList`、控件组等复杂控件的子字段结构 | + +**注意:`approvals.get.form` 不是 `instances.create` 可直接复用的 payload 模板。** 它是“定义快照”,主要用于识别字段结构与选项值范围。 + +## node_list 的使用重点 + +`node_list` 主要用于后续决定是否要补 `node_approver_list` / `node_cc_list`。 + +重点看: + +| 字段 | 说明 | +|------|------| +| `node_list[].custom_node_id` | 自定义节点标识;后续补节点参数时优先作为 key | +| `node_list[].node_id` | 节点 ID;若没有 `custom_node_id`,通常退回用它做 key | +| `node_list[].need_approver` | 是否要求发起人补充审批人 | +| `node_list[].approver_chosen_multi` | 是否允许为该节点选择多个审批人 | + +## 使用建议 + +- **这是发起原生审批实例前的必要只读步骤。** 推荐固定走:`approvals search` -> `approvals get` -> `instances create`。 +- **如果用户已经明确给了 `approval_code`,直接用这个命令。** 不必再走 `approvals search`。 +- **先确认 `approval_name`。** 避免把相似名称的审批定义搞混。 +- **先用 `form` 识别控件结构,再组装创建 payload。** 不要在未看详情时猜控件 `id`、`type` 或选项值。 +- **先用 `node_list` 看是否需要补审批人。** 若某节点 `need_approver=true`,创建实例时通常要补 `node_approver_list`。 +- **`node_list` 的 key 优先取 `custom_node_id`。** 若不存在,再使用 `node_id`。 +- **`approver_chosen_multi=false` 时,一个节点通常只能补一个审批人。** + +## 输出与后续操作 + +读取定义详情后,常见下一步: + +```bash +# 发起原生审批实例 +lark-cli approval instances create --data '{"approval_code":"","form":"[...]"}' --as user --yes +``` + +如果需要进一步理解控件取值与节点参数,优先参考: + +- `lark-approval-instance-form-control-parameters.md` +- `lark-approval-instance-value-sourcing.md` +- `lark-approval-initiate.md` + +## 结果整理方式 + +**将结果整理为“审批定义概览 + 表单结构摘要 + 节点要求摘要”。** + +建议输出成下面这种结构: + +```text +审批定义:请假申请 +approval_code: 7C468A54-8745-2245-9675-08B7C63E7A85 + +表单控件摘要: +- leave_type: radio,可选值 [annual_leave, sick_leave] +- reason: textarea +- start_end: dateInterval + +节点要求摘要: +- manager_node:need_approver=true,approver_chosen_multi=false +- hr_node:need_approver=false +``` diff --git a/skills/lark-approval/references/lark-approval-approvals-search.md b/skills/lark-approval/references/lark-approval-approvals-search.md new file mode 100644 index 00000000..3ef6ed76 --- /dev/null +++ b/skills/lark-approval/references/lark-approval-approvals-search.md @@ -0,0 +1,103 @@ + +# approval approvals search + +搜索**当前用户可发起**的审批定义(launchable approvals)。只读操作,不会创建审批实例。 + +需要的 scopes: ["approval:approval:read"] + +## 命令 + +```bash +# 按关键词搜索可发起审批定义 +lark-cli approval approvals search --data '{"keyword":"请假"}' --as user + +# 使用 page_token 翻页 +lark-cli approval approvals search --data '{"keyword":"请假", "page_token":"example_page_token"}' --as user + +# 表格格式输出,便于快速浏览候选定义 +lark-cli approval approvals search --data '{"keyword":"出差"}' --format table --as user + +# 预览 API 调用,不执行 +lark-cli approval approvals search --data '{"keyword":"请假"}' --as user --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--data '{...}'` | 是 | 查询参数,使用 JSON 传入 | +| `keyword` | 是 | 搜索关键词,例如 `请假`、`报销`、`出差`、`采购` | +| `locale` | 否 | 返回语言,例如 `zh-CN`、`en-US`、`ja-JP` | +| `page_size` | 否 | 分页大小 | +| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` | +| `--as user` | 否 | 建议显式指定用户身份;“可发起审批定义”是面向当前用户的查询 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 这个命令解决什么问题 + +当用户只有自然语言意图,还没有 `approval_code` 时,先用它把“可发起的审批定义候选项”找出来。 + +典型场景: + +- “帮我找一下请假审批” +- “有哪些可以发起的报销单?” +- “先搜一下出差审批,再帮我提单” + +## 输出重点字段 + +返回结果里,优先关注以下字段: + +| 字段 | 说明 | +|------|------| +| `approval_code` | 审批定义 Code;后续 `approvals get` 和 `instances create` 都要用它 | +| `approval_name` | 审批定义名称;给用户做候选选择时最关键 | +| `is_external` | 是否为三方审批定义;`true` 表示不能走原生 `instances.create` | +| `create_link` | 三方审批定义的发起链接;`is_external=true` 时优先返回给用户 | + +## 使用规则 + +- **这是发起审批工作流的第一步。** 标准顺序是:`approvals search` -> `approvals get` -> `instances create`。 +- **搜索结果为空时,不要猜。** 直接告诉用户当前关键词下没有可发起定义,并建议用户换关键词。 +- **命中多个结果时,不要替用户拍板。** 先把候选定义列出来,让用户选择目标审批定义。 +- **`is_external=true` 时不要调用 `approval instances create`。** 这类定义属于三方审批,优先返回 `create_link` 并说明需要通过链接发起。 +- **只有 `is_external=false` 的原生定义,才继续 `approvals get`。** +- **如果用户已经明确给出 `approval_code`,不要再 search。** 直接执行 `approval approvals get`。 + +## 结果整理方式 + +**将结果整理为候选清单,优先展示“名称 + approval_code + 是否三方定义 + 下一步建议”。** + +建议输出成下面这种结构: + +```text +找到 3 个可发起审批定义: + +1. 请假申请 + - approval_code: 7C468A54-8745-2245-9675-08B7C63E7A85 + - is_external: false + - next: 可继续读取 definitions 详情(approvals get) + +2. 差旅报销 + - approval_code: 99887766-xxxx + - is_external: true + - next: 返回 create_link,引导用户通过链接发起 +``` + +## 常见后续操作 + +### 1)用户选中了某个定义,继续查看详情 + +```bash +lark-cli approval approvals get --params '{"approval_code":""}' --as user +``` + +### 2)确认是原生定义后,再准备发起审批实例 + +```bash +lark-cli approval instances create --data '{"approval_code":"","form":"[...]"}' --as user --yes +``` + +### 3)确认是三方定义时,直接返回链接 + +当 `is_external=true` 时,优先向用户返回 `create_link`,说明该审批需在三方系统或跳转页面中发起,而不是通过原生 `instances.create`。 diff --git a/skills/lark-approval/references/lark-approval-initiate.md b/skills/lark-approval/references/lark-approval-initiate.md index d5621a2c..a8085f24 100644 --- a/skills/lark-approval/references/lark-approval-initiate.md +++ b/skills/lark-approval/references/lark-approval-initiate.md @@ -2,14 +2,15 @@ ## 执行摘要 -- **原生审批提单必须固定走 `approvals.search` -> `approvals.get` -> `instances.create`。** 不要跳过 `get` 直接拼请求。 -- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances.create`,应优先使用 `create_link`。 +- **原生审批提单如果用户未明确给出 `approval_code`,必须固定走 `approvals search` -> `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求。 +- **原生审批提单如果用户明确给出 `approval_code`,固定走 `approvals get` -> `instances create`** 不要跳过 `get` 直接拼请求。 +- **`is_external=true` 的定义是三方定义。** 这类定义不要调用 `instances create`,应优先使用 `create_link`。 - **所有人员类参数默认使用 `open_id`。** 若用户给的是姓名、邮箱或其他身份,先用 [`../../lark-contact/SKILL.md`](../../lark-contact/SKILL.md) 解析。 -- **先读控件参数 reference 和值来源 reference,再看 `schema`。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md),并运行 `lark-cli schema approval.instances.create`。 -- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id`、`type`、选项值范围和明细子控件结构;真正的 `instances.create.data.form` 中,请求字段与节点字段以 `schema` / `meta` 为准,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。 -- **节点参数只从 `node_list` 和 `schema` / `meta` 里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,要先与当前 `schema` 字段名和 ID 口径对齐,不要混用姓名或其他身份标识。 +- **先读控件参数 reference 和值来源 reference,再读本文里的创建参数规则。** 提单前必须先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md)。 +- **`approvals.get.form` 不是创建 payload 的原样模板。** 它主要用于识别控件 `id`、`type`、选项值范围和明细子控件结构;真正的 `instances create --data.form` 中,控件 `value` 结构以 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 为准。 +- **节点参数只从 `node_list` 和本文里的节点参数规则里取。** 节点 key 必须来自定义详情返回的节点标识;审批人/抄送人列表传用户 ID 时,不要混用姓名或其他身份标识。 - **看到 `need_approver=true` 就说明该节点需要发起人补充审批人。** 如果 `approver_chosen_multi=false`,该节点只允许一个 `open_id`。 -- **创建实例前先确认。** `approval instances create` 是写操作,真正执行时显式传 `--yes`。 +- **创建实例前先确认。** `approval instances create` 是写操作,执行前,让用户确认最终定义、表单值和节点参数;真正执行时显式传 `--yes`。 ## 适用场景 @@ -20,11 +21,10 @@ ## 严禁行为 -- **严禁在未先查看 `schema` 的情况下猜测 `--data` 结构。** -- **严禁在未先阅读 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md)、[`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 且未先查看 `schema` 的情况下直接提单。** -- **严禁跳过 `approvals.get`。** 未拿到 `form` 和 `node_list` 前,不得调用 `instances.create`。 +- **严禁在未先阅读本文中的创建参数规则、[`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 和 [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md) 的情况下直接提单。** +- **严禁跳过 `approvals.get`。** 未拿到 `form` 和 `node_list` 前,不得调用 `instances create`。 - **严禁把姓名直接写进 `node_approver_list`、`node_cc_list` 或表单人员控件。** 必须先转成 `open_id`。 -- **严禁对三方定义调用 `instances.create`。** +- **严禁对三方定义调用 `instances create`。** - **严禁对 API 不支持的控件硬提单。** 如果目标定义包含创建实例 API 不支持的控件,应明确告诉用户该定义不能仅通过 API 完整发起。 - **严禁把 `approvals.get.form` 当成可直接提交的原样模板。** - **严禁在未得到用户确认前直接执行真实提单。** @@ -33,10 +33,9 @@ ### 1. 搜索可发起审批定义 -先用 `schema` 看参数,再搜索定义: +先搜索定义: ```bash -lark-cli schema approval.approvals.search lark-cli approval approvals search --data '{"keyword":"请假"}' ``` @@ -44,7 +43,7 @@ lark-cli approval approvals search --data '{"keyword":"请假"}' - 若结果为空,告诉用户当前关键词下没有可发起定义。 - 若命中多个定义,必须把候选项列给用户选择,不要自行猜测。 -- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances.create`。 +- 若目标定义 `is_external=true`,优先返回 `create_link`,说明这是三方定义,不能走原生 `instances create`。 - 只有 `is_external=false` 的原生定义才继续下一步。 ### 2. 获取审批定义详情 @@ -52,7 +51,6 @@ lark-cli approval approvals search --data '{"keyword":"请假"}' 拿到 `approval_code` 后,读取定义详情: ```bash -lark-cli schema approval.approvals.get lark-cli approval approvals get \ --params '{"approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85"}' ``` @@ -63,12 +61,30 @@ lark-cli approval approvals get \ - `form`: 表单定义快照,用于识别控件 `id`、`type`、选项值范围以及明细子控件结构;不是创建实例时可直接原样提交的 payload 模板。 - `node_list`: 流程节点信息,是后续 `node_approver_list` / `node_cc_list` 的唯一可靠来源。 -### 3. 组装 `form` +### 3. 创建请求参数速查 -`instances.create.data.form` 是一个 JSON 数组字符串。组装原则: +输入参数如下: -- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按 `schema` / `meta` 与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。 -- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前 `schema` 要求;不要假设定义快照里出现的其他字段都能直接照搬。 +| 参数 | 必填 | 说明 | +|---|---|---| +| `--data '{...}'` | 是 | 请求体,使用 JSON 传入 | +| `approval_code` | 是 | 审批定义 Code;必须先通过 `approvals search` / `approvals get` 确认 | +| `form` | 是 | 表单值,**JSON 数组字符串**,不是普通对象 | +| `node_approver_list` | 否 | 节点审批人列表;仅在定义要求补充审批人时传 | +| `node_cc_list` | 否 | 节点抄送人列表;仅在用户明确需要补充节点抄送人时传 | +| `uuid` | 否 | 幂等标识;重复重试同一请求时建议显式传入 | +| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入 | +| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;涉及人员类 ID 时建议显式传 `open_id` | +| `--as user` | 否 | 建议显式指定用户身份;审批发起通常应使用用户身份 | +| `--yes` | 是 | 写操作确认;真实执行时必须显式传入 | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +### 4. 组装 `form` + +`instances create --data.form` 是一个 JSON 数组字符串。组装原则: + +- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按本文中的创建参数规则与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。 +- 提交时必须至少保证每个控件的 `id`、`type` 与 `value` 符合当前接口要求;不要假设定义快照里出现的其他字段都能直接照搬。 - 如果用户提供的是人员信息,优先转换成 `open_id` 后再写入对应控件。 - 单选/多选控件提交的是选项 `value`,该值可从 `approvals.get.form` 的选项定义中取得。 - `contact`、`department`、`fieldList`、`dateInterval`、`amount`、`telephone`、`document` 等控件的 `value` 结构各不相同,必须按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 单独组装,不要套用文本控件的写法。 @@ -100,7 +116,7 @@ lark-cli approval approvals get \ - `input` / `textarea`: `value` 是字符串 - `date`: `value` 是 RFC3339 时间字符串 - `dateInterval`: `value` 是对象,包含 `start` / `end` / `interval` -- `radio` / `radioV2`: `value` 是单个选项值,取自定义详情里的 option.value;关联外部选项时传 `options.id` +- `radio` / `radioV2`: `value` 是单个选项值,取定义详情里的 `option.value`;关联外部选项时传 `options.id` - `checkbox` / `checkboxV2`: `value` 是选项值数组 - `number`: `value` 是数字 - `amount`: `value` 是数字,还要带 `currency` @@ -129,7 +145,7 @@ lark-cli approval approvals get \ - 再严格按 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 的示例组装 `value` - 不要把控件组整体当成普通字符串或扁平对象提交 -### 4. 组装节点参数 +### 5. 组装节点参数 从 `node_list` 推导节点参数: @@ -139,13 +155,13 @@ lark-cli approval approvals get \ - 若 `approver_chosen_multi=false`,该节点只允许一个审批人 `open_id`。 - `node_cc_list` 仅在用户明确需要补充节点抄送人时才填写;其 `key/value` 规则与 `node_approver_list` 相同。 -### 5. 创建审批实例 +### 6. 创建审批实例 -先看 `schema`,确认最终结构后再执行: +创建命令使用 `approval instances create`,需要的 scopes: ["approval:instance:write"] + +确认最终表单值和节点参数后再执行: ```bash -lark-cli schema approval.instances.create - lark-cli approval instances create \ --data '{ "approval_code":"7C468A54-8745-2245-9675-08B7C63E7A85", @@ -157,6 +173,8 @@ lark-cli approval instances create \ } ] }' \ + --params '{"user_id_type":"open_id"}' \ + --as user \ --yes ``` @@ -170,7 +188,7 @@ lark-cli approval instances create \ 优先级固定如下: -1. `lark-cli schema approval.instances.create` 与对应 `meta`:决定创建请求体有哪些字段、节点参数怎么传。 +1. 本文中的创建请求参数、节点参数和返回结果说明:决定 `instances create` 要传哪些字段、怎么执行、成功后回什么。 2. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md):决定每种控件的 `value` 结构与支持范围。 3. [`lark-approval-instance-value-sourcing.md`](./lark-approval-instance-value-sourcing.md):决定每类值应该从哪里拿,以及当前哪些值必须由用户直接提供。 4. `approvals.get.form`:提供当前审批定义里实际有哪些控件、控件 `id`、控件 `type`、选项值范围、明细子控件结构。 @@ -184,8 +202,8 @@ lark-cli approval instances create \ |---|---| | 只有口语需求,比如“帮我提个请假审批” | 先 `approvals.search` | | 已经拿到 `approval_code` | 直接 `approvals.get` | -| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances.create` | -| `is_external=true` | 返回 `create_link`,不要调 `instances.create` | +| 已拿到 `form` / `node_list`,且用户已给出表单值和审批人 | 组装 `instances create` | +| `is_external=true` | 返回 `create_link`,不要调 `instances create` | ## 返回结果 @@ -194,3 +212,13 @@ lark-cli approval instances create \ - `approval_name` - `instance_code` - `instance_link` + +建议整理为下面这种结构: + +```text +审批已创建成功: + +- approval_name: 请假申请 +- instance_code: 19EAC829-F1CB-527F-BE2A-1330422E60C0 +- instance_link: https://... +``` diff --git a/skills/lark-approval/references/lark-approval-instance-value-sourcing.md b/skills/lark-approval/references/lark-approval-instance-value-sourcing.md index 30d7d9c3..0bb0842e 100644 --- a/skills/lark-approval/references/lark-approval-instance-value-sourcing.md +++ b/skills/lark-approval/references/lark-approval-instance-value-sourcing.md @@ -6,14 +6,14 @@ 阅读顺序固定如下: -1. `lark-cli schema approval.instances.create` +1. [`lark-approval-initiate.md`](./lark-approval-initiate.md) 中的创建请求参数、节点参数和返回结果说明 2. `approval approvals get` 返回的 `form` / `node_list` 3. [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 4. 本文 ## 总原则 -- `schema` / `meta` 决定请求字段名、字段层级、节点参数结构。 +- `lark-approval-initiate.md` 决定创建请求字段名、字段层级、节点参数结构。 - `approvals.get.form` 决定控件 `id`、`type`、选项值范围、子控件结构。 - `approvals.get.node_list` 决定节点 key、是否必须补审批人、是否允许多人。 - [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 决定各控件 `value` 的最终结构。 diff --git a/skills/lark-approval/references/lark-approval-instances-cancel.md b/skills/lark-approval/references/lark-approval-instances-cancel.md new file mode 100644 index 00000000..1d211eb7 --- /dev/null +++ b/skills/lark-approval/references/lark-approval-instances-cancel.md @@ -0,0 +1,78 @@ + +# approval instances cancel + +撤回一个已发起的审批实例(用户级写操作)。通常先通过 `instances initiated`、`tasks query` 或 `instances get` 确认目标审批实例,拿到 `instance_code` 后再执行撤回。 + +> [!CAUTION] +> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要撤回该审批实例且目标实例无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。 + +需要的 scopes: ["approval:instance:write"] + +## 命令 + +```bash +# 先预览请求,不实际执行 +lark-cli approval instances cancel \ + --data '{"instance_code":""}' \ + --as user \ + --dry-run + +# 撤回一个审批实例 +lark-cli approval instances cancel \ + --data '{"instance_code":""}' \ + --as user \ + --yes + +# 通过文件传入请求体 +lark-cli approval instances cancel \ + --data @./cancel-body.json \ + --as user \ + --yes +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 | +| `instance_code` | 是 | 审批实例 Code;通常先通过 `instances initiated`、`tasks query` 或 `instances get` 获取 | +| `--as user` | 否 | 建议显式指定用户身份;审批实例撤回通常必须以用户身份执行 | +| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 典型前置步骤 + +如果你要找“我发起的审批实例”,可先查询已发起列表: + +```bash +lark-cli approval instances initiated --params '{"page_size":20}' --as user +``` + +如果你已经在任务列表中定位到某个审批,也可以从任务里拿到实例 Code: + +```bash +lark-cli approval tasks query --params '{"topic":"1"}' --as user +``` + +常用到的字段: + +| 字段 | 说明 | +|------|------| +| `instances[].instance_code` | 审批实例 Code;撤回时必须提供 | +| `tasks[].instance_code` | 审批任务关联的审批实例 Code;也可作为撤回输入 | +| `tasks[].instance_status` | 审批实例状态;可用于判断是否仍处于可撤回阶段 | + +如需先确认审批表单、当前节点、流转状态,可继续查看实例详情: + +```bash +lark-cli approval instances get --params '{"instance_code":""}' --as user +``` + +## 使用建议 + +- **撤回的是审批实例,不是单个任务**:`instances cancel` 只需要 `instance_code`,不需要 `task_id`。 +- **优先确认实例是否仍可撤回**:已经通过、已拒绝、已撤销或已终止的实例通常不适合继续撤回。 +- **优先从 `instances initiated` 获取目标实例**:因为撤回通常针对“我发起的审批”,这个入口最直接。 +- **也可从 `tasks query` 反查 `instance_code`**:当你是从某个待办/已办上下文进入时,这样更方便。 +- **先 `--dry-run` 再执行**:尤其在实例来源不明确、用户只给了标题关键字,或一次要核对多个实例时,先预览更安全。 diff --git a/skills/lark-approval/references/lark-approval-instances-cc.md b/skills/lark-approval/references/lark-approval-instances-cc.md new file mode 100644 index 00000000..6f0c295b --- /dev/null +++ b/skills/lark-approval/references/lark-approval-instances-cc.md @@ -0,0 +1,105 @@ + +# approval instances cc + +给一个审批实例追加抄送人(用户级写操作)。通常先通过 `instances initiated`、`tasks query` 或 `instances get` 确认目标审批实例,拿到 `instance_code` 后,再提供抄送人的用户 ID 执行抄送。 + +> [!CAUTION] +> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要抄送该审批实例且目标实例、抄送对象都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。 + +需要的 scopes: ["approval:instance:write"] + +## 命令 + +```bash +# 先预览请求,不实际执行 +lark-cli approval instances cc \ + --data '{"instance_code":"","cc_user_ids":["ou_xxx"],"comment":"抄送给项目 owner 了解进展"}' \ + --params '{"user_id_type":"open_id"}' \ + --as user \ + --dry-run + +# 按 open_id 抄送一个人 +lark-cli approval instances cc \ + --data '{"instance_code":"","cc_user_ids":["ou_xxx"],"comment":"抄送给你知悉"}' \ + --params '{"user_id_type":"open_id"}' \ + --as user \ + --yes + +# 一次抄送多个人 +lark-cli approval instances cc \ + --data '{"instance_code":"","cc_user_ids":["ou_xxx","ou_yyy"],"comment":"请相关同学同步关注"}' \ + --params '{"user_id_type":"open_id"}' \ + --as user \ + --yes + +# 按 user_id 抄送 +lark-cli approval instances cc \ + --data '{"instance_code":"","cc_user_ids":["123456789"],"comment":"抄送给财务负责人"}' \ + --params '{"user_id_type":"user_id"}' \ + --as user \ + --yes + +# 通过文件传入请求体 +lark-cli approval instances cc \ + --data @./cc-body.json \ + --params '{"user_id_type":"open_id"}' \ + --as user \ + --yes +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 | +| `instance_code` | 是 | 审批实例 Code;通常先通过 `instances initiated`、`tasks query` 或 `instances get` 获取 | +| `cc_user_ids` | 是 | 抄送人的用户 ID 数组;需要和 `user_id_type` 保持一致 | +| `comment` | 否 | 抄送留言,例如 `抄送给你知悉`、`请同步关注该审批进展` | +| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON;用于声明 `cc_user_ids` 内用户 ID 的类型 | +| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;未显式指定时要特别确认抄送人的 ID 类型 | +| `--as user` | 否 | 建议显式指定用户身份;审批实例抄送通常必须以用户身份执行 | +| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 典型前置步骤 + +如果你要找“我发起的审批实例”,可先查询已发起列表: + +```bash +lark-cli approval instances initiated --params '{"page_size":20}' --as user +``` + +如果你已经在任务列表中定位到某个审批,也可以从任务里拿到实例 Code: + +```bash +lark-cli approval tasks query --params '{"topic":"1"}' --as user +``` + +常用到的字段: + +| 字段 | 说明 | +|------|------| +| `instances[].instance_code` | 审批实例 Code;抄送时必须提供 | +| `tasks[].instance_code` | 审批任务关联的审批实例 Code;也可作为抄送输入 | +| `tasks[].title` | 任务标题,可用于确认是否是要操作的那个审批 | +| `tasks[].instance_status` | 审批实例状态;可用于判断当前审批是否仍处于进行中 | + +如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID,再执行抄送。 + +如需先确认审批表单、当前节点、流转状态,可继续查看实例详情: + +```bash +lark-cli approval instances get --params '{"instance_code":""}' --as user +``` + +## 使用建议 + +- **抄送的是审批实例,不是单个任务**:`instances cc` 只需要 `instance_code`,不需要 `task_id`。 +- **`cc_user_ids` 与 `user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。 +- **`cc_user_ids` 是数组**:即使只抄送一个人,也要按数组形式传入。 +- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。 +- **优先从 `instances initiated` 获取目标实例**:因为抄送常见于“我发起的审批”场景,这个入口最直接。 +- **也可从 `tasks query` 反查 `instance_code`**:当你是从某个审批上下文进入时,这样更方便。 +- **`comment` 建议简洁明确**:例如 `抄送给你知悉`、`请同步关注审批进展`。避免过长或模糊描述。 +- **先 `--dry-run` 再执行**:尤其在抄送对象较多、抄送人来源不明确,或需要让用户先核对实例标题时,先预览更安全。 diff --git a/skills/lark-approval/references/lark-approval-instances-get.md b/skills/lark-approval/references/lark-approval-instances-get.md new file mode 100644 index 00000000..06f7ea18 --- /dev/null +++ b/skills/lark-approval/references/lark-approval-instances-get.md @@ -0,0 +1,145 @@ + +# approval instances get + +获取单个审批实例详情(用户级只读操作)。适合在执行 approve / reject / transfer / rollback / cancel / cc / remind 之前,先查看审批表单、当前节点、任务列表、审批动态和整体状态。 + +需要的 scopes: ["approval:instance:read"] + +## 命令 + +```bash +# 按实例 Code 查询详情 +lark-cli approval instances get --params '{"instance_code":""}' --as user + +# 表格格式输出,便于快速浏览顶层字段 +lark-cli approval instances get --params '{"instance_code":""}' --format table --as user + +# 预览 API 调用,不执行 +lark-cli approval instances get --params '{"instance_code":""}' --as user --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--params '{...}'` | 是 | 查询参数,使用 JSON 传入 | +| `instance_code` | 是 | 审批实例 Code | +| `locale` | 否 | 返回语言,例如 `zh-CN`、`en-US`、`ja-JP` | +| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id` | +| `--as user` | 否 | 建议显式指定用户身份;审批实例详情查询通常应使用用户身份 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 常见输入来源 + +如果你已经有实例 Code,可直接查询: + +```bash +lark-cli approval instances get --params '{"instance_code":""}' --as user +``` + +如果你还没有实例 Code,可先从以下命令获取: + +```bash +# 查询我发起的审批实例 +lark-cli approval instances initiated --params '{"page_size":20}' --as user + +# 或从任务列表里拿到关联实例 Code +lark-cli approval tasks query --params '{"topic":"1"}' --as user +``` + +## 输出重点字段 + +返回结果中常见字段: + +| 字段 | 说明 | +|------|------| +| `instance_code` | 审批实例 Code | +| `serial_number` | 审批单编号 | +| `definition_code` | 审批定义 Code | +| `definition_name` | 审批名称 | +| `user_id` | 发起审批的用户 ID | +| `department_id` | 发起人所在部门 ID | +| `status` | 审批实例状态,见下方“status 枚举” | +| `reverted` | 单据是否已被撤销 | +| `start_time` | 审批创建时间 | +| `end_time` | 审批完成时间,未完成时通常为 `0` | +| `form` | 表单数据,JSON 字符串 | +| `current_nodes` | 当前审批节点列表 | +| `tasks` | 审批任务列表 | +| `operation_records` | 审批动态,例如通过、拒绝、转交、加签、回退、撤回、抄送 | +| `comments` | 评论列表 | + +## status 枚举 + +| 值 | 含义 | +|----|------| +| `PENDING` | 审批中 | +| `APPROVED` | 已通过 | +| `REJECTED` | 已拒绝 | +| `CANCELED` | 已撤回 | +| `DELETED` | 已删除 | + +## current_nodes 重点字段 + +`current_nodes` 常用于判断审批流当前卡在哪一层: + +| 字段 | 说明 | +|------|------------------------------------------| +| `current_nodes[].node_id` | 当前审批节点 ID | +| `current_nodes[].node_name` | 当前审批节点名称 | +| `current_nodes[].type` | 审批方式:`AND` 会签、`OR` 或签、`SEQUENTIAL` 依次审批等 | +| `current_nodes[].approvers[].task_id` | 当前审批人关联任务 ID | +| `current_nodes[].approvers[].user_id` | 当前审批人用户 ID | + +## tasks 重点字段 + +`tasks` 常用于把实例和具体审批任务关联起来: + +| 字段 | 说明 | +|------|------| +| `tasks[].id` | 审批任务 ID | +| `tasks[].node_id` | 任务所属节点 ID | +| `tasks[].node_name` | 任务所属节点名称 | +| `tasks[].user_id` | 审批人用户 ID | +| `tasks[].status` | 任务状态:`PENDING`、`APPROVED`、`REJECTED`、`TRANSFERRED`、`DONE` | +| `tasks[].start_time` | 任务开始时间 | +| `tasks[].end_time` | 任务完成时间 | + +## operation_records 重点字段 + +`operation_records` 常用于审计审批过程: + +| 字段 | 说明 | +|------|------| +| `operation_records[].type` | 事件类型,如 `PASS`、`REJECT`、`TRANSFER`、`ROLLBACK`、`CANCEL`、`CC` | +| `operation_records[].create_time` | 事件发生时间 | +| `operation_records[].user_id` | 触发该事件的用户 ID | +| `operation_records[].task_id` | 关联任务 ID | +| `operation_records[].node_id` | 关联节点 ID | +| `operation_records[].comment` | 理由 / 备注 | +| `operation_records[].cc_user_ids` | 被抄送人列表(抄送事件时) | + +## 使用建议 + +- **这是最适合做“详情确认”的只读命令**:当你已经拿到 `instance_code`,需要确认表单、当前节点、任务状态、审批动态时,优先使用它。 +- **在执行写操作前先看详情**:例如做 `tasks rollback` 前确认可退回节点,做 `instances cancel` 前确认实例状态,做 `tasks remind` 前确认当前任务是否仍待处理。 +- **`form` 是 JSON 字符串**:调用方通常还需要再解析一层,才能拿到表单字段值。 +- **`current_nodes` 和 `tasks` 可以联动看**:前者看“当前卡在哪个节点”,后者看“每个任务目前由谁处理、状态如何”。 +- **`operation_records` 适合做时间线回溯**:例如排查谁转交过、谁加签过、什么时候撤回或抄送过。 +- **优先显式传 `locale` 和 `user_id_type`**:这样 agent 更容易理解返回文本和 ID 语义,减少歧义。 + +## 输出与后续操作 + +读取详情后,常见下一步: + +```bash +# 同意审批任务 +lark-cli approval tasks approve --data '{"instance_code":"","task_id":""}' --as user --yes + +# 撤回审批实例 +lark-cli approval instances cancel --data '{"instance_code":""}' --as user --yes + +# 催办审批任务 +lark-cli approval tasks remind --data '{"instance_code":"","task_ids":[""]}' --as user --yes +``` diff --git a/skills/lark-approval/references/lark-approval-instances-initiated.md b/skills/lark-approval/references/lark-approval-instances-initiated.md new file mode 100644 index 00000000..a19eb047 --- /dev/null +++ b/skills/lark-approval/references/lark-approval-instances-initiated.md @@ -0,0 +1,122 @@ + +# approval instances initiated + +查询当前用户已发起的审批实例列表(用户级只读操作)。适合在需要查看“我发起了哪些审批”、筛选某类审批定义、获取 `instance_code` 供后续 `instances get` / `instances cancel` / `instances cc` 等命令使用时调用。 + +需要的 scopes: ["approval:instance:read"] + +## 命令 + +```bash +# 查询我发起的审批列表 +lark-cli approval instances initiated --params '{"page_size":20}' --as user + +# 只看某个审批定义下我发起的实例 +lark-cli approval instances initiated --params '{"definition_code":"","page_size":20}' --as user + +# 使用 page_token 翻页 +lark-cli approval instances initiated --params '{"page_size":20,"page_token":"example_page_token"}' --as user + +# 表格格式输出,便于快速浏览 +lark-cli approval instances initiated --params '{"page_size":20}' --format table --as user + +# 预览 API 调用,不执行 +lark-cli approval instances initiated --params '{"page_size":20}' --as user --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入;不传时使用默认分页与筛选 | +| `definition_code` | 否 | 审批定义 Code,用于只查看某个审批定义下我发起的实例 | +| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` | +| `page_size` | 否 | 分页大小 | +| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` | +| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id` | +| `--as user` | 否 | 建议显式指定用户身份;已发起审批列表查询通常应使用用户身份 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 输出重点字段 + +返回结果中常见字段: + +| 字段 | 说明 | +|------|------| +| `count` | 列表计数,只在第一页返回;大于等于 100 个实例时返回 `99` | +| `has_more` | 是否还有更多数据 | +| `page_token` | 下一页翻页 Token | +| `instances[].instance_code` | 审批实例 Code;后续查询详情或执行撤回 / 抄送时通常需要 | +| `instances[].definition_code` | 审批定义 Code | +| `instances[].definition_name` | 审批定义名称 | +| `instances[].definition_group_id` | 审批定义分组 ID | +| `instances[].definition_group_name` | 审批定义分组名称 | +| `instances[].initiator` | 发起人 ID | +| `instances[].initiator_name` | 发起人姓名 | +| `instances[].instance_status` | 审批实例状态,见下方“instance_status 枚举” | +| `instances[].instance_external_id` | 第三方审批实例 ID(仅第三方审批实例存在) | +| `instances[].link` | 三方审批跳转链接 | +| `instances[].summaries` | 摘要字段列表 | + +## instance_status 枚举 + +| 值 | 含义 | +|----|------| +| `0` | 无流程状态,不展示对应标签 | +| `1` | 流程实例流转中 | +| `2` | 已通过 | +| `3` | 已拒绝 | +| `4` | 已撤销 | +| `5` | 已终止 | + +## 常见使用场景 + +### 1) 找到我要操作的审批实例 + +```bash +lark-cli approval instances initiated --params '{"page_size":20}' --format table --as user +``` + +拿到 `instances[].instance_code` 后,可继续: + +```bash +# 查看审批实例详情 +lark-cli approval instances get --params '{"instance_code":""}' --as user + +# 撤回审批实例 +lark-cli approval instances cancel --data '{"instance_code":""}' --as user --yes +``` + +### 2) 只看某类审批 + +```bash +lark-cli approval instances initiated \ + --params '{"definition_code":"","page_size":20}' \ + --as user +``` + + +## 使用建议 + +- **这是定位“我发起的审批实例”的首选命令**:如果你的目标是撤回、抄送、查看某个已发起审批,优先从这里拿 `instance_code`。 +- **优先用 `definition_code` 缩小范围**:当你已知审批定义时,先筛掉无关实例,可显著提升可读性。 +- **结果很多时优先 `--format table`**:适合人工快速浏览。 +- **`count` 只在第一页返回**:做分页处理时不要假设后续页还会带总数。 +- **`instance_status` 可直接判断下一步**:例如状态为 `1` 时通常可继续查看详情或考虑撤回,状态为 `4` 表示已经撤销,无需重复撤回。 +- **摘要字段 `summaries` 很适合做列表预览**:当审批标题不够明确时,可结合摘要值帮助识别目标实例。 + +## 输出与后续操作 + +拿到列表后,常见下一步: + +```bash +# 查看单个审批实例详情 +lark-cli approval instances get --params '{"instance_code":""}' --as user + +# 撤回审批实例 +lark-cli approval instances cancel --data '{"instance_code":""}' --as user --yes + +# 给审批实例追加抄送人 +lark-cli approval instances cc --data '{"instance_code":"","cc_user_ids":[""]}' --params '{"user_id_type":"open_id"}' --as user --yes +``` diff --git a/skills/lark-approval/references/lark-approval-tasks-add-sign.md b/skills/lark-approval/references/lark-approval-tasks-add-sign.md new file mode 100644 index 00000000..c0b0759b --- /dev/null +++ b/skills/lark-approval/references/lark-approval-tasks-add-sign.md @@ -0,0 +1,120 @@ + +# approval tasks add_sign + +给一个审批任务加签(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,确认目标任务后,再提供被加签人的用户 ID、加签方式等参数执行加签。 + +> [!CAUTION] +> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要对该审批任务加签且目标任务、加签对象、加签方式都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。 + +需要的 scopes: ["approval:task:write"] + +## 命令 + +```bash +# 先预览请求,不实际执行 +lark-cli approval tasks add_sign \ + --data '{"instance_code":"","task_id":"","add_sign_type":1,"add_sign_user_ids":["ou_xxx"],"approval_method":1,"comment":"前加签给财务复核"}' \ + --params '{"user_id_type":"open_id"}' \ + --as user \ + --dry-run + +# 前加签(需要 approval_method) +lark-cli approval tasks add_sign \ + --data '{"instance_code":"","task_id":"","add_sign_type":1,"add_sign_user_ids":["ou_xxx"],"approval_method":1,"comment":"请先补充审核"}' \ + --params '{"user_id_type":"open_id"}' \ + --as user \ + --yes + +# 后加签(需要 approval_method) +lark-cli approval tasks add_sign \ + --data '{"instance_code":"","task_id":"","add_sign_type":2,"add_sign_user_ids":["ou_xxx","ou_yyy"],"approval_method":2,"comment":"当前审批完成后请两位继续审核"}' \ + --params '{"user_id_type":"open_id"}' \ + --as user \ + --yes + +# 并加签(常见场景可不传 approval_method) +lark-cli approval tasks add_sign \ + --data '{"instance_code":"","task_id":"","add_sign_type":3,"add_sign_user_ids":["123456789"],"comment":"并加签给项目 owner"}' \ + --params '{"user_id_type":"user_id"}' \ + --as user \ + --yes + +# 通过文件传入请求体,适合较长 comment 或较多加签人 +lark-cli approval tasks add_sign \ + --data @./add-sign-body.json \ + --params '{"user_id_type":"open_id"}' \ + --as user \ + --yes +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 | +| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 | +| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 | +| `add_sign_type` | 是 | 加签类型:`1` 前加签、`2` 后加签、`3` 并加签 | +| `add_sign_user_ids` | 是 | 被加签人 ID 数组;需要和 `user_id_type` 保持一致 | +| `approval_method` | 否 | 审批方式:`1` 或签、`2` 会签、`3` 依次审批;**仅在前加签、后加签时需要填写** | +| `comment` | 否 | 审批意见或加签说明,例如 `前加签给财务复核`、`请项目 owner 一并确认` | +| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON;用于声明 `add_sign_user_ids` 内用户 ID 的类型 | +| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;未显式指定时要特别确认被加签人的 ID 类型 | +| `--as user` | 否 | 建议显式指定用户身份;审批加签通常必须以用户身份执行 | +| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 枚举说明 + +### add_sign_type + +| 值 | 含义 | +|----|------| +| `1` | 前加签 | +| `2` | 后加签 | +| `3` | 并加签 | + +### approval_method + +| 值 | 含义 | 适用场景 | +|----|------|----------| +| `1` | 或签 | 前加签 / 后加签 | +| `2` | 会签 | 前加签 / 后加签 | +| `3` | 依次审批 | 前加签 / 后加签 | + +## 典型前置步骤 + +先查到待办任务: + +```bash +lark-cli approval tasks query --params '{"topic":"1"}' --as user +``` + +常用到的字段: + +| 字段 | 说明 | +|------|------| +| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / transfer / rollback / add_sign 等操作时通常都需要 | +| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 | +| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;加签前建议先检查 | + +如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID,再执行加签。 + +如需先确认表单、节点、审批流进度,可继续查看实例详情: + +```bash +lark-cli approval instances get --params '{"instance_code":""}' --as user +``` + +## 使用建议 + +- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行加签操作。 +- **`add_sign_user_ids` 与 `user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。 +- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。 +- **`add_sign_type` 要和业务意图一致**:前加签是在当前审批前插入审批人,后加签是在当前审批后追加审批人,并加签则是增加并行审批人。 +- **前加签 / 后加签要补 `approval_method`**:不要遗漏,否则请求可能无法准确表达审批方式。 +- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 add_sign 的输入来源。 +- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行处理动作,加签前应谨慎验证。 +- **`comment` 建议写明加签原因**:例如 `增加财务复核`、`增加项目 owner 并行确认`,方便相关人员理解上下文。 +- **先 `--dry-run` 再执行**:尤其在多人加签、跨部门加签或加签对象来源不明确时,先预览更安全。 diff --git a/skills/lark-approval/references/lark-approval-tasks-approve.md b/skills/lark-approval/references/lark-approval-tasks-approve.md new file mode 100644 index 00000000..5c0eb33b --- /dev/null +++ b/skills/lark-approval/references/lark-approval-tasks-approve.md @@ -0,0 +1,81 @@ + +# approval tasks approve + +同意一个审批任务(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,必要时再用 `instances get` 查看详情,然后再执行同意。 + +> [!CAUTION] +> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确同意审批且目标任务无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。 + +需要的 scopes: ["approval:task:write"] + +## 命令 + +```bash +# 先预览请求,不实际执行 +lark-cli approval tasks approve \ + --data '{"instance_code":"","task_id":"","comment":"同意"}' \ + --as user \ + --dry-run + +# 同意审批任务,并附带审批意见 +lark-cli approval tasks approve \ + --data '{"instance_code":"","task_id":"","comment":"同意"}' \ + --as user \ + --yes + +# 需要回填表单时,传入 form(按当前命令定义,form 为字符串化 JSON) +lark-cli approval tasks approve \ + --data '{"instance_code":"","task_id":"","comment":"同意并补充信息","form":"[{\"id\":\"user_name\",\"type\":\"input\",\"value\":\"Alice\"}]"}' \ + --as user \ + --yes + +# 通过文件传入请求体,适合较长 comment / form +lark-cli approval tasks approve \ + --data @./approve-body.json \ + --as user \ + --yes +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 | +| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 | +| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 | +| `comment` | 否 | 审批意见,例如 `同意`、`已确认` | +| `form` | 否 | 表单数据;按当前命令定义,字段类型为 `string`,通常传字符串化 JSON;仅在审批动作需要同时回填表单时使用 | +| `--as user` | 否 | 建议显式指定用户身份;审批同意通常必须以用户身份执行 | +| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 典型前置步骤 + +先查到待办任务: + +```bash +lark-cli approval tasks query --params '{"topic":"1"}' --as user +``` + +常用到的两个字段: + +| 字段 | 说明 | +|------|------| +| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / rollback 等操作时通常都需要 | +| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 | + +如需先确认表单、节点、审批流进度,可继续查看实例详情: + +```bash +lark-cli approval instances get --params '{"instance_code":""}' --as user +``` + +## 使用建议 + +- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行同意操作。 +- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 approve 的输入来源。 +- **先检查是否支持 API 操作**:如果上一步 `tasks query` 返回的 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 同意/拒绝。 +- **`comment` 建议简洁明确**:例如 `同意`、`同意,信息已核对`。没有审批意见要求时可省略。 +- **`form` 只在确有需要时传**:大多数简单同意场景只传 `instance_code`、`task_id`、可选 `comment` 即可。 +- **先 `--dry-run` 再执行**:尤其在批量处理、表单回填或任务来源不明确时,先预览更安全。 diff --git a/skills/lark-approval/references/lark-approval-tasks-query.md b/skills/lark-approval/references/lark-approval-tasks-query.md new file mode 100644 index 00000000..d5fb59a3 --- /dev/null +++ b/skills/lark-approval/references/lark-approval-tasks-query.md @@ -0,0 +1,76 @@ + +# approval tasks query + +查询当前用户的审批任务列表,可用于查看待办、已办、知会等分组。只读操作,不会修改审批状态。 + +需要的 scopes: ["approval:task:read"] + +## 命令 + +```bash +# 查询待办审批 +lark-cli approval tasks query --params '{"topic":"1"}' --as user + +# 查询已办审批 +lark-cli approval tasks query --params '{"topic":"2"}' --as user + +# 使用 page_token 翻页 +lark-cli approval tasks query --params '{"topic":"1","page_token":"example_page_token"}' --as user + +# 表格格式输出,便于快速浏览 +lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--params '{"topic":"..."}'` | 是 | 查询参数,使用 JSON 传入 | +| `topic` | 是 | 任务分组主题,见下方“topic 枚举” | +| `definition_code` | 否 | 审批定义 Code,用于仅查询某个审批定义下的任务 | +| `locale` | 否 | 返回语言:`zh-CN`、`en-US`、`ja-JP` | +| `page_size` | 否 | 分页大小 | +| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` | +| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id` | +| `--as user` | 否 | 建议显式指定用户身份;审批任务查询通常应使用用户身份 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## topic 枚举 + +| 值 | 含义 | +|----|------| +| `1` | 待办审批 | +| `2` | 已办审批 | +| `17` | 未读知会 | +| `18` | 已读知会 | + +## 输出重点字段 + +返回结果中常见字段: + +| 字段 | 说明 | +|------|------| +| `count` | 列表计数,只在第一页返回;当任务数大于等于 100 时返回 `99` | +| `has_more` | 是否还有更多数据 | +| `page_token` | 下一页翻页 Token | +| `tasks[].task_id` | 任务 ID,全局唯一 | +| `tasks[].instance_code` | 审批实例 Code;后续执行 approve / reject / rollback 等操作时通常需要与 `task_id` 成对使用 | +| `tasks[].title` | 任务标题 | +| `tasks[].status` | 任务状态:`1` 待办、`2` 已办、`17` 未读、`18` 已读、`33` 处理中、`34` 撤回 | +| `tasks[].topic` | 任务所属分组主题 | +| `tasks[].instance_status` | 审批实例状态:`0` 无状态、`1` 流转中、`2` 已通过、`3` 已拒绝、`4` 已撤销、`5` 已终止 | +| `tasks[].definition_code` | 审批定义 Code | +| `tasks[].definition_name` | 审批定义名称 | +| `tasks[].initiator` | 发起人 ID | +| `tasks[].initiator_name` | 发起人姓名 | +| `tasks[].summaries` | 表单摘要字段列表 | +| `tasks[].support_api_operate` | 是否支持通过 API 同意或拒绝该任务 | +| `tasks[].user_id` | 任务所属用户 ID | + +## 使用建议 + +- 常见处理链:先用 `tasks query` 拿到 `task_id` 和 `instance_code`,若用户需要查看详情、当前节点、表单内容、流程进度等内容,则调用 `instances get` 查看详情,最后执行 `tasks approve` / `tasks reject` / `tasks transfer` / `tasks add_sign` / `tasks rollback`。 +- 如果你只想看“已发起的审批实例”,使用 `instances initiated`;`tasks query` 更适合围绕“任务分组”来拉取列表。 +- 需要继续翻页时,直接把上一次返回的 `page_token` 放回 `--params`。 +- 当结果量较大时,优先使用 `--format table` 提升可读性。 diff --git a/skills/lark-approval/references/lark-approval-tasks-reject.md b/skills/lark-approval/references/lark-approval-tasks-reject.md new file mode 100644 index 00000000..b6d8a46e --- /dev/null +++ b/skills/lark-approval/references/lark-approval-tasks-reject.md @@ -0,0 +1,73 @@ + +# approval tasks reject + +拒绝一个审批任务(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,必要时再用 `instances get` 查看详情,然后再执行拒绝。 + +> [!CAUTION] +> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要拒绝该审批且目标任务无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。 + +需要的 scopes: ["approval:task:write"] + +## 命令 + +```bash +# 先预览请求,不实际执行 +lark-cli approval tasks reject \ + --data '{"instance_code":"","task_id":"","comment":"拒绝"}' \ + --as user \ + --dry-run + +# 拒绝审批任务,并附带审批意见 +lark-cli approval tasks reject \ + --data '{"instance_code":"","task_id":"","comment":"拒绝,信息不完整"}' \ + --as user \ + --yes + +# 通过文件传入请求体,适合较长 comment +lark-cli approval tasks reject \ + --data @./reject-body.json \ + --as user \ + --yes +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 | +| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 | +| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 | +| `comment` | 否 | 审批意见,例如 `拒绝`、`拒绝,信息不完整` | +| `--as user` | 否 | 建议显式指定用户身份;审批拒绝通常必须以用户身份执行 | +| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 典型前置步骤 + +先查到待办任务: + +```bash +lark-cli approval tasks query --params '{"topic":"1"}' --as user +``` + +常用到的两个字段: + +| 字段 | 说明 | +|------|------| +| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / rollback 等操作时通常都需要 | +| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 | + +如需先确认表单、节点、审批流进度,可继续查看实例详情: + +```bash +lark-cli approval instances get --params '{"instance_code":""}' --as user +``` + +## 使用建议 + +- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行拒绝操作。 +- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 reject 的输入来源。 +- **先检查是否支持 API 操作**:如果上一步 `tasks query` 返回的 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 同意/拒绝。 +- **`comment` 建议写清拒绝原因**:例如 `拒绝,缺少合同附件`、`拒绝,预算字段填写不完整`。这有助于发起人理解原因并补充材料。 +- **先 `--dry-run` 再执行**:尤其在批量处理或任务来源不明确时,先预览更安全。 diff --git a/skills/lark-approval/references/lark-approval-tasks-remind.md b/skills/lark-approval/references/lark-approval-tasks-remind.md new file mode 100644 index 00000000..c46708c8 --- /dev/null +++ b/skills/lark-approval/references/lark-approval-tasks-remind.md @@ -0,0 +1,82 @@ + +# approval tasks remind + +对审批实例中的指定任务发起催办(用户级写操作)。通常先通过 `tasks query` 找到待办任务,拿到 `instance_code` 和要催办的 `task_ids`,必要时再用 `instances get` 查看详情,然后执行催办。 + +> [!CAUTION] +> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要催办该审批且目标实例、目标任务都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。 + +需要的 scopes: ["approval:instance:write"] + +## 命令 + +```bash +# 先预览请求,不实际执行 +lark-cli approval tasks remind \ + --data '{"instance_code":"","task_ids":[""],"comment":"请尽快处理"}' \ + --as user \ + --dry-run + +# 催办单个审批任务 +lark-cli approval tasks remind \ + --data '{"instance_code":"","task_ids":[""],"comment":"请尽快审批该单据"}' \ + --as user \ + --yes + +# 同一实例下催办多个任务 +lark-cli approval tasks remind \ + --data '{"instance_code":"","task_ids":["",""],"comment":"请相关审批人尽快处理"}' \ + --as user \ + --yes + +# 通过文件传入请求体,适合较长 comment 或多个 task_ids +lark-cli approval tasks remind \ + --data @./remind-body.json \ + --as user \ + --yes +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 | +| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances get` 获取 | +| `task_ids` | 是 | 被催办的任务 ID 数组;应与 `instance_code` 属于同一审批实例 | +| `comment` | 否 | 催办说明,例如 `请尽快处理`、`该单据较急,请优先审批` | +| `--as user` | 否 | 建议显式指定用户身份;审批催办通常必须以用户身份执行 | +| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 典型前置步骤 + +先查到待办任务: + +```bash +lark-cli approval tasks query --params '{"topic":"1"}' --as user +``` + +常用到的字段: + +| 字段 | 说明 | +|------|------| +| `tasks[].instance_code` | 审批实例 Code;催办时必须提供 | +| `tasks[].task_id` | 审批任务 ID;放入 `task_ids` 数组中 | +| `tasks[].title` | 任务标题,可用于确认催办对象是否正确 | +| `tasks[].status` | 任务状态;一般优先催办仍处于待处理状态的任务 | + +如需进一步确认当前审批流、节点和人员信息,可继续查看实例详情: + +```bash +lark-cli approval instances get --params '{"instance_code":""}' --as user +``` + +## 使用建议 + +- **`instance_code` 和 `task_ids` 要对应同一个审批实例**:不要把不同实例下的任务 ID 混在同一次催办请求中。 +- **`task_ids` 是数组**:即使只催办一个任务,也要按数组形式传入。 +- **优先从 `tasks query` 的待办列表拿参数**:尤其是 `topic=1` 的待办审批,最适合作为 remind 的输入来源。 +- **催办前先确认任务仍需处理**:已经审批完成、已撤回或已终止的任务一般不适合继续催办。 +- **`comment` 建议简洁且明确**:例如 `该单据较急,请优先审批`、`请今天内处理`。避免过长或模糊描述。 +- **先 `--dry-run` 再执行**:尤其在一次催办多个任务、任务来源不明确或需让用户复核催办对象时,先预览更安全。 diff --git a/skills/lark-approval/references/lark-approval-tasks-rollback.md b/skills/lark-approval/references/lark-approval-tasks-rollback.md new file mode 100644 index 00000000..b9f35404 --- /dev/null +++ b/skills/lark-approval/references/lark-approval-tasks-rollback.md @@ -0,0 +1,83 @@ + +# approval tasks rollback + +将一个审批任务退回到指定节点(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,再结合实例详情确认可退回的目标节点 `node_ids`,最后执行退回。 + +> [!CAUTION] +> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要退回该审批且目标任务、退回节点都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。 + +需要的 scopes: ["approval:task:write"] + +## 命令 + +```bash +# 先预览请求,不实际执行 +lark-cli approval tasks rollback \ + --data '{"instance_code":"","task_id":"","node_ids":[""],"comment":"退回补充材料"}' \ + --as user \ + --dry-run + +# 退回到单个节点 +lark-cli approval tasks rollback \ + --data '{"instance_code":"","task_id":"","node_ids":[""],"comment":"请补充附件后重新提交"}' \ + --as user \ + --yes + +# 传多个候选节点 ID(以实际审批定义支持情况为准) +lark-cli approval tasks rollback \ + --data '{"instance_code":"","task_id":"","node_ids":["",""],"comment":"退回上一处理节点"}' \ + --as user \ + --yes + +# 通过文件传入请求体,适合较长 comment 或较多 node_ids +lark-cli approval tasks rollback \ + --data @./rollback-body.json \ + --as user \ + --yes +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 | +| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 | +| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 | +| `node_ids` | 是 | 退回目标节点 ID 数组;执行前应先确认这些节点确实可作为退回目标 | +| `comment` | 否 | 审批意见或退回说明,例如 `请补充附件后重新提交`、`预算说明不完整,请补充` | +| `--as user` | 否 | 建议显式指定用户身份;审批退回通常必须以用户身份执行 | +| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 典型前置步骤 + +先查到待办任务: + +```bash +lark-cli approval tasks query --params '{"topic":"1"}' --as user +``` + +常用到的字段: + +| 字段 | 说明 | +|------|------| +| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / transfer / rollback 等操作时通常都需要 | +| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 | +| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;退回前建议先检查 | + +如需确认流程节点、当前进度和可退回位置,可先查看实例详情: + +```bash +lark-cli approval instances get --params '{"instance_code":""}' --as user +``` + +## 使用建议 + +- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行退回操作。 +- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组。 +- **先确认节点是否可退回**:不同审批定义支持的退回目标可能不同;在不确定时,先通过 `instances get` 或业务侧流程信息核实。 +- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 rollback 的输入来源。 +- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行处理动作,退回前应谨慎验证。 +- **`comment` 建议写清退回原因**:例如 `附件缺失,请补齐后重新提交`、`费用说明不完整,请补充明细`,方便发起人或上一步处理人理解原因。 +- **先 `--dry-run` 再执行**:尤其在节点来源不明确、审批链路复杂或批量处理时,先预览更安全。 diff --git a/skills/lark-approval/references/lark-approval-tasks-transfer.md b/skills/lark-approval/references/lark-approval-tasks-transfer.md new file mode 100644 index 00000000..edc09fdb --- /dev/null +++ b/skills/lark-approval/references/lark-approval-tasks-transfer.md @@ -0,0 +1,91 @@ + +# approval tasks transfer + +转交一个审批任务给其他用户处理(用户级写操作)。通常先通过 `tasks query` 拿到 `task_id` 和 `instance_code`,确认目标任务后,再提供被转交人的用户 ID 执行转交。 + +> [!CAUTION] +> 这是 **high-risk-write** 写操作。建议先用 `--dry-run` 预览;真正执行时,如果用户已明确要转交该审批且目标任务、转交对象都无误,再带 `--yes` 运行。不要在未获用户明确同意时静默追加 `--yes`。 + +需要的 scopes: ["approval:task:write"] + +## 命令 + +```bash +# 先预览请求,不实际执行 +lark-cli approval tasks transfer \ + --data '{"instance_code":"","task_id":"","transfer_user_id":"ou_xxx","comment":"请你继续处理"}' \ + --params '{"user_id_type":"open_id"}' \ + --as user \ + --dry-run + +# 按 open_id 转交审批任务 +lark-cli approval tasks transfer \ + --data '{"instance_code":"","task_id":"","transfer_user_id":"ou_xxx","comment":"转交给你处理"}' \ + --params '{"user_id_type":"open_id"}' \ + --as user \ + --yes + +# 按 user_id 转交审批任务 +lark-cli approval tasks transfer \ + --data '{"instance_code":"","task_id":"","transfer_user_id":"123456789","comment":"请补充审核"}' \ + --params '{"user_id_type":"user_id"}' \ + --as user \ + --yes + +# 通过文件传入请求体,适合较长 comment +lark-cli approval tasks transfer \ + --data @./transfer-body.json \ + --params '{"user_id_type":"open_id"}' \ + --as user \ + --yes +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--data '{...}'` | 是 | 请求体 JSON,使用 JSON 传入 | +| `instance_code` | 是 | 审批实例 Code;通常先通过 `tasks query` 或 `instances initiated` / `instances get` 获取 | +| `task_id` | 是 | 审批任务 ID;通常先通过 `tasks query` 获取 | +| `transfer_user_id` | 是 | 被转交人的用户 ID;需要和 `user_id_type` 保持一致 | +| `comment` | 否 | 审批意见或转交说明,例如 `转交给你处理`、`请继续审核该单据` | +| `--params '{"user_id_type":"..."}'` | 否 | 查询参数 JSON;用于声明 `transfer_user_id` 的 ID 类型 | +| `user_id_type` | 否 | 用户 ID 类型:`user_id`、`union_id`、`open_id`;未显式指定时要特别确认 `transfer_user_id` 的真实类型 | +| `--as user` | 否 | 建议显式指定用户身份;审批转交通常必须以用户身份执行 | +| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 | +| `--format` | 否 | 输出格式:`json`(默认)、`ndjson`、`table`、`csv` | +| `--dry-run` | 否 | 预览 API 调用,不执行 | + +## 典型前置步骤 + +先查到待办任务: + +```bash +lark-cli approval tasks query --params '{"topic":"1"}' --as user +``` + +常用到的字段: + +| 字段 | 说明 | +|------|------| +| `tasks[].instance_code` | 审批实例 Code;执行 approve / reject / transfer / rollback 等操作时通常都需要 | +| `tasks[].task_id` | 审批任务 ID;与 `instance_code` 配对使用 | +| `tasks[].support_api_operate` | 是否支持通过 API 处理该任务;转交前建议先检查 | + +如果你手里只有姓名或邮箱,建议先通过联系人能力解析出正确的用户 ID,再执行转交。 + +如需先确认表单、节点、审批流进度,可继续查看实例详情: + +```bash +lark-cli approval instances get --params '{"instance_code":""}' --as user +``` + +## 使用建议 + +- **`instance_code` 和 `task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行转交操作。 +- **`transfer_user_id` 与 `user_id_type` 必须匹配**:例如传 open_id 就把 `user_id_type` 设为 `open_id`;不要混用。 +- **优先显式传 `user_id_type`**:这样 agent 更容易判断参数含义,也能减少 ID 类型不匹配带来的失败。 +- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 transfer 的输入来源。 +- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate` 为 `false`,说明该任务可能不支持通过 API 执行同意/拒绝等处理动作,转交前也应谨慎验证。 +- **`comment` 建议写明转交原因**:例如 `你更熟悉该项目,请继续处理`、`转交给预算 owner 审核`,方便接收人理解上下文。 +- **先 `--dry-run` 再执行**:尤其在跨部门转交、批量处理或转交对象来源不明确时,先预览更安全。 diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index 86b66092..b8bc7599 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-apps version: 1.0.0 -description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、可见范围时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" +description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda(应用运行时域名形如 *.aiforce.cloud)、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。" metadata: requires: bins: ["lark-cli"] @@ -22,13 +22,27 @@ metadata: | 找已有 app_id、按名字过滤应用 | `+list --keyword ` | [`lark-apps-list.md`](references/lark-apps-list.md) | | 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) | | 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) | -| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | +| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git)。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md),含端到端流程和领域规则 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) | | 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) | -| 看表、看 schema、跑 SQL、初始化 dev/online 多环境 DB | `+db-table-list`, `+db-table-get`, `+db-execute`, `+db-env-create` | 对应 `lark-apps-db-*.md` | +| 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) | +| 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-list`, `+analytics-list` | [`lark-apps-observability.md`](references/lark-apps-observability.md) | +| 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / dev→online 发布 / 时间点恢复 / 查 DB 用量 | `+db-table-list`、`+db-table-get`、`+db-env-create`、`+db-data-export`/`+db-data-import`、`+db-changelog-list`、`+db-audit-status`/`+db-audit-enable`/`+db-audit-disable`/`+db-audit-list`、`+db-env-diff`/`+db-env-migrate`、`+db-recovery-diff`/`+db-recovery-apply`、`+db-quota-get` | [`lark-apps-db.md`](references/lark-apps-db.md) | +| 逐条执行 SQL(SELECT / DML / DDL) | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md) | +| 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) | | **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) | | 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference | | 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) | +| 管理妙搭应用开放 API Key(创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) | | 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id `(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) | +| 外部能力(AI模型能力和飞书平台能力)集成/插件/Plugin/Capability | `+plugin-install`, `+plugin-list`, `+plugin-uninstall` | [`lark-apps-plugin-install.md`](references/lark-apps-plugin-install.md), [`lark-apps-plugin-uninstall.md`](references/lark-apps-plugin-uninstall.md), [`lark-apps-plugin-list.md`](references/lark-apps-plugin-list.md) | + +## 高频路径 + +- **性能/监控/观测指标**:用户问“接口请求量、错误量、错误率、接口慢、延迟、CPU、内存、最近一小时/七天趋势”时,不要去当前工作区搜索监控文件,也不要询问“监控数据在哪”。先按「app_id 获取」解析应用:`lark-cli apps +list --keyword "<应用名>" --as user`;拿到 `app_id` 后读 [`lark-apps-observability.md`](references/lark-apps-observability.md),用 `+metric-list`。 +- **请求量 + 错误量 + 延迟**:请求量/错误量用 `lark-cli apps +metric-list --app-id --metric requests --since --as user`(不传 `--series` 会同时返回 total/error);延迟用 `--metric latency`(不传 `--series` 会返回 p50/p99)。如果用户给了具体接口,再加 `--api `;不要臆造 group-by 参数。 +- **PV/UV/访问量/活跃用户**:先解析 `app_id`,再用 `+analytics-list`,不要误用 `+metric-list`。 +- **设置环境变量**:如果用户只给应用名,仍先 `+list --keyword` 解析 app_id;设置 online 环境且用户已经明确说“确认/直接执行”时,调用 `+env-set --environment online ... --yes`,不要再次要求确认。回复和日志摘要里只提 key / env / app,不回显真实 value;需要传复杂值时优先用 `@file` 或 stdin。 +- **删除环境变量**:`+env-delete` 是破坏性操作。除非用户在同一轮已经明确确认删除这个 app/env/key,否则先向用户确认应用、环境、key 和删除后果;确认后再加 `--yes`。不要因为认证失败/重登完成就自动继续删除,必须保留确认门槛。 ## 选择开发路径(进意图路由前先判这步) @@ -54,8 +68,8 @@ metadata: ## 能力边界 -- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)/ 自动化 / 插件。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。 -- 用户要配置权限 / 自动化 / 插件时,引导其使用开发态连接前往云端开发(妙搭 web)处理。 +- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限)/ 自动化。`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限。 +- 用户要配置权限 / 自动化时,引导其使用开发态连接前往云端开发(妙搭 web)处理。 ## app_id 获取 diff --git a/skills/lark-apps/references/lark-apps-db-env-create.md b/skills/lark-apps/references/lark-apps-db-env-create.md deleted file mode 100644 index 6dd933a2..00000000 --- a/skills/lark-apps/references/lark-apps-db-env-create.md +++ /dev/null @@ -1,31 +0,0 @@ -# apps +db-env-create - -把存量单库应用初始化为 `dev` / `online` 多环境数据库。运行时命令事实以 `lark-cli apps +db-env-create --help` 为准。 - -## 何时用 - -仅用于存量单库应用需要拆成 `dev` / `online` 两套数据库的场景。普通查看表、查 schema、执行 SQL 不需要先初始化。注意:通过 `+create --app-type full_stack` 新建的应用通常已自带多环境,无需再初始化(重复初始化会返回「已初始化」错误)。 - -## 命令骨架 - -- 必填:`--app-id`。 -- `--env`:要创建的环境,由调用方传入,目前只支持 `dev`(默认 `dev`)。 -- `--sync-data`:bool 开关,传 `--sync-data` 则把现有 online 数据复制到新环境;不传则不复制(默认)。 -- risk 是 `high-risk-write`;单库拆成 dev/online 后不可逆。 - -## 示例 - -```bash -lark-cli apps +db-env-create --app-id app_xxx --env dev --dry-run -lark-cli apps +db-env-create --app-id app_xxx --env dev --sync-data --yes -``` - -## 输出契约 - -- 成功读取 `data.status`、`data.environments`、`data.data_synced`;pretty 会提示是否初始化、多环境列表、是否同步数据。 -- 未确认时返回 `confirmation_required` / exit 10;按 lark-shared 询问用户后再补 `--yes` 重试。 -- 如果服务端提示已启用多环境(`Multi-env is already initialized`),转述状态即可,不要重复初始化。 - -## Agent 规则 - -不要静默追加 `--yes`。遇到 confirmation_required 时,按 `lark-shared` 的 exit-10 协议向用户确认不可逆风险;用户明确同意后才在原 argv 末尾追加 `--yes` 重试。 diff --git a/skills/lark-apps/references/lark-apps-db-execute.md b/skills/lark-apps/references/lark-apps-db-execute.md index f7d78819..c0baf815 100644 --- a/skills/lark-apps/references/lark-apps-db-execute.md +++ b/skills/lark-apps/references/lark-apps-db-execute.md @@ -11,30 +11,34 @@ - 必填:`--app-id`,以及 `--sql` / `--file` 二选一(互斥)。 - `--sql`:内联 SQL 文本;传 `-` 时从 stdin 读。绝对路径文件经 stdin 传入:`--sql - < `(shell 解析路径,CLI 仅接收内容)。 - `--file`:`.sql` 文件路径,需为工作目录内的相对路径(如 `--file ./migration.sql`);绝对路径、或经 `..`/符号链接越出工作目录的路径会被拒绝。文件不在工作目录内时,改用 `--sql - < <文件路径>` 经 stdin 传入。 -- `--env` 枚举:`dev` / `online`,**默认 `dev`**;需要操作线上环境数据库时,显式指定 `--env online`。 +- `--environment` 枚举:`dev` / `online`,**默认 `dev`**;操作线上库、或**未开启多环境的应用(其数据库在 `online`,没有 dev 分支)**时显式 `--environment online`。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。 - risk 是 `high-risk-write`(SQL 可含 DML/DDL):任何执行都需 `--yes`,否则返回 `confirmation_required` / exit 10。`--dry-run` 预览不需要 `--yes`。 -- CLI 永远传 `transactional=false`;不默认包事务。 +- **不会自动为你包事务,事务边界需自己在 SQL 里控制**:多语句默认逐条独立提交,中间某条失败时前序语句已生效、不会回滚;若需要「要么全部成功、要么全部回滚」的原子性,请在 SQL 内显式写 `BEGIN … COMMIT`(详见下「Agent 规则」)。 ## 示例 ```bash -lark-cli apps +db-execute --app-id app_xxx --env dev --sql "select * from orders limit 5" --yes -lark-cli apps +db-execute --app-id app_xxx --env dev --file ./migration.sql --dry-run +lark-cli apps +db-execute --app-id app_xxx --environment dev --sql "select * from orders limit 5" --yes +lark-cli apps +db-execute --app-id app_xxx --environment dev --file ./migration.sql --dry-run # 绝对路径文件 / cwd 不固定:经 stdin 传入 -lark-cli apps +db-execute --app-id app_xxx --env dev --sql - --yes < /Users/.../migrations/0001_init.sql +lark-cli apps +db-execute --app-id app_xxx --environment dev --sql - --yes < /Users/.../migrations/0001_init.sql ``` ## 输出契约 -- 成功默认 JSON 读取 `data.results[]`;每个元素对应一条 SQL,常见字段有 `sql_type`、`data`、`record_count`、`affected_rows`。 +- 成功默认 JSON 的 `data` 按 SQL 类型自适应(不透传后端原始串): + - 单 SELECT → `data` 是行数组 `[{...}]`(空 → `[]`),直接 `-q '.data[].col'` 取字段。 + - 单 DML → `data = {command, rows_affected}`(如 `{"command":"INSERT","rows_affected":1}`)。 + - 单 DDL → `data = {command}`(如 `{"command":"CREATE_TABLE"}`)。 + - 多语句 → `data` 是元素数组:SELECT 为 `{command:"SELECT", rows:[...]}`,DML 为 `{command, rows_affected}`,DDL 为 `{command}`。 - pretty 会按 SELECT/DML/DDL 自适应渲染;多语句会逐条显示 Statement 摘要。 -- 失败可能仍有前序语句已执行;此时 stdout 输出 `ok:false` 的 envelope(exit 非 0),从 `data` 读 `results[]`(全部逐条结果,失败语句 `sql_type` 为 `ERROR`)、`statement_index`、`error_code`、`error_message`、`rolled_back` 和 `note`,决定从哪条继续。 +- 失败返回 typed `error`(`type:"api"`、`subtype:"server_error"`、`code`、`message`、`hint`):失败位置在 `message` 的「(at statement N of M)」;前序是否落地 / 是否整批回滚写在 `hint`——事务内失败「Transaction rolled back; no changes persisted.」;非事务多语句前序已落地「Earlier statements were committed and not rolled back; fix statement N and re-run the remaining statements.」;首句即失败(无前序落地)「No statements were applied; fix the SQL and re-run.」。据此决定整段重跑还是只跑剩余语句。 ## Agent 规则 - 该命令为 high-risk-write,执行一律需 `--yes`;无 `--yes` 会返回 `confirmation_required` / exit 10。 - **只读查询、以及不删除/不丢失既有数据且可撤回的语句**:已授权时可直接带 `--yes` 执行。 - **会删除或丢失既有数据、或难以撤回的语句**:先 `--dry-run` 预览(无需 `--yes`),向用户确认后再带 `--yes` 执行;不要在用户不知情时自动补 `--yes`。 -- 多语句失败时,失败前的语句可能已经 auto-commit。不要整批重跑;按错误 detail/hint 修失败语句,并从剩余语句继续。 +- 多语句失败时,失败前的语句可能已经 commit 落地。不要整批重跑;按错误 message/hint 修失败语句,并从剩余语句继续。 - 如果需要原子性,让用户在 SQL 内显式写 `BEGIN` / `COMMIT`,不要假设 CLI 会包事务。 - 不要把数据库连接串从 env 中取出来裸连。 diff --git a/skills/lark-apps/references/lark-apps-db-table-get.md b/skills/lark-apps/references/lark-apps-db-table-get.md deleted file mode 100644 index 301aea68..00000000 --- a/skills/lark-apps/references/lark-apps-db-table-get.md +++ /dev/null @@ -1,29 +0,0 @@ -# apps +db-table-get - -查看妙搭应用数据库某张表的结构。运行时命令事实以 `lark-cli apps +db-table-get --help` 为准。 - -## 何时用 - -用于查看已知表的字段、索引、约束,或给 SQL/迁移生成提供依据。只想知道有哪些表时先 `+db-table-list`。 - -## 命令骨架 - -- 必填:`--app-id`、`--table`。 -- `--env` 枚举:`dev` / `online`,默认 `online`。 -- `--format pretty` 会向服务端请求 DDL,并直接输出 DDL 文本;默认 JSON 返回结构化 columns/indexes/constraints/stats。 - -## 示例 - -```bash -lark-cli apps +db-table-get --app-id app_xxx --table orders -lark-cli apps +db-table-get --app-id app_xxx --table orders --env dev --format pretty -``` - -## 输出契约 - -- 默认 JSON 读取 `data.name`、`columns`、`indexes`、`constraints`、`estimated_row_count`、`size_bytes`。 -- `--format pretty` stdout 是服务端返回的 DDL 文本,不是 JSON envelope;需要建表语句时可原样给用户。 - -## Agent 规则 - -需要给用户看建表语句或迁移参照时用 `--format pretty`;需要程序化分析字段/索引/约束时保留默认 JSON。 diff --git a/skills/lark-apps/references/lark-apps-db-table-list.md b/skills/lark-apps/references/lark-apps-db-table-list.md deleted file mode 100644 index 9a08a093..00000000 --- a/skills/lark-apps/references/lark-apps-db-table-list.md +++ /dev/null @@ -1,31 +0,0 @@ -# apps +db-table-list - -列出妙搭应用某个数据库环境的数据表。运行时命令事实以 `lark-cli apps +db-table-list --help` 为准。 - -## 何时用 - -用于先摸清应用数据库里有哪些表,或在用户只给业务对象名时定位可能的表名。已知表名且要字段/索引时直接用 `+db-table-get`。 - -## 命令骨架 - -- 必填:`--app-id`。 -- `--env` 枚举:`dev` / `online`,默认 `online`。 -- 分页:`--page-size` 默认 20,`--page-token` 使用上一页 cursor。 -- pretty 输出列包含 `name`、`description`、`estimated_row_count`、`size`、`columns`(列数)。 - -## 示例 - -```bash -lark-cli apps +db-table-list --app-id app_xxx -lark-cli apps +db-table-list --app-id app_xxx --env dev --page-size 50 -``` - -## 输出契约 - -- 成功读取 `data.items[]`;每项字段是 `name`、`description`、`estimated_row_count`、`size_bytes`、`column_count`(列数)。CLI 默认不透出每表完整 `columns[]`(与 `+db-table-get` 重复且放大 token),只给 `column_count`;要完整列定义/索引/约束用 `+db-table-get`。 -- pretty 输出是 5 列扫描表:`name`、`description`、`estimated_row_count`、`size`、`columns`(即列数)。 -- 若响应带 `has_more=true`,用返回的 `page_token` / `next_page_token` 翻页。 - -## Agent 规则 - -用户说“本地/开发库/调试库”时优先 `--env dev`;线上问题排查用 `--env online`。如果 dev 返回服务端错误提示未初始化,多环境入口是 [`+db-env-create`](lark-apps-db-env-create.md)。 diff --git a/skills/lark-apps/references/lark-apps-db.md b/skills/lark-apps/references/lark-apps-db.md new file mode 100644 index 00000000..e903b2f0 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-db.md @@ -0,0 +1,162 @@ +# apps db 域命令 + +管理妙搭应用数据库:看表与结构、初始化与发布多环境、数据搬运、变更治理、时间点恢复、用量。逐条跑 SQL(SELECT/DML/DDL)走 [`+db-execute`](lark-apps-db-execute.md)(单独一篇)。运行时命令事实以 `lark-cli apps + --help` 为准;认证、`--as user`、exit 码、`_notice` 等通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 与本域 [`SKILL.md`](../SKILL.md)。 + +## 何时用 + +用户要看应用里有哪些表 / 某张表的结构、把单库应用拆成 dev/online 多环境、把数据导进导出表、查谁在什么时候改了表结构或表数据、开关行级审计、把开发环境的库结构发布到线上、把库恢复到过去某个时间点、或看数据库用量时。逐条执行 SQL 走 [`+db-execute`](lark-apps-db-execute.md);文件存储(上传/下载文件)走 [`lark-apps-file.md`](lark-apps-file.md)。 + +## 命令一览 + +| 命令 | 做什么 | 关键参数 | +|---|---|---| +| `+db-table-list` | 列出某环境的数据表 | `--environment`、`--page-size`/`--page-token` | +| `+db-table-get` | 看单张表的结构(字段/索引/约束/DDL) | `--table`、`--environment`、`--format` | +| `+db-env-create` | 把单库应用初始化为 dev/online 多环境(高危) | `--environment`、`--sync-data`、`--yes` | +| `+db-data-export` | 把一张表的数据导出到本地文件 | `--table`、`--output`、`--limit`、`--environment` | +| `+db-data-import` | 把本地 csv/json 文件导进一张表(高危) | `--file`、`--table`、`--environment`、`--yes` | +| `+db-changelog-list` | 查表结构变更(DDL)历史 | `--table`、`--change-id`、`--since`/`--until`、`--environment` | +| `+db-audit-status` | 看哪些表开了行级审计、保留期 | `--table`、`--environment` | +| `+db-audit-enable` | 给某表开启行级变更审计 | `--table`、`--retention`、`--environment` | +| `+db-audit-disable` | 关闭某表的行级审计 | `--table`、`--environment` | +| `+db-audit-list` | 列出表的行级变更事件(增删改追溯) | `--table`(可重复)、`--since`/`--until`、`--environment` | +| `+db-env-diff` | 预览开发环境待发布到线上的结构变更 | `--app-id` | +| `+db-env-migrate` | 把开发环境的结构变更发布到线上(高危) | `--app-id`、`--yes` | +| `+db-recovery-diff` | 预览把库恢复到某时间点会带来的变更 | `--target` | +| `+db-recovery-apply` | 把库恢复到某个时间点、覆盖当前数据(高危) | `--target`、`--yes` | +| `+db-quota-get` | 查数据库存储用量 | `--environment` | + +## 约定(先读) + +- **环境 `--environment dev|online`(所有 db 命令统一默认 `dev`)**:看表、看结构、数据导入导出、变更追溯、审计、配额都按环境区分,写操作建议先在 `dev` 验。**注意:只有开启了多环境(`+db-env-create`)的应用才有 `dev` 分支;未开启多环境的应用其数据库在 `online`——对这类应用必须显式 `--environment online`,否则默认的 `dev` 分支不存在、会报错**。旧名 `--env` 已**移除**:传入会报 validation 错(提示改用 `--environment`),一律用 `--environment`。`+db-env-diff`/`+db-env-migrate` 是「dev→online 发布」语义、`+db-recovery-*` 作用于当前库,二者**没有** `--environment`。 +- **本地文件 / `--output` 用工作目录内相对路径**:导入 `--file ./orders.csv`、导出 `--output ./out.csv`;绝对路径、或经 `..`/符号链接越出工作目录的 `--output` 会被拒(validation / exit 2)。路径在别处先 `cd` 过去或改成相对路径。 +- **高危操作必须带 `--yes`**:`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply` 缺省会被确认关卡拦下;动手前先用对应的预览命令或 `--dry-run` 看清影响。 +- **时间参数按口语自然传**(`--since`/`--until`/`--target`),格式见末尾。 + +## 各命令 + +### 表与结构 + +**`+db-table-list`**:列出某环境的数据表。分页 `--page-size`(默认 20)/ `--page-token`(上一页 cursor)。每项给表名、描述、估算行数、大小、列数;要完整列定义 / 索引 / 约束用 `+db-table-get`。只知道业务对象名时,先用它定位可能的表名。 + +```bash +lark-cli apps +db-table-list --app-id app_xxx +lark-cli apps +db-table-list --app-id app_xxx --environment dev --page-size 50 +``` + +**`+db-table-get`**:看单张表的结构。默认 JSON 给结构化的字段 / 索引 / 约束 / 估算行数 / 大小;`--format pretty` 直接输出建表 DDL 文本(给用户看建表语句或做迁移参照时用)。 + +```bash +lark-cli apps +db-table-get --app-id app_xxx --table orders +lark-cli apps +db-table-get --app-id app_xxx --table orders --environment dev --format pretty +``` + +### 多环境数据库(初始化 + 发布) + +**`+db-env-create`(高危)**:把存量单库应用初始化为 dev/online 两套库,不可逆,必须带 `--yes`。`--environment` 目前只支持 `dev`(默认 `dev`);`--sync-data` 把现有 online 数据复制到新环境(不传则不复制)。注意:`+create --app-type full_stack` 新建的应用通常已自带多环境,重复初始化会返回冲突错误(应用已是多环境)——按 `error.hint` 转述状态即可,别重复初始化。 + +```bash +lark-cli apps +db-env-create --app-id app_xxx --environment dev --dry-run +lark-cli apps +db-env-create --app-id app_xxx --environment dev --sync-data --yes +``` + +**`+db-env-diff`**:预览开发环境里待发布到线上的表结构变更,不落地。发布前先看这个。无待发布变更时明确返回「无变更」。 + +**`+db-env-migrate`(高危)**:把开发环境的结构变更正式发布到线上,不可逆,必须带 `--yes`,返回实际发布的变更条数。发布是异步的,命令会等到完成再返回结果。 + +> 预览与发布同一端点,故 `+db-env-diff` 也需 `spark:app:write` scope(不是纯只读权限)。 + +```bash +lark-cli apps +db-env-diff --app-id app_xxx +lark-cli apps +db-env-migrate --app-id app_xxx --yes +``` + +### 数据导入导出 + +**`+db-data-export`**:把一张表导出到本地文件。导出格式**只由 `--output` 的扩展名决定**——`.csv` / `.json` / `.sql`,缺省按 `<表名>.csv` 落在当前目录。注意:全局 `--format json|pretty` 只控制**命令自身输出**(成功摘要 / 错误信封)的渲染,**不影响导出文件的格式**;`--output` 后缀必须是 `.csv/.json/.sql` 之一,否则报 validation 错误(exit 2),且不支持导出到 stdout。两道体量约束: + +- `--limit`(1..5000,默认 5000)是**行数上限守卫**:表的行数超过它会被整体拒掉(不是「只导前 N 行」); +- 导出产物 >1 MB 也会被拒。 + +超大表别硬导:先用 `+db-execute` 加 `WHERE` / `LIMIT` 缩小范围、分批导。 + +```bash +lark-cli apps +db-data-export --app-id app_xxx --table orders --output ./orders.csv +lark-cli apps +db-data-export --app-id app_xxx --table orders --output ./orders.json --environment dev +``` + +**`+db-data-import`(高危)**:把本地 csv/json 文件的数据导进表。文件需是 `.csv`/`.json`、≤1 MB,必须带 `--yes`。目标表缺省取文件名去掉**最后一个**扩展名(如 `orders.csv`→`orders`,`orders.2026.csv`→`orders.2026`);文件名带点号时建议显式传 `--table` 以免落到意外的表名。 + +```bash +lark-cli apps +db-data-import --app-id app_xxx --table orders --file ./orders.csv --environment dev --yes +``` + +**导入/导出限额**:体积 ≤ **1 MB**、行数 ≤ **5000**,导入导出都一样,超限会被拒。超限就分批——导入拆成 ≤1 MB / ≤5000 行的多个文件,导出用 `WHERE` / `LIMIT` 缩小范围。 + +### 变更追溯与审计 + +**`+db-changelog-list`**:查表结构变更(DDL)历史——谁、什么时候、改了哪张表、做了什么。可按 `--table` 过滤、按 `--change-id` 精确定位某条、用 `--since`/`--until` 圈时间区间,分页 `--page-size`/`--page-token`。 + +```bash +lark-cli apps +db-changelog-list --app-id app_xxx --table orders --since 7d +``` + +**`+db-audit-status`**:看审计开关状态。给 `--table` 看单表,不给则列出所有已配置的表(开没开、保留期)。 + +**`+db-audit-enable` / `+db-audit-disable`**:开 / 关某张表的行级变更审计。`--retention` 设保留期,取值 `7d`/`30d`/`180d`/`360d`/`forever`(默认 `7d`)。不要对已经开启审计的表重复 enable——不确定就先用 `+db-audit-status` 查。 + +```bash +lark-cli apps +db-audit-enable --app-id app_xxx --table orders --retention 30d +lark-cli apps +db-audit-disable --app-id app_xxx --table orders +``` + +**`+db-audit-list`**:列出表的行级变更事件(INSERT/UPDATE/DELETE 的前后值与操作人)。`--table` 必填、可重复传多张表;`--since`/`--until` 圈时间。 +- **多表查询**:会先帮用户把不存在、或没开审计的表过滤掉再查,被过滤的表及原因列在结果的 `skipped` 里——据此告诉用户哪些表没纳入及为什么。 +- **单表查询**:不预过滤,表不存在 / 未开审计会直接报错(按 `error.hint` 转述给用户,引导先 `+db-audit-enable`)。 + +```bash +lark-cli apps +db-audit-list --app-id app_xxx --table orders --since 24h +lark-cli apps +db-audit-list --app-id app_xxx --table orders --table users +``` + +### 时间点恢复(PITR) + +**`+db-recovery-diff`**:预览把库恢复到 `--target` 时间点会带来哪些变更(受影响的表、行数、预计耗时),不落地。同样需 `spark:app:write` scope。 + +**`+db-recovery-apply`(高危)**:把库恢复到某个时间点,**会覆盖当前数据**,不可逆,必须带 `--yes`。 + +- 可恢复窗口最长 **7 天**,且不早于**最近一次 `+db-env-migrate`**;超出窗口的目标会被拒。 +- 目标时间点与当前库一致时返回 `no_changes`(空操作),不算失败。 +- 动手前务必先 `+db-recovery-diff` 给用户确认。 + +```bash +lark-cli apps +db-recovery-diff --app-id app_xxx --target 2h +lark-cli apps +db-recovery-apply --app-id app_xxx --target 2026-04-15T10:00:00Z --yes +``` + +### 配额 + +**`+db-quota-get`**:查数据库存储用量(已用量、表数、视图数;配额接入后还会给总配额与使用率)。 + +```bash +lark-cli apps +db-quota-get --app-id app_xxx --environment dev +``` + +## 时间格式(`--since` / `--until` / `--target`) + +按用户口语自然传入即可,支持: +- 相对时间 `7d` / `2h` / `30s`(从现在往前推) +- 日期 `2026-04-15` +- 日期时间 `2026-04-15T10:00:00` +- 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00` + +> **时区**:不带时区的 `日期` / `日期时间` 按**运行机器的本地时区**解析(再归一化到 UTC)。CI(UTC)与本地(如 UTC+8)跑同一条命令,时间边界会差几小时;要精确锁定时区时显式写 ISO 8601 带偏移(如 `...+08:00` / `...Z`)。`--target`(PITR 恢复)尤其建议带时区,避免恢复到非预期时间点。 + +## Agent 规则 + +- 用户说「本地 / 开发库 / 调试库」优先 `--environment dev`,线上排查用 `--environment online`;数据面写操作(导入 / 审计开关)默认先在 `dev` 验再动 `online`。 +- 看表用 `+db-table-list`,看结构用 `+db-table-get`(要建表语句加 `--format pretty`);`+db-env-create` 仅用于存量单库拆多环境,新建的 full_stack 应用一般不需要。 +- 四个高危命令(`+db-env-create`、`+db-data-import`、`+db-env-migrate`、`+db-recovery-apply`)动手前先看清影响再带 `--yes`:发布 / 恢复先跑对应预览 `+db-env-diff` / `+db-recovery-diff`,导入无预览命令、可先 `--dry-run` 看请求或先在 `--environment dev` 验;不要静默追加 `--yes`,遇 confirmation_required(exit 10)按 lark-shared 协议向用户确认不可逆风险后再补 `--yes` 重试。 +- 导入 / 导出的本地路径用工作目录内相对路径;超大表导出会被行数 / 体积上限拒,改用 `+db-execute` 分批。 +- `+db-audit-list` 多表查询时,把结果里 `skipped` 的表(不存在 / 未开审计)连同原因一并向用户说明,不要让用户以为这些表「没有变更」。 +- 恢复是覆盖式且不可逆:`+db-recovery-apply` 前必须先 `+db-recovery-diff`,并明确告知用户会覆盖当前数据。 diff --git a/skills/lark-apps/references/lark-apps-env-pull.md b/skills/lark-apps/references/lark-apps-env-pull.md index e1e0082d..148cefa6 100644 --- a/skills/lark-apps/references/lark-apps-env-pull.md +++ b/skills/lark-apps/references/lark-apps-env-pull.md @@ -2,11 +2,13 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 -把妙搭应用的启动期环境变量拉取到本地项目根的 `.env.local`。身份固定 `--as user`;scope `spark:app:read`。`--app-id` 必填,目标项目根默认当前工作目录(`--project-path` 可指定)。 +把妙搭应用 dev 启动期环境变量拉取到本地项目根的 `.env.local`。身份固定 `--as user`;scope `spark:app:read`。`--app-id` 必填,目标项目根默认当前工作目录(`--project-path` 可指定)。 + +这个命令是 dev-only 的本地恢复工具:内部固定 `POST env_vars`,body 为 `env=dev`。它没有 `--env` flag,也不管理线上环境变量。 ## 何时别用(核心反模式) -**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并把用户刚改完的 `.env.local` 临时改动覆盖掉。 +**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并用服务端返回值覆盖 `.env.local` 里的同名 key;本地无关行和注释会保留。 只在这些兜底场景用: @@ -21,7 +23,7 @@ ## 示例 ```bash -lark-cli apps +env-pull --app-id app_xxx +lark-cli apps +env-pull --app-id ``` ## 失败处理 diff --git a/skills/lark-apps/references/lark-apps-env.md b/skills/lark-apps/references/lark-apps-env.md new file mode 100644 index 00000000..b6c3aa1f --- /dev/null +++ b/skills/lark-apps/references/lark-apps-env.md @@ -0,0 +1,48 @@ +# apps env + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 + +管理妙搭应用环境变量。查看用 `+env-list`,设置用 `+env-set`,删除用 `+env-delete`。没有单变量 get 命令;要确认某个 key 是否存在,使用 list 后用 `--jq` 过滤。 + +环境 flag 使用 `--environment`;不要使用旧的 `--env`,也不要使用短选项。 + +## 查看 + +`+env-list` 默认查 dev,且默认不返回 value。只有显式传 `--include-values` 后,响应中才可能出现变量值;不要在公开日志里展示带值输出。 + +接口契约:list 使用 `POST env_vars`,body 固定包含 `env` 和 CLI 场景 `scene=2`;set 使用 `POST create_or_update_env_var`;delete 使用 `POST delete_env_vars`。`--include-values` 只控制 CLI 输出是否展示 value,不作为服务端查询参数发送。 + +```bash +lark-cli apps +env-list --app-id +lark-cli apps +env-list --app-id --environment online +lark-cli apps +env-list --app-id --include-values --jq '.data.items[] | select(.key == "FOO")' +``` + +## 设置 + +dev 环境设置不需要 `--yes`。设置 online 环境需要人类确认并显式传 `--yes`;如果用户在同一轮已经明确说“确认/直接执行”,视为已确认,直接带 `--yes`,不要再次追问。`--dry-run` 可用于预览请求且不需要 `--yes`。变量值支持直接传 ``,也支持 `@file` 或 stdin 输入。 + +回复中只说明 app/env/key 和执行结果;不要回显真实 value。需要举例时使用 ``、`@file` 或 stdin。 + +```bash +lark-cli apps +env-set --app-id --key FOO --value +lark-cli apps +env-set --app-id --key FOO --value @./secret.txt +lark-cli apps +env-set --app-id --environment online --key FOO --value --dry-run +lark-cli apps +env-set --app-id --environment online --key FOO --value --yes +``` + +## 删除 + +`+env-delete` 是 high-risk-write。尊重 exit 10 confirmation protocol:先让用户确认 app/env/key 和删除后果,再传 `--yes`。不要自动补 `--yes`。如果只是认证失败后让用户重登,重登完成不等于删除确认;继续删除前仍需确认。 + +```bash +lark-cli apps +env-delete --app-id --key FOO --dry-run +lark-cli apps +env-delete --app-id --key FOO --yes +lark-cli apps +env-delete --app-id --environment online --key FOO --yes +``` + +## 反模式 + +- 不要把 `+env-pull` 当成环境变量管理命令;它只是刷新本地 `.env.local` 的兜底工具。 +- 不要为了看一个变量臆造名为 env-get 的 apps shortcut;用 `+env-list --include-values` 加 `--jq`。 +- 不要把真实 secret 写进示例或对话输出;需要示例时使用 ``、`@file` 或 stdin。 diff --git a/skills/lark-apps/references/lark-apps-file.md b/skills/lark-apps/references/lark-apps-file.md new file mode 100644 index 00000000..94d4a7ab --- /dev/null +++ b/skills/lark-apps/references/lark-apps-file.md @@ -0,0 +1,96 @@ +# apps file 域命令(应用存储) + +管理妙搭应用的文件存储:上传 / 下载本地文件、列出与查看已存文件、生成临时分享链接、批量删除、查看用量。运行时命令事实以 `lark-cli apps + --help` 为准;认证、`--as user`、exit 码、`_notice` 等通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) 与本域 [`SKILL.md`](../SKILL.md)。 + +## 何时用 + +用户要在某个妙搭应用里上传 / 下载 / 列出 / 删除文件、拿文件的临时分享链接、或看存储用量时。普通飞书云盘走 [`lark-drive`](../../lark-drive/SKILL.md);数据库里的表数据走 `+db-*`。 + +## 命令一览 + +| 命令 | 做什么 | 关键参数 | +|---|---|---| +| `+file-list` | 列出文件,可按名/路径/类型/大小/上传时间过滤 | `--app-id`、过滤器、`--page-size`/`--page-token` | +| `+file-get` | 查单个文件的元数据 | `--app-id`、`--path` | +| `+file-sign` | 生成有时效的下载链接(用于分享 / 直接下载) | `--app-id`、`--path`、`--expires-in` | +| `+file-download` | 把远端文件保存到本地 | `--app-id`、`--path`、`--output` | +| `+file-upload` | 上传本地文件到应用存储 | `--app-id`、`--file` | +| `+file-delete` | 按路径批量删除文件 | `--app-id`、`--path`(可重复)、`--yes` | +| `+file-quota-get` | 查应用的文件存储用量 | `--app-id` | + +## 寻址与约定(先读) + +- **远端文件统一用 `--path` 精确寻址**(远端路径,带前导 `/`)。只知道文件名时,先用 `+file-list --name <名>` 定位拿到 `path`,再做后续操作。 +- **本地文件 / 输出路径用工作目录内的相对路径**(如 `--file ./report.pdf`、`--output ./out.png`);路径在别处时先 `cd` 过去或改成相对路径。 +- 上传只接收本地 `--file`:文件名沿用本地文件名,远端路径由平台分配、全局唯一(无需也无法手填)。 +- file 域不区分环境,没有 `--env`。 + +## 各命令 + +### +file-list +列出应用文件,支持精确过滤:`--name`(文件名)、`--path`(远端路径)、`--type`(MIME 类型)、`--size-gt`/`--size-lt`(字节)、`--uploaded-since`/`--uploaded-until`(上传时间区间,时间格式见末尾)。分页 `--page-size`(默认 20)/ `--page-token`。列表每项给名称、路径、大小、类型、上传时间(pretty 表格即这 5 列);上传者、下载地址(如有)仅在 JSON 输出里,单文件详情用 `+file-get`。 + +```bash +lark-cli apps +file-list --app-id app_xxx +lark-cli apps +file-list --app-id app_xxx --type image/png --uploaded-since 7d +``` + +### +file-get +按 `--path` 查单个文件的元数据。路径不存在时返回明确的「文件不存在」错误。 + +```bash +lark-cli apps +file-get --app-id app_xxx --path /1858537546760216.png +``` + +### +file-sign +为指定文件生成一个**有时效的下载链接**——适合发给用户分享、或直接下载。`--expires-in` 设有效期秒数(默认 1 天,最长 30 天)。`pretty` 模式只输出链接本身,便于复制 / 管道;要把到期时间一并告诉用户时用默认 JSON 输出(含到期时间)。 + +```bash +lark-cli apps +file-sign --app-id app_xxx --path /1858537546760216.png --expires-in 3600 +``` + +### +file-download +把远端文件保存到本地。`--output` 指定保存路径,缺省时按远端文件名保存到当前目录。 + +```bash +lark-cli apps +file-download --app-id app_xxx --path /1858537546760216.png --output ./logo.png +``` + +### +file-upload +上传一个本地文件。文件名沿用本地文件名(特殊字符做 URL 编码透传;以 `.` 开头的隐藏文件名会加 `_` 前缀,避免下载回本地时覆盖隐藏文件),远端路径由平台分配。单文件上限 100 MB。 + +```bash +lark-cli apps +file-upload --app-id app_xxx --file ./report.pdf +``` + +### +file-delete(高危) +按路径批量删除,`--path` 可重复传多个。删除是高危操作,必须带 `--yes`;缺省会被确认关卡拦下。**逐项返回结果**:部分文件删除失败(如某个路径不存在)不影响其余文件,整体仍算成功,失败项在结果里单独标出原因。 + +```bash +lark-cli apps +file-delete --app-id app_xxx --path /1858537546760216.png --yes +lark-cli apps +file-delete --app-id app_xxx --path /a.png --path /b.png --yes +``` + +### +file-quota-get +查应用的文件存储用量(已用量、文件数;配额接入后还会给总配额与使用率)。 + +```bash +lark-cli apps +file-quota-get --app-id app_xxx +``` + +## 时间格式(`--uploaded-since` / `--uploaded-until`) + +按用户口语自然传入即可,支持: +- 相对时间 `7d` / `2h` / `30s`(从现在往前推) +- 日期 `2026-04-15` +- 日期时间 `2026-04-15T10:00:00` +- 带时区的 ISO 8601 `2026-04-15T10:00:00Z` / `2026-04-15T10:00:00+08:00` + +> **时区**:不带时区的 `日期` / `日期时间` 按**运行机器的本地时区**解析(再归一化到 UTC 发给服务端)。CI(UTC)与本地(如 UTC+8)跑同一条命令,过滤边界会差几小时;要精确到某时区时显式写 ISO 8601 带偏移(如 `...+08:00` / `...Z`)。 + +## Agent 规则 + +- 寻址一律用 `--path`;用户只给文件名时先 `+file-list --name <名>` 定位,多个同名再让用户确认。 +- 上传 / 下载的本地路径用工作目录内相对路径;不在当前目录就 `cd` 过去或改相对路径。 +- 用户要「分享链接 / 临时下载地址」时用 `+file-sign`,把返回的链接转述给用户。 +- 删除前判断意图:已明确要删且授权时可直接带 `--yes`;不确定删哪些时先 `+file-list` 给用户确认。批量删除部分失败不报错,按逐项结果向用户说明哪些成功、哪些没删掉及原因。 diff --git a/skills/lark-apps/references/lark-apps-local-dev.md b/skills/lark-apps/references/lark-apps-local-dev.md index d404e34c..621d2294 100644 --- a/skills/lark-apps/references/lark-apps-local-dev.md +++ b/skills/lark-apps/references/lark-apps-local-dev.md @@ -11,7 +11,7 @@ ## 端到端流程(新建应用) -`+create(full_stack)` -> `+init`(或手动 `+git-credential-init` + `git clone`)-> `npm install && npm run dev` -> 按需 `+db-*` 调库 -> `git add` + `git commit`(提交本次改动)-> `git push origin sprint/default` -> `+release-create` -> `+release-get`。 +`+create(full_stack)` -> `+init`(或手动 `+git-credential-init` + `git clone`)-> 读仓库 Skill -> `npm install && npm run dev` -> 按需 `+db-*` 调库 -> `git add` + `git commit`(提交本次改动)-> `git push origin sprint/default` -> `+release-create` -> `+release-get`。 ```bash # 新建 full_stack 应用 @@ -36,6 +36,8 @@ lark-cli apps +release-create --app-id app_xxx `+init` 是推荐便捷入口;想逐步手动控制时,先 `+git-credential-init` 拿 `repository_url`,再用原生 `git clone` / `git checkout sprint/default`。 +**`+init` 完成后必须执行**:`cat /.agents/skills/plugin-guide/SKILL.md`,读取仓库插件指引。该文件包含插件目录、实例配置规则和调用代码生成方式——不读就无法正确集成插件能力。文件不存在则跳过。 + ## 改完代码后部署上线 已拉到本地、改完代码,用户说"推上去""部署""上线""发布到云端"时,按此序列。 diff --git a/skills/lark-apps/references/lark-apps-observability.md b/skills/lark-apps/references/lark-apps-observability.md new file mode 100644 index 00000000..e40a9a86 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-observability.md @@ -0,0 +1,48 @@ +# apps observability + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。 + +查询妙搭应用的线上运行观测和产品访问分析。所有 observability 命令只支持 `--environment online`;省略 `--environment` 时默认就是 online,传 dev 或其他环境是不支持的。不要使用旧的 `--env`,也不要使用短选项。 + +日志和 trace 的用户侧环境仍然是 online;但 OpenAPI 请求体里的后端 `app_env` 固定发送 `runtime`,因为线上应用的运行时日志和 trace 存储在 runtime 观测环境下。dry-run 输出会展示这个后端参数。 + +metric / analytics 的 `--environment` 只是 CLI 侧 online-only 校验:`+metric-list` 和 `+analytics-list` 不会向 OpenAPI body 发送 `env` 或 `app_env`。dry-run 里看不到环境字段是预期行为,不要补造参数。 + +时间过滤支持相对时间(如 `30s`、`5m`、`0.5h`、`2h`、`3d`、`1w`)、本地日期 / 时间和 RFC3339。 + +## 命令选择 + +- 日志检索:用 `+log-list` 搜索日志,用 `+log-get` 按 log ID 取单条日志。 +- `+log-list` 不再支持 `--log-id`;已有 log ID 时直接用 `+log-get --log-id `。 +- 前端 ERROR 日志详情:`+log-get` 可能补充 `source_stack`;没有独立的 source-stack 命令。 +- Trace 检索:用 `+trace-list` 搜索 trace,用 `+trace-get` 按 trace ID 取详情。 +- 运行时指标:请求数、错误、延迟、CPU、memory 用 `+metric-list`。 +- 产品分析:PV、UV、访问量这类业务访问分析用 `+analytics-list`,不要放到 runtime metric 里混查。 +- `+analytics-list` 按最新 OpenAPI 发送 `metric_types`、纳秒时间戳和 `need_pack_lack_point=false`;`group_by` 暂不支持。 +- 用户询问“最近一小时接口请求量、错误量、延迟、接口慢/报错多”时,这是平台运行时监控,不是本地项目文件。先用 `apps +list --keyword` 找 `app_id`,再查 `+metric-list`。 + +## 示例 + +```bash +lark-cli apps +log-list --app-id --level error --keyword timeout --since 0.5h +lark-cli apps +log-get --app-id --log-id +lark-cli apps +trace-list --app-id --trace-id +lark-cli apps +trace-get --app-id --trace-id +lark-cli apps +metric-list --app-id --metric requests --series total --since 1d +lark-cli apps +metric-list --app-id --metric requests --since 1h +lark-cli apps +metric-list --app-id --metric latency --since 1h +lark-cli apps +metric-list --app-id --metric latency --series p99 --since 1d +lark-cli apps +metric-list --app-id --metric cpu --since 1h +lark-cli apps +metric-list --app-id --metric memory --since 1h +lark-cli apps +analytics-list --app-id --analytics users --series active-users --granularity day +lark-cli apps +analytics-list --app-id --analytics page-view --granularity day +``` + +## 使用边界 + +- 如果用户问“接口慢、报错多、CPU/内存高”,优先走 `+metric-list`。 +- `+metric-list --metric requests` 不传 `--series` 会同时返回请求总量 total 和错误量 error;`--metric latency` 不传 `--series` 会同时返回 p50 和 p99。只想看单条曲线时再传 `--series total|error|p50|p99`。 +- 按接口收窄范围时使用 `--api `;当前没有 `group-by` 参数,不要臆造。 +- `+metric-list` 未显式传 `--down-sample` 时会按时间范围自动选择粒度:短范围用 `1m`,中等范围用 `1h`,长范围用 `1d`;显式传入时尊重用户指定。 +- 如果用户问“页面访问量、PV、UV、活跃用户”,优先走 `+analytics-list`。 +- 如果用户已有 `trace_id` 或 `log_id`,直接用对应 get 命令;不知道 ID 时先 list。 diff --git a/skills/lark-apps/references/lark-apps-openapi-key.md b/skills/lark-apps/references/lark-apps-openapi-key.md new file mode 100644 index 00000000..37cb7d37 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-openapi-key.md @@ -0,0 +1,79 @@ +# apps openapi-key 命令族 SOP + +管理妙搭应用对外暴露的 HTTP API Key(`/openapi/**` 鉴权凭证)。全部操作需 `--as user`(AuthType: user)。`--help` 是参数细节的完整来源;本文件只记录 Agent 不看就会做错的领域规则。 + +## 命令路由 + +| 命令 | 用途 | +|---|---| +| `+openapi-key-list` | 列出应用所有 API Key(脱敏) | +| `+openapi-key-get` | 查看单个 Key 详情(脱敏) | +| `+openapi-key-create` | 创建新 Key,**原始密钥一次性可见** | +| `+openapi-key-update` | 改名或改 config(不改 status) | +| `+openapi-key-enable` | 启用 Key(status→1) | +| `+openapi-key-disable` | 停用 Key(status→0),**泄露/疑似泄露优先用这个而非 delete** | +| `+openapi-key-delete` | 永久删除 Key(不可逆) | +| `+openapi-key-reset` | 轮换密钥(刷新原始 Key),**一次性可见** | + +## 脱敏口径(安全关键) + +- `list` / `get` / `update` / `enable` / `disable`:返回结构里 **无** `api_key` 字段,只有 `key_preview`(格式:`****` + 原始密钥末 4 位,如 `****5f4a`)。 +- `create` / `reset`:**仅** 在 `data.api_key`(顶层)返回原始密钥一次;同时在 stderr 打印一次性提示: + ``` + warning: this api_key is shown only once and is NOT stored by lark-cli — copy it now and store it in your own secret manager. + ``` +- 原始密钥绝不写入 cache / config / recent / debug log / 错误信息。 + +## 一次性密钥语义 + +CLI 不保存原始密钥。密钥在 `create` / `reset` 时仅随响应返回一次。**密钥丢失不能用 `get` 找回**——唯一恢复方式是 `+openapi-key-reset` 重新生成新密钥(旧密钥同时失效)。 + +## scope 结构与 CLI 表达 + +后端 `config.request_scope` 的真实结构(**snake_case**——Lark 开放网关 `/open-apis/` 对外契约约定;`api_key.thrift` 的 camelCase go.tag 是内部表示,OGW 已转成 snake_case): + +```json +{ + "allow_all": true, + "http_infos": [ + { "http_method": "GET", "http_path": "/openapi/some-path" } + ] +} +``` + +- `allow_all=true`:放开该应用所有 `/openapi/**` 路由;`http_infos` 此时忽略。 +- `allow_all=false`:按 `http_infos` 逐条授权,每条需 `http_method`(大写)+ `http_path`(`/openapi/` 开头)。 + +CLI 提供三种互斥的 scope 表达方式: + +| flag | 用途 | 备注 | +|---|---|---| +| `--scope-all` | `allow_all=true`,放开所有路由 | bool flag,显式传 `--scope-all=false` 也算"已设置" | +| `--scope-api 'METHOD /openapi/path'` | 逐条授权一个路由,可重复 | 路由从应用 `docs/openapi.json` 取 | +| `--scope ''` | 高级逃生口,直传 request_scope JSON(snake_case) | CLI 只校验合法 JSON;`--scope` 与 `--scope-all`/`--scope-api` 互斥 | + +### scope 值来源 + +妙搭应用的 `/openapi/**` 路由定义在应用仓库,并同步维护在 `docs/openapi.json`(`paths` 下每个 `"/openapi/..."` 条目 + HTTP 方法)。要授权哪些路由,读目标应用自己的 `docs/openapi.json`,取 `(method, path)` 对。CLI 本身不提供 API 路由发现功能(P1 规划中)。 + +## 高风险操作 + +`delete` 和 `reset` 是高风险(`high-risk-write`),有以下约束: + +- 需显式传 `--yes`(框架 `cmdutil.RequireConfirmation`);缺少时退出码 10,**不要自动补 `--yes`**(遵循 lark-shared 安全红线)。 +- 支持 `--dry-run` 查看将要执行的 HTTP 请求(不含密钥);不确定时先 dry-run。 +- **泄露场景**:应优先 `+openapi-key-disable` 立即停用,而非 `+openapi-key-delete`——停用可随时 enable 恢复,delete 不可逆。 + +## 典型决策场景 + +| 用户意图 | 正确操作 | +|---|---| +| "key 泄露了,先停掉" | `+openapi-key-disable`(不是 delete) | +| "key 丢了/忘了,再给我一个" | `+openapi-key-reset`(不是 create 新 key;reset 轮换密钥、保留原 key 配置) | +| "我的 key 密钥是什么" | 解释:list/get 不回显原始密钥,只能用 `+openapi-key-reset` 轮换 | +| "给应用创建一个有权限限制的 key" | `+openapi-key-create --name ... --scope-api 'GET /openapi/...'`(路由取自应用 `docs/openapi.json`) | + +## 不在本 skill 范围 + +- OpenAPI spec 全量导出、实时日志 tail、Webhook 消费、多鉴权方式:本期不支持。 +- 身份选择、权限不足处理(`permission_violations`→`console_url`)、exit-10 审批、通用"禁输出密钥"红线、高风险操作通用框架:见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不在此重复。 diff --git a/skills/lark-apps/references/lark-apps-plugin-install.md b/skills/lark-apps/references/lark-apps-plugin-install.md new file mode 100644 index 00000000..f4dabd57 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-install.md @@ -0,0 +1,36 @@ +# apps +plugin-install + +> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。 + +安装插件包到项目。运行时命令事实以 `lark-cli apps +plugin-install --help` 为准。 + +## 何时用 + +用户要接入 AI 能力或飞书平台能力,需要先安装对应的插件包。安装后才能创建插件实例。具体有哪些可用插件、该选哪个,读取创建的应用仓库 Skill:`.agents/skills/plugin-guide/SKILL.md`。 + +**插件包 ≠ npm 包**:插件包写入 `actionPlugins`,npm 写入 `dependencies`,两套独立机制。禁止用 `npm install` 代替本命令。 + +## 命令骨架 + +- `--name `:插件包 key(从仓库 Skill 的「AI 插件目录」获取)。不传则批量安装 `actionPlugins` 中声明的所有插件。 +- `--version `:指定版本(如 `1.0.0`)。不传则安装最新版。 + +在项目根目录下运行(和 npm 一样,无需指定路径)。 + +## 示例 + +```bash +# 安装最新版 +lark-cli apps +plugin-install --name + +# 安装指定版本 +lark-cli apps +plugin-install --name --version 1.0.0 + +# 批量安装已声明的所有插件 +lark-cli apps +plugin-install +``` + +## 输出契约 + +- 已安装同版本会跳过(status=already_installed)。 +- 失败时 hint 指示原因(网络/版本不存在/package.json 缺失)。 diff --git a/skills/lark-apps/references/lark-apps-plugin-list.md b/skills/lark-apps/references/lark-apps-plugin-list.md new file mode 100644 index 00000000..7f733765 --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-list.md @@ -0,0 +1,23 @@ +# apps +plugin-list + +> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。 + +列出已声明的插件包及安装状态。运行时命令事实以 `lark-cli apps +plugin-list --help` 为准。 + +## 何时用 + +查看当前项目声明了哪些插件、是否已安装。`declared_not_installed` 状态表示需要运行 `+plugin-install` 安装。 + +## 命令骨架 + +在项目根目录下运行(和 npm 一样,无需指定路径)。 + +## 示例 + +```bash +lark-cli apps +plugin-list --format json +``` + +## 输出契约 + +- `data.plugins[]` 包含 `key`、`version`、`status`(`installed` / `declared_not_installed`)。 diff --git a/skills/lark-apps/references/lark-apps-plugin-uninstall.md b/skills/lark-apps/references/lark-apps-plugin-uninstall.md new file mode 100644 index 00000000..a29bcc6f --- /dev/null +++ b/skills/lark-apps/references/lark-apps-plugin-uninstall.md @@ -0,0 +1,25 @@ +# apps +plugin-uninstall + +> **本地命令**:读当前目录的 `package.json`,在项目根目录下运行(和 npm 一样)。**不接受 `--app-id`**——它不是远端 API 命令。 + +卸载插件包。运行时命令事实以 `lark-cli apps +plugin-uninstall --help` 为准。 + +## 何时用 + +用户不再需要某个插件能力时,卸载对应的插件包。卸载前应先删除该插件的所有实例。 + +## 命令骨架 + +- `--name `:要卸载的插件包 key。 + +在项目根目录下运行(和 npm 一样,无需指定路径)。 + +## 示例 + +```bash +lark-cli apps +plugin-uninstall --name +``` + +## 输出契约 + +- 删除 `node_modules/{key}` + 移除 `actionPlugins` 条目。 diff --git a/skills/lark-apps/references/lark-apps-release-create.md b/skills/lark-apps/references/lark-apps-release-create.md index 7c1a48e4..a852fe68 100644 --- a/skills/lark-apps/references/lark-apps-release-create.md +++ b/skills/lark-apps/references/lark-apps-release-create.md @@ -22,7 +22,7 @@ lark-cli apps +release-create --app-id app_xxx --branch sprint/default --dry-run ## 输出契约 - 成功读取 `data.release_id` 和 `data.status`;`release_id` 是后续 `+release-get` 的入参。 -- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询。 +- `status=publishing` 表示发布仍在进行;继续用 `+release-get` 轮询,轮询间隔应该为 20s。应用发布平均耗时大约 2min,整体超时时间大约 5min。 - `+release-create` 返回 release 只代表发布已发起。只有 `+release-get` 对同一个 `release_id` 返回 `finished` 后,才能说本轮最新版本已部署。 ## Agent 规则 diff --git a/skills/lark-base/references/lark-base-field-json.md b/skills/lark-base/references/lark-base-field-json.md index bfe77c3a..26bf023f 100644 --- a/skills/lark-base/references/lark-base-field-json.md +++ b/skills/lark-base/references/lark-base-field-json.md @@ -243,6 +243,7 @@ 默认值 / 约束: - `style.format` 默认 `yyyy/MM/dd` 可用格式:`yyyy/MM/dd`、`yyyy/MM/dd HH:mm`、`yyyy/MM/dd HH:mm Z`、`yyyy-MM-dd`、`yyyy-MM-dd HH:mm`、`yyyy-MM-dd HH:mm Z`、`MM-dd`、`MM/dd/yyyy`、`dd/MM/yyyy` +- `style.format` 只控制前端显示格式;当前可配置格式最多显示到分钟,底层时间值仍可保留秒级精度。 常用写法: diff --git a/skills/lark-contact/SKILL.md b/skills/lark-contact/SKILL.md index 0a1b2731..ea618304 100644 --- a/skills/lark-contact/SKILL.md +++ b/skills/lark-contact/SKILL.md @@ -8,10 +8,6 @@ metadata: cliHelp: "lark-cli contact --help" --- -# contact (v2) - -**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** - ## 选哪个命令 **user 身份和 bot 身份是两条完全独立的路径**。先确定当前身份,再按下表选命令: diff --git a/skills/lark-contact/references/lark-contact-search-user.md b/skills/lark-contact/references/lark-contact-search-user.md index ed346da4..6ef3d78d 100644 --- a/skills/lark-contact/references/lark-contact-search-user.md +++ b/skills/lark-contact/references/lark-contact-search-user.md @@ -1,13 +1,12 @@ # +search-user -仅 user 身份。需要 scope `contact:user:search`。 +仅支持 user 身份。 ## 适用范围 - ✅ 已知姓名 / 邮箱 / 「聊过的人」想找出 open_id - ✅ 已知一组 open_id 想批量校验或回填字段(`--user-ids`,最多 100,支持 `me`) - ✅ 按聊天关系 / 在职状态 / 租户边界 / 企业邮箱等维度筛选员工 -- ❌ 已知 open_id 想拿完整 profile → 用 `+get-user --as bot` - ❌ 已知 open_id 想发消息 → 直接走 `lark-im`,不经过本命令 ## 关键 flag diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index de803512..d5d4d774 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -1,11 +1,11 @@ --- name: lark-doc version: 2.0.0 -description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。" +description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文档内容。当用户给出文档 URL 或 token,或需要查看、创建、编辑文档、插入或下载文档图片附件时使用。文档中嵌入的电子表格、多维表格、画板,先用本 skill 提取 token 再切到对应 skill。当用户给出 doubao.com 的 /docx/ 或 /wiki/ URL/token 时,也应直接使用本 skill;路由依据是 URL 路径模式和 token,而不是域名。不负责文档评论管理,也不负责表格或 Base 的数据操作。当用户明确要操作飞书思维笔记时,也使用本 skill。" metadata: requires: bins: ["lark-cli"] - cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help" + cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help; lark-cli mindnotes nodes list --help; lark-cli mindnotes nodes create --help" --- # docs @@ -33,6 +33,7 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '

> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。 ## 快速决策 +- 用户要**复制文档 / 创建文档副本 / 另存为副本**时,切到 [`lark-drive`](../lark-drive/SKILL.md),按其中的复制指引使用 `lark-cli drive files copy`;不要用 `docs +fetch` + `docs +create` 重建正文,也不要走 `drive +export` / `drive +import`。 - 先判定任务路径:找文档 / 导入导出走 [`lark-drive`](../lark-drive/SKILL.md);只读 / 摘要用 `docs +fetch` 默认 `simple`;明确旧文本 → 新文本直接 `str_replace`;只有 block 链接、评论锚点、插入 / 替换 / 删除 / 移动才局部 fetch `with-ids`;保真改写已有内容才读 `full` - block 直达链接格式:`文档基础 URL#block_id`;没有 block_id 时局部 fetch `with-ids` - 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID @@ -44,7 +45,9 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '

- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover` - `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*` - 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`) +- 用户明确要操作思维笔记时;已有**思维笔记**,走 [思维笔记链路](references/lark-doc-mindnote.md);新建**思维笔记**,走 [lark-doc-whiteboard](references/lark-doc-whiteboard.md) - 拿到 spreadsheet URL/token 后 → 切到 `lark-sheets` 做对象内部操作 +- 用户需要统计文档的**总字数 / 总字符数**(word count / character count)时,先读取 [`lark-doc-word-stat.md`](references/lark-doc-word-stat.md),并按其中流程调用 [`scripts/doc_word_stat.py`](scripts/doc_word_stat.py);统计口径以该脚本为准,不要改用其他方式自行计算。 - 用户说"给文档加评论""查看评论""回复评论""给评论加/删除表情 reaction" → 切到 `lark-drive` 处理 - 文档内容中出现嵌入的 ``、`` 或 `` 标签时 → **必须主动提取 token 并切到对应技能下钻读取内部数据**,不能只呈现标签本身 diff --git a/skills/lark-doc/references/lark-doc-create.md b/skills/lark-doc/references/lark-doc-create.md index 17fa6bc2..ce286013 100644 --- a/skills/lark-doc/references/lark-doc-create.md +++ b/skills/lark-doc/references/lark-doc-create.md @@ -60,6 +60,7 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'# | ------------------- | -- |---------------------------------------------| | `--title` | 否 | 文档标题,Markdown 导入时使用;XML 创建推荐在 `--content` 开头写 `...`;多个标题仅保留第一个并在 `warnings` / `degrade_details` 提示 | | `--content` | 视情况 | 文档内容(XML 或 Markdown 格式);不传 `--content` 时必须传 `--title` | +| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 | | `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) | | `--parent-token` | 否 | 父文件夹或知识库节点 token(与 `--parent-position` 互斥) | | `--parent-position` | 否 | 父节点位置,如 `my_library`(与 `--parent-token` 互斥) | diff --git a/skills/lark-doc/references/lark-doc-media-download.md b/skills/lark-doc/references/lark-doc-media-download.md index 73f4946c..2920c320 100644 --- a/skills/lark-doc/references/lark-doc-media-download.md +++ b/skills/lark-doc/references/lark-doc-media-download.md @@ -34,9 +34,9 @@ lark-cli docs +media-download --type whiteboard --token "wbcnxxxxxxxx" --output ## token 从哪里来 -- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含: - - 图片:`` - - 文件:`` +- 若你是从文档内容里提取:`lark-doc-fetch` 返回的内容里可能包含: + - 图片:`` + - 文件:`` - 画板:`` ## 排障 diff --git a/skills/lark-doc/references/lark-doc-media-preview.md b/skills/lark-doc/references/lark-doc-media-preview.md index b29bebff..ed4e8d79 100644 --- a/skills/lark-doc/references/lark-doc-media-preview.md +++ b/skills/lark-doc/references/lark-doc-media-preview.md @@ -30,9 +30,9 @@ lark-cli docs +media-preview --token "Z1Fjxxxxxxxx" --output ./asset.png ## token 从哪里来 -- 若你是从文档内容里提取:`lark-doc-fetch` 返回的 Markdown 里可能包含: - - 图片:`` - - 文件:`` +- 若你是从文档内容里提取:`lark-doc-fetch` 返回的内容里可能包含: + - 图片:`` + - 文件:`` ## 参考 diff --git a/skills/lark-doc/references/lark-doc-mindnote.md b/skills/lark-doc/references/lark-doc-mindnote.md new file mode 100644 index 00000000..7974c165 --- /dev/null +++ b/skills/lark-doc/references/lark-doc-mindnote.md @@ -0,0 +1,113 @@ +# 飞书思维笔记(Mindnote) + +> **前置条件:** 先阅读 [`../SKILL.md`](../SKILL.md) 和 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和路由规则。 + +当用户要操作思维笔记时,入口属于 `lark-doc`,但实际执行命令使用 `lark-cli mindnotes nodes list/create`,不是 `docs +...`。 + +> [!IMPORTANT] +> 当前这条链路只支持**读取已有思维笔记**,以及在**已有思维笔记**里读取节点、创建子节点。 +> `mindnotes nodes create` 是新增/更新节点命令,**不是**新建一个新的思维笔记。 +> 如果用户要**新建思维笔记**,不要走本链路,改走 [lark-doc-whiteboard](lark-doc-whiteboard.md)。 + +## 命令 + +```bash +# 先看命令帮助 +lark-cli mindnotes nodes list --help +lark-cli mindnotes nodes create --help + +# 读取节点列表 +lark-cli mindnotes nodes list --mindnote-id "" + +# 创建子节点 +lark-cli mindnotes nodes create \ + --mindnote-id "" \ + --data '{"client_token":"","nodes":[{"parent_id":"node_parent123","texts":[{"element_type":"text","text":{"content":"子节点内容"}}],"highlight":"yellow","finish":false}]}' + +# 更新已有节点 +lark-cli mindnotes nodes create \ + --mindnote-id "" \ + --data '{"client_token":"","nodes":[{"node_id":"node_existing123","texts":[{"element_type":"text","text":{"content":"更新后的节点内容"}}],"highlight":"blue","finish":true}]}' +``` + +## 参数 + +### `mindnotes nodes list` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 | + +返回重点:`data.nodes` 中常见字段有 `node_id`、`parent_id`、`texts`、`notes`、`images`、`finish`、`highlight`。 + +### `mindnotes nodes create` + +命令参数: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--mindnote-id` | 是 | 思维笔记 token / 唯一标识 | +| `--data` | 是 | JSON 请求体 | + +请求体字段: + +| 字段 | 必填 | 说明 | +|------|------|------| +| `client_token` | 否 | 幂等 token,建议写操作传入;推荐使用时间戳或 UUID | +| `nodes` | 是 | 待创建或更新的节点数组 | +| `nodes[].node_id` | 否 | 节点 ID;传入已有 `node_id` 时表示更新对应节点 | +| `nodes[].parent_id` | 否 | 父节点 ID;创建子节点时传入 | +| `nodes[].texts` | 否 | 节点正文富文本数组 | +| `nodes[].notes` | 否 | 节点备注富文本数组 | +| `nodes[].images` | 否 | 节点图片列表 | +| `nodes[].highlight` | 否 | `red` / `yellow` / `pink` / `blue` / `cyan` / `olive` / `grey` | +| `nodes[].finish` | 否 | 节点完成状态 | + +富文本字段 `texts` / `notes` 是元素数组。最常见的是: + +```json +[{"element_type":"text","text":{"content":"节点内容"}}] +``` + +### 节点图片(`nodes[].images`) + +`nodes[].images` 接收的是**图片 token**,不是本地文件路径,也不是 URL。 + +```bash +# 先上传图片,拿到 token +lark-cli docs +media-upload --file ./image.png --parent-type mindnote_image --parent-node + +# 再把 token 写进节点 +lark-cli mindnotes nodes create \ + --mindnote-id "" \ + --data '{"client_token":"","nodes":[{"node_id":"node_existing123","images":[{"token":"canonical_token"}]}]}' +``` + +参数说明: + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file` | 是 | 本地图片路径 | +| `--parent-type` | 是 | 上传目标类型;图片使用 `mindnote_image` | +| `--parent-node` | 是 | 传 Mindnote 的 token | +| `nodes[].images[].token` | 是 | 上传后返回的图片 token | + +## 推荐工作流 + +1. 先判断用户目标是不是“新建一个思维笔记”。 +2. 如果是新建思维笔记,切到 [lark-doc-whiteboard](lark-doc-whiteboard.md)。 +3. 如果是操作已有思维笔记,先通过 token 类别判断。 +4. 确认是 **Mindnote** 后再拿到 `mindnote_id`。 +5. 先执行 `mindnotes nodes list`,确认目标 `parent_id`。 +6. 新增子节点时,在 `nodes[]` 里传 `parent_id`;更新已有节点时,在 `nodes[]` 里传已有 `node_id`。 +7. 再执行 `mindnotes nodes create`。 +8. 写操作优先带 `client_token`,推荐使用时间戳或 UUID,避免重试时重复创建或重复更新。 + +> [!CAUTION] +> `mindnotes nodes create` 是写操作。创建时确认插入位置,更新时确认 `node_id` 指向的就是目标节点。 + +## 参考 + +- [lark-doc-fetch](lark-doc-fetch.md) — 获取文档内容 +- [lark-doc-whiteboard](lark-doc-whiteboard.md) — 新建思维笔记走画板链路 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-doc/references/lark-doc-update.md b/skills/lark-doc/references/lark-doc-update.md index 62fc467a..43beb7f5 100644 --- a/skills/lark-doc/references/lark-doc-update.md +++ b/skills/lark-doc/references/lark-doc-update.md @@ -24,6 +24,7 @@ | `--command` | 是 | 操作指令(见下方指令速查表) | | `--doc-format` | 否 | 内容格式:`xml`(默认,始终优先使用)\| `markdown`(仅用户明确要求时) | | `--content` | 视指令 | 写入内容(`str_replace` 传空字符串可实现删除) | +| `--reference-map` | 否 | 结构化 `reference_map` JSON object;必须与 `--content` 一起使用。普通写入优先把结构写在正文里;该参数主要用于保留或回放已有 `document.reference_map`。支持直接 JSON、`@reference-map.json`(相对路径)或 `-` 从 stdin 读取。 | | `--pattern` | 视指令 | 匹配文本(str_replace) | | `--block-id` | 视指令 | 目标 block ID(block_* 操作),逗号分隔可批量删除,-1 表示末尾 | | `--src-block-ids` | 视指令 | 源 block ID(逗号分隔),用于 block_copy_insert_after / block_move_after | diff --git a/skills/lark-doc/references/lark-doc-word-stat.md b/skills/lark-doc/references/lark-doc-word-stat.md new file mode 100644 index 00000000..b20b74ce --- /dev/null +++ b/skills/lark-doc/references/lark-doc-word-stat.md @@ -0,0 +1,77 @@ +# 文档统计:总字数 / 总字符数 + +当用户需要统计 Docx / Wiki 文档的总字数或总字符数时,使用本 skill 附带脚本 `scripts/doc_word_stat.py`。统计口径以该脚本为准,不要改用其他方式自行计算,也不要只读取 simple 摘要后统计。 + +## 调用方式 + +在线文档使用 XML full 内容,并让脚本读取 `docs +fetch --format json` 的 envelope: + +```bash +lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \ + | python3 skills/lark-doc/scripts/doc_word_stat.py --protocol xml --lark-json --pretty +``` + +`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。 + +如需在自动化或回归验证中发现未覆盖块类型,追加严格参数: + +```bash +lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \ + | python3 skills/lark-doc/scripts/doc_word_stat.py --protocol xml --lark-json --pretty --fail-on-unsupported --fail-on-unknown +``` + +## 如何读取结果 + +脚本输出 JSON。对用户汇报时默认只读两个核心字段: + +- `word_count`:总字数。按语义单位统计汉字、英文单词/URL/code path、数字、中文标点;普通贴着英文的英文标点不计入,但独立 ASCII 符号、中文之间的 `/` 等以脚本结果为准。 +- `char_count`:总字符数。统计汉字、英文字母、数字、中英文标点和脚本识别的可见符号;空格不计入。 + +其余字段用于排查或解释: + +- `breakdown`:拆分统计来源,例如 `han_chars`、`english_words`、`digits`、`chinese_punctuations`。 +- `unknown_blocks`:脚本遇到未知 XML/Markdown 块类型;通常表示需要扩展解析规则。 +- `unsupported_blocks`:脚本识别到块类型,但当前无法可靠提取可见文本。 +- `diagnostics.has_unknown` / `diagnostics.has_unsupported`:快速判断统计是否存在覆盖风险。 + +如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。 + +## 输出示例 + +输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下: + +```json +{ + "word_count": 10, + "char_count": 15, + "breakdown": { + "han_chars": 7, + "english_words": 2, + "number_words": 0, + "chinese_punctuations": 1, + "english_letters": 7, + "digits": 0, + "english_punctuations": 0, + "symbol_words": 0, + "symbol_chars": 0 + }, + "protocol": "xml", + "unknown_blocks": [], + "unsupported_blocks": [], + "diagnostics": { + "has_unknown": false, + "has_unsupported": false, + "types": {}, + "unknown_types": {}, + "unsupported_types": {}, + "actions": {} + } +} +``` + +面向用户的回复可简化为: + +```text +总字数:10 +总字符数:15 +``` diff --git a/skills/lark-doc/scripts/doc_word_stat.py b/skills/lark-doc/scripts/doc_word_stat.py new file mode 100755 index 00000000..46fe1ff8 --- /dev/null +++ b/skills/lark-doc/scripts/doc_word_stat.py @@ -0,0 +1,1243 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Lark Technologies Pte. Ltd. +# SPDX-License-Identifier: MIT +"""Standalone Lark Docs word and character counter for XML or Markdown input.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import unicodedata +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Literal, Protocol +from xml.etree import ElementTree as ET + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class TextRun: + text: str + attrs: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class Block: + type: str + attrs: dict[str, Any] = field(default_factory=dict) + children: list["Block"] = field(default_factory=list) + text_runs: list[TextRun] = field(default_factory=list) + raw: Any = None + + +@dataclass +class Segment: + text: str + block_type: str + block_id: str | None = None + kind: str = "text" + boundary_before: bool = True + boundary_after: bool = True + + def to_dict(self) -> dict[str, Any]: + return { + "text": self.text, + "block_type": self.block_type, + "block_id": self.block_id, + "kind": self.kind, + "boundary_before": self.boundary_before, + "boundary_after": self.boundary_after, + } + + +@dataclass(frozen=True) +class UnknownBlock: + type: str + block_id: str | None = None + action: str = "recurse_children" + + def to_dict(self) -> dict[str, str | None]: + return { + "type": self.type, + "block_id": self.block_id, + "action": self.action, + } + +# --------------------------------------------------------------------------- +# Counting rules +# --------------------------------------------------------------------------- + +CHINESE_PUNCTUATION = set(",。!?;:、()《》〈〉“”‘’【】「」『』〔〕…—~·¥") +ENGLISH_PUNCTUATION = set( + r"""!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~""" +) + + +LexemeKind = Literal["english", "number"] +URL_TOKEN_RE = re.compile(r"https?://[!-~]+") +ASCII_COMPOUND_TOKEN_RE = re.compile( + r"[A-Za-z0-9]+(?:[._/@:-][A-Za-z0-9]+)+" +) + + +@dataclass +class Stats: + word_count: int = 0 + char_count: int = 0 + han_chars: int = 0 + english_words: int = 0 + number_words: int = 0 + chinese_punctuations: int = 0 + english_letters: int = 0 + digits: int = 0 + english_punctuations: int = 0 + symbol_words: int = 0 + symbol_chars: int = 0 + + def to_dict(self) -> dict[str, object]: + return { + "word_count": self.word_count, + "char_count": self.char_count, + "breakdown": { + "han_chars": self.han_chars, + "english_words": self.english_words, + "number_words": self.number_words, + "chinese_punctuations": self.chinese_punctuations, + "english_letters": self.english_letters, + "digits": self.digits, + "english_punctuations": self.english_punctuations, + "symbol_words": self.symbol_words, + "symbol_chars": self.symbol_chars, + }, + } + + +def is_han(ch: str) -> bool: + code = ord(ch) + return ( + 0x3400 <= code <= 0x4DBF + or 0x4E00 <= code <= 0x9FFF + or 0xF900 <= code <= 0xFAFF + or 0x20000 <= code <= 0x2A6DF + or 0x2A700 <= code <= 0x2B73F + or 0x2B740 <= code <= 0x2B81F + or 0x2B820 <= code <= 0x2CEAF + or 0x30000 <= code <= 0x3134F + ) + + +def is_ascii_letter(ch: str) -> bool: + return ("a" <= ch <= "z") or ("A" <= ch <= "Z") + + +def is_digit(ch: str) -> bool: + return "0" <= ch <= "9" + + +def is_chinese_punctuation(ch: str) -> bool: + if ch in CHINESE_PUNCTUATION: + return True + return unicodedata.category(ch).startswith("P") and unicodedata.east_asian_width(ch) in { + "W", + "F", + } + + +def is_english_punctuation(ch: str) -> bool: + return ch in ENGLISH_PUNCTUATION + + +def is_unicode_symbol(ch: str) -> bool: + return unicodedata.category(ch).startswith("S") + + +def utf16_units(ch: str) -> int: + return len(ch.encode("utf-16-le")) // 2 + + +class Counter: + def __init__(self) -> None: + self.stats = Stats() + self._lexeme_kind: LexemeKind | None = None + self._lexeme_has_digit = False + self._symbol_run_length = 0 + self._at_boundary = True + + def count_segments(self, segments: list[Segment]) -> Stats: + for segment in segments: + if segment.boundary_before: + self._end_unit() + self._at_boundary = True + if segment.kind == "marker": + self.write_marker(segment.text) + elif segment.kind == "code": + self.write_code(segment.text) + else: + self.write(segment.text) + if segment.boundary_after: + self._end_unit() + self._at_boundary = True + self._end_unit() + return self.stats + + def write(self, text: str) -> None: + i = 0 + while i < len(text): + consumed = self._write_ascii_compound_token(text, i) + if consumed: + i += consumed + continue + if self._write_visible_ascii_separator(text, i): + i += 1 + continue + self._write_char(text[i]) + i += 1 + + def write_marker(self, text: str) -> None: + for ch in text: + if ch.isspace(): + continue + self._end_unit() + self.stats.word_count += 1 + self.stats.char_count += 1 + self._at_boundary = False + + def write_code(self, text: str) -> None: + for ch in text: + self._write_code_char(ch) + + def _write_code_char(self, ch: str) -> None: + if ch.isspace(): + self._end_unit() + self._at_boundary = True + return + + if is_han(ch): + self._end_lexeme() + self._end_symbol_run(count_word=False) + self.stats.han_chars += 1 + self.stats.word_count += 1 + self.stats.char_count += 1 + self._at_boundary = False + return + + if is_ascii_letter(ch): + self._end_symbol_run(count_word=False) + self.stats.english_letters += 1 + self.stats.char_count += 1 + if self._lexeme_kind is None: + self._lexeme_kind = "english" + elif self._lexeme_kind == "number": + self._lexeme_kind = "english" + self._at_boundary = False + return + + if is_digit(ch): + self._end_symbol_run(count_word=False) + self.stats.digits += 1 + self.stats.char_count += 1 + self._at_boundary = False + return + + if is_chinese_punctuation(ch): + self._end_lexeme() + self._end_symbol_run(count_word=False) + self.stats.chinese_punctuations += 1 + self.stats.word_count += 1 + self.stats.char_count += 1 + self._at_boundary = False + return + + if is_english_punctuation(ch): + keeps_lexeme = self._lexeme_kind == "english" and ch in {"'", "-"} + if not keeps_lexeme: + had_lexeme = self._lexeme_kind is not None + self._end_lexeme() + if not had_lexeme and (self._symbol_run_length > 0 or self._at_boundary): + self._symbol_run_length += 1 + self.stats.english_punctuations += 1 + self.stats.char_count += 1 + if keeps_lexeme: + self._at_boundary = False + return + + if is_unicode_symbol(ch): + self._write_symbol_char(ch) + return + + self._end_lexeme() + self._end_symbol_run(count_word=False) + self._at_boundary = False + + def _write_char(self, ch: str) -> None: + if ch.isspace(): + self._end_unit() + self._at_boundary = True + return + + if is_han(ch): + self._end_lexeme() + self._end_symbol_run(count_word=False) + self.stats.han_chars += 1 + self.stats.word_count += 1 + self.stats.char_count += 1 + self._at_boundary = False + return + + if is_ascii_letter(ch): + self._end_symbol_run(count_word=False) + self.stats.english_letters += 1 + self.stats.char_count += 1 + if self._lexeme_kind is None: + self._lexeme_kind = "english" + elif self._lexeme_kind == "number": + self._lexeme_kind = "english" + self._at_boundary = False + return + + if is_digit(ch): + self._end_symbol_run(count_word=False) + self.stats.digits += 1 + self.stats.char_count += 1 + self._lexeme_has_digit = True + if self._lexeme_kind is None: + self._lexeme_kind = "number" + self._at_boundary = False + return + + if is_chinese_punctuation(ch): + self._end_lexeme() + self._end_symbol_run(count_word=False) + self.stats.chinese_punctuations += 1 + self.stats.word_count += 1 + self.stats.char_count += 1 + self._at_boundary = False + return + + if is_english_punctuation(ch): + # Apostrophes/hyphens can connect English runs. Dot/comma/hyphen + # can format numeric runs such as 3.14, 1,000, 2026-06-30, or + # 7-9. Alphanumeric versions like v1.2.3 should remain one semantic + # run too. These punctuations still count as characters. + keeps_lexeme = ( + self._lexeme_kind == "english" + and (ch in {"'", "-"} or (self._lexeme_has_digit and ch == ".")) + ) or ( + self._lexeme_kind == "number" + and ch in {".", ",", "-"} + ) + if not keeps_lexeme: + had_lexeme = self._lexeme_kind is not None + self._end_lexeme() + if not had_lexeme and (self._symbol_run_length > 0 or self._at_boundary): + self._symbol_run_length += 1 + self.stats.english_punctuations += 1 + self.stats.char_count += 1 + if keeps_lexeme: + self._at_boundary = False + return + + if is_unicode_symbol(ch): + self._write_symbol_char(ch) + return + + self._end_lexeme() + self._end_symbol_run(count_word=False) + self._at_boundary = False + + def _write_visible_ascii_separator(self, text: str, index: int) -> bool: + ch = text[index] + if ch != "/" or index == 0 or index + 1 >= len(text): + return False + if not is_han(text[index - 1]) or not is_han(text[index + 1]): + return False + + self._end_unit() + self.stats.english_punctuations += 1 + self.stats.symbol_words += 1 + self.stats.word_count += 1 + self.stats.char_count += 1 + self._at_boundary = False + return True + + def _write_ascii_compound_token(self, text: str, start: int) -> int: + token = self._match_ascii_compound_token(text, start) + if not token: + return 0 + + self._end_unit() + self.stats.english_words += 1 + self.stats.word_count += 1 + for ch in token: + if is_ascii_letter(ch): + self.stats.english_letters += 1 + self.stats.char_count += 1 + elif is_digit(ch): + self.stats.digits += 1 + self.stats.char_count += 1 + elif is_english_punctuation(ch): + self.stats.english_punctuations += 1 + self.stats.char_count += 1 + elif is_unicode_symbol(ch): + units = utf16_units(ch) + self.stats.symbol_chars += units + self.stats.char_count += units + elif is_chinese_punctuation(ch): + self.stats.chinese_punctuations += 1 + self.stats.char_count += 1 + elif is_han(ch): + self.stats.han_chars += 1 + self.stats.char_count += 1 + self._at_boundary = False + return len(token) + + def _match_ascii_compound_token(self, text: str, start: int) -> str | None: + match = URL_TOKEN_RE.match(text, start) + if match: + return match.group(0) + + match = ASCII_COMPOUND_TOKEN_RE.match(text, start) + if not match: + return None + token = match.group(0) + if any(is_ascii_letter(ch) for ch in token): + return token + return None + + def _write_symbol_char(self, ch: str) -> None: + self._end_lexeme() + self._end_symbol_run(count_word=False) + units = utf16_units(ch) + self.stats.symbol_words += 1 + self.stats.symbol_chars += units + self.stats.word_count += 1 + self.stats.char_count += units + self._at_boundary = False + + def _end_unit(self) -> None: + self._end_lexeme() + self._end_symbol_run(count_word=True) + + def _end_lexeme(self) -> None: + if self._lexeme_kind == "english": + self.stats.english_words += 1 + self.stats.word_count += 1 + elif self._lexeme_kind == "number": + self.stats.number_words += 1 + self.stats.word_count += 1 + self._lexeme_kind = None + self._lexeme_has_digit = False + + def _end_symbol_run(self, *, count_word: bool) -> None: + if self._symbol_run_length >= 1 and count_word: + self.stats.symbol_words += 1 + self.stats.word_count += 1 + if self._symbol_run_length: + self._at_boundary = False + self._symbol_run_length = 0 + +# --------------------------------------------------------------------------- +# Markdown parser +# --------------------------------------------------------------------------- + +HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$") +LIST_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])\s+(.*)$") +QUOTE_RE = re.compile(r"^\s*>\s?(.*)$") +TABLE_SEP_RE = re.compile(r"^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$") + + +def parse_markdown(source: str) -> list[Block]: + lines = source.splitlines() + blocks: list[Block] = [] + paragraph: list[str] = [] + i = 0 + + def flush_paragraph() -> None: + if paragraph: + blocks.append(Block(type="paragraph", text_runs=[TextRun(clean_inline(" ".join(paragraph)))])) + paragraph.clear() + + while i < len(lines): + line = lines[i] + stripped = line.strip() + if not stripped: + flush_paragraph() + i += 1 + continue + + if stripped.startswith("```") or stripped.startswith("~~~"): + flush_paragraph() + fence = stripped[:3] + code_lines: list[str] = [] + i += 1 + while i < len(lines) and not lines[i].strip().startswith(fence): + code_lines.append(lines[i]) + i += 1 + if i < len(lines): + i += 1 + blocks.append(Block(type="code", text_runs=[TextRun("\n".join(code_lines))])) + continue + + heading = HEADING_RE.match(line) + if heading: + flush_paragraph() + blocks.append(Block(type="heading", text_runs=[TextRun(clean_inline(heading.group(2)))])) + i += 1 + continue + + if _looks_like_table(lines, i): + flush_paragraph() + table, consumed = _parse_table(lines, i) + blocks.append(table) + i += consumed + continue + + item = LIST_RE.match(line) + if item: + flush_paragraph() + items: list[Block] = [] + while i < len(lines): + match = LIST_RE.match(lines[i]) + if not match: + break + items.append(Block(type="list_item", text_runs=[TextRun(clean_inline(match.group(1)))])) + i += 1 + blocks.append(Block(type="list", children=items)) + continue + + quote = QUOTE_RE.match(line) + if quote: + flush_paragraph() + quote_lines: list[str] = [] + while i < len(lines): + match = QUOTE_RE.match(lines[i]) + if not match: + break + quote_lines.append(match.group(1)) + i += 1 + blocks.append(Block(type="quote", text_runs=[TextRun(clean_inline(" ".join(quote_lines)))])) + continue + + paragraph.append(stripped) + i += 1 + + flush_paragraph() + return blocks + + +def clean_inline(text: str) -> str: + text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", text) + text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text) + text = re.sub(r"([*_`~]{1,3})(.*?)\1", r"\2", text) + text = text.replace("\\", "") + return text + + +def _looks_like_table(lines: list[str], i: int) -> bool: + return i + 1 < len(lines) and "|" in lines[i] and TABLE_SEP_RE.match(lines[i + 1]) is not None + + +def _parse_table(lines: list[str], i: int) -> tuple[Block, int]: + rows: list[Block] = [] + consumed = 0 + while i + consumed < len(lines): + line = lines[i + consumed] + stripped = line.strip() + if not stripped or "|" not in stripped: + break + if consumed == 1 and TABLE_SEP_RE.match(stripped): + consumed += 1 + continue + cells = [cell.strip() for cell in stripped.strip("|").split("|")] + row = Block( + type="tr", + children=[ + Block(type="table_cell", text_runs=[TextRun(clean_inline(cell))]) + for cell in cells + if cell + ], + ) + rows.append(row) + consumed += 1 + return Block(type="table", children=rows), consumed + +# --------------------------------------------------------------------------- +# XML parser +# --------------------------------------------------------------------------- + +INLINE_TAGS = { + "b", + "strong", + "i", + "em", + "u", + "s", + "del", + "span", + "text", + "plain_text", + "code", + "a", + "link", + "mention", + "mention-doc", + "mention-user", +} + +TYPE_ALIASES = { + "doc": "document", + "document": "document", + "fragment": "fragment", + "p": "paragraph", + "paragraph": "paragraph", + "heading": "heading", + "h1": "heading", + "h2": "heading", + "h3": "heading", + "h4": "heading", + "h5": "heading", + "h6": "heading", + "h7": "heading", + "h8": "heading", + "h9": "heading", + "ul": "list", + "ol": "list", + "li": "list_item", + "task": "task", + "todo": "list_item", + "blockquote": "quote", + "quote": "quote", + "br": "br", + "hr": "hr", + "title": "title", + "checkbox": "checkbox", + "grid": "grid", + "column": "column", + "table": "table", + "colgroup": "colgroup", + "col": "col", + "tr": "tr", + "td": "table_cell", + "th": "table_cell", + "pre": "code", + "code_block": "code", + "callout": "callout", + "figure": "figure", + "toggle": "toggle", + "img": "image", + "source": "source", + "file": "file", + "media": "media", + "latex": "latex", + "cite": "cite", + "bookmark": "bookmark", + "button": "button", + "whiteboard": "whiteboard", + "mermaid": "mermaid", + "plantuml": "plantuml", + "poll": "poll", + "isv": "isv", + "mindnote": "mindnote", + "diagram": "diagram", + "sheet": "sheet", + "bitable": "bitable", + "base-ref": "base_ref", + "base_ref": "base_ref", + "base-refer": "base_ref", + "base_refer": "base_ref", + "synced-reference": "synced_reference", + "synced_reference": "synced_reference", + "synced-source": "synced_source", + "synced_source": "synced_source", + "okr": "okr", + "chat-card": "chat_card", + "chat_card": "chat_card", + "sub_page_list": "sub-page-list", + "sub-page-list": "sub-page-list", +} + +SUBTYPE_ATTR_TAGS = { + "a", + "button", + "cite", + "img", + "sheet", + "source", + "whiteboard", + "base-ref", + "base_ref", + "base-refer", + "base_refer", + "synced-reference", + "synced_reference", + "synced-source", + "synced_source", + "okr", + "chat-card", + "chat_card", + "sub_page_list", + "sub-page-list", +} + +MAX_XML_INPUT_CHARS = 20_000_000 +FORBIDDEN_XML_DECL_RE = re.compile(r" str: + if "}" in tag: + return tag.rsplit("}", 1)[1] + return tag + + +def block_type_for(elem: ET.Element) -> str: + tag = local_name(elem.tag) + explicit = elem.attrib.get("block_type") + if explicit is None and tag not in SUBTYPE_ATTR_TAGS: + explicit = elem.attrib.get("type") + if explicit: + return TYPE_ALIASES.get(explicit, explicit) + return TYPE_ALIASES.get(tag, tag) + + +def ensure_safe_xml_source(source: str) -> None: + if len(source) > MAX_XML_INPUT_CHARS: + raise UserInputError( + f"XML input is too large ({len(source)} chars, limit {MAX_XML_INPUT_CHARS})" + ) + if FORBIDDEN_XML_DECL_RE.search(source): + raise UserInputError("XML input must not contain DOCTYPE or ENTITY declarations") + + +def parse_xml(source: str) -> list[Block]: + source = source.strip() + if not source: + return [] + ensure_safe_xml_source(source) + try: + root = ET.fromstring(source) + except ET.ParseError: + # docs +fetch raw output can occasionally include adjacent top-level + # blocks. Wrap them so standard ElementTree can parse the stream. + root = ET.fromstring(f"{source}") + return [_parse_block(root)] + + +def _parse_block(elem: ET.Element) -> Block: + block = Block(type=block_type_for(elem), attrs=dict(elem.attrib), raw=elem) + _collect_content(elem, block) + if not block.text_runs and not block.children: + if block.type == "image": + display = elem.attrib.get("caption") + else: + display = ( + elem.attrib.get("text") + or elem.attrib.get("name") + or elem.attrib.get("title") + or elem.attrib.get("alt") + or elem.attrib.get("caption") + ) + if display: + block.text_runs.append(TextRun(display, dict(elem.attrib))) + return block + + +def _collect_content(elem: ET.Element, block: Block) -> None: + if elem.text: + block.text_runs.append(TextRun(elem.text)) + + for child in list(elem): + tag = local_name(child.tag) + if tag == "br": + block.text_runs.append(TextRun("\n")) + elif tag in INLINE_TAGS: + _collect_inline(child, block) + else: + block.children.append(_parse_block(child)) + if child.tail: + block.text_runs.append(TextRun(child.tail)) + + +def _collect_inline(elem: ET.Element, block: Block) -> None: + if local_name(elem.tag) == "br": + block.text_runs.append(TextRun("\n", dict(elem.attrib))) + return + + display = ( + elem.attrib.get("text") + or elem.attrib.get("name") + or elem.attrib.get("title") + or elem.attrib.get("alt") + ) + if display: + block.text_runs.append(TextRun(display, dict(elem.attrib))) + return + + if elem.text: + block.text_runs.append(TextRun(elem.text, dict(elem.attrib))) + for child in list(elem): + _collect_inline(child, block) + if child.tail: + block.text_runs.append(TextRun(child.tail)) + +# --------------------------------------------------------------------------- +# Block extraction registry +# --------------------------------------------------------------------------- + +@dataclass +class ExtractContext: + unknown_blocks: list[UnknownBlock] = field(default_factory=list) + resource_texts: dict[str, str] = field(default_factory=dict) + + +class Handler(Protocol): + def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: + raise NotImplementedError + + +def block_id(block: Block) -> str | None: + for key in ("id", "block_id", "block-id", "token"): + value = block.attrs.get(key) + if isinstance(value, str) and value: + return value + return None + + +def runs_text(block: Block) -> str: + return "".join(run.text for run in block.text_runs) + + +def raw_tag(block: Block) -> str: + tag = getattr(getattr(block, "raw", None), "tag", "") or "" + if "}" in tag: + return tag.rsplit("}", 1)[1] + return tag + + +class TextBlockHandler: + def __init__(self, kind: str = "text") -> None: + self.kind = kind + + def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: + segments: list[Segment] = [] + text = runs_text(block) + if text.strip(): + segments.append( + Segment( + text=text, + block_type=block.type, + block_id=block_id(block), + kind=self.kind, + ) + ) + for child in block.children: + segments.extend(registry.extract(child, ctx)) + return segments + + +class ContainerHandler: + def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: + segments: list[Segment] = [] + text = runs_text(block) + if text.strip(): + segments.append( + Segment(text=text, block_type=block.type, block_id=block_id(block), kind="text") + ) + for child in block.children: + segments.extend(registry.extract(child, ctx)) + return segments + + +class ListHandler: + def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: + tag = raw_tag(block) + if tag not in {"ol", "ul"}: + return ContainerHandler().extract(block, registry, ctx) + + segments: list[Segment] = [] + text = runs_text(block) + if text.strip(): + segments.append( + Segment(text=text, block_type=block.type, block_id=block_id(block), kind="text") + ) + + next_seq = 1 + for child in block.children: + if child.type == "list_item": + if tag == "ol": + seq = child.attrs.get("seq") + if isinstance(seq, str) and seq.isdigit(): + marker = seq + next_seq = int(seq) + 1 + else: + marker = str(next_seq) + next_seq += 1 + segments.append( + Segment( + text=f"{marker}.", + block_type="list_marker", + block_id=block_id(child), + kind="text", + ) + ) + else: + segments.append( + Segment( + text="•", + block_type="list_marker", + block_id=block_id(child), + kind="marker", + ) + ) + segments.extend(registry.extract(child, ctx)) + return segments + + +class CheckboxHandler(TextBlockHandler): + def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: + return [ + Segment( + text="☑" if block.attrs.get("done") == "true" else "☐", + block_type="checkbox_marker", + block_id=block_id(block), + kind="marker", + ), + *super().extract(block, registry, ctx), + ] + + +class UnknownHandler: + def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: + ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block))) + return ContainerHandler().extract(block, registry, ctx) + + +class IgnoreHandler: + def __init__(self, action: str = "ignored") -> None: + self.action = action + + def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: + ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action=self.action)) + return [] + + +class TaskHandler: + def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: + task_id = block.attrs.get("task-id") or block.attrs.get("task_id") + if isinstance(task_id, str) and task_id: + text = ctx.resource_texts.get(f"task:{task_id}") + if text and text.strip(): + marker = "☑" if block.attrs.get("status") in {"done", "completed", "complete"} else "☐" + return [ + Segment(text=marker, block_type="task_marker", block_id=block_id(block), kind="marker"), + Segment(text=text, block_type="task", block_id=block_id(block), kind="resource_title"), + ] + + ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action="ignored_resource")) + return [] + + +class WhiteboardHandler: + def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: + board_type = block.attrs.get("type") + is_empty_resource_shell = not board_type and not block.children and not runs_text(block).strip() + action = ( + "ignored_resource" + if board_type in {"blank", "mermaid", "plantuml", "svg"} or is_empty_resource_shell + else "unsupported_resource" + ) + ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action=action)) + return [] + + +class SyncedSourceHandler: + def extract(self, block: Block, registry: "Registry", ctx: ExtractContext) -> list[Segment]: + if block.children: + segments: list[Segment] = [] + for child in block.children: + segments.extend(registry.extract(child, ctx)) + return segments + + ctx.unknown_blocks.append(UnknownBlock(type=block.type, block_id=block_id(block), action="unsupported_resource")) + return [] + + +class Registry: + def __init__(self) -> None: + self._handlers: dict[str, Handler] = {} + self._unknown = UnknownHandler() + + def register(self, *types: str, handler: Handler) -> None: + for typ in types: + self._handlers[typ] = handler + + def extract(self, block: Block, ctx: ExtractContext) -> list[Segment]: + handler = self._handlers.get(block.type, self._unknown) + return handler.extract(block, self, ctx) + + +def default_registry() -> Registry: + registry = Registry() + registry.register("document", "fragment", "root", handler=ContainerHandler()) + registry.register("title", handler=TextBlockHandler("title")) + registry.register("paragraph", "p", handler=TextBlockHandler("text")) + registry.register( + "heading", + "h", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "h7", + "h8", + "h9", + handler=TextBlockHandler("heading"), + ) + registry.register("list", "ul", "ol", handler=ListHandler()) + registry.register("list_item", "li", "todo", handler=TextBlockHandler("list_item")) + registry.register("checkbox", handler=CheckboxHandler("list_item")) + registry.register( + "quote", + "blockquote", + "callout", + "toggle", + "grid", + "column", + "figure", + handler=ContainerHandler(), + ) + registry.register("table", "thead", "tbody", "tr", handler=ContainerHandler()) + registry.register("table_cell", "td", "th", handler=TextBlockHandler("table_cell")) + registry.register("code", "code_block", "pre", handler=TextBlockHandler("code")) + registry.register("link", "a", "mention", "mention-doc", "mention-user", "time", handler=TextBlockHandler("inline")) + registry.register("image", "img", handler=TextBlockHandler("caption")) + registry.register("colgroup", "col", "br", "hr", handler=IgnoreHandler("ignored_structure")) + registry.register("button", "cite", "latex", "bookmark", handler=IgnoreHandler("ignored_inline")) + registry.register("task", handler=TaskHandler()) + registry.register("whiteboard", handler=WhiteboardHandler()) + registry.register("synced_source", handler=SyncedSourceHandler()) + registry.register( + "mermaid", + "sheet", + "source", + "file", + "media", + "chat_card", + "base_ref", + "bitable", + "synced_reference", + "poll", + "isv", + "mindnote", + "diagram", + "sub-page-list", + handler=IgnoreHandler("ignored_resource"), + ) + registry.register( + "okr", + "plantuml", + handler=IgnoreHandler("unsupported_resource"), + ) + return registry + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +VERSION = "0.1-alpha" + + +def build_diagnostics(items: list) -> dict[str, object]: + actions: dict[str, int] = {} + types: dict[str, int] = {} + unsupported_types: dict[str, int] = {} + unknown_types: dict[str, int] = {} + for item in items: + actions[item.action] = actions.get(item.action, 0) + 1 + types[item.type] = types.get(item.type, 0) + 1 + if item.action == "unsupported_resource": + unsupported_types[item.type] = unsupported_types.get(item.type, 0) + 1 + if item.action == "recurse_children": + unknown_types[item.type] = unknown_types.get(item.type, 0) + 1 + return { + "actions": actions, + "types": types, + "unsupported_types": unsupported_types, + "unknown_types": unknown_types, + "has_unsupported": bool(unsupported_types), + "has_unknown": bool(unknown_types), + } + + +def read_input(path: str) -> str: + if path == "-": + return sys.stdin.read() + return Path(path).read_text(encoding="utf-8") + + +def read_resource_texts(path: str | None) -> dict[str, str]: + if not path: + return {} + payload = json.loads(Path(path).read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("--resource-texts must be a JSON object") + return {str(key): str(value) for key, value in payload.items()} + + +def extract_lark_json_content(source: str) -> str: + try: + envelope = json.loads(source) + except json.JSONDecodeError as exc: + raise UserInputError(f"could not parse lark-cli JSON envelope: {exc}") from exc + + if not isinstance(envelope, dict): + raise UserInputError("lark-cli JSON envelope must be an object") + data = envelope.get("data") + if not isinstance(data, dict): + raise UserInputError("lark-cli JSON envelope is missing object field data") + document = data.get("document") + if not isinstance(document, dict): + raise UserInputError("lark-cli JSON envelope is missing object field data.document") + content = document.get("content") + if not isinstance(content, str): + raise UserInputError("lark-cli JSON envelope is missing string field data.document.content") + return content + + +HELP_EPILOG = """ +Examples: + Local XML file: + python3 doc_word_stat.py --protocol xml /absolute/path/doc.xml + + Local Markdown file: + python3 doc_word_stat.py --protocol md /absolute/path/doc.md + + Pipe an extracted local file: + cat /absolute/path/doc.xml | python3 doc_word_stat.py --protocol xml --pretty + + Lark CLI XML fetch, JSON envelope output: + lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \\ + | python3 doc_word_stat.py --protocol xml --lark-json --pretty + + Lark CLI Markdown fetch, raw content output: + lark-cli docs +fetch --doc "$URL" --doc-format markdown \\ + | python3 doc_word_stat.py --protocol md + + Strict integration for agents or automation: + lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \\ + | python3 doc_word_stat.py --protocol xml --lark-json --fail-on-unsupported --fail-on-unknown +""" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Count semantic words and visible characters in Lark Docs XML or Markdown.", + epilog=HELP_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--version", + action="version", + version=f"%(prog)s {VERSION}", + ) + parser.add_argument( + "input", + nargs="?", + default="-", + help="input file path, or '-' / omitted for stdin", + ) + parser.add_argument( + "--protocol", + choices=("xml", "md"), + required=True, + help="input protocol produced by docs +fetch", + ) + parser.add_argument( + "--pretty", + action="store_true", + help="pretty-print JSON output", + ) + parser.add_argument( + "--segments", + action="store_true", + help="include extracted text segments for debugging", + ) + parser.add_argument( + "--lark-json", + action="store_true", + help="read lark-cli docs +fetch JSON and count data.document.content", + ) + parser.add_argument( + "--resource-texts", + help='optional JSON object mapping resource keys to visible text, e.g. {"task:": "title"}', + ) + parser.add_argument( + "--fail-on-unsupported", + action="store_true", + help="exit with code 2 when unsupported_blocks is non-empty", + ) + parser.add_argument( + "--fail-on-unknown", + action="store_true", + help="exit with code 3 when unknown XML/Markdown block types are encountered", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + source = read_input(args.input) + if args.lark_json: + try: + source = extract_lark_json_content(source) + except UserInputError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + if args.protocol == "xml": + try: + blocks = parse_xml(source) + except (ET.ParseError, UserInputError) as exc: + print(f"error: could not parse XML input: {exc}", file=sys.stderr) + return 1 + else: + blocks = parse_markdown(source) + + ctx = ExtractContext(resource_texts=read_resource_texts(args.resource_texts)) + registry = default_registry() + segments = [] + for block in blocks: + segments.extend(registry.extract(block, ctx)) + + stats = Counter().count_segments(segments) + payload = stats.to_dict() + payload["protocol"] = args.protocol + payload["unknown_blocks"] = [item.to_dict() for item in ctx.unknown_blocks] + payload["unsupported_blocks"] = [ + item.to_dict() for item in ctx.unknown_blocks if item.action == "unsupported_resource" + ] + payload["diagnostics"] = build_diagnostics(ctx.unknown_blocks) + if args.segments: + payload["segments"] = [segment.to_dict() for segment in segments] + + indent = 2 if args.pretty else None + print(json.dumps(payload, ensure_ascii=False, indent=indent, sort_keys=args.pretty)) + if args.fail_on_unsupported and payload["unsupported_blocks"]: + return 2 + if args.fail_on_unknown and payload["diagnostics"]["has_unknown"]: + return 3 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 393e1381..a1f1b908 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -16,8 +16,11 @@ metadata: > **导入分流规则:** 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,必须优先使用 `lark-cli drive +import --type bitable`。不要先切到 `lark-base`;`lark-base` 只负责导入完成后的表内操作。 +> **副本分流规则:** 如果用户要复制在线文档、创建文档副本、把文档复制到另一个文件夹,必须使用 `lark-cli drive files copy`。不要用 `drive +export` 下载后再 `drive +import` 上传,也不要用 `docs +fetch` + `docs +create` 重建正文;导出/导入只用于本地文件转换或离线产物。 + ## 快速决策 +- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token,先用 `lark-cli drive +inspect` 获取底层 `token` 和 `type`,不要把 wiki token 直接当 `file_token`。`params.file_token` 传源文档 token,`data.folder_token` 传目标文件夹 token,`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":""}' --data '{"folder_token":"","name":"","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。 - 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。 - 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow-knowledge-organize.md`](references/lark-drive-workflow-knowledge-organize.md)。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。 - 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。 @@ -161,7 +164,7 @@ lark-cli drive [flags] # 调用 API ### files - - `copy` — 复制文件 + - `copy` — 复制文件;在线文档创建副本的首选能力,完整参数见上方“快速决策”,不要用 `drive +export` / `drive +import` 绕行复制 - `create_folder` — 新建文件夹 - `list` — 获取文件夹下的清单;使用前阅读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md) - `patch` — 修改文件标题 diff --git a/skills/lark-drive/references/lark-drive-search.md b/skills/lark-drive/references/lark-drive-search.md index 20be6810..0021d14f 100644 --- a/skills/lark-drive/references/lark-drive-search.md +++ b/skills/lark-drive/references/lark-drive-search.md @@ -3,7 +3,7 @@ > **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 -基于 Search v2 接口 `POST /open-apis/search/v2/doc_wiki/search`,以**用户身份**统一搜索云空间(云盘/云存储)对象。 +基于 Search v2 接口 `POST /open-apis/search/v2/doc_wiki/search`,支持以**用户身份或应用身份**统一搜索云空间(云盘/云存储)对象。 核心特性: @@ -14,6 +14,8 @@ > **资源发现入口统一**:`drive +search` 同样返回 `SHEET` / `Base` / `FOLDER` 等全部云空间(云盘/云存储)对象,不只是文档 / Wiki。用户说"找一个表格"、"找报表"、"最近打开的表格"时,也从这里开始;定位后再切到对应业务 skill(如 `lark-sheets`)做对象内部操作。 +> **身份边界**:普通关键词、类型、文件夹、Wiki 空间、owner/open_id 等显式过滤支持 `--as user` 或 `--as bot`。`--mine` / `--created-by-me` 依赖当前登录用户 open_id 自动填充过滤条件;应用身份下如果没有配置用户 open_id,请改用显式 `--creator-ids` / `--original-creator-ids`。 + ## 命令 > **关键约束:搜索关键词必须通过 `--query` 传递。** diff --git a/skills/lark-event/SKILL.md b/skills/lark-event/SKILL.md index 217b5055..885446e6 100644 --- a/skills/lark-event/SKILL.md +++ b/skills/lark-event/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-event version: 1.0.0 -description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume ` (covers IM messages/reactions/chat changes, Task updates, VC meeting ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses." +description: "Lark/Feishu real-time event listening / subscribing / consuming: stream events as NDJSON via `lark-cli event consume ` (covers IM messages/reactions/chat changes, Task updates, VC meeting started/joined/ended, Minutes generated, Whiteboard updated, etc.). Use for Lark bots, real-time message processing, long-running subscribers, streaming webhook/push handlers. Supports `--max-events` / `--timeout` bounded runs and a stderr ready-marker contract — designed for AI agents running as subprocesses." metadata: requires: bins: ["lark-cli"] @@ -149,6 +149,6 @@ Lark-defined semantic tags (**not** JSON Schema's standard `format`). Common val |------------|------------------------------------------------------------------------------|---| | IM | [`references/lark-event-im.md`](references/lark-event-im.md) | Catalog of 12 IM EventKeys + shape notes (flat vs V2 envelope) + `im.message.receive_v1` field gotchas (`sender_id` is open_id only; `.content` is plain text except for `interactive` cards) + common jq recipes (filter by chat_type / message_type / sender); for `card.action.trigger` see also [`../lark-im/references/lark-im-card-action-reply.md`](../lark-im/references/lark-im-card-action-reply.md) | | Task | [`references/lark-event-task.md`](references/lark-event-task.md) | Catalog of 1 Task EventKey (`task.task.update_user_access_v2`) + Native V2 envelope shape + task commit types + user/bot subscription notes | -| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 2 VC EventKeys (`vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) | +| VC | [`references/lark-event-vc.md`](references/lark-event-vc.md) | Catalog of 4 VC EventKeys (`vc.meeting.participant_meeting_started_v1`, `vc.meeting.participant_meeting_joined_v1`, `vc.meeting.participant_meeting_ended_v1`, `vc.note.generated_v1`) + field reference + source type semantics (meeting only) | | Minutes | [`references/lark-event-minutes.md`](references/lark-event-minutes.md) | Catalog of 1 Minutes EventKey (`minutes.minute.generated_v1`) + field reference + source type semantics (meeting only) | | Whiteboard | [`references/lark-event-whiteboard.md`](references/lark-event-whiteboard.md) | Catalog of 1 Board EventKey (`board.whiteboard.updated_v1`) + per-whiteboard subscription model (requires `-p whiteboard_id=`) + payload field reference (whiteboard_id / operator_ids triple-id) | diff --git a/skills/lark-event/references/lark-event-vc.md b/skills/lark-event/references/lark-event-vc.md index 37e54cd5..16679aae 100644 --- a/skills/lark-event/references/lark-event-vc.md +++ b/skills/lark-event/references/lark-event-vc.md @@ -2,48 +2,60 @@ > **Prerequisite:** Read [`../SKILL.md`](../SKILL.md) first for the `event consume` essentials (commands, subprocess contract, jq usage). -## Key catalog (2) +## Key catalog (4) | EventKey | Purpose | |---|---| +| `vc.meeting.participant_meeting_started_v1` | A meeting the current user participates in has started | +| `vc.meeting.participant_meeting_joined_v1` | The current user has joined a meeting | | `vc.meeting.participant_meeting_ended_v1` | A meeting the current user participates in has ended | | `vc.note.generated_v1` | A note has been generated (meeting, recording, upload, etc.) | -Both keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. Both require `--as user`. +All four keys use a **Custom schema** (flat output) and carry a **PreConsume hook** that auto-subscribes / unsubscribes via OAPI on first / last consumer. All require `--as user`. ## Scopes & auth | EventKey | Scope | Auth | |---|---|---| +| `vc.meeting.participant_meeting_started_v1` | `vc:meeting.meetingevent:read` | user | +| `vc.meeting.participant_meeting_joined_v1` | `vc:meeting.meetingevent:read` | user | | `vc.meeting.participant_meeting_ended_v1` | `vc:meeting.meetingevent:read` | user | | `vc.note.generated_v1` | `vc:note:read` | user | --- -## `vc.meeting.participant_meeting_ended_v1` +## Meeting participant events + +Covered keys: + +- `vc.meeting.participant_meeting_started_v1` +- `vc.meeting.participant_meeting_joined_v1` +- `vc.meeting.participant_meeting_ended_v1` ### Output fields | Field | Type | Description | |---|---|---| -| `type` | string | Event type; always `vc.meeting.participant_meeting_ended_v1` | +| `type` | string | Event type; one of the covered meeting participant EventKeys | | `event_id` | string | Globally unique event ID; safe for deduplication | | `timestamp` | string (timestamp_ms) | Event delivery time (ms timestamp string) | | `meeting_id` | string | Meeting ID | | `topic` | string | Meeting topic | | `meeting_no` | string | Meeting number | | `start_time` | string | Meeting start time in RFC3339, converted to the local timezone | -| `end_time` | string | Meeting end time in RFC3339, converted to the local timezone | | `calendar_event_id` | string | Calendar event ID associated with the meeting | +| `end_time` | string | Meeting end time in RFC3339, converted to the local timezone; only present for `vc.meeting.participant_meeting_ended_v1` | ### Gotchas -- `start_time` / `end_time` are **not** the raw unix-seconds from OAPI — the Process hook converts them to local-timezone RFC3339. If the raw value is empty or non-numeric, the field is left empty. +- `start_time` / `end_time` are **not** the raw unix-seconds from OAPI — the Process hook converts them to local-timezone RFC3339. If the raw value is empty or non-numeric, the field is left empty. `end_time` is emitted only for `vc.meeting.participant_meeting_ended_v1`. - No detail API call is made; all fields come from the event payload itself. ### Example ```bash +lark-cli event consume vc.meeting.participant_meeting_started_v1 --as user +lark-cli event consume vc.meeting.participant_meeting_joined_v1 --as user lark-cli event consume vc.meeting.participant_meeting_ended_v1 --as user # Project meeting topic and end time only diff --git a/skills/lark-im/SKILL.md b/skills/lark-im/SKILL.md index 76eec025..d07ceb3a 100644 --- a/skills/lark-im/SKILL.md +++ b/skills/lark-im/SKILL.md @@ -59,6 +59,8 @@ The four message-pulling shortcuts (`+messages-mget`, `+chat-messages-list`, `+m ### Card Messages (Interactive) +**Before sending or replying with any `interactive` card (`+messages-send` / `+messages-reply`), you MUST read [`references/card/lark-im-card-create.md`](references/card/lark-im-card-create.md) and follow its workflow.** The card JSON passed to `--msg-type interactive --content` must be the output of that workflow — never hand-write or copy a card payload. + Card messages (`interactive` type) are not yet supported for compact conversion in event subscriptions. The raw event data will be returned instead, with a hint printed to stderr. `interactive` cards support callback events (`card.action.trigger`) — see [`references/lark-im-card-action-reply.md`](references/lark-im-card-action-reply.md). @@ -102,6 +104,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im + [flags]`)。 |----------|------| | [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager | | [`+chat-list`](references/lark-im-chat-list.md) | List chats the current user/bot is a member of; defaults to groups; pass --types=p2p,group to include p2p single chats (user-only); user/bot; supports sorting, pagination, --exclude-muted (user-only) | +| [`+chat-members-list`](references/lark-im-chat-members-list.md) | List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket | | [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination | | [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by --query keyword and/or --member-ids; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, pagination, and --exclude-muted (user identity only) | | [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description | @@ -139,10 +142,8 @@ lark-cli im [flags] # 调用 API ### chat.members - - `bots` — 获取群内机器人列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats. - `create` — 将用户或机器人拉入群聊。Identity: supports `user` and `bot`; the caller must be in the target chat; for `bot` calls, added users must be within the app's availability; for internal chats the operator must belong to the same tenant; if only owners/admins can add members, the caller must be an owner/admin, or a chat-creator bot with `im:chat:operate_as_owner`. - `delete` — 将用户或机器人移出群聊。Identity: supports `user` and `bot`; only group owner, admin, or creator bot can remove others; max 50 users or 5 bots per request. - - `get` — 获取群成员列表。Identity: supports `user` and `bot`; the caller must be in the target chat and must belong to the same tenant for internal chats. ### chat.user_setting @@ -213,10 +214,10 @@ lark-cli im [flags] # 调用 API | `chats.get` | `im:chat:read` | | `chats.link` | `im:chat:read` | | `chats.update` | `im:chat:update` | -| `chat.members.bots` | `im:chat.members:read` | | `chat.members.create` | `im:chat.members:write_only` | | `chat.members.delete` | `im:chat.members:write_only` | | `chat.members.get` | `im:chat.members:read` | +| `+chat-members-list` | `im:chat.members:read` | | `chat.user_setting.batch_query` | `im:chat.user_setting:read` | | `chat.user_setting.batch_update` | `im:chat.user_setting:write` | | `chat.managers.add_managers` | `im:chat.managers:write_only` | diff --git a/skills/lark-im/references/card/card-2.0-schema.md b/skills/lark-im/references/card/card-2.0-schema.md new file mode 100644 index 00000000..7d731c76 --- /dev/null +++ b/skills/lark-im/references/card/card-2.0-schema.md @@ -0,0 +1,107 @@ +# 卡片 2.0 组件大纲 + +Card 2.0 组件按**容器 / 展示 / 交互**三类,均通过 `tag` 字段声明。先在下表按用途选组件,再点明细看字段:有明细文件的点 `components/.md`(完整字段+示例+易错点),低频组件点链接看官方文档。 + +## 根结构 + +顶层固定四字段,先搭骨架再往 `body.elements` 填组件。以下为**推荐完整骨架**(含 type scale、light/dark color token、header 三件套): + +```json +{ + "schema": "2.0", + "config": { + "update_multi": true, + "width_mode": "default", + "style": { + "text_size": { + "title": { "default": "heading-2", "pc": "heading-2", "mobile": "heading-3" }, + "body": { "default": "normal", "pc": "normal", "mobile": "normal" }, + "caption": { "default": "notation", "pc": "notation", "mobile": "notation" } + }, + "color": { + "cus-primary": { "light_mode": "rgba(30,120,255,1)", "dark_mode": "rgba(80,150,255,1)" }, + "cus-primary-bg": { "light_mode": "rgba(30,120,255,0.08)", "dark_mode": "rgba(80,150,255,0.12)" }, + "cus-muted": { "light_mode": "rgba(100,106,115,1)", "dark_mode": "rgba(150,155,163,1)" } + } + } + }, + "header": { + "title": { "tag": "plain_text", "content": "卡片标题" }, + "subtitle": { "tag": "plain_text", "content": "副标题:一句上下文(时间/来源/状态)" }, + "template": "blue", + "icon": { "tag": "standard_icon", "token": "notice_colorful" }, + "text_tag_list": [ + { "tag": "text_tag", "text": { "tag": "plain_text", "content": "状态标签" }, "color": "blue" } + ] + }, + "body": { "direction": "vertical", "padding": "12px 12px 20px 12px", "elements": [] } +} +``` + +> **按需裁剪**:`subtitle` / `text_tag_list` / color token 按实际诉求取舍,不强制全用。组件里用 `"text_size": "title"` / `"caption"` 引用 token,用 `"font_color": "cus-muted"` 引用颜色 token;主色系变化时只需改 config 里的 RGBA,全卡自动跟随。 + +- `schema` 必须显式为 `"2.0"`,否则按 1.0 渲染。`header` 详见 `components/header.md`。 +- **元素通用字段**(所有 `elements[]` 组件):`tag`(必填) · `element_id`(卡内唯一,字母开头、≤20 字符) · `margin`(外边距 [-99,99]px)。 +- `card_link`(整卡跳转):`{url, pc_url, ios_url, android_url}`,至少填 `url`;某端禁跳设 `lark://msgcard/unsupported_action`。 +- 硬限制:单卡 ≤ **200** 元素;需客户端 **≥ 7.20**(旧版仅显示 header)。 +- 颜色 / 图标枚举见 `resource/colors.md` · `resource/icons.md`。 + +**config**(全局行为,可整体省略): + +| 字段 | 默认 | 说明 | +|---|---|---| +| `update_multi` | true | 共享卡片,v2 仅支持 true | +| `width_mode` | default | `default`(≤600px) / `compact`(400px) / `fill`(撑满) | +| `enable_forward` | true | 是否允许转发 | +| `summary` | — | 会话列表预览:`{content, i18n_content:{zh_cn,en_us,…}}` | +| `streaming_mode` | false | 流式更新模式(配 `streaming_config`) | +| `style.text_size` | — | 自定义字号 token,格式 `{"<名称>":{default,pc,mobile}}`;名称可自定义(如 `title`/`caption`),组件 `text_size` 引用该名称 | +| `style.color` | — | 自定义颜色 token,格式 `{"<名称>":{light_mode,dark_mode}}`(RGBA);名称可自定义(如 `cus-primary`),组件 `font_color`/`background_style` 等字段引用 | + +> 多语言:`config.locales` 限定生效语种、`use_custom_translation` 优先用自带 i18n。 + +**body 布局字段**(均 v2 新增):`direction`(vertical/horizontal) · `padding`([0,99]px) · `horizontal_spacing`/`vertical_spacing`(`small`4/`medium`8/`large`12/`extra_large`16 或 px) · `horizontal_align`/`vertical_align`。 + +--- + +## 容器类(布局 / 组织交互) + +| 组件 | 用途 | +|---|---| +| [column_set](components/column_set.md) | 横向分栏,多列图文对齐(数据表、字段对、列表) | +| [collapsible_panel](components/collapsible_panel.md) | 折叠面板,收纳备注/长文本等次要信息 | +| [form](components/form.md) | 表单容器,批量录入表单项后一次提交 | +| [interactive_container](components/interactive_container.md) | 整块可点击区域,可统一定义样式与交互 | +| [循环容器](components/recycling_container.md) | 批量渲染同版式不同数据(仅搭建工具) | + +## 展示类(无交互) + +| 组件 | 用途 | +|---|---| +| [header](components/header.md) | 卡片标题区:主/副标题、后缀标签、主题色 | +| [div](components/div.md) | 普通文本,带前缀图标、字段对 | +| [markdown](components/markdown.md) | 富文本,最常用;@人、彩色、链接、列表、表格等 | +| [img](components/img.md) | 单图 | +| [img_combination](components/img_combination.md) | 多图拼排(双图/三图/宫格) | +| [person](components/person.md) | 单个人员头像/姓名 | +| [person_list](components/person_list.md) | 多个人员头像/姓名 | +| [chart](components/chart.md) | VChart 图表(折线/柱/饼/词云等) | +| [table](components/table.md) | 多列数据表(只能放根节点) | +| [hr](components/hr.md) | 分割线 | + +## 交互类 + +| 组件 | 用途 | +|---|---| +| [button](components/button.md) | 按钮:回调 / 跳转 / 表单提交 | +| [input](components/input.md) | 文本输入框(多嵌在 form 内) | +| [overflow](components/overflow.md) | 折叠按钮组,收纳多个操作 | +| [select_static](components/select_static.md) | 下拉单选 | +| [multi_select_static](components/multi_select_static.md) | 下拉多选 | +| [select_person](components/select_person.md) | 人员单选 | +| [multi_select_person](components/multi_select_person.md) | 人员多选 | +| [date_picker](components/date_picker.md) | 日期选择器 | +| [picker_time](components/picker_time.md) | 时间选择器 | +| [picker_datetime](components/picker_datetime.md) | 日期时间选择器 | +| [select_img](components/select_img.md) | 图片选择(单/多选) | +| [checker](components/checker.md) | 勾选器,任务勾选回调 | diff --git a/skills/lark-im/references/card/components/button.md b/skills/lark-im/references/card/components/button.md new file mode 100644 index 00000000..3b240cf3 --- /dev/null +++ b/skills/lark-im/references/card/components/button.md @@ -0,0 +1,63 @@ +# 按钮 `button` + +交互按钮,支持跳转 / 回调 / 表单提交三类行为。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "button", + "text": { "tag": "plain_text", "content": "确定" }, + "type": "primary", + "behaviors": [{ "type": "callback", "value": { "action": "ok" } }] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `button` | +| `text` | 否 | Object | / | `{tag:"plain_text", content}`,≤100 字符 | +| `type` | 否 | String | default | 见下方 type 枚举 | +| `size` | 否 | String | medium | `tiny` / `small` / `medium` / `large` | +| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` | +| `behaviors` | 是* | Array | / | 交互行为,见下;表单内按钮不用 behaviors 而用 `form_action_type` | +| `icon` | 否 | Object | / | 前缀图标(同 `div.icon`) | +| `hover_tips` | 否 | Object | / | PC 端悬浮提示,plain_text | +| `disabled` | 否 | Boolean | false | 是否禁用 | +| `disabled_tips` | 否 | Object | / | 禁用后悬浮提示,plain_text | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}`(均 plain_text,title 必填) | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | +| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 | + +**type 枚举**:`default`(黑字描边) / `primary`(蓝字描边) / `danger`(红字描边) / `text` / `primary_text` / `danger_text`(无边框) / `primary_filled`(蓝底白字) / `danger_filled`(红底白字) / `laser`(镭射)。 + +## 按钮主次(强制) + +- 全卡仅 1 个按钮 → `type: "primary_filled"`,并 `width: "fill"` 撑满成强焦点。 +- 多个并列按钮 → 第一个(主操作)`primary_filled`,其余一律 `default`,形成「一主多次」层级。 +- 删除 / 拒绝等危险操作用 `danger` 系(`danger` 或 `danger_filled`)。 + +## behaviors(交互行为) + +```json +// 1. 服务端回调 +{ "type": "callback", "value": { "key": "v" } } +// 2. 跳转链接(可与 callback 同数组共存) +{ "type": "open_url", "default_url": "https://x", "pc_url": "", "ios_url": "", "android_url": "" } +``` + +表单容器内的按钮 **不用 behaviors**,改用根字段: + +| 字段 | 必填 | 说明 | +|---|---|---| +| `name` | 是 | 表单内唯一标识 | +| `form_action_type` | 是 | `submit`(提交表单)/ `reset`(重置) | + +## 嵌套 / 易错点 + +- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。 +- 2.0 已废弃 `action` 交互模块,按钮直接放 `elements`,用间距控制排列。 +- 旧式 `url`/`value` 顶层字段是 1.0 写法;2.0 一律用 `behaviors`。 +- 点击触发 `card.action.trigger`,回传 `action.tag="button"` + `action.value`(即 callback 的 value)。 diff --git a/skills/lark-im/references/card/components/chart.md b/skills/lark-im/references/card/components/chart.md new file mode 100644 index 00000000..eb0717d0 --- /dev/null +++ b/skills/lark-im/references/card/components/chart.md @@ -0,0 +1,57 @@ +# 图表 `chart` + +基于 VChart 的可视化图表(折线/柱/饼/词云等)。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "chart", + "chart_spec": { + "type": "line", + "title": { "text": "趋势" }, + "data": { "values": [ + { "time": "周一", "value": 8 }, + { "time": "周二", "value": 14 } + ] }, + "xField": "time", + "yField": "value" + } +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `chart` | +| `chart_spec` | 是 | Object | / | VChart 图表定义,见下 | +| `aspect_ratio` | 否 | String | 16:9(PC)/1:1(移动) | `1:1` / `2:1` / `4:3` / `16:9` | +| `color_theme` | 否 | String | brand | `brand` / `rainbow` / `complementary` / `converse` / `primary`;chart_spec 里声明了样式则此项无效 | +| `height` | 否 | String | auto | `auto`(按宽高比) 或 `[1,999]px`(设固定高则 aspect_ratio 失效) | +| `preview` | 否 | Boolean | true | 是否可独立窗口/全屏查看 | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | +| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 | + +## chart_spec 常用类型 + +`chart_spec` 是标准 VChart spec。核心字段:`type`、`data.values`(数据数组)、`xField`/`yField`(轴字段)、`seriesField`(分组)、`title.text`、`legends`。 + +| 图表 | type | 关键字段 | +|---|---|---| +| 折线 | `line` | `xField`, `yField` | +| 面积 | `area` | `xField`, `yField` | +| 柱状 | `bar` | `xField`, `yField`,分组加 `seriesField` | +| 条形(横向) | `bar` | `direction:"horizontal"`,`xField`=值,`yField`=类别 | +| 饼/环 | `pie` | `valueField`, `categoryField`,环图加 `innerRadius` | +| 散点 | `scatter` | `xField`, `yField` | +| 词云 | `wordCloud` | `nameField`, `valueField` | + +完整属性参考 [VChart 官方文档](https://www.visactor.io/vchart/option/barChart)。 + +## 易错点 + +- 不支持 JavaScript 语法,`chart_spec` 必须是纯 JSON。 +- 单卡建议 ≤5 个图表。 +- 移动端不支持部分 VChart 属性(纹理 texture、conical 渐变、grid 词云布局等),用了会在移动端加载失败。 +- 平台默认给 chart_spec 追加 media query 自适应;要自控可设 `"media": []`。 diff --git a/skills/lark-im/references/card/components/checker.md b/skills/lark-im/references/card/components/checker.md new file mode 100644 index 00000000..e0cf80ba --- /dev/null +++ b/skills/lark-im/references/card/components/checker.md @@ -0,0 +1,38 @@ +# 勾选器 `checker` + +任务勾选场景的交互组件,支持配置回调响应。仅支持手写 JSON,搭建工具不支持构建。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "checker", + "name": "check_1", + "checked": false, + "text": { "tag": "plain_text", "content": "完成新品上市计划报告" }, + "behaviors": [{ "type": "callback", "value": { "key": "todo1" } }] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `checker` | +| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** | +| `checked` | 否 | Boolean | false | 初始勾选状态 | +| `text` | 否 | Object | / | `{tag:"plain_text"\|"lark_md", content, text_size?, text_color?, text_align?}`(text_color 见 `../resource/colors.md`) | +| `overall_checkable` | 否 | Boolean | true | 悬浮时整体是否有阴影效果 | +| `button_area` | 否 | Object | / | `{pc_display_rule:"always"|"on_hover", buttons:[<=3 个 button]}` | +| `checked_style` | 否 | Object | / | `{show_strikethrough, opacity}`,勾选后的内容样式 | +| `disabled` / `disabled_tips` | 否 | Boolean/Object | false / 空 | 禁用及禁用提示 | +| `hover_tips` | 否 | Object | 空 | 悬浮提示;与 `disabled_tips` 同配时后者生效 | +| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]`;**未配置时仅本地勾选生效,不触发回调** | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` | +| `padding`/`margin` | 否 | String | 0 | [-99,99]px | + +## 嵌套 / 易错点 + +- 可嵌套在 form / 交互容器 / column_set / collapsible_panel 内。 +- 不配置 `behaviors` 时勾选仅前端本地生效,不会触发服务端回调——需要业务侧感知必须显式配置。 +- 回调:`action.tag="checker"` + `action.checked`(布尔值);form 内则读 `form_value[name]`。 diff --git a/skills/lark-im/references/card/components/collapsible_panel.md b/skills/lark-im/references/card/components/collapsible_panel.md new file mode 100644 index 00000000..0abf1d2a --- /dev/null +++ b/skills/lark-im/references/card/components/collapsible_panel.md @@ -0,0 +1,46 @@ +# 折叠面板 `collapsible_panel` + +折叠次要内容(备注、长文本),点标题展开/收起。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "collapsible_panel", + "expanded": false, + "header": { "title": { "tag": "plain_text", "content": "面板标题" } }, + "elements": [{ "tag": "markdown", "content": "折叠的内容" }] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `collapsible_panel` | +| `header` | 是 | Object | / | 标题区,见下 | +| `elements` | 否 | Array | / | 面板内组件;**不能放 `form`** | +| `expanded` | 否 | Boolean | false | 是否默认展开 | +| `background_color` | 否 | String | 透明 | 面板背景,颜色枚举(见 `../resource/colors.md`) | +| `border` | 否 | Object | / | `{ color, corner_radius }` | +| `direction` | 否 | String | vertical | `vertical` / `horizontal` | +| `vertical_spacing`/`horizontal_spacing` | 否 | String | 8px | 间距枚举或 [0,99]px | +| `padding` | 否 | String | 0 | 内边距 [0,99]px | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | + +**header 字段**: + +| 字段 | 必填 | 说明 | +|---|---|---| +| `title` | 否 | `{tag:"plain_text"\|"markdown", content}` | +| `background_color` | 否 | 标题区背景,颜色枚举 | +| `width` | 否 | `fill` / `auto` / `auto_when_fold`(收起时自适应) | +| `vertical_align` | 否 | `top`/`center`/`bottom` | +| `icon` | 否 | 图标 `{tag, token, color, size}`(同 `div.icon`,多 `size`) | +| `icon_position` | 否 | `left` / `right` / `follow_text` | +| `icon_expanded_angle` | 否 | 展开时图标旋转角:`-180`/`-90`/`90`/`180` | + +## 嵌套 / 易错点 + +- 内部不支持 `form`;容器最多嵌套 5 层。 +- 仅支持写 JSON,搭建工具不支持。 diff --git a/skills/lark-im/references/card/components/column_set.md b/skills/lark-im/references/card/components/column_set.md new file mode 100644 index 00000000..259bb9e9 --- /dev/null +++ b/skills/lark-im/references/card/components/column_set.md @@ -0,0 +1,53 @@ +# 分栏 `column_set` + `column` + +横向多列布局容器。`column_set` 装若干 `column`,每个 `column` 内再放组件。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "column_set", + "flex_mode": "none", + "columns": [ + { "tag": "column", "width": "weighted", "weight": 1, + "elements": [{ "tag": "markdown", "content": "左列" }] }, + { "tag": "column", "width": "weighted", "weight": 1, + "elements": [{ "tag": "markdown", "content": "右列" }] } + ] +} +``` + +## column_set 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `column_set` | +| `columns` | 是 | column[] | / | 列数组,子节点只能是 `column` | +| `flex_mode` | 否 | String | none | 窄屏自适应:`none`(按比例压缩) / `stretch`(变上下堆叠) / `flow`(自动换行) / `bisect`(两等分) / `trisect`(三等分) | +| `horizontal_spacing` | 否 | String | 8px | `small`(4)/`medium`(8)/`large`(12)/`extra_large`(16) 或 `[0,99]px` | +| `horizontal_align` | 否 | String | left | `left` / `center` / `right` | +| `background_style` | 否 | String | default | `default` 或颜色枚举/RGBA(见 `../resource/colors.md`);嵌套时上层覆盖下层 | +| `action` | 否 | Object | / | 整块点击跳转 `{ multi_url:{url,pc_url,ios_url,android_url} }` | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | + +## column 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `column` | +| `elements` | 否 | Element[] | / | 列内组件;**不能放 `form` 和 `table`**,可放 `column_set` | +| `width` | 否 | String | auto | 仅 `flex_mode:none` 生效:`auto` / `weighted`(配 weight) / `[16,600]px` | +| `weight` | 否 | Number | 1 | `width:weighted` 时的宽度占比,1~5 整数 | +| `vertical_align` | 否 | String | top | `top` / `center` / `bottom` | +| `direction` | 否 | String | vertical | `vertical` / `horizontal` | +| `horizontal_spacing`/`vertical_spacing` | 否 | String | 8px | 同上间距枚举或 `[0,99]px` | +| `padding` | 否 | String | 0 | 内边距 [0,99]px | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | +| `background_style` | 否 | String | default | 同上 | +| `action` | 否 | Object | / | 点击列跳转,同 column_set.action | + +## 嵌套 / 易错点 + +- **column_set 的直接子节点只能是 `column`**;不能 `column_set → column_set`。二级分栏要走 `column_set → column → column_set`。 +- column 内可放除 `form` / `table` 外的所有组件。 +- 最多嵌套 5 层,过深会压缩展示空间。 diff --git a/skills/lark-im/references/card/components/date_picker.md b/skills/lark-im/references/card/components/date_picker.md new file mode 100644 index 00000000..44ddcfd7 --- /dev/null +++ b/skills/lark-im/references/card/components/date_picker.md @@ -0,0 +1,34 @@ +# 日期选择器 `date_picker` + +提供日期选项的交互组件,默认拥有交互能力(无需显式 `behaviors` 也会回调)。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "date_picker", + "placeholder": { "tag": "plain_text", "content": "请选择" }, + "initial_date": "2024-01-01" +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `date_picker` | +| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** | +| `required` | 否 | Boolean | false | 是否必选(form 内生效) | +| `initial_date` | 否 | String | / | 初始值,格式 `yyyy-MM-dd`,会覆盖 `placeholder` | +| `placeholder` | 否 | Object | / | 占位文本,plain_text;未设 `initial_date` 时必填 | +| `width` | 否 | String | default | `default`/`fill`/`[100,∞)px` | +| `disabled` | 否 | Boolean | false | 是否禁用(需端版本 V7.4+) | +| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` | +| `margin` | 否 | String | 0 | [-99,99]px | + +## 嵌套 / 易错点 + +- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / 交互容器内;搭建工具中暂不支持嵌套在交互容器中。 +- 提醒用户注意时区语境(如预定海外酒店用酒店所在地时区);服务端只返回用户当前时区作为参考,不代表用户选的就是该时区。 +- 回调:`action.tag="date_picker"` + `action.option`(日期字符串,如 `"2025-06-10 +0800"`)+ `action.timezone`;form 内则读 `form_value[name]`。 diff --git a/skills/lark-im/references/card/components/div.md b/skills/lark-im/references/card/components/div.md new file mode 100644 index 00000000..9b1eb77e --- /dev/null +++ b/skills/lark-im/references/card/components/div.md @@ -0,0 +1,36 @@ +# 普通文本 `div` + +带样式的文本块,支持前缀图标和 label-value 字段对。**Card 2.0**。富文本用 `markdown` 组件。 + +## 最小示例 + +```json +{ + "tag": "div", + "text": { "tag": "plain_text", "content": "示例文本" } +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `div` | +| `text` | 否 | Object | / | 文本对象,见下 | +| `text.tag` | 是 | String | plain_text | `plain_text` 或 `lark_md`(部分 Markdown,语法见 `markdown.md`) | +| `text.content` | 是 | String | / | 文本内容 | +| `text.text_size` | 否 | String | normal | `heading-0`~`heading-4` / `normal`(14px) / `notation`(12px) 等;可在 `config.style.text_size` 自定义 pc/mobile 不同字号 | +| `text.text_color` | 否 | String | default | 颜色枚举(见 `../resource/colors.md`),仅 `plain_text` 生效 | +| `text.text_align` | 否 | String | left | `left` / `center` / `right` | +| `text.lines` | 否 | Int | / | 最大显示行数,超出 `...` 省略 | +| `icon` | 否 | Object | / | 前缀图标,见下 | +| `icon.tag` | 否 | String | / | `standard_icon`(用 `token`+`color`,token 见 `../resource/icons.md`)或 `custom_icon`(用 `img_key`) | +| `width` | 否 | String | fill | `fill` / `auto` / `[16,999]px` | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | +| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符;流式更新时 `text.element_id` 指定文本 | + +> `fields` 字段(多列 label-value):数组,每项 `{ is_short, text:{tag,content} }`,`is_short:true` 可并排。 + +## 易错点 + +- `text_color` 只在 `text.tag` 为 `plain_text` 时生效;`lark_md` 用内联 `` 着色。 diff --git a/skills/lark-im/references/card/components/form.md b/skills/lark-im/references/card/components/form.md new file mode 100644 index 00000000..b3bd6c6d --- /dev/null +++ b/skills/lark-im/references/card/components/form.md @@ -0,0 +1,51 @@ +# 表单容器 `form` + +批量录入表单项后一次提交:用户在前端填写多个表单项,点击提交按钮后将所有值打包一次性回调到服务端。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "form", + "name": "form_1", + "elements": [ + { "tag": "input", "name": "reason", "required": true }, + { + "tag": "button", + "text": { "tag": "plain_text", "content": "提交" }, + "type": "primary", + "form_action_type": "submit", + "name": "Button_submit" + } + ] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `form` | +| `name` | 是 | String | / | 表单容器唯一标识,卡片内全局唯一,用于识别提交数据归属 | +| `elements` | 是 | Element[] | [] | 子节点,支持除 `table` 和 `form` 外的所有组件 | +| `direction` | 否 | String | vertical | `vertical` / `horizontal` | +| `horizontal_spacing`/`vertical_spacing` | 否 | String | 8px/12px | 间距枚举 `small`(4)/`medium`(8)/`large`(12)/`extra_large`(16) 或 `[0,99]px` | +| `horizontal_align` | 否 | String | left | `left`/`center`/`right` | +| `vertical_align` | 否 | String | top | `top`/`center`/`bottom` | +| `padding`/`margin` | 否 | String | 0 | [-99,99]px,支持单值/双值/四值写法 | +| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 | + +### 子组件内嵌字段(交互组件嵌在 form 内时生效) + +| 字段 | 必填 | 说明 | +|---|---|---| +| `name` | 是 | 表单内组件唯一标识,卡片全局唯一,否则提交失败 | +| `required` | 否 | 是否必填;为 true 且未填时点提交会本地拦截,不发起回调 | +| `form_action_type` | 是(按钮) | `submit`(提交)/ `reset`(重置初始值);表单内按钮**不用** `behaviors` | + +## 嵌套 / 易错点 + +- `form` 不支持嵌套 `table` 和 `form`;且 `form` 本身只能放卡片根节点下,不能被其他组件嵌套。 +- form 内所有交互组件的 `name` 必须填且全局唯一,否则提交失败。 +- 表单内必须包含一个 `form_action_type: submit` 的按钮。 +- 回调来源:`card.action.trigger` 中 `action.tag="button"` + `action.form_value`(按组件 `name` 映射各字段值)。 diff --git a/skills/lark-im/references/card/components/header.md b/skills/lark-im/references/card/components/header.md new file mode 100644 index 00000000..c6e7ebd2 --- /dev/null +++ b/skills/lark-im/references/card/components/header.md @@ -0,0 +1,34 @@ +# 标题 `header` + +卡片顶部标题区(主/副标题、后缀标签、图标、主题色)。**Card 2.0**。挂在卡片根的 `header` 键下,不在 `body.elements` 内,单卡仅一个。 + +## 最小示例 + +```json +{ + "header": { + "title": { "tag": "plain_text", "content": "卡片标题" }, + "template": "blue" + } +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 说明 | +|---|---|---|---| +| `title` | 是 | Object | 主标题,`{tag:"plain_text"\|"lark_md", content}`,最多 4 行 | +| `subtitle` | 否 | Object | 副标题,同 title,最多 1 行;只配副标题会按主标题展示 | +| `template` | 否 | String | 主题色枚举,见下;默认 `default` | +| `text_tag_list` | 否 | Array | 后缀标签,最多 3 个,每项 `{tag:"text_tag", text:{tag:"plain_text",content}, color}` | +| `i18n_text_tag_list` | 否 | Object | 多语言后缀标签;与 `text_tag_list` 二选一,同配以多语言为准 | +| `icon` | 否 | Object | 前缀图标(同 `div.icon`) | +| `padding` | 否 | String | 内边距,默认 12px,[0,99]px | + +**template 枚举**(13 色):`blue` / `wathet` / `turquoise` / `green` / `yellow` / `orange` / `red` / `carmine` / `violet` / `purple` / `indigo` / `grey` / `default`。 + +**标签 color 枚举**:`neutral`/`blue`/`turquoise`/`lime`/`orange`/`violet`/`indigo`/`wathet`/`green`/`yellow`/`red`/`purple`/`carmine`。深浅档位及 RGBA 见 `../resource/colors.md`。 + +## 选色建议 + +按场景选 template 颜色见 `../lark-im-card-style.md` 意图表。常见语义:green=成功/完成,orange=警告,red=错误/危险,grey=失效/归档,blue=通用信息。 diff --git a/skills/lark-im/references/card/components/hr.md b/skills/lark-im/references/card/components/hr.md new file mode 100644 index 00000000..0dd83fc4 --- /dev/null +++ b/skills/lark-im/references/card/components/hr.md @@ -0,0 +1,17 @@ +# 分割线 `hr` + +分隔卡片内容的水平线。**Card 2.0**(1.0 同名 `hr`)。 + +## 最小示例 + +```json +{ "tag": "hr" } +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `hr` | +| `margin` | 否 | String | 0 | 外边距,范围 [-99,99]px,如 `"8px 0"` | +| `element_id` | 否 | String | / | 组件唯一标识,字母开头、≤20 字符 | diff --git a/skills/lark-im/references/card/components/img.md b/skills/lark-im/references/card/components/img.md new file mode 100644 index 00000000..afad6a23 --- /dev/null +++ b/skills/lark-im/references/card/components/img.md @@ -0,0 +1,34 @@ +# 图片 `img` + +展示图片。需先调上传图片接口拿 `img_key`。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "img", + "img_key": "img_v3_xxx", + "alt": { "tag": "plain_text", "content": "" } +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `img` | +| `img_key` | 是 | String | / | 图片 key,上传图片接口获取 | +| `alt` | 是 | Object | / | hover 说明,`{tag:"plain_text", content:""}`,不需要传空 | +| `title` | 否 | Object | / | 图片标题,plain_text 对象 | +| `scale_type` | 否 | String | crop_center | `crop_center` / `crop_top` / `fit_horizontal`(不裁剪) | +| `size` | 否 | String | / | 仅 `crop_*` 生效:`stretch`/`large`(160)/`medium`(80)/`small`(40)/`tiny`(16),或 `"100px 100px"` | +| `corner_radius` | 否 | String | / | 圆角,`[0,∞]px` 或 `[0,100]%` | +| `transparent` | 否 | Boolean | false | 是否透明底 | +| `preview` | 否 | Boolean | true | 点击是否放大;配 `card_link` 跳转时设 false | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | +| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 | + +## 易错点 + +- 通栏效果:2.0 不再支持 `size: stretch_without_padding`,改用负 `margin`(如 `"4px -12px"`)。 +- 上传规范:≤10M、尺寸 ≤1500×3000px、高:宽 ≤16:9。 diff --git a/skills/lark-im/references/card/components/img_combination.md b/skills/lark-im/references/card/components/img_combination.md new file mode 100644 index 00000000..43a8664c --- /dev/null +++ b/skills/lark-im/references/card/components/img_combination.md @@ -0,0 +1,30 @@ +# 多图混排 `img_combination` + +多张图片按预设版式拼排。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "img_combination", + "combination_mode": "double", + "img_list": [{ "img_key": "img_v3_a" }, { "img_key": "img_v3_b" }] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `img_combination` | +| `combination_mode` | 是 | String | / | `double`(≤2) / `triple`(≤3) / `bisect`(双列,≤6) / `trisect`(三列,≤9) | +| `img_list` | 是 | Array | / | 每项 `{ img_key }`,顺序即排列顺序 | +| `combination_transparent` | 否 | Boolean | false | 是否透明底 | +| `corner_radius` | 否 | String | / | 圆角,`[0,∞]px` 或 `[0,100]%` | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | +| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 | + +## 易错点 + +- 图片数超过 mode 上限:只显示靠前的,其余丢弃;不足则留空白。 +- 上传规范:≤10M、≤1500×3000px、高:宽 ≤16:9。 diff --git a/skills/lark-im/references/card/components/input.md b/skills/lark-im/references/card/components/input.md new file mode 100644 index 00000000..2a996fd4 --- /dev/null +++ b/skills/lark-im/references/card/components/input.md @@ -0,0 +1,43 @@ +# 输入框 `input` + +收集用户文本输入。常嵌在 `form` 内配合提交按钮使用。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "input", + "name": "comment", + "placeholder": { "tag": "plain_text", "content": "请输入" }, + "label": { "tag": "plain_text", "content": "备注:" } +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `input` | +| `name` | 否* | String | / | 唯一标识;**在 form 内必填且全局唯一**,用于识别提交数据 | +| `required` | 否 | Boolean | false | 是否必填(仅 form 内生效) | +| `placeholder` | 否 | Object | / | 占位文本,plain_text,≤100 字符 | +| `default_value` | 否 | String | / | 预填内容 | +| `label` | 否 | Object | / | 描述文本,plain_text | +| `label_position` | 否 | String | top | `top` / `left`(窄屏自动转 top) | +| `input_type` | 否 | String | text | `text` / `multiline_text`(多行,回调含 `\n`) / `password` | +| `rows` | 否 | Number | 5 | 多行时默认行数 | +| `auto_resize` | 否 | Boolean | false | 多行时高度自适应(仅 PC) | +| `max_rows` | 否 | Number | / | `auto_resize` 时最大行数 | +| `max_length` | 否 | Number | 1000 | 最大字符数,[1,1000] | +| `show_icon` | 否 | Boolean | true | password 时是否显示前缀图标 | +| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` | +| `disabled` | 否 | Boolean | false | 是否禁用(配 `disabled_tips` plain_text) | +| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | + +## 嵌套 / 易错点 + +- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。 +- 在 form 内为**异步提交**:用户填完点提交按钮才一次性回调全部表单数据。 +- 回调里 `action.tag="input"` + `action.input_value`(用户输入值);form 提交则值在 `form_value` 内。 diff --git a/skills/lark-im/references/card/components/interactive_container.md b/skills/lark-im/references/card/components/interactive_container.md new file mode 100644 index 00000000..6920e879 --- /dev/null +++ b/skills/lark-im/references/card/components/interactive_container.md @@ -0,0 +1,46 @@ +# 交互容器 `interactive_container` + +整块可点击区域,统一定义内嵌内容的样式和交互(callback/open_url),适合卡片内的列表项、可点击卡片块。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "interactive_container", + "width": "fill", + "has_border": true, + "border_color": "grey", + "corner_radius": "8px", + "padding": "4px 12px 4px 12px", + "behaviors": [{ "type": "callback", "value": { "key": "value" } }], + "elements": [ + { "tag": "markdown", "content": "帮我生成一篇产品方案的框架" } + ] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `interactive_container` | +| `elements` | 是 | Element[] | [] | 子节点,支持除 `form`/`table` 外的所有组件 | +| `behaviors` | 是 | Array | / | 点击整容器的交互:`callback`(回传)/ `open_url`(跳转),可同数组共存 | +| `width` | 否 | String | fill | `fill`/`auto`/`[16,999]px` | +| `height` | 否 | String | auto | `auto`/`[10,999]px` | +| `direction` | 否 | String | vertical | `vertical`/`horizontal` | +| `horizontal_align`/`vertical_align` | 否 | String | left/top | 对齐方式 | +| `background_style` | 否 | String | default | `default`/`laser`/颜色枚举/RGBA(见 `../resource/colors.md`) | +| `has_border` | 否 | Boolean | false | 是否展示 1px 边框 | +| `border_color` | 否 | String | grey | `has_border` 为 true 时生效 | +| `corner_radius` | 否 | String | 0px | `[0,∞]px` 或 `[0,100]%` | +| `padding`/`margin` | 否 | String | 4px,12px / 0px | 同间距写法 | +| `disabled` / `disabled_tips` | 否 | Boolean/Object | false / 空 | 禁用整容器及禁用提示 | +| `hover_tips` | 否 | Object | 空 | PC 端悬浮提示 | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` | + +## 嵌套 / 易错点 + +- 可嵌套除 `form`/`table` 外的所有组件,包括嵌套自身(列表项常见写法)。 +- 若容器内有交互组件(如内部 `button`),优先响应该子组件的交互,容器级 `behaviors` 不会触发。 +- 回调来源:`card.action.trigger`,`action.tag` 取决于内部触发的具体组件;容器本身被点击时 `action.value` 即容器 `behaviors.value`。 diff --git a/skills/lark-im/references/card/components/markdown.md b/skills/lark-im/references/card/components/markdown.md new file mode 100644 index 00000000..d6b7e270 --- /dev/null +++ b/skills/lark-im/references/card/components/markdown.md @@ -0,0 +1,56 @@ +# 富文本 `markdown` + +支持 Markdown + 部分 HTML 的富文本。最常用的内容组件。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "markdown", + "content": "**标题**\n正文,红字,[链接](https://x)" +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `markdown` | +| `content` | 是 | String | / | Markdown 文本;JSON 里用 `\n` 换行 | +| `text_size` | 否 | String | normal | `heading-0`~`heading-4` / `normal`(14px) / `notation`(12px) 等;可在 `config.style.text_size` 自定义 pc/mobile 字号 | +| `text_align` | 否 | String | left | `left` / `center` / `right` | +| `icon` | 否 | Object | / | 前缀图标(同 `div.icon`) | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | +| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 | + +## 常用语法 + +| 效果 | 语法 | +|---|---| +| 粗 / 斜 / 删除线 | `**粗**`、`*斜*`、`~~删~~`(前后留空格更稳) | +| 换行 | JSON 内 `\n`;或 `
` | +| 文字链接 | `[文字](https://x)`(必须带 http/https) | +| 带图标链接 | `文案`(icon token 见 `../resource/icons.md`) | +| 彩色文本 | `红字`(color 枚举见 `../resource/colors.md`;链接文本不可着色) | +| 标签 | `标签`(color:neutral/blue/turquoise/lime/orange/violet/indigo/wathet/green/yellow/red/purple/carmine) | +| @ 人 | ``、``、`` | +| @所有人 | ``(需群主开权限,否则发送失败) | +| 人员卡片 | `` | +| 数字角标 | `1`(0-99,可加 background_color/font_color/url) | +| 国际化时间 | `` | +| 标题 | `# 一级` ~ `###### 六级`(大标题显丑,正文优先用加粗,见易错点) | +| 列表 | `- 项`(无序)/ `1. 项`(有序),4 空格一层缩进 | +| 引用 | `> 引用文字` | +| 行内/块代码 | `` `code` `` / ```` ```go ... ``` ````(可指定语言) | +| 分割线 | `


` 或 `---`(需单独一行) | +| 图片 | `![hover文案](img_key)` | +| 表格 | 标准 MD 表格;除标题最多 5 行(超出分页),单组件 ≤4 表 | +| 飞书表情 | `:DONE:`、`:OK:` | + +## 易错点 + +- **慎用大标题**:`#` / `##` / `###` 一~三级标题字号过大、显丑,正文里一律用 `**加粗**` 替代来突出重点。**唯一例外**是「指标卡」里用 `##` 放大数值(见 `../lark-im-card-style.md` 视觉规范)。 +- **少用 `markdown` 的 `margin`**:间距优先交给父容器的 `vertical_spacing` / `padding`,多数情况置 `0px`;仅精细缩进时设非零值(见 `../lark-im-card-style.md` 间距纪律)。 +- 2.0 不再支持旧的 `[xx]($urlVal)` + `href` 差异化跳转语法,改用 ``。 +- 要展示 Markdown 特殊字符(`* ~ > < [ ] ( ) # : _` 等)须 HTML 转义,如 `<`→`<`、`*`→`*`。 +- `content` 里的引号注意与 JSON 转义;属性值用单引号可减少冲突。 diff --git a/skills/lark-im/references/card/components/multi_select_person.md b/skills/lark-im/references/card/components/multi_select_person.md new file mode 100644 index 00000000..95430351 --- /dev/null +++ b/skills/lark-im/references/card/components/multi_select_person.md @@ -0,0 +1,40 @@ +# 人员选择-多选 `multi_select_person` + +从候选人员中多选。**Card 2.0**。字段与 `select_person` 基本一致,差别在多选默认值。 + +## 最小示例 + +```json +{ + "tag": "multi_select_person", + "name": "reviewers", + "placeholder": { "tag": "plain_text", "content": "请选择" }, + "options": [ + { "value": "ou_xxx" }, + { "value": "ou_yyy" } + ] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `multi_select_person` | +| `options` | 否 | Array | / | 候选人 `{value: open_id}`;为空或全无效时候选项为会话全体成员 | +| `selected_values` | 否 | String[] | / | 默认选中的 open_id 数组 | +| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** | +| `required` | 否 | Boolean | false | 是否必选(form 内生效) | +| `type` | 否 | String | default | `default`(带框) / `text`(纯文本) | +| `placeholder` | 否 | Object | / | 占位文本,plain_text | +| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` | +| `disabled` | 否 | Boolean | false | 是否禁用 | +| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | + +## 嵌套 / 易错点 + +- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。 +- `options[].value` 只接受 **open_id**;默认选中用 `selected_values`(数组)。 +- 回调返回选中的多个 open_id。 diff --git a/skills/lark-im/references/card/components/multi_select_static.md b/skills/lark-im/references/card/components/multi_select_static.md new file mode 100644 index 00000000..ffc5f2ff --- /dev/null +++ b/skills/lark-im/references/card/components/multi_select_static.md @@ -0,0 +1,40 @@ +# 下拉多选 `multi_select_static` + +下拉菜单多选。**Card 2.0**。字段与 `select_static` 基本一致,差别在多选默认值。 + +## 最小示例 + +```json +{ + "tag": "multi_select_static", + "name": "tags", + "placeholder": { "tag": "plain_text", "content": "请选择" }, + "options": [ + { "text": { "tag": "plain_text", "content": "选项1" }, "value": "1" }, + { "text": { "tag": "plain_text", "content": "选项2" }, "value": "2" } + ] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `multi_select_static` | +| `options` | 否 | Array | / | 选项 `{text:{plain_text}, value, icon?}`,`value` 不可重复 | +| `selected_values` | 否 | String[] | / | 默认选中的 value 数组 | +| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** | +| `required` | 否 | Boolean | false | 是否必选(form 内生效) | +| `type` | 否 | String | default | `default`(带框) / `text`(纯文本) | +| `placeholder` | 否 | Object | / | 占位文本,plain_text | +| `width` | 否 | String | default | `default`(带框固定282px) / `fill` / `[100,∞)px` | +| `disabled` | 否 | Boolean | false | 是否禁用 | +| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | + +## 嵌套 / 易错点 + +- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。 +- 选项 `value` 唯一;默认选中用 `selected_values`(数组)而非单选的 `initial_*`。 +- 回调返回选中的多个值。 diff --git a/skills/lark-im/references/card/components/overflow.md b/skills/lark-im/references/card/components/overflow.md new file mode 100644 index 00000000..0ce373b5 --- /dev/null +++ b/skills/lark-im/references/card/components/overflow.md @@ -0,0 +1,36 @@ +# 折叠按钮组 `overflow` + +折叠多个选项按钮,点击展开。适用于操作较多的场景。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "overflow", + "options": [ + { "text": { "tag": "plain_text", "content": "选项A" }, "value": "a" }, + { "text": { "tag": "plain_text", "content": "选项B" }, "value": "b" } + ] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `overflow` | +| `options` | 是 | Array | / | 选项按钮,见下 | +| `options[].text` | 否 | Object | / | `{tag:"plain_text", content}`,≤100 字符 | +| `options[].value` | 否 | String | / | 点击回传值,用于区分点了哪个选项(回调 `action.option`) | +| `options[].multi_url` | 否 | Object | / | 跳转链接 `{url, pc_url, ios_url, android_url}` | +| `behaviors` | 否 | Array | / | 额外回传:`[{type:"callback", value:{...}}]` | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}`(均 plain_text) | +| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | +| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 | + +## 嵌套 / 易错点 + +- 可嵌套在 form / collapsible_panel / 循环容器 / interactive_container / column_set 内。 +- 多按钮时务必给每个 `options[].value`,否则回调无法区分点了哪个。 +- 点击触发 `card.action.trigger`,回传 `action.tag = "overflow"` + `action.option`。 diff --git a/skills/lark-im/references/card/components/person.md b/skills/lark-im/references/card/components/person.md new file mode 100644 index 00000000..afcf30a8 --- /dev/null +++ b/skills/lark-im/references/card/components/person.md @@ -0,0 +1,30 @@ +# 人员 `person` + +展示单个用户的头像/姓名,点击可看名片。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "person", + "user_id": "ou_xxx", + "show_name": true +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `person` | +| `user_id` | 是 | String | / | 人员 ID,支持 open_id / union_id / user_id | +| `size` | 否 | String | medium | `extra_small` / `small` / `medium` / `large` | +| `show_avatar` | 否 | Boolean | true | 是否显示头像 | +| `show_name` | 否 | Boolean | false | 是否显示姓名 | +| `style` | 否 | String | normal | `normal` / `capsule`(胶囊) | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | +| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 | + +## 易错点 + +- 发卡应用需有访问用户 ID 的权限,否则人员信息无法展示。 diff --git a/skills/lark-im/references/card/components/person_list.md b/skills/lark-im/references/card/components/person_list.md new file mode 100644 index 00000000..bf8c9ac7 --- /dev/null +++ b/skills/lark-im/references/card/components/person_list.md @@ -0,0 +1,31 @@ +# 人员列表 `person_list` + +展示多个用户的头像/姓名。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "person_list", + "persons": [{ "id": "ou_xxx" }, { "id": "ou_yyy" }] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `person_list` | +| `persons` | 是 | Array | / | 每项 `{ id }`,id 支持 open_id / union_id / user_id | +| `show_name` | 否 | Boolean | true | 是否显示姓名;关掉且多人时为"葫芦串"叠头像样式 | +| `show_avatar` | 否 | Boolean | false | 是否显示头像 | +| `size` | 否 | String | medium | `extra_small` / `small` / `medium` / `large` | +| `lines` | 否 | Int | / | 最大行数,不可为 0 | +| `drop_invalid_user_id` | 否 | Boolean | false | true 忽略无效 ID;false 则有无效 ID 时报错 | +| `icon` / `ud_icon` | 否 | Object | / | 前缀图标(同 `div.icon`);两者同设以 `icon` 为准 | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | +| `element_id` | 否 | String | / | 唯一标识,字母开头 ≤20 字符 | + +## 易错点 + +- 发卡应用需有访问用户 ID 的权限,否则无法展示人员信息。 diff --git a/skills/lark-im/references/card/components/picker_datetime.md b/skills/lark-im/references/card/components/picker_datetime.md new file mode 100644 index 00000000..8ee77d82 --- /dev/null +++ b/skills/lark-im/references/card/components/picker_datetime.md @@ -0,0 +1,34 @@ +# 日期时间选择器 `picker_datetime` + +提供日期+时间选项的交互组件,默认拥有交互能力。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "picker_datetime", + "placeholder": { "tag": "plain_text", "content": "请选择" }, + "initial_datetime": "2024-01-01 08:00" +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `picker_datetime` | +| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** | +| `required` | 否 | Boolean | false | 是否必选(form 内生效) | +| `initial_datetime` | 否 | String | / | 初始值,格式 `yyyy-MM-dd HH:mm`,会覆盖 `placeholder` | +| `placeholder` | 否 | Object | / | 占位文本,plain_text;未设 `initial_datetime` 时必填 | +| `width` | 否 | String | default | `default`/`fill`/`[100,∞)px` | +| `disabled` | 否 | Boolean | false | 是否禁用(需端版本 V7.4+) | +| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` | +| `margin` | 否 | String | 0 | [-99,99]px | + +## 嵌套 / 易错点 + +- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / 交互容器内;搭建工具中暂不支持嵌套在交互容器中。 +- 提醒用户注意时区语境;服务端只返回用户当前时区作为参考,不代表用户选的就是该时区。 +- 回调:`action.tag="picker_datetime"` + `action.option`(如 `"2025-06-10 19:19 +0800"`)+ `action.timezone`;form 内则读 `form_value[name]`。 diff --git a/skills/lark-im/references/card/components/picker_time.md b/skills/lark-im/references/card/components/picker_time.md new file mode 100644 index 00000000..a56a9580 --- /dev/null +++ b/skills/lark-im/references/card/components/picker_time.md @@ -0,0 +1,34 @@ +# 时间选择器 `picker_time` + +提供时间选项的交互组件,默认拥有交互能力。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "picker_time", + "placeholder": { "tag": "plain_text", "content": "请选择" }, + "initial_time": "09:00" +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `picker_time` | +| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** | +| `required` | 否 | Boolean | false | 是否必选(form 内生效) | +| `initial_time` | 否 | String | / | 初始值,格式 `HH:mm`,会覆盖 `placeholder` | +| `placeholder` | 否 | Object | / | 占位文本,plain_text;未设 `initial_time` 时必填 | +| `width` | 否 | String | default | `default`/`fill`/`[100,∞)px` | +| `disabled` | 否 | Boolean | false | 是否禁用(需端版本 V7.4+) | +| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` | +| `margin` | 否 | String | 0 | [-99,99]px | + +## 嵌套 / 易错点 + +- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / 交互容器内;搭建工具中暂不支持嵌套在交互容器中。 +- 提醒用户注意时区语境;服务端只返回用户当前时区作为参考,不代表用户选的就是该时区。 +- 回调:`action.tag="picker_time"` + `action.option`(时间字符串,如 `"05:05 +0800"`)+ `action.timezone`;form 内则读 `form_value[name]`。 diff --git a/skills/lark-im/references/card/components/recycling_container.md b/skills/lark-im/references/card/components/recycling_container.md new file mode 100644 index 00000000..40754f47 --- /dev/null +++ b/skills/lark-im/references/card/components/recycling_container.md @@ -0,0 +1,35 @@ +# 循环容器(搭建工具专属,无 JSON tag) + +批量渲染同版式不同数据的列表(如商品列表、推荐列表)。**仅支持在飞书卡片搭建工具中可视化构建,不支持手写卡片 JSON 代码实现**——因此没有 `tag` 字段可直接编排。 + +## 使用方式 + +1. 在[卡片搭建工具](https://open.feishu.cn/cardkit)中添加循环容器组件,绑定一个对象数组变量。 +2. 在容器内添加任意展示/交互/分栏组件,并将其字段绑定到对象数组的子变量。 +3. 发布卡片模板后,发送时通过 `template_variable` 传入实际数据数组,数组每个元素对应一条循环项。 + +## 发送示例(模板 + 变量赋值) + +```json +{ + "type": "template", + "data": { + "template_id": "AAqi6xJ8rabcd", + "template_version_name": "1.0.0", + "template_variable": { + "looping": [ + { "title": "**和风陶韵**", "description": "...", "image": { "img_key": "img_v3_xxx" } }, + { "title": "**匠心之作**", "description": "...", "image": { "img_key": "img_v3_yyy" } } + ] + } + } +} +``` + +将以上 JSON 压缩转义后作为 `messages.create` 的 `content`,`msg_type` 为 `interactive`。 + +## 嵌套 / 易错点 + +- 不支持再嵌套循环容器(对象数组变量不支持嵌套对象数组类型)。 +- 数组元素个数即渲染条数,可直接控制列表长度。 +- 若循环容器内嵌表单容器的交互组件(如 input),交互组件的 `name`(表单项标识)必须绑定到不重复的子变量,否则预览/发送报错。 diff --git a/skills/lark-im/references/card/components/select_img.md b/skills/lark-im/references/card/components/select_img.md new file mode 100644 index 00000000..50c09632 --- /dev/null +++ b/skills/lark-im/references/card/components/select_img.md @@ -0,0 +1,42 @@ +# 多图选择 `select_img` + +以图片为选项的交互组件,支持单选/多选(如商品图、模板图、AI 生成图)。仅支持手写 JSON,搭建工具不支持。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "select_img", + "name": "select_img_1", + "layout": "bisect", + "aspect_ratio": "16:9", + "options": [ + { "img_key": "img_v2_xxx", "value": "picture1" }, + { "img_key": "img_v2_yyy", "value": "picture2" } + ] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `select_img` | +| `options` | 是 | Array | / | 选项,每项 `{img_key, value, disabled?, disabled_tips?, hover_tips?}` | +| `multi_select` | 否 | Boolean | false | 多选仅支持异步提交,**必须**内嵌在 form 中,否则报错 | +| `layout` | 否 | String | bisect | 图片布局:`stretch`(撑满)/`bisect`(二等分)/`trisect`(三等分) | +| `aspect_ratio` | 否 | String | 16:9 | `1:1`/`16:9`/`4:3` | +| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** | +| `required` | 否 | Boolean | false | 是否必选(form 内生效) | +| `can_preview` | 否 | Boolean | true | 点击图片是否弹窗放大(仅 form 内生效) | +| `disabled` | 否 | Boolean | false | 是否禁用整组件 | +| `value` | 否 | String/Object | / | 自定义回传参数 | +| `behaviors` | 是 | Array | / | `[{type:"callback", value:{...}}]` | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` | + +## 嵌套 / 易错点 + +- 可嵌套在根节点 / column_set / form / 交互容器(搭建工具暂不支持嵌套交互容器)。 +- **不在 form 内**:仅支持单选,点击立即提交触发回调,不支持多选/异步提交。 +- **在 form 内**:支持单选/多选 + 异步提交(随表单一起提交)。 +- 回调(非 form):`action.tag="select_img"` + `action.options`(单选时仍是该字段);form 内则读 `form_value[name]`。 diff --git a/skills/lark-im/references/card/components/select_person.md b/skills/lark-im/references/card/components/select_person.md new file mode 100644 index 00000000..9ba5e261 --- /dev/null +++ b/skills/lark-im/references/card/components/select_person.md @@ -0,0 +1,39 @@ +# 人员选择-单选 `select_person` + +从候选人员中单选一人。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "select_person", + "placeholder": { "tag": "plain_text", "content": "请选择" }, + "options": [ + { "value": "ou_xxx" }, + { "value": "ou_yyy" } + ] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `select_person` | +| `options` | 否 | Array | / | 候选人,每项 `{value: open_id}`;**为空或全无效时,候选项为会话内全体成员** | +| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** | +| `required` | 否 | Boolean | false | 是否必选(form 内生效) | +| `type` | 否 | String | default | `default`(带框) / `text`(纯文本) | +| `placeholder` | 否 | Object | / | 占位文本,plain_text | +| `initial_option` | 否 | String | / | 初始选中的 open_id,须在 options 内 | +| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` | +| `disabled` | 否 | Boolean | false | 是否禁用 | +| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | + +## 嵌套 / 易错点 + +- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。 +- `options[].value` 只接受 **open_id**。 +- 回调 `action.tag="select_person"` + `action.option`(选中人的 open_id)。 diff --git a/skills/lark-im/references/card/components/select_static.md b/skills/lark-im/references/card/components/select_static.md new file mode 100644 index 00000000..d1aeb557 --- /dev/null +++ b/skills/lark-im/references/card/components/select_static.md @@ -0,0 +1,43 @@ +# 下拉单选 `select_static` + +下拉菜单单选。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "select_static", + "placeholder": { "tag": "plain_text", "content": "请选择" }, + "options": [ + { "text": { "tag": "plain_text", "content": "选项1" }, "value": "1" }, + { "text": { "tag": "plain_text", "content": "选项2" }, "value": "2" } + ] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `select_static` | +| `options` | 否 | Array | / | 选项,见下 | +| `options[].text` | 是 | Object | / | 选项名,plain_text | +| `options[].value` | 是 | String | / | 选项回调值,**同组件内不可重复** | +| `options[].icon` | 否 | Object | / | 选项前缀图标(同 `div.icon`) | +| `name` | 否* | String | / | 唯一标识;**form 内必填且全局唯一** | +| `required` | 否 | Boolean | false | 是否必选(form 内生效) | +| `type` | 否 | String | default | `default`(带框) / `text`(纯文本) | +| `placeholder` | 否 | Object | / | 占位文本,plain_text | +| `initial_option` | 否 | String | / | 初始选中内容(覆盖 placeholder 和 initial_index) | +| `initial_index` | 否 | Int | / | 初始选中序号,0=不选,1=第一个 | +| `width` | 否 | String | default | `default` / `fill` / `[100,∞)px` | +| `disabled` | 否 | Boolean | false | 是否禁用 | +| `behaviors` | 否 | Array | / | `[{type:"callback", value:{...}}]` | +| `confirm` | 否 | Object | / | 二次确认弹窗 `{title, text}` | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | + +## 嵌套 / 易错点 + +- 可嵌套在 column_set / form / collapsible_panel / 循环容器 / interactive_container 内。 +- 选项 `value` 必须唯一,否则交互异常、服务端无法区分选了哪个。 +- 回调 `action.tag="select_static"` + `action.option`(选中项的 value)。 diff --git a/skills/lark-im/references/card/components/table.md b/skills/lark-im/references/card/components/table.md new file mode 100644 index 00000000..97734e04 --- /dev/null +++ b/skills/lark-im/references/card/components/table.md @@ -0,0 +1,53 @@ +# 表格 `table` + +多列数据表,支持文本/数字/选项/人员/日期等列类型。**Card 2.0**。 + +## 最小示例 + +```json +{ + "tag": "table", + "columns": [ + { "name": "city", "display_name": "城市", "data_type": "text" }, + { "name": "qty", "display_name": "数量", "data_type": "number" } + ], + "rows": [ + { "city": "北京", "qty": 12 }, + { "city": "上海", "qty": 8 } + ] +} +``` + +## 字段 + +| 字段 | 必填 | 类型 | 默认 | 说明 | +|---|---|---|---|---| +| `tag` | 是 | String | / | 固定 `table` | +| `columns` | 是 | column[] | / | 列定义,≤50 列,见下 | +| `rows` | 是 | Object[] | / | 行数据,按 `列name: 值` 填充 | +| `page_size` | 否 | Number | 5 | 每页行数,[1,10] | +| `row_height` | 否 | String | low | `low`/`middle`/`high`/`auto`/`[32,124]px` | +| `row_max_height` | 否 | String | 124px | `row_height:auto` 时最大行高 [32,999]px | +| `freeze_first_column` | 否 | Boolean | false | 冻结首列 | +| `header_style` | 否 | Object | / | 表头样式:`{text_align, text_size, background_style:grey\|none, text_color, bold, lines}` | +| `margin` | 否 | String | 0 | 外边距 [-99,99]px | + +**column 字段**:`name`(必填,键名) / `display_name`(表头名) / `data_type`(见下) / `width`(`auto`/`[80,600]px`/`%`) / `horizontal_align` / `vertical_align`;`number` 列可加 `format:{precision, symbol, separator}`;`date` 列可加 `date_format`(如 `YYYY/MM/DD`)。 + +**data_type 与行值结构**: + +| data_type | 行值 | +|---|---| +| `text` | `"飞书"` | +| `lark_md` | `"[链接](https://x)"` | +| `number` | `168.23` | +| `options` | `[{text:"S2", color:"blue"}]`(颜色枚举见 `../resource/colors.md`,文本勿过长) | +| `persons` | `"ou_xxx"` 或 `["ou_a","ou_b"]` | +| `date` | `1699341315000`(毫秒时间戳,按本地时区显示) | +| `markdown` | `"![img](img_key)"` 完整 Markdown | + +## 嵌套 / 易错点 + +- **table 只能放卡片根 `body.elements`**:不能被任何容器嵌套,自身也不能嵌别的组件。 +- 单卡最多 5 个 table(多语言每语言 5 个)。 +- `rows` 的键必须与 `columns[].name` 对应。 diff --git a/skills/lark-im/references/card/lark-im-card-create.md b/skills/lark-im/references/card/lark-im-card-create.md new file mode 100644 index 00000000..8dbefadc --- /dev/null +++ b/skills/lark-im/references/card/lark-im-card-create.md @@ -0,0 +1,180 @@ +# 发送 Interactive 卡片工作流 + +用户需要发送一张飞书互动卡片时,遵循本工作流。每次都必须严格按步骤执行。 + +--- + +## 入口分支:文字 / 图片 / 图片+文字组合 + +判断用户输入类型: + +- **纯文字诉求**(无图片)→ 跳到 Step 1「文字诉求路径」。 +- **纯图片**(截图 / 设计稿,无额外文字说明内容)→ 走「以图片为输入」路径,图片既是内容源也是风格源。 +- **图片 + 文字组合** → 走「以图片为输入」路径,但**图片仅当样式/布局参考,内容来源以文字为准**(见第 5 点)。 + +### 以图片为输入时的处理 + +1. **分析图片**:从图片中提取视觉风格信息—— + - 配色方案(色环定位)、间距节奏、层级关系、分组方式、组件类型 + - 图片类型(见第 2 点) + +2. **判断图片类型**,决定保真策略: + + | 图片类型 | 判断依据 | 构造策略 | + |---|---|---| + | **飞书卡片截图** | 能识别出 header / body / components 等飞书卡片结构特征 | **高保真复刻**:将截图中每一块视觉结构映射到相近的卡片 2.0 组件;复刻后仍需过 P0–P7 硬 Gate | + | **其它设计稿 / 海报 / 网页 UI** | 无飞书卡片结构特征 | **风格萃取 + 按原则重构**:提取配色、间距、层级关系等风格 token;布局按 P0–P7 原则重构(**不像素级仿制**),产出说明偏差 | + +3. **确定内容来源**: + - **纯图片**:从图片提取内容/信息点(文字、字段、操作)作为诉求(喂给 P0) + - **图片 + 文字组合**:**以文字/文档为内容来源**,图片仅提供样式和布局参考。将文字内容按图片风格组织进卡片 + +4. **冲突处理**:当图片样式与 P0–P7 或卡片组件能力冲突时—— + - 飞书卡片截图:在组件能力允许范围内**尽量保真**,冲突处微调并告知用户偏差原因 + - 其它设计稿 / 海报 / UI:**以 P0–P7 为准**,图片仅当风格参考,冲突时不硬搬 + +5. 分析明确后,向用户简要说明你的**类型判断结论 + 保真策略 + 内容来源方案**。然后进入 Step 2 加载组件文档,进入构造。 + +--- + +## Step 1(文字诉求路径):分析意图,输出设计方案 + +**目标**:在动手写 JSON 之前,先明确所有决策并告知用户。Step 2 的文档加载量取决于这里的组件列表,所以要尽量在这一步想清楚。 + +分析以下内容并向用户简要说明: + +1. **版本**:Card 2.0 支持的组件更丰富,**推荐使用 Card 2.0**;仅当用户明确要求 1.0 时才用 1.0。 +2. **组件组合**:在 `lark-im-card-style.md` 「意图 → 组件组合」表里匹配最接近的意图行,参考推荐组件组合和该行的 `header.template` 颜色(部分行为"无 header")。推荐组合仅供参考,**最终选型以符合用户意图为准**;使用 Card 2.0 时,可同时参考 `card-2.0-schema.md` 中的组件概述来补充或调整组件选择。 +3. **交互类型**(若有):是否含会回调服务端的交互组件,以及是否有纯跳转(open_url)。回调分两类:① `select` / `multi_select` / `input` / `picker` / `overflow` 操作即默认回调;② `button` / `checker` / `interactive_container` 需显式配置 `behaviors`;`form` 提交统一回调。细则见 Step 5。 +4. **宽度模式**:`compact`(400px) 适合通知/祝福/轻提醒(内容精简、单焦点);`default`(≤600px) 适合大多数场景;`fill`(撑满) 适合数据看板、含 `table` 的宽表格。默认 `default`,有明确理由才偏离。 + +> 输出示例:"Card 2.0,header green,`default`,组件:`column_set` / `column` / `markdown`,无交互。" + +--- + +## Step 2:按需加载组件文档 + +> ⚠️ **仅 Card 2.0 适用**:`card-2.0-schema.md`、`components/` 明细都是 2.0 结构。若 Step 1 定为 **Card 1.0**(含 Step 4 降级场景),这些**不可参考**,跳过本步,直接按 1.0 结构构造。 + +**目标**:读组件明细 + 「好看的标准」,不全量加载。 + +> 组件列表来源:**文字路径** = Step 1 的设计方案;**图片路径** = 入口分支图片分析阶段确定的组件列表。 + +1. 阅读 `card-2.0-schema.md` —— 同时满足两个目的:① 了解组件概述,辅助组件选型;② 找到各组件的明细文档路由链接。**仅读一次,不重复加载。** +2. 按路由逐个读取 `components/.md`(如 `components/column_set.md`、`components/button.md`) +3. 阅读 `lark-im-card-style.md` 开头的「**好看的标准(P0–P7)**」和「视觉规范」——这是 Step 3 构造和自检的裁判基准,**构造前先内化**。 + +--- + +## Step 3:构造卡片 JSON + +按 Step 2 中对应版本的根结构骨架构造卡片,组件选型遵循 Step 1(或图片分析阶段)的设计方案。 + +- Card 2.0 必须有 `"schema": "2.0"`,否则卡片不渲染 +- `form` 容器内按钮用 `form_action_type: "submit"`,不写 `behaviors` +- `column_set` 的子节点只能是 `column`,不能直接放其他组件 +- `table` **只能放 body 根节点**,不能嵌套进 `column_set` / `interactive_container` 等容器 +- `collapsible_panel` 内**不能包含 `form`**;`interactive_container` 内**不能包含 `form`/`table`** + +### 发送前硬 Gate(按 P0–P7 自检,不过不许进 Step 4) + +构造完成后,逐条用 `lark-im-card-style.md` 的「好看的标准」做**结构化自检**。**P0 + P1–P3 是阻断项,任一不过必须回到本步修正后重判**,不得带病发送。 + +**阻断项(必须全过):** +- [ ] **P0 符合诉求**:把用户诉求拆成信息点清单,逐点在 JSON 里找到承载组件;需要的操作(按钮/表单/跳转)都齐备;无与诉求无关的填充 +- [ ] **P1 层级**:body 内有且仅有**一个**最强焦点;标题用 `**加粗**` 与正文拉开,次要信息用 grey +- [ ] **P2 分组**:同主题字段收进同一容器,不同主题分容器;**没有「一路 hr 平铺」或多主题挤在同一 markdown** +- [ ] **P3 复杂度适中**:视觉块 **2–5** 个、主色系 ≤3;且 >1 个块、至少含一个非纯文本结构元素(背景块/指标卡/图标/表格)——**既不能纯文字流水账,也不能堆砌过载** + +**基础卫生(应满足):** +- [ ] **P4 对比**:标题与正文在字号或粗细上至少差一档;正文不滥用 `#/##/###`(指标卡数值放大除外) +- [ ] **P5 对齐**:不滥用散设 `margin`,间距优先交容器;间距取值种类 ≤4;非末尾顶级容器间距一致 + +**加分项(尽量满足):** +- [ ] **P6 语义一致**:同色同义(红=降/警、绿=升/成、grey=次要);主色系起始色与 header 一致、取邻近色环 +- [ ] **P7 健壮**:并列/指标列默认 `weighted`/`none`、慎用 `stretch`;必要时配 `config.style.color` light/dark + +--- + +## Step 4:发送卡片 + +```bash +# 发送到群聊 +lark-cli im +messages-send --chat-id oc_xxx --msg-type interactive --content '' + +# 发送给指定用户(私聊) +lark-cli im +messages-send --user-id ou_xxx --msg-type interactive --content '' +``` + +**发送失败时**:先对照下方常见失败列表排查,若能匹配则按对应处理方式修复后重新发送;否则根据错误信息修复 JSON 后重新发送。最多尝试 **3 次**。若 3 次后仍失败,**降级为 Card 1.0 卡片**重新构造并发送。**不参考之前发送 2.0 的记忆**,完全根据用户意图重新构造 1.0 卡片。1.0 无本地参考文档(components/、resource/ 均为 2.0)。 +**常见失败列表** + +| # | 错误信息 | 处理方式 | +|---|---|---| +| 1 | `there is an invalid user resource (at/person) in your card` | 卡片中含有 at/person 组件,但使用了无效的用户 ID。询问用户其真实的 open_id / user_id,替换后重新发送。 | + +--- + +## Step 5:交互回调(可选) + +若卡片含**会回调服务端的交互组件**,则**支持**监听 `card.action.trigger` 回调(是否监听由实际需求决定,非必须): + +**需显式配置 `behaviors: [{type:"callback"}]` 才会回调:** +- `button`(带 callback behavior) +- `checker` —— 未配置 behaviors 时仅本地勾选生效,不触发服务端回调 +- `interactive_container` —— behaviors 为必填,支持 callback / open_url + +**选中 / 输入即默认回调,无需显式 `behaviors`:** +- `select_static` / `multi_select_static` / `select_person` / `multi_select_person` +- `overflow` / `input` / `date_picker` / `picker_time` / `picker_datetime` + +**form 提交统一回调(按钮用 `form_action_type: "submit"`,无需 behaviors):** +- form 内所有表单组件的值通过 `action.form_value` 一次性回传 + +> 纯 `open_url` 跳转按钮在客户端本地跳转,不回调服务端。 + +如需处理回调(监听事件、读取字段、更新卡片),见 `../lark-im-card-action-reply.md`。 + +--- + +## Step 6:用户反馈修正(按需进入) + +用户看到已发送卡片后提出修改意见时,遵循以下流程。**不要整卡重做,外科手术式修改。** + +### 1. 定位改动范围 + +把用户意见逐条映射到具体组件和字段: + +| 用户反馈类型 | 映射目标 | +|---|---| +| 文案/措辞不满意 | 对应 `markdown.content` / `button.text` / `header.title` | +| 颜色/风格不满意 | 对应 `background_style` / `font_color` / `header.template` / config color token | +| 布局/排列不满意 | 对应 `column_set.flex_mode` / `width` / `weight` / `padding` | +| 缺少某个字段/信息 | 新增 `div.fields` 条目或 `markdown` 行 | +| 某个块太拥挤/太空 | 调整 `padding` / `vertical_spacing` / `margin` | +| 交互行为问题 | 对应 `behaviors` / `confirm` / `disabled` | + +### 2. 最小改动原则 + +- 只改被指出的组件,不动周边结构。 +- 改完后**只对被修改组件所涉及的 P 项重新自检**(改颜色 → 过 P6;改分组 → 过 P1+P2;改间距 → 过 P5)。 + +### 3. 重发 + +修正完成后,重新发送一张新卡(同 Step 4),告知用户"已重新发送修正版"。 + +### 4. 执行前告知 + +向用户复述"我将修改 ×××",确认后再执行,不要静默改动。 + +--- + +## 执行清单 + +- [ ] 入口:判断是文字诉求(→ Step 1)还是图片输入(→ 图片分支 → 判断类型→保真策略→组件映射) +- [ ] Step 1:分析意图,输出设计方案(版本 / 宽度模式 / 颜色 / 组件) +- [ ] Step 2:读 schema.md + 组件明细 + 「好看的标准 P0–P7」 +- [ ] Step 3:构造 JSON → 过 P0–P7 硬 Gate(P0+P1–P3 阻断),不过先修 +- [ ] Step 4:发送,失败按常见失败表排查重试(≤3 次);仍失败则降级 Card 1.0 重构发送 +- [ ] Step 5:若有交互,参考 ../lark-im-card-action-reply.md +- [ ] Step 6:用户提出修改意见时,定位组件→最小改动→原地更新或重发 diff --git a/skills/lark-im/references/card/lark-im-card-style.md b/skills/lark-im/references/card/lark-im-card-style.md new file mode 100644 index 00000000..4093e1f9 --- /dev/null +++ b/skills/lark-im/references/card/lark-im-card-style.md @@ -0,0 +1,281 @@ +# Card Style Guide + +选择组件组合和视觉样式的决策指南。字段写法见 `card-2.0-schema.md`。 + +--- + +## 好看的标准(P0–P7,唯一裁判基准) + +**先读这一节。** 下面的「意图→组件」表和「视觉规范」都是为这套标准服务的手段;构造和自检卡片时**以 P0–P7 为准**。 + +**目标函数**:一张好卡片 = 让收件人在**约 2 秒一瞥**内 get 到「这是什么 + 最重要的是什么 + 要不要操作」,且观感**有序、克制、不嘈杂**。高效传达与视觉舒适在此统一。 + +**用力分配**:P0 必过(前置闸)→ P1–P3 强约束(阻断)→ P4–P5 基础卫生 → P6–P7 加分。 + +每条都附**结构化验证句**——卡片不能渲染成图,只能对 JSON 结构推理,所以验证靠「数结构」而非「眯眼看」。 + +| | 准则 | 可操作要求 | 结构化验证(自检句) | +|---|---|---|---| +| **P0** | **符合诉求**(前置闸·阻断) | 精确承载用户要的信息/意图/操作,不缺、不多、不跑题;意图类型与组件组合匹配 | 把诉求拆成信息点清单,逐点在 JSON 里找到承载组件;操作诉求逐个找到交互组件。有缺=不过 | +| **P1** | **层级**(强约束·阻断) | header 承载「这是什么」;body 内**有且仅有一个**最强焦点(最大字号/最重色/指标卡大数字),其余为支撑;标题用 `**加粗**`、次要信息用 grey | 列出所有文本的「字号+粗细+颜色」三元组,能否排出主>次>辅三层;焦点是否唯一 | +| **P2** | **分组**(强约束·阻断) | 同主题字段收进同一容器(`column_set`/`interactive_container`/背景块),不同主题分容器;块边界靠容器底色/描边/间距,**而非一路 `hr` 平铺** | 数顶层视觉块个数;是否存在「多主题挤在同一无分隔 markdown / 一路 hr 平铺」反模式 | +| **P3** | **复杂度适中**(强约束·阻断·双边带) | 下限:不得纯文字流水账,至少有分块+层级+适度色彩/图标;上限:视觉块 2–5、主色系 ≤3、组件不堆砌、焦点唯一 | ①是否 >1 个视觉块且含≥1 个非纯文本结构元素(背景块/指标卡/图标/表格);②块数 ≤5、主色系 ≤3。两端都满足才过 | +| **P4** | **对比**(基础卫生) | 标题与正文字号或粗细至少差一档;强调用色/放大;正文不滥用 `#/##/###`(数值焦点放大除外,见 P1) | 标题与正文是否在「字号或粗细」上至少差一档 | +| **P5** | **对齐**(基础卫生) | 间距优先交容器 `vertical_spacing`/`horizontal_spacing`/`padding`,**不滥用散设 margin 造成疏密无规律**;间距值收敛到一套档位(2/4/8/12px);顶层容器间距一致 | 是否存在无规律的散落 margin;间距取值种类是否 ≤4 | +| **P6** | **语义一致**(加分) | 红=降/警/失败、绿=升/成/通过、grey=次要;主色系起始色由 header 决定、取邻近色环;同色同义 | 同一颜色是否对应同一语义;header 模板色与块色是否同色系 | +| **P7** | **健壮**(加分) | 并列/指标列默认 `weighted` 或 `none`、**慎用 `stretch`**(防移动端拉伸);需要时配 `config.style.color` 的 light/dark;不靠固定像素宽硬排 | 是否存在 stretch 拉伸风险;深浅色是否都可读 | + +--- + +## 意图 → 组件组合 + +### 通知类(无交互或只读) + +| 用户意图 | 推荐组件组合 | header.template | +|---|---|---| +| 纯文字通知 / 系统公告 | `column_set`(通知正文,带 `blue-50` 背景)+ `button(open_url)` | `blue` | +| 活动公告(带主视觉图) | `img`(主图)+ `markdown`(时间/地点)+ `column_set`(详情对)+ `button(open_url)` | `turquoise` / `blue` | +| 成功 / 完成状态通知 | `column_set`(关键字段,带 `green-50` 背景)+ `markdown`(结论加粗) | `green` | +| 审批结果反馈(已通过 / 已拒绝) | `column_set`(申请信息)+ `column_set`(审批结论 + icon,带 `green-50`/`red-50` 背景) | `green` / `red` | +| 生日 / 节日祝福 | `img`(主图)+ `column_set`(人名/日期)+ `button(open_url)` | `orange` | +| 产品 / 功能上线推广 | `img`(主图)+ `markdown`(亮点)+ `column_set`(功能高亮块)+ `button(open_url)` | `blue` / `violet` | +| 多图展示(图集、AI 生成图) | `img_combination` 或 多个 `img` + `markdown`(说明)+ `button(callback)` | `default` | + +### 提醒 + 操作类 + +| 用户意图 | 推荐组件组合 | header.template | +|---|---|---| +| 提醒 + 一键操作 | `column_set`(详情,带 `yellow-50` 背景)+ `button(callback)` | `yellow` | +| 任务清单 / 待办跟踪 | `checker` × N(每项带 `behaviors: callback`)+ `button(callback)`(全部完成操作) | `blue` | +| 告警触发(需立即处理) | `column_set`(告警指标,带 `red-50` 背景)+ `column_set`(描述 + input 快速备注)+ `button(callback)` | `red` | +| 告警已解决 / 状态变更 | `column_set`(解决时间 / 负责人,带 `green-50` 背景)+ `markdown`(结论加粗) | `green` | +| 审批待处理(含备注输入) | `column_set`(申请信息,带 `grey-50` 背景)+ `column_set`(input 审批意见)+ `button(callback)` × 2(通过 / 拒绝) | `default` | +| 日历 / 日程提醒(含参与人) | `column_set`(时间 / 地点,带 `yellow-50` 背景)+ `person_list`(参与人)+ `button(callback)` | `yellow` | +| 危险操作确认 | `column_set`(说明,带 `red-50` 背景)+ `button(callback)` + `confirm` 弹窗配置 | `red` | + +### 数据 / 报告类 + +| 用户意图 | 推荐组件组合 | header.template | +|---|---|---| +| 日报 / 工作汇报 | `column_set`(指标,带背景色)+ `interactive_container`(进展分块,带描边)× N;内容过长的块用 `collapsible_panel` 折叠次要细节 | `blue` / `default` | +| 数据看板(含图表) | `column_set`(指标,带 `blue-50` 背景)+ `chart` + `table`(根节点,不可嵌套)+ `markdown`(说明) | `blue` | +| 排行榜 | `column_set` 固定列宽(序号 + 头像 `img` + 名字 + 指标)循环条目 | `grey` | +| 订单 / 工单详情 | `div.fields`(字段对)或 `column_set`(需彩色背景块时)+ `button(callback)` | `orange` | + +### 表单 / 收集类 + +| 用户意图 | 推荐组件组合 | header.template | +|---|---|---| +| 纯文字表单收集 | `form`(内含 `input` + `button(form_action_type: submit)`) | `blue` | +| 带下拉选择的表单(单选) | `form`(内含 `select_static` / `select_person` + `input` + `button`) | `wathet` | +| 带多选的表单 | `form`(内含 `multi_select_static` / `multi_select_person` + `input` + `button`) | `wathet` | +| 含日期 / 时间的表单 | `form`(内含 `date_picker` / `picker_time` / `picker_datetime` + `input` + `button`) | `blue` | +| 设备 / 服务反馈 | `form`(内含 `select_static`(满意度)+ `input`(备注)+ `button`) | `yellow` | +| 多步骤进度 / 引导 | `column_set`(横向步骤,带 `blue-50` 背景)+ `markdown`(当前状态)+ `button` | `blue` | + +### 推荐 / 选择类 + +| 用户意图 | 推荐组件组合 | header.template | +|---|---|---| +| 推荐列表(带图卡片,可点击) | `interactive_container`(内含 `img` + `markdown`)× N + `button(open_url)` | `blue` | +| AI 引导选项 / 功能菜单 | `markdown`(欢迎语)+ `interactive_container`(内含 `markdown` 选项说明)× N | 无 header | +| Bot 功能引导 / 教程 | `column_set`(步骤说明,带背景)+ `button` × 2(主操作 / 次操作) | `blue` | +| 服务台 / 多操作入口 | `column_set`(说明,带背景)+ `button` × N(≤3 个主操作,`type` 区分主次);次要操作超过 3 个时改用 `overflow`(折叠菜单) | 无 header | + +### 社交 / 互动类 + +| 用户意图 | 推荐组件组合 | header.template | +|---|---|---| +| 工作圈 / 社交分享 | `img_combination`(多图)+ `markdown`(正文)+ `button(open_url)` × 2 | `blue` | +| 成交 / 业绩公告 | `img`(庆祝图)+ `markdown`(成绩)+ `column_set`(关键数字) | `green` | + +--- + +## 视觉规范(实现 P0–P7 的具体战术) + +组件选型只解决「有没有」,下面各条是落地上面 P0–P7 的具体手段,括号标注它主要服务的原则。 + +> **P3 特例 — 数据看板类**:`chart + table + column_set + markdown` 是四种不同组件各出现一次,不算「堆砌」,P3 上限照常满足;但仍须保证每类只出现一次。 + +### 0. Header 图标(服务 P3 · 视觉质感底线) + +**几乎所有卡片都应配 header icon**——这是提升「精致感」成本最低的一步,缺失会让 header 显得空洞、平价。 + +```json +"header": { + "title": { "tag": "plain_text", "content": "卡片标题" }, + "template": "blue", + "icon": { "tag": "standard_icon", "token": "mail_colorful" } +} +``` + +- `token` 从 `resource/icons.md` 按场景选取;彩色图标用 `*_colorful` 后缀,单色用普通名称。 +- 常用速查:通知 `notice_colorful`、告警 `warning_colorful`、审批 `approve_colorful`、日历 `calendar_colorful`、数据 `chart_colorful`、任务 `todo_colorful`、AI `myai_colorful`。 + +### 1. 配色纪律(服务 P6 语义一致) + +- **邻近色环**:`Red → Carmine → Orange → Yellow → Green → Turquoise → Wathet → Blue → Violet → Purple →(回到)Red`。一张卡只能取色环上**相邻**的颜色,严禁跳跃(❌ blue + green + red)。 +- **最多 3 种主色系**(不含 grey / white)。 +- **起始色由 header 决定**: + - header `blue` → blue / violet / purple + - header `green` → green / turquoise / wathet + - header `red` → red / carmine / orange + - 无 header → 默认 blue / violet / purple +- **深浅语义**(写法 `blue-50`、`blue-600`、`grey-500`): + - `-50` 区块背景 · `-100` 标签背景 · `-500` 正文文字 · `-600`/`-700` 强调文字 + +### 2. 间距纪律(服务 P5 对齐 · 视觉决定性因素) + +- **body padding 推荐**:`"padding": "12px 12px 20px 12px"`(上右下左;底部 20px 留白更舒适)。 +- **优先不用 `markdown` / `column` 的 `margin` 控间距**:交给父容器的 `vertical_spacing` / `horizontal_spacing` / `padding` 统一管理,多数情况显式置 `0px`;仅在需要精细缩进(如层级左缩进)时才设非零值。 +- 容器内 `vertical_spacing` 推荐值:`2px`(高亮块内标题↔正文)/ `4px`(正文段落、列表项)/ `8px`(需拉开的元素)。 +- **容器间智能 margin**:某个顶级容器若**不是** body 最后一个元素 → 设 `"margin": "0px 0px 12px 0px"`;若**是**最后一个 → `"0px"` 或不设,避免卡片底部多余留白。 + +### 3. 指标卡模式(服务 P1 焦点 · 出现 KPI / 数值 / 统计词时强制使用) + +触发:内容含 `KPI/ROI/CTR/UV/PV/DAU/GMV/转化率/增长率/总数/营收` 等数值类信息。 + +- 多个指标并列放进一个 `column_set`,`flex_mode` **默认用 `"none"`、慎用 `"stretch"`**(防移动端拉伸变形,P7);仅在各列内容等宽、确认移动端不变形时才用 stretch。 +- 数值:用 `##` 放大(**唯一允许用 markdown 标题的特例**),可配 `` 上色。 +- 描述:`` + `text_size: "notation"`。 +- 居中 `text_align: "center"`;列背景 `background_style: "grey-50"`;`padding: "12px"`;`vertical_spacing: "2px"`。 + +```json +{ + "tag": "column_set", + "flex_mode": "none", + "horizontal_spacing": "12px", + "columns": [ + { "tag": "column", "width": "weighted", "weight": 1, + "background_style": "grey-50", "corner_radius": "8px", + "padding": "12px", "vertical_spacing": "2px", + "elements": [ + { "tag": "markdown", "content": "## 5,483", "text_align": "center" }, + { "tag": "markdown", "content": "GMV($)", "text_align": "center", "text_size": "notation" } + ] } + ] +} +``` + +### 4. 描边卡片模式(服务 P2 分组 · 进展 / 事项 / 列表项分块展示) + +用 `interactive_container` 给每个事项块加描边 + 圆角,视觉上比彩色底色更轻盈,适合进展/工单/任务列表等「多条目」场景。 + +```json +{ + "tag": "interactive_container", + "width": "fill", + "has_border": true, + "border_color": "blue-100", + "corner_radius": "8px", + "background_style": "blue-50", + "padding": "12px 12px 12px 12px", + "vertical_spacing": "4px", + "margin": "0px 0px 12px 0px", + "elements": [ + { + "tag": "markdown", + "content": "**事项标题**" + }, + { + "tag": "markdown", + "content": "事项正文内容……", + "text_size": "normal" + } + ] +} +``` + +- `border_color` 跟随主色系(蓝系用 `blue-100`,绿系用 `green-100`)。 +- 不需要交互时可省略 `behaviors`;需要点击回调时加 `"behaviors": [{"type":"callback","value":{...}}]`。 +- **不能在内部放 `form` 或 `table`**。 + +### 5. 高亮块模式(服务 P2 分组 · 多分类信息成块展示) + +两层结构:外层 `column_set` 管布局,内层 `column` 管样式(彩色背景)。 + +- 每个 `column` 设 `background_style` 用浅色(如 `blue-50` / `green-50`),`padding: "12px 12px 12px 12px"`,`vertical_spacing: "4px"`,`weight: 1`。 +- 块内首行用 `**分类标题**` 着色加粗,正文紧随。 +- **布局选择**:分类 ≤ 3 个且内容简短 → 水平,优先用 `flex_mode: "bisect"`(2 列)或 `"trisect"`(3 列);各列字数严格等宽且已确认移动端不变形时才用 `stretch`(慎用,见 §9);**分类 ≥ 4 个、奇数、或任一块内容 > 3 行 → 垂直**(每块独占一行)。配色按上面第 1 条邻近色环依次取色。 +- ⚠️ **版本依赖**:`column.background_style` 需客户端 **≥ v7.9**,旧版静默丢背景。要求强健壮性时改用 `interactive_container` 的 `background_style`(无版本限制)替代 column 背景色。 + +### 6. Header 三件套(服务 P1 层级 · 语境补全) + +header 有三层能力,**尽量用满**(至少用 `title` + `icon`;`subtitle` 和 `text_tag_list` 按实际诉求取舍)——这是成本最低、语境最清晰的一步: + +- `title`:这是什么(必填) +- `subtitle`:一句上下文(谁发 / 什么时间 / 什么状态),≤1 行,`plain_text` +- `text_tag_list`:状态标签,≤3 个,颜色语义与 P6 保持一致(`blue`=信息、`yellow`=待处理、`red`=紧急、`green`=完成) + +```json +"header": { + "title": { "tag": "plain_text", "content": "发版审批" }, + "subtitle": { "tag": "plain_text", "content": "2026-06-25 · 后端服务" }, + "template": "blue", + "icon": { "tag": "standard_icon", "token": "approve_colorful" }, + "text_tag_list": [ + { "tag": "text_tag", "text": { "tag": "plain_text", "content": "待审批" }, "color": "yellow" } + ] +} +``` + +**禁止**:在 `header.title` 里写 emoji;把 subtitle 信息改塞进 body 第一行 markdown,让 header 空洞;严肃场景(审批/告警/财务)在 title 或 body 标题里用装饰性 emoji。 + +### 7. 字段对用 `div.fields`,不要用 `column_set` 模拟(服务 P5 对齐) + +详情型"label: value"(订单字段、审批信息、日程详情)首选 `div.fields`——原生对齐,结构更轻: + +```json +{ + "tag": "div", + "fields": [ + { "is_short": true, "text": { "tag": "lark_md", "content": "**提交人**\n张三" } }, + { "is_short": true, "text": { "tag": "lark_md", "content": "**部门**\n研发中台" } }, + { "is_short": true, "text": { "tag": "lark_md", "content": "**提交时间**\n2026-06-25 10:30" } }, + { "is_short": true, "text": { "tag": "lark_md", "content": "**优先级**\nP0" } } + ] +} +``` + +`is_short: true` 的字段自动两两并排,对齐由组件保证。`column_set` 留给**需要彩色背景块 / 不等宽 / 嵌套复杂结构**的场景,不要用它模拟简单字段对。 + +### 8. 长文本必须设 `lines` 截断(服务 P3 复杂度上限) + +凡接收动态数据的文本字段,必须设最大行数避免卡片被撑爆: + +| 位置 | 字段 | 推荐上限 | +|---|---|---| +| `div.text` | `lines` | 正文 ≤4,次要说明 ≤2 | +| `person_list` | `lines` | ≤2 | +| `table.header_style` | `lines` | ≤1 | +| `collapsible_panel` | 默认折叠 | 长文本优先用折叠面板而非截断 | + +不设 `lines` 的动态文本 = P3 上限的隐患。 + +### 9. `flex_mode` 决策表(服务 P7 健壮) + +| 场景 | 推荐 flex_mode | 原因 | +|---|---|---| +| 指标卡并列(内容不等长) | `none` + `width: weighted` | 防移动端拉伸;各列按比例压缩 | +| 2 列等宽内容(字数相近) | `bisect` | 语义最清晰的两等分 | +| 3 列等宽内容 | `trisect` | 三等分,不写 weight | +| 多 tag / 多图标横排,允许换行 | `flow` | 窄屏自动折行,不挤压 | +| 明确要求两端对齐撑满且内容等宽 | `stretch` | 慎用:移动端窄屏内容过长时会拉伸变形 | + +> `stretch` 只在各列字数高度相近、且已确认移动端不变形时使用;其余场景默认 `none`。 + +### 10. `chart` 配色纳入 P6 纪律 + +`chart.color_theme` 必须与全卡色系保持一致: + +- **默认**:`brand`(单色系,跟随飞书品牌色)或 `primary`(主色单色系),安全选项。 +- **禁止**:`rainbow`——会把色环上的跳跃色全打进图表,直接击穿 P6 的"主色系 ≤3 + 邻近色环"约束。 +- **例外**:数据维度 ≥4 个系列、且各系列无主次关系(如区域对比图)时,可用 `complementary` 或在 `chart_spec` 里自定义与主色系邻近的颜色数组。 + +### 11. `laser` 样式的克制规则(服务 P6 语义一致) + +`button.type: "laser"` 和 `background_style: "laser"` 是高饱和渐变效果: + +- **允许**:AI 生成类、节日庆祝类、营销推广类,每卡 **≤1 处**,且位置在主操作按钮或视觉焦点块。 +- **禁止**:审批、告警、财务、工单、日程等严肃场景——laser 在这些场景里显得轻浮廉价。 +- **默认不用**;Step 1 设计方案里若要用,需显式说明"×× 场景适合 laser 风格"并得到确认。 diff --git a/skills/lark-im/references/card/resource/colors.md b/skills/lark-im/references/card/resource/colors.md new file mode 100644 index 00000000..0fec0f67 --- /dev/null +++ b/skills/lark-im/references/card/resource/colors.md @@ -0,0 +1,34 @@ +# 颜色枚举 + +卡片所有颜色字段(`font_color` / `text_color` / `background_style` / `border_color` / icon `color` 等)共用同一套枚举,按属性名区分用途,无单独的文字/背景色表。 + +## 基础色名(14 色系) + +`blue` `carmine` `green` `indigo` `lime` `orange` `purple` `red` `sunflower` `turquoise` `violet` `wathet` `yellow` `grey` + +> **标签例外**:`text_tag` / `` 的灰色用 `neutral`(不是 `grey`);标签枚举无 `grey`。 + +## 深浅后缀 + +- 彩色系(13 个非 grey):`-50 -100 -200 -300 -350 -400 -500 -600 -700 -800 -900`,数字越大越深。 +- **无后缀基础名(如 `blue`)= `-600`**(同色值)。 +- grey 范围更细:`-00 -50 -100 … -650 … -950 -1000`。 +- 用法语义:`-50` 区块背景 · `-100` 标签背景 · `-500` 正文 · `-600/-700` 强调文字。 + +## 特殊值 + +`white`(白)· `bg-white`(背景白:浅色 #ffffff / 深色 #1A1A1A)。无 `transparent` 枚举。 + +## 自定义 RGBA + +在 `config.style.color` 定义 token 再引用: + +```json +"config": { "style": { "color": { + "cus-0": { "light_mode": "rgba(5,157,178,0.52)", "dark_mode": "rgba(...)" } +} } } +``` + +组件里写 `"font_color": "cus-0"`。RGBA 支持的属性同枚举(font/text_color、background_style、border_color、icon color 等)。 + +> `column` 的 `background_style` 需客户端 v7.9+。配色搭配规则见 `../lark-im-card-style.md` 视觉规范。 diff --git a/skills/lark-im/references/card/resource/icons.md b/skills/lark-im/references/card/resource/icons.md new file mode 100644 index 00000000..4b88ca02 --- /dev/null +++ b/skills/lark-im/references/card/resource/icons.md @@ -0,0 +1,38 @@ +# 图标枚举 + +用于 `header.icon`、`div.icon`、`markdown` 的 `` 等。 + +## 结构 + +```json +// 系统图标(推荐):用 token +{ "tag": "standard_icon", "token": "info_outlined", "color": "blue" } +// 自定义图标:用上传的 img_key +{ "tag": "custom_icon", "img_key": "img_v3_xxx" } +``` + +`color` 取颜色枚举(见 `colors.md`),仅对 `standard_icon` 生效。 + +## token 命名 + +- 线性:后缀 `_outlined`;面性(实心):后缀 `_filled`。 +- 主体 kebab-case,如 `calendar-add_outlined`、`delete-trash_outlined`。 + +## 常用 token(业务卡片) + +| 含义 | token | 含义 | token | +|---|---|---|---| +| 完成/对勾 | `done_outlined` | 关闭/叉 | `close_outlined` | +| 新增 | `add_outlined` | 编辑 | `edit_outlined` | +| 删除 | `delete-trash_outlined` | 搜索 | `search_outlined` | +| 设置 | `setting_outlined` | 信息 | `info_outlined` | +| 警告 | `warning_outlined` | 时间 | `time_outlined` | +| 日历 | `calendar_outlined` | 成员 | `member_outlined` | +| 群组 | `group_outlined` | 会话 | `chat_outlined` | +| 邮件 | `mail_outlined` | 链接 | `link-copy_outlined` | +| 分享 | `share_outlined` | 下载 | `download_outlined` | +| 通知/铃铛 | `bell_outlined` | 定位 | `pin_outlined` | +| 附件 | `attachment_outlined` | 审批 | `approval_outlined` | + +> token 必须与官方完全一致,否则图标不渲染。上表为常用项,全量(数百个,分系统/商务/沟通/用户/媒体/文档等类目)以官方图标库为准: +> https://open.larkoffice.com/document/feishu-cards/enumerations-for-icons diff --git a/skills/lark-im/references/lark-im-chat-members-list.md b/skills/lark-im/references/lark-im-chat-members-list.md new file mode 100644 index 00000000..9a22b9ae --- /dev/null +++ b/skills/lark-im/references/lark-im-chat-members-list.md @@ -0,0 +1,83 @@ +# im +chat-members-list + +> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules. + +List the members of a chat. Users and bots are returned in **separate buckets** — `users[]` and `bots[]` — with per-bucket totals (`user_total` / `bot_total`). Use `--member-types` to return only one kind. + +This skill maps to the shortcut: `lark-cli im +chat-members-list` (internally calls `GET /open-apis/im/v1/chats/{chat_id}/members/list`). + +## Commands + +```bash +# Single page (default) +lark-cli im +chat-members-list --chat-id oc_xxx + +# Only users, or only bots +lark-cli im +chat-members-list --chat-id oc_xxx --member-types user +lark-cli im +chat-members-list --chat-id oc_xxx --member-types user,bot + +# Walk every page (capped by --page-limit; 0 = unlimited) +lark-cli im +chat-members-list --chat-id oc_xxx --page-all --page-limit 0 + +# Resume from a specific cursor (single page; --page-all is ignored) +lark-cli im +chat-members-list --chat-id oc_xxx --page-token "xxx" + +# JSON output / preview the request +lark-cli im +chat-members-list --chat-id oc_xxx --format json +lark-cli im +chat-members-list --chat-id oc_xxx --dry-run +``` + +## Parameters + +| Parameter | Required | Limits | Description | +|------|------|------|------| +| `--chat-id ` | Yes | `oc_xxx` | Target chat | +| `--member-types ` | No | `user`, `bot` (comma-separated or repeated) | Member types to return. Omitted = all | +| `--member-id-type ` | No | `open_id` (default), `union_id`, `user_id` | ID type for `member_id` in the response | +| `--page-size ` | No | 1-100, default 20 | Results per page. With `--page-all` and no explicit `--page-size`, the max (100) is used automatically to minimize round-trips | +| `--page-token ` | No | - | Pagination cursor; **implies a single-page fetch** (disables auto-pagination) | +| `--page-all` | No | - | Automatically walk every page (capped by `--page-limit`) | +| `--page-limit ` | No | default 10, `0` = unlimited | Max pages to fetch with `--page-all` | +| `--page-delay ` | No | default 200, `0` = no delay | Delay between pages during `--page-all` (throttle to avoid rate limits on large lists) | +| `--format json` | No | - | Output as JSON | +| `--dry-run` | No | - | Preview the request without executing it | + +> Supports both `--as user` (default) and `--as bot`. The caller must be in the target chat, and must belong to the same tenant for internal chats. + +## Output Fields + +| Field | Description | +|------|------| +| `chat_id` | The queried chat ID | +| `users` | Array of user members (`member_id`, `name`, `tenant_key`, …) | +| `bots` | Array of bot members (`member_id`, `app_id`, `name`, …) | +| `user_total` / `bot_total` | Server-reported totals for each bucket | +| `truncations` | Non-empty when the server **capped a bucket** due to security config — see below | +| `has_more` / `page_token` | Paging signals from the final page fetched | + +## Truncation: the result may be incomplete + +The server applies a security cap to large member lists. When a bucket is capped, the response carries a `truncations[]` entry (e.g. `[{"limit": 100, "member_type": "user"}]`) **on the final page only**. The shortcut surfaces this two ways so it is never missed: + +- **stderr**: `⚠️ member list truncated by server security config: user bucket capped at 100 — the list is INCOMPLETE.` +- **stdout JSON**: the `truncations` array is preserved verbatim in the output. + +A truncated result is *not* fixable by paging further — it is a server-side cap. Treat `users`/`bots` as a partial list whenever `truncations` is non-empty. + +## Pagination notes + +- Default fetches a single page. Pass `--page-all` to drain every page. +- With `--page-all` and no explicit `--page-size`, the shortcut uses the maximum page size (100) so a full walk takes the fewest round-trips. An explicit `--page-size` is always honored. +- `--page-all` sleeps `--page-delay` ms (default 200) between pages to avoid hammering the API when a tenant has no server-side member cap and the list spans many pages. Set `--page-delay 0` to disable. +- `--page-all` stops at `--page-limit` pages (default 10). When it stops early, `has_more` stays `true` so you know the result is incomplete; re-run with `--page-limit 0` for everything. +- `--page-token` and `--page-all` together: `--page-token` wins (single-page fetch from the supplied cursor); a stderr warning is emitted. +- Across pages, `users[]` and `bots[]` are concatenated; `truncations` / `has_more` / `page_token` come from the last page fetched. + +## Common Errors and Troubleshooting + +| Symptom | Root Cause | | Solution | +|---------|---------|---|---------| +| `--chat-id is required` | `--chat-id` omitted | | Provide the `oc_xxx` chat ID | +| `--page-size must be an integer between 1 and 100` | out of range | | Use 1-100 | +| `--member-types contains invalid value` | value other than `user`/`bot` | | Use `user`, `bot`, or both | +| Permission denied | missing `im:chat.members:read` | | Bot: enable the scope in the console. User: `lark-cli auth login --scope "im:chat.members:read"` | diff --git a/skills/lark-im/references/lark-im-messages-reply.md b/skills/lark-im/references/lark-im-messages-reply.md index 8872fdc7..a82f5208 100644 --- a/skills/lark-im/references/lark-im-messages-reply.md +++ b/skills/lark-im/references/lark-im-messages-reply.md @@ -155,6 +155,15 @@ lark-cli im +messages-reply --message-id om_xxx --text "Received" --idempotency- # Preview the request without executing it lark-cli im +messages-reply --message-id om_xxx --markdown $'## Test\n\nhello' --dry-run + +# ===== Interactive Card ===== +# 🚫 STOP — before constructing ANY interactive card JSON, you MUST read +# card/lark-im-card-create.md and follow its workflow. Do NOT +# hand-write or copy a card payload. The JSON passed to --content must be +# the OUTPUT of that workflow. This is non-negotiable. + +# Once the workflow has produced the card JSON, reply with it: +lark-cli im +messages-reply --message-id om_xxx --msg-type interactive --content '' ``` ## Media Input Rules @@ -265,3 +274,4 @@ Card content is **not** normalized — use the card-native `` syntax inside - `--as user` uses a user access token (UAT) and requires the `im:message.send_as_user` and `im:message` scopes; the reply is sent as the authorized end user - `--as bot` uses a tenant access token (TAT), and requires the `im:message:send_as_bot` scope - When using `--markdown` with images, pre-uploading via `images.create` to obtain an `image_key` is recommended for reliability; remote URLs may be auto-resolved at runtime, but if download/upload fails the image is removed with a warning; local paths are not supported +- **Interactive cards are gated:** you MUST read and follow the [`card/lark-im-card-create.md`](card/lark-im-card-create.md) workflow to produce the card JSON *before* replying. Do not hand-write or copy a card payload — the JSON given to `--msg-type interactive --content` must be the workflow's output. This applies every time, with no exception diff --git a/skills/lark-im/references/lark-im-messages-send.md b/skills/lark-im/references/lark-im-messages-send.md index 03aca166..2215787d 100644 --- a/skills/lark-im/references/lark-im-messages-send.md +++ b/skills/lark-im/references/lark-im-messages-send.md @@ -158,6 +158,15 @@ lark-cli im +messages-send --chat-id oc_xxx --text "Hello" --idempotency-key my- # Preview the request without executing it lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry-run + +# ===== Interactive Card ===== +# 🚫 STOP — before constructing ANY interactive card JSON, you MUST read +# card/lark-im-card-create.md and follow its workflow. Do NOT +# hand-write or copy a card payload from the examples below. The JSON passed +# to --content must be the OUTPUT of that workflow. This is non-negotiable. + +# Once the workflow has produced the card JSON, send it: +lark-cli im +messages-send --chat-id oc_xxx --msg-type interactive --content '' ``` ## Media Input Rules @@ -213,7 +222,9 @@ lark-cli im +messages-send --chat-id oc_xxx --markdown $'## Test\n\nhello' --dry | `media` | `{"file_key":"file_xxx","image_key":"img_xxx"}` (video; `image_key` is the cover from `--video-cover` — **required**) | | `share_chat` | `{"chat_id":"oc_xxx"}` | | `share_user` | `{"user_id":"ou_xxx"}` | -| `interactive` | Card JSON (see Feishu interactive card documentation) | +| `interactive` | Card JSON — **MUST** be produced by the [`card/lark-im-card-create.md`](card/lark-im-card-create.md) workflow. Read it before writing any card; never hand-craft the JSON here | + +> **`post` vs `interactive`:** `post` is a static rich-text message (title, paragraphs, @mentions, links, inline images) — content is fixed once sent. `interactive` is a card with structured layout and UI components (buttons, forms, selects, date pickers, charts) — content can be updated after sending and supports user-action callbacks. Use `post` for read-only content; use `interactive` when the message needs user interaction or dynamic updates. `interactive` cards support callback events (`card.action.trigger`) — see [`lark-im-card-action-reply.md`](lark-im-card-action-reply.md). @@ -265,3 +276,4 @@ Card content is **not** normalized — use the card-native `` syntax inside - `--as bot` uses a tenant access token (TAT) and requires the `im:message:send_as_bot` scope - When sending as a bot, the app must already be in the target group or already have a direct-message relationship with the target user - When using `--markdown` with images, pre-uploading via `images.create` to obtain an `image_key` is recommended for reliability; remote URLs may be auto-resolved at runtime, but if download/upload fails the image is removed with a warning; local paths are not supported +- **Interactive cards are gated:** you MUST read and follow the [`card/lark-im-card-create.md`](card/lark-im-card-create.md) workflow to produce the card JSON *before* sending. Do not hand-write or copy a card payload — the JSON given to `--msg-type interactive --content` must be the workflow's output. This applies every time, with no exception diff --git a/skills/lark-okr/SKILL.md b/skills/lark-okr/SKILL.md index 8d02fd4d..4c59768e 100644 --- a/skills/lark-okr/SKILL.md +++ b/skills/lark-okr/SKILL.md @@ -31,12 +31,13 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr + [flags]`) | [`+batch-create`](references/lark-okr-batch-create.md) | 批量创建 Objective 和 KR | | [`+reorder`](references/lark-okr-reorder.md) | 调整 Objective 或 KR 的顺位 | | [`+weight`](references/lark-okr-weight.md) | 调整 Objective 或 KR 的权重 | -| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的指标当前值 | +| [`+indicator-update`](references/lark-okr-indicator-update.md) | 更新 Objective 或 KR 的指标当前值(简单场景推荐)。更复杂的指标操作见 [量化指标管理](references/lark-okr-indicators.md) | +| [`+patch`](references/lark-okr-patch.md) | 部分更新 Objective 或 KR(content、notes、score、deadline) | ## 格式说明 - [`OKR 业务实体`](references/lark-okr-entities.md) 获取 OKR 实体结构,定义和关系,帮助你更好的使用 OKR 功能 -- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note 字段使用的富文本格式说明 +- [`ContentBlock 富文本格式`](references/lark-okr-contentblock.md) — Objective/KeyResult/Progress 中 Content/Note 字段使用的富文本格式说明,以及简化的半纯文本(SemiPlainContent)格式的进一步说明。 - **强烈建议** 在操作 OKR 前,阅读[`OKR 业务实体`](references/lark-okr-entities.md)以了解基础概念 ## API Resources @@ -46,6 +47,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr + [flags]`) - `delete` — 删除对齐关系 - `get` — 获取对齐关系 +> **操作指南:** [OKR 对齐关系管理](references/lark-okr-alignments.md) 包含 list/create/delete 完整工作流 + ### categories - `list` — 批量获取分类 @@ -71,6 +74,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr + [flags]`) - `patch` — 更新量化指标 +> **操作指南:** [OKR 量化指标管理](references/lark-okr-indicators.md) 包含目标/KR 指标查询和 patch 更新完整工作流 + ### key_results - `delete` — 删除关键结果 @@ -81,6 +86,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli okr + [flags]`) - `list` — 获取关键结果的量化指标 +> **操作指南:** [OKR 量化指标管理](references/lark-okr-indicators.md) + ### objectives - `delete` — 删除目标 diff --git a/skills/lark-okr/references/lark-okr-alignments.md b/skills/lark-okr/references/lark-okr-alignments.md new file mode 100644 index 00000000..bcdc3728 --- /dev/null +++ b/skills/lark-okr/references/lark-okr-alignments.md @@ -0,0 +1,180 @@ +# OKR 对齐关系管理 + +> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +管理 OKR 目标之间的对齐关系,包括查询、创建和删除对齐。 + +## 对齐关系说明 + +OKR 对齐关系表示两个目标之间的关联: +- **对齐(aligning)**:目标 A 对齐到目标 B,表示 A 的完成有助于 B 的完成 +- **被对齐(aligned)**:目标 B 被目标 A 对齐 + +每个对齐关系有唯一的 `alignment_id`,用于删除操作。 + +--- + +## 一、查询对齐关系 + +### 命令 + +```bash +lark-cli okr objective.alignments list --objective-id "<目标ID>" [flags] +``` + +### 常用示例 + +```bash +# 获取目标的所有对齐关系(同时包含对齐和被对齐) +lark-cli okr objective.alignments list \ + --objective-id "7652569715131075772" + +# 只查询该目标主动对齐他人的关系 +lark-cli okr objective.alignments list \ + --objective-id "7652569715131075772" \ + --align-type "aligning" + +# 只查询他人对齐该目标的关系 +lark-cli okr objective.alignments list \ + --objective-id "7652569715131075772" \ + --align-type "aligned" + +# 自动分页获取全部数据 +lark-cli okr objective.alignments list \ + --objective-id "7652569715131075772" \ + --page-all +``` + +### 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|----------------------|----|----------------|--------------------------------------------------------------------| +| `--objective-id` | 是 | — | 目标 ID | +| `--align-type` | 否 | — | 对齐类型:`aligning`(该目标对齐他人)\| `aligned`(他人对齐该目标)。留空返回全部。 | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--page-size` | 否 | `10` | 分页大小,最大 100 | +| `--page-all` | 否 | — | 自动分页获取全部数据 | + +### 返回字段说明 + +- `items[].id`:对齐关系 ID(删除时需要) +- `items[].from_entity_id`:发起对齐的目标 ID +- `items[].to_entity_id`:被对齐的目标 ID +- `items[].from_owner` / `to_owner`:双方所有者信息 + +--- + +## 二、创建对齐关系 + +### 命令 + +```bash +lark-cli okr objective.alignments create --objective-id "<发起对齐的目标ID>" --data '' +``` + +### 常用示例 + +```bash +# 创建对齐关系:目标 7652569715131075772 对齐到目标 7652569715131075773 +lark-cli okr objective.alignments create \ + --objective-id "7652569715131075772" \ + --data '{"to_entity_id":"7652569715131075773","to_entity_type":2}' + +# 从文件读取请求体 +lark-cli okr objective.alignments create \ + --objective-id "7652569715131075772" \ + --data @alignment.json +``` + +### 参数 + +| 参数 | 必填 | 说明 | +|------------------|----|--------------------------------------------------------------------| +| `--objective-id` | 是 | 发起对齐的目标 ID("我"的目标) | +| `--data` | 是 | JSON 请求体,格式见下方。支持 `@文件路径` 从文件读取。 | + +### 请求体格式 + +```json +{ + "to_entity_id": "7652569715131075773", // 被对齐的目标 ID + "to_entity_type": 2 // 固定值 2,表示目标类型 +} +``` + +### 对齐规则 + +- **禁止自对齐**:不能自己对齐自己 +- **周期时间重叠**:两个目标所在周期的时间范围必须有重叠 +- **权限要求**:需要对发起对齐的目标有编辑权限 + +### 返回 + +成功后返回 `alignment_id`,保存好以便后续删除。 + +--- + +## 三、删除对齐关系 + +### 命令 + +```bash +lark-cli okr alignments delete --alignment-id "<对齐关系ID>" +``` + +### 常用示例 + +```bash +# 删除指定的对齐关系 +lark-cli okr alignments delete \ + --alignment-id "7652569715131075780" +``` + +### 参数 + +| 参数 | 必填 | 说明 | +|------------------|----|--------------------------------------| +| `--alignment-id` | 是 | 对齐关系 ID(从 list 或 create 返回) | + +### 注意事项 + +- 删除操作不可逆,请谨慎操作 +- 需要对关联的目标有编辑权限 + +--- + +## 完整工作流示例 + +### 场景:将目标 A 对齐到目标 B + +1. **查询现有对齐关系**(确认是否已存在) + ```bash + lark-cli okr objective.alignments list \ + --objective-id "目标A的ID" \ + --align-type "aligning" + ``` + +2. **创建对齐关系** + ```bash + lark-cli okr objective.alignments create \ + --objective-id "目标A的ID" \ + --data '{"to_entity_id":"目标B的ID","to_entity_type":2}' + ``` + +3. **验证对齐结果** + ```bash + lark-cli okr objective.alignments list \ + --objective-id "目标A的ID" \ + --align-type "aligning" + ``` + +4. **(如需)删除对齐关系** + ```bash + lark-cli okr alignments delete \ + --alignment-id "从步骤1返回的alignment_id" + ``` + +## 参考 + +- [lark-okr](../SKILL.md) -- 所有 OKR 命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-okr/references/lark-okr-contentblock.md b/skills/lark-okr/references/lark-okr-contentblock.md index 9c60bccd..310a42eb 100644 --- a/skills/lark-okr/references/lark-okr-contentblock.md +++ b/skills/lark-okr/references/lark-okr-contentblock.md @@ -2,6 +2,17 @@ OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock` 富文本格式。本文档描述其结构和使用方式。 +## 两种输入输出风格 + +OKR shortcuts 支持 `--style` 标志控制 content/notes 字段的输入输出格式: + +| `--style` 值 | 说明 | 适用场景 | +|--------------|--------------------------------------------------------------------|--------------------------| +| `simple`(默认) | 半纯文本格式 `SemiPlainContent`,简化的 JSON 结构,仅包含 text、mention、docs、images | 大多数场景,简单易用 | +| `richtext` | 原始 `ContentBlock` 富文本格式,完整的块结构和样式信息 | 需要精确控制@提及用户位置、包含图片/文档链接时 | + +**重要**:输入时严格根据 `--style` 值验证格式,不会自动检测。输出时读操作(如 `+cycle-detail`、`+progress-get`)根据 `--style` 返回对应格式。 + ## ContentBlock 结构概览 ```json @@ -215,9 +226,66 @@ OKR 的 Objective、KeyResult 中的 content/notes 字段使用 `ContentBlock` |-------|----------|--------| | `url` | `string` | 链接 URL | +## SemiPlainContent 半纯文本格式 + +`SemiPlainContent` 是 `ContentBlock` 的简化、有损表示形式,适用于大多数不需要复杂格式的场景。 + +### 结构 + +```json +{ + "text": "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ", + "mention": ["ou_zhangsan", "ou_lisi"], + "docs": [ + { + "title": "产品需求文档", + "url": "https://larkoffice.com/docx/xxx" + } + ], + "images": [ + "https://example.com/image.png" + ] +} +``` + +### 类型定义 + +| 字段 | 类型 | 说明 | +|-----------|------------------|-----------------------------------------------------------------------------------------------------------| +| `text` | `string` | 纯文本内容(必填,不能为空)。**输出时**包含 ` @{userID} ` 占位符以保留提及的位置上下文;**输入时** `@{...}` 占位符会被自动 strip 掉,只识别 `mention` 字段内容 | +| `mention` | `string[]` | 用户 ID 列表(可选),与 text 中的 `@{userID}` 占位符一一对应,输入时按顺序转换为 mention 元素**置于文本末尾** | +| `docs` | `SemiPlainDoc[]` | 文档列表(仅输出时包含,输入时 simple 风格不支持) | +| `images` | `string[]` | 图片 URL 列表(仅输出时包含,输入时 simple 风格不支持) | + +### SemiPlainDoc + +| 字段 | 类型 | 说明 | +|---------|----------|--------| +| `title` | `string` | 文档标题 | +| `url` | `string` | 文档 URL | + +### 双向转换说明 + +- **ContentBlock → SemiPlainContent**(输出时):提取纯文本、提及用户、文档链接和图片 URL,丢弃格式信息(粗体、列表、颜色等)。**提及的位置信息通过 ` @{userID} ` 占位符保留在 text 中**,同时 userID 也会被收集到 mention 数组中 +- **SemiPlainContent → ContentBlock**(输入时):自动 strip 掉 text 中的 `@{...}` 占位符,然后将 text 和 mention 合并为单个段落,mention 按顺序附加在文本末尾。docs 和 images 在输入时被忽略(simple 风格不支持) + ## 使用示例 -### 示例 1:简单文本段落 +### 示例 0:--style simple 半纯文本格式 + +```json +{ + "text": "提升用户满意度", + "mention": ["ou_123"] +} +``` + +使用方式: +```bash +lark-cli okr +patch --level objective --style simple --target-id 123 --content '{"text":"提升用户满意度","mention":["ou_123"]}' +``` + +### 示例 1:简单文本段落(richtext 风格) ```json { diff --git a/skills/lark-okr/references/lark-okr-cycle-detail.md b/skills/lark-okr/references/lark-okr-cycle-detail.md index 532bbe5c..9992f002 100644 --- a/skills/lark-okr/references/lark-okr-cycle-detail.md +++ b/skills/lark-okr/references/lark-okr-cycle-detail.md @@ -7,20 +7,24 @@ ## 推荐命令 ```bash -# 列出指定周期的目标和关键结果 +# 列出指定周期的目标和关键结果(默认 simple 风格,半纯文本格式,推荐使用,更简洁) lark-cli okr +cycle-detail --cycle-id 1234567890123456789 +# 列出指定周期的目标和关键结果(richtext 风格,原始 ContentBlock JSON) +lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --style richtext + # 预览 API 调用而不实际执行 lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run ``` ## 参数 -| 参数 | 必填 | 默认值 | 说明 | -|--------------|----|--------|-----------------------------------------| -| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型)。从 `+cycle-list` 获取。 | -| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | -| `--format` | 否 | `json` | 输出格式。 | +| 参数 | 必填 | 默认值 | 说明 | +|--------------|----|----------|-----------------------------------------------------------------------------------------------------------------------------| +| `--cycle-id` | 是 | — | OKR 周期 ID(int64 类型)。从 `+cycle-list` 获取。 | +| `--style` | 否 | `simple` | 输出风格:`simple`(半纯文本格式,不涉及字体/颜色等信息时推荐使用) \| `richtext`(原始 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | ## 工作流程 @@ -75,8 +79,11 @@ lark-cli okr +cycle-detail --cycle-id 1234567890123456789 --dry-run } ``` -其中,content 和 notes 字段是 JSON 字符串,为 OKR ContentBlock -富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。 +其中,content 和 notes 字段格式由 `--style` 控制: +- `--style simple`(默认):`SemiPlainContent` 对象,包含 `text`、`mention`、`docs` 字段 +- `--style richtext`:JSON 字符串,为 OKR ContentBlock 富文本格式 + +请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解两种格式的详细信息。 ## 参考 diff --git a/skills/lark-okr/references/lark-okr-cycle-list.md b/skills/lark-okr/references/lark-okr-cycle-list.md index ac7a1223..11632573 100644 --- a/skills/lark-okr/references/lark-okr-cycle-list.md +++ b/skills/lark-okr/references/lark-okr-cycle-list.md @@ -46,20 +46,20 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run "cycles": [ { "id": "1234567890123456789", - "create_time": "2025-01-01 00:00:00", - "update_time": "2025-01-01 00:00:00", - "tenant_cycle_id": "789", - "owner": { - "owner_type": "user", - "user_id": "ou_xxx" - }, "start_time": "2025-01-01 00:00:00", "end_time": "2025-06-30 00:00:00", - "cycle_status": "normal", - "score": 0 + "cycle_status": "normal" } ], - "total": 1 + "total": 1, + "current_active_cycles": [ + { + "id": "1234567890123456789", + "start_time": "2025-01-01 00:00:00", + "end_time": "2025-06-30 00:00:00", + "cycle_status": "normal" + } + ] } ``` @@ -67,11 +67,14 @@ lark-cli okr +cycle-list --user-id "ou_xxx" --dry-run - `id` 是这个周期的 ID,你通常需要用它在之后使用 `okr +cycle-detail` 获取 OKR 内容详情 - `start_time` `end_time` 是周期的起止时间,总是从某个月1日开始,直到此月或之后某月的最后一日结束。 - - 在 OKR 系统中,我们只关注这个时间的年月部分,如 “2025-01-01开始,2025-06-30结束” 的周期被称作 “2025 年 1-6 月” 周期,而 - “2025-01-01开始,2025-01-31结束” 的周期被称作 “2025 年 1 月”周期。 - - 如果一个周期从某年1月1日开始,某年12月31日结束,则它是这一年的年度周期,如 “2025-01-01开始,2025-12-31结束” 的周期就是 - “2025 年” 的年度周期 + - 在 OKR 系统中,我们只关注这个时间的年月部分,如 "2025-01-01开始,2025-06-30结束" 的周期被称作 "2025 年 1-6 月" 周期,而 + "2025-01-01开始,2025-01-31结束" 的周期被称作 "2025 年 1 月"周期。 + - 如果一个周期从某年1月1日开始,某年12月31日结束,则它是这一年的年度周期,如 "2025-01-01开始,2025-12-31结束" 的周期就是 + "2025 年" 的年度周期 - `cycle_status` 为周期状态值,参见下文。 +- `current_active_cycles` 是当前生效的周期列表,不过根据用户的周期设置,可能会出现为空的场景。 + +如果需要获取周期的创建时间/总分等信息,可以通过原生 API `okr cycles list` 获取。 ### 周期状态值 diff --git a/skills/lark-okr/references/lark-okr-indicators.md b/skills/lark-okr/references/lark-okr-indicators.md new file mode 100644 index 00000000..b16ab6bf --- /dev/null +++ b/skills/lark-okr/references/lark-okr-indicators.md @@ -0,0 +1,223 @@ +# OKR 量化指标管理 + +> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +管理 OKR 目标(Objective)和关键结果(Key Result)的量化指标,包括查询和更新指标。 + +> **快速更新当前值:** 如果只需要更新指标的当前值,推荐使用 shortcut [`okr +indicator-update`](lark-okr-indicator-update.md),无需手动查询指标 ID。 +> +> 本指南中的原生 API 适用于需要修改指标其他字段(如 `unit`、`target_value`、`status_calculate_type` 等)的场景。 + +--- + +## 指标字段说明 + +| 字段 | 类型 | 说明 | +|-----------------------------|------|--------------------------------------------------------------------| +| `id` | string | 指标 ID(更新时需要) | +| `entity_id` / `entity_type` | string/int | 所属实体 ID 和类型(2=目标,3=关键结果) | +| `current_value` | number | 当前值 | +| `target_value` | number | 目标值 | +| `start_value` | number | 起始值 | +| `indicator_status` | int | 状态:-1=未定义,0=正常,1=有风险,2=已延期 | +| `status_calculate_type` | int | 状态计算方式:0=手动更新,1=基于进度和当前时间自动更新,2=基于风险最高的 KR 状态更新 | +| `current_value_calculate_type` | int | 当前值计算方式:0=手动更新,1=基于 KR 进度自动更新(目标),2=基于拆解 KR 进度更新(KR) | +| `unit` | object | 单位,包含 `unit_type`(0=公共,1=自定义)和 `unit_value`(如 PERCENT、YUAN 等) | +| `owner` | object | 所有者 | + +--- + +## 一、查询目标的量化指标 + +### 命令 + +```bash +lark-cli okr objective.indicators list --objective-id "<目标ID>" [flags] +``` + +### 常用示例 + +```bash +# 获取目标的量化指标 +lark-cli okr objective.indicators list \ + --objective-id 7652569715131075772 + +# 指定用户 ID 类型 +lark-cli okr objective.indicators list \ + --objective-id 7652569715131075772 \ + --user-id-type "user_id" +``` + +### 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|----------------------|----|----------------|-----------------------------------------------------| +| `--objective-id` | 是 | — | 目标 ID | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--department-id-type` | 否 | `open_department_id` | 部门 ID 类型:`open_department_id` \| `department_id` | + +### 返回 + +返回 `indicator` 字段,包含该目标的量化指标详情。 + +--- + +## 二、查询关键结果的量化指标 + +### 命令 + +```bash +lark-cli okr key_result.indicators list --key-result-id "<关键结果ID>" [flags] +``` + +### 常用示例 + +```bash +# 获取关键结果的量化指标 +lark-cli okr key_result.indicators list \ + --key-result-id "7652569715131075780" +``` + +### 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|----------------------|----|----------------|-----------------------------------------------------| +| `--key-result-id` | 是 | — | 关键结果 ID | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--department-id-type` | 否 | `open_department_id` | 部门 ID 类型:`open_department_id` \| `department_id` | + +### 返回 + +返回 `indicator` 字段,包含该关键结果的量化指标详情。 + +--- + +## 三、更新量化指标 + +### 命令 + +```bash +lark-cli okr indicators patch --indicator-id "<指标ID>" --data '' +``` + +### 常用示例 + +```bash +# 更新指标的当前值(手动更新方式) +lark-cli okr indicators patch \ + --indicator-id "ind-123" \ + --data '{"current_value": 75.5, "current_value_calculate_type": 0}' + +# 更新指标状态为"有风险"(需 status_calculate_type=0) +lark-cli okr indicators patch \ + --indicator-id "ind-123" \ + --data '{"indicator_status": 1, "status_calculate_type": 0}' + +# 更新关键结果指标的目标值和单位 +lark-cli okr indicators patch \ + --indicator-id "ind-456" \ + --data '{ + "target_value": 100, + "unit": {"unit_type": 0, "unit_value": "PERCENT"} + }' + +# 从文件读取请求体 +lark-cli okr indicators patch \ + --indicator-id "ind-123" \ + --data @indicator_update.json +``` + +### 参数 + +| 参数 | 必填 | 说明 | +|------------------|----|--------------------------------------------------------------------| +| `--indicator-id` | 是 | 指标 ID(从 list 接口获取) | +| `--data` | 是 | JSON 请求体,包含要更新的字段。支持 `@文件路径` 从文件读取。 | +| `--user-id-type` | 否 | 用户 ID 类型 | + +### 请求体字段 + +根据需要更新的字段选择传入,支持增量更新: + +| 字段 | 类型 | 适用实体 | 说明 | +|-----------------------------|------|------|--------------------------------------------------------------------| +| `current_value` | number | 全部 | 当前值,范围 -99999999999 到 99999999999 | +| `current_value_calculate_type` | int | 全部 | 当前值计算方式:0=手动,1=基于 KR 进度(目标),2=基于拆解 KR 进度(KR) | +| `indicator_status` | int | 全部 | 状态:-1=未定义,0=正常,1=有风险,2=已延期。仅 `status_calculate_type=0` 时可修改 | +| `status_calculate_type` | int | 全部 | 状态计算方式:0=手动,1=自动(进度+时间),2=自动(最高风险 KR)。目标支持 0/1/2,KR 支持 0/1 | +| `start_value` | number | KR | 起始值。目标不支持修改 | +| `target_value` | number | KR | 目标值。目标不支持修改;有承接记录的 KR 不支持修改 | +| `unit` | object | KR | 单位。目标不支持修改;有承接记录的 KR 不支持修改 | + +### 单位 (`unit`) 格式 + +```json +{ + "unit": { + "unit_type": 0, // 0=公共单位,1=自定义单位 + "unit_value": "PERCENT" // 公共单位枚举:PERCENT、NONE、YUAN、DOLLAR;自定义单位:最长5字符 + } +} +``` + +### 限制说明 + +- **目标指标**:不支持修改 `start_value`、`target_value`、`unit` +- **关键结果指标**:有承接记录的 KR 不支持修改 `target_value`、`unit` +- **自动计算的指标**:`current_value_calculate_type != 0` 时,不能手动修改 `current_value` +- **自动状态的指标**:`status_calculate_type != 0` 时,不能手动修改 `indicator_status` + +--- + +## 完整工作流示例 + +### 场景:更新关键结果的指标当前值和状态 + +1. **查询关键结果的指标**(获取 `indicator_id` 和当前配置) + ```bash + lark-cli okr key_result.indicators list \ + --key-result-id 7652569715131075780 + ``` + +2. **检查指标配置**,确认: + - `current_value_calculate_type` 为 0(手动更新)才能修改 `current_value` + - `status_calculate_type` 为 0(手动更新)才能修改 `indicator_status` + +3. **更新指标** + ```bash + lark-cli okr indicators patch \ + --indicator-id "ind-123" \ + --data '{ + "current_value": 65.0, + "current_value_calculate_type": 0, + "indicator_status": 1, + "status_calculate_type": 0 + }' + ``` + +4. **验证更新结果** + ```bash + lark-cli okr key_result.indicators list \ + --key-result-id 7652569715131075780 + ``` + +### 场景:修改关键结果指标的目标值和单位 + +```bash +# 1. 查询获取 indicator_id +lark-cli okr key_result.indicators list --key-result-id 7652569715131075780 + +# 2. 更新目标值和单位 +lark-cli okr indicators patch \ + --indicator-id 7652569715131075781 \ + --data '{ + "target_value": 500, + "unit": {"unit_type": 0, "unit_value": "YUAN"} + }' +``` + +## 参考 + +- [lark-okr](../SKILL.md) -- 所有 OKR 命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 +- [okr +indicator-update](lark-okr-indicator-update.md) -- 快捷更新指标当前值(推荐) diff --git a/skills/lark-okr/references/lark-okr-patch.md b/skills/lark-okr/references/lark-okr-patch.md new file mode 100644 index 00000000..867d527f --- /dev/null +++ b/skills/lark-okr/references/lark-okr-patch.md @@ -0,0 +1,104 @@ +# okr +patch + +> **前置条件:** 先阅读 [`lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +部分更新 OKR 目标(Objective)或关键结果(Key Result)的 content、notes、score、deadline 字段。支持增量更新,只需提供要修改的字段。 + +## 推荐命令 + +```bash +# 更新目标的 content(默认 simple 风格,半纯文本格式) +lark-cli okr +patch \ + --level objective \ + --target-id 1234567890123456789 \ + --content '{"text":"更新后的目标内容","mention":["ou_123"]}' + +# 更新关键结果的分数(0.0-1.0 的一位小数) +lark-cli okr +patch \ + --level key-result \ + --target-id 2345678901234567890 \ + --score 0.7 + +# 同时更新目标的多个字段(richtext 风格,完整 ContentBlock 格式) +lark-cli okr +patch \ + --level objective \ + --target-id 1234567890123456789 \ + --style richtext \ + --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"更新后的目标内容"}}]}}]}' \ + --notes '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"更新后的备注"}}]}}]}' \ + --score 0.5 \ + --deadline 1735776000000 + +# 预览 API 调用而不实际执行 +lark-cli okr +patch \ + --level objective \ + --target-id 1234567890123456789 \ + --content '{"text":"测试更新"}' \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 默认值 | 说明 | +|----------------|----|-----------|--------------------------------------------------------------------------------------------------------------------------------------| +| `--level` | 是 | — | 更新级别:`objective`(目标) \| `key-result`(关键结果) | +| `--target-id` | 是 | — | 目标 ID 或关键结果 ID(int64 类型,正整数) | +| `--style` | 否 | `simple` | 输入风格:`simple`(半纯文本 JSON,推荐) \| `richtext`(完整 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md) 了解两种格式。 | +| `--content` | 否¹ | — | 内容。根据 `--style` 指定格式。支持 `@文件路径` 从文件读取。 | +| `--notes` | 否¹ | — | 备注(仅 `--level=objective` 时支持)。根据 `--style` 指定格式。支持 `@文件路径` 从文件读取。 | +| `--score` | 否¹ | — | 分数值,0-1 之间,最多一位小数(如 0.5、1.0)。 | +| `--deadline` | 否¹ | — | 截止时间,毫秒级时间戳(如 1735776000000)。 | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | + +> ¹ 至少需要提供 `--content`、`--notes`、`--score`、`--deadline` 中的一个字段。 + +## 工作流程 + +1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。 +2. 确定要更新的字段: + - **content/notes**:构造内容 + - **推荐**:使用 `simple` 风格(默认),构造 SemiPlainContent JSON:`{"text":"内容","mention":["ou_xxx"]}` + - 如需复杂格式:使用 `richtext` 风格,构造 ContentBlock JSON。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 + - **score**:0-1 之间的数字,最多一位小数(如 0.3、0.7、1.0) + - **deadline**:毫秒级时间戳 +3. 执行 `lark-cli okr +patch --level objective --target-id "..." --content "..."`。 +4. 报告结果:更新的级别、目标 ID、以及哪些字段被更新。 + +## 输出 + +返回 JSON: + +```json +{ + "level": "objective", + "target_id": "1234567890123456789", + "patched": { + "content": true, + "notes": true, + "score": true, + "deadline": true + } +} +``` + +其中 `patched` 对象中的每个字段表示该字段是否被更新。 + +## 注意事项 + +- **`--notes` 仅适用于目标**:关键结果(key-result)不支持 notes 字段,使用时会报错。 +- **score 格式**:必须在 0-1 之间,且最多一位小数(如 0.5 正确,0.51 错误)。 +- **严格验证**:输入格式严格根据 `--style` 值验证,不会自动检测。使用 ContentBlock JSON 时必须指定 `--style richtext`。 +- **simple 风格输入限制**:simple 风格的输入不支持 `docs` 和 `images` 字段,如需包含文档或图片请使用 `richtext` 风格。 + +## 关于 1001001 错误 + +有时,当你涉及修改目标或关键结果的分数时,即使输入的参数完全正确, +patch 也会返回 1001001 错误(invalid parameters)。 +这可能是因为在用户的租户设置中停用了目标/关键结果的分数功能,或禁用了目标分数的手动计算。此时可以先去掉 --score 参数再修改,并向用户确认是否启用了对应的功能。 + +## 参考 + +- [lark-okr](../SKILL.md) -- 所有 OKR 命令(shortcut 和 API 接口) +- [ContentBlock 格式](lark-okr-contentblock.md) -- content/notes 使用的富文本格式 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-okr/references/lark-okr-progress-create.md b/skills/lark-okr/references/lark-okr-progress-create.md index 3731f922..dd56a8e9 100644 --- a/skills/lark-okr/references/lark-okr-progress-create.md +++ b/skills/lark-okr/references/lark-okr-progress-create.md @@ -7,15 +7,16 @@ ## 推荐命令 ```bash -# 为目标创建进展记录 +# 为目标创建进展记录(默认 simple 风格,半纯文本格式) lark-cli okr +progress-create \ - --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"本周完成了核心模块开发"}}]}}]}' \ + --content '{"text":"本周完成了核心模块开发","mention":["ou_123"]}' \ --target-id 1234567890123456789 \ --target-type objective -# 为关键结果创建进展记录(带进度百分比和状态) +# 为关键结果创建进展记录(richtext 风格,完整 ContentBlock 格式) lark-cli okr +progress-create \ --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"指标已达到 80%"}}]}}]}' \ + --style richtext \ --target-id 2345678901234567891 \ --target-type key_result \ --progress-percent 80 \ @@ -32,7 +33,8 @@ lark-cli okr +progress-create \ | 参数 | 必填 | 默认值 | 说明 | |----------------------|----|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------| -| `--content` | 是 | — | 进展内容,ContentBlock JSON 格式。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--content` | 是 | — | 进展内容。根据 `--style` 指定格式:`simple` 风格为 SemiPlainContent JSON,`richtext` 风格为 ContentBlock JSON。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--style` | 否 | `simple` | 输入风格:`simple`(半纯文本 JSON,推荐) \| `richtext`(完整 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md) 了解两种格式。 | | `--target-id` | 是 | — | 目标 ID 或关键结果 ID(int64 类型,正整数) | | `--target-type` | 是 | — | 目标类型:`objective` \| `key_result` | | `--progress-percent` | 否 | — | 进度百分比(-99999999999 - 99999999999)。百分比的取值通常在 0-100,但允许超过此范围,以表示超额完成或负增长等情况。挂载的目标或关键结果的量化指标不使用百分比单位时,以这个字段更新当前值。系统内最多保留两位小数 | @@ -46,7 +48,9 @@ lark-cli okr +progress-create \ ## 工作流程 1. 使用 `+cycle-list` 和 `+cycle-detail` 获取目标或关键结果的 ID。 -2. 构造 ContentBlock JSON 格式的进展内容。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 +2. 构造进展内容: + - **推荐**:使用 `simple` 风格(默认),构造 SemiPlainContent JSON:`{"text":"内容","mention":["ou_xxx"]}`,mention 中提及的用户会统一连接在文本末尾。 + - 如需复杂格式:使用 `richtext` 风格,构造 ContentBlock JSON。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。若需要插入图片/飞书文档或复杂文本格式,则必须使用 richtext 风格 3. 执行 `lark-cli okr +progress-create --content "..." --target-id "..." --target-type objective`。 4. 报告结果:新创建的进展记录 ID、修改时间等。 diff --git a/skills/lark-okr/references/lark-okr-progress-get.md b/skills/lark-okr/references/lark-okr-progress-get.md index ddc5e29e..e65010be 100644 --- a/skills/lark-okr/references/lark-okr-progress-get.md +++ b/skills/lark-okr/references/lark-okr-progress-get.md @@ -7,9 +7,12 @@ ## 推荐命令 ```bash -# 获取指定 ID 的进展记录 +# 获取指定 ID 的进展记录(默认 simple 风格,半纯文本格式) lark-cli okr +progress-get --progress-id 1234567890123456789 +# 获取指定 ID 的进展记录(richtext 风格,原始 ContentBlock JSON) +lark-cli okr +progress-get --progress-id 1234567890123456789 --style richtext + # 使用特定的用户 ID 类型 lark-cli okr +progress-get --progress-id 1234567890123456789 --user-id-type open_id @@ -19,12 +22,13 @@ lark-cli okr +progress-get --progress-id 1234567890123456789 --dry-run ## 参数 -| 参数 | 必填 | 默认值 | 说明 | -|------------------|----|-----------|-----------------------------------------------| -| `--progress-id` | 是 | — | 进展记录 ID(int64 类型,正整数) | -| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | -| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | -| `--format` | 否 | `json` | 输出格式。 | +| 参数 | 必填 | 默认值 | 说明 | +|------------------|----|-------------|--------------------------------------------------------------------| +| `--progress-id` | 是 | — | 进展记录 ID(int64 类型,正整数) | +| `--style` | 否 | `simple` | 输出风格:`simple`(半纯文本 SemiPlainContent,推荐) \| `richtext`(原始 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | +| `--dry-run` | 否 | — | 预览 API 调用而不实际执行。 | +| `--format` | 否 | `json` | 输出格式。 | ## 工作流程 @@ -34,26 +38,53 @@ lark-cli okr +progress-get --progress-id 1234567890123456789 --dry-run ## 输出 -返回 JSON: +返回 JSON,`content` 字段格式由 `--style` 控制: + +### `--style simple`(默认)输出示例: ```json { "progress": { "progress_id": "1234567890123456789", "modify_time": "2025-01-15 10:30:00", - "content": "{...}", + "content": { + "text": "已完成 80% 的开发工作 @{ou_zhangsan} ", + "mention": ["ou_zhangsan"], + "docs": [], + "images": [] + }, "progress_rate": { "percent": 75.0, "status": "normal" } - } + }, + "style": "simple" +} +``` + +### `--style richtext` 输出示例: + +```json +{ + "progress": { + "progress_id": "1234567890123456789", + "modify_time": "2025-01-15 10:30:00", + "content": "{\"blocks\":[{\"block_element_type\":\"paragraph\",\"paragraph\":{\"elements\":[{\"paragraph_element_type\":\"textRun\",\"text_run\":{\"text\":\"已完成 80% 的开发工作 \"}},{\"paragraph_element_type\":\"mention\",\"mention\":{\"user_id\":\"ou_zhangsan\"}}]}}]}", + "progress_rate": { + "percent": 75.0, + "status": "normal" + } + }, + "style": "richtext" } ``` 其中: -- `content` 字段是 JSON 字符串,为 OKR ContentBlock - 富文本格式。请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解详细信息。 +- `content` 字段格式由 `--style` 控制: + - `--style simple`(默认):`SemiPlainContent` 对象,包含 `text`、`mention`、`docs`、`images` 字段。`text` 中包含 `@{userID}` 占位符用于标识 mention 位置。 + - `--style richtext`:JSON 字符串,为 OKR ContentBlock 富文本格式 +- 请参考 [lark-okr-contentblock.md](lark-okr-contentblock.md) 了解两种格式的详细信息。 - `progress_rate.status` 返回可读字符串:`normal`(正常)、`overdue`(逾期)、`done`(已完成)。 ## 参考 diff --git a/skills/lark-okr/references/lark-okr-progress-update.md b/skills/lark-okr/references/lark-okr-progress-update.md index a047da84..7491ba7f 100644 --- a/skills/lark-okr/references/lark-okr-progress-update.md +++ b/skills/lark-okr/references/lark-okr-progress-update.md @@ -7,15 +7,16 @@ ## 推荐命令 ```bash -# 更新进展记录内容 +# 更新进展记录内容(默认 simple 风格,半纯文本格式) lark-cli okr +progress-update \ --progress-id 1234567890123456789 \ - --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"更新后的进展内容"}}]}}]}' + --content '{"text":"更新后的进展内容","mention":["ou_123"]}' -# 更新进展记录内容并同时更新进度 +# 更新进展记录内容并同时更新进度(richtext 风格,完整 ContentBlock 格式) lark-cli okr +progress-update \ --progress-id 1234567890123456789 \ --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"进度已更新至 90%"}}]}}]}' \ + --style richtext \ --progress-percent 90 \ --progress-status normal @@ -27,7 +28,7 @@ lark-cli okr +progress-update \ # 预览 API 调用而不实际执行 lark-cli okr +progress-update \ --progress-id 1234567890123456789 \ - --content '{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test"}}]}}]}' \ + --content '{"text":"test"}' \ --dry-run ``` @@ -36,7 +37,8 @@ lark-cli okr +progress-update \ | 参数 | 必填 | 默认值 | 说明 | |----------------------|----|-----------|----------------------------------------------------------------------------------------------------------------| | `--progress-id` | 是 | — | 进展记录 ID(int64 类型,正整数) | -| `--content` | 是 | — | 进展内容,ContentBlock JSON 格式。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--content` | 是 | — | 进展内容。根据 `--style` 指定格式:`simple` 风格为 SemiPlainContent JSON,`richtext` 风格为 ContentBlock JSON。支持 `@文件路径` 从文件读取。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 | +| `--style` | 否 | `simple` | 输入风格:`simple`(半纯文本 JSON,推荐) \| `richtext`(完整 ContentBlock JSON)。请参考 [ContentBlock 格式](lark-okr-contentblock.md) 了解两种格式。 | | `--progress-percent` | 否 | — | 进度百分比(-99999999999 - 99999999999)。百分比的取值通常在 0-100,但允许超过此范围,以表示超额完成或负增长等情况。挂载的目标或关键结果的量化指标不使用百分比单位时,以这个字段更新当前值。系统内最多保留两位小数 | | `--progress-status` | 否 | — | 进度状态:`normal`(正常) \| `overdue`(逾期) \| `done`(已完成)。仅在指定 `--progress-percent` 时生效。 | | `--user-id-type` | 否 | `open_id` | 用户 ID 类型:`open_id` \| `union_id` \| `user_id` | @@ -46,7 +48,9 @@ lark-cli okr +progress-update \ ## 工作流程 1. 使用 `+progress-get` 获取要更新的进展记录的 ID 和当前内容。 -2. 修改 ContentBlock JSON 格式的进展内容。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。 +2. 修改进展内容: + - **推荐**:使用 `simple` 风格(默认),构造 SemiPlainContent JSON:`{"text":"内容","mention":["ou_xxx"]}`,mention 中提及的用户会统一连接在文本末尾。 + - 如需复杂格式:使用 `richtext` 风格,构造 ContentBlock JSON。请参考 [ContentBlock 格式](lark-okr-contentblock.md)。若需要插入图片/飞书文档或复杂文本格式,则必须使用 richtext 风格 3. 执行 `lark-cli okr +progress-update --progress-id "..." --content "..."`。 4. 报告结果:更新后的进展记录 ID、修改时间、进度百分比等。 diff --git a/skills/lark-shared/SKILL.md b/skills/lark-shared/SKILL.md index 8b4813ec..272b2bae 100644 --- a/skills/lark-shared/SKILL.md +++ b/skills/lark-shared/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-shared version: 1.0.0 -description: "Use when first setting up lark-cli, running auth login, switching user/bot identity (--as), handling permission denied or scope errors, needing to update lark-cli, or seeing _notice in JSON output." +description: "Use for lark-cli setup/auth tasks: auth login/status/logout, user vs bot identity, business-domain permissions (--domain, including all/docs/drive), missing scopes, revoking authorization, or handling _notice JSON." --- # lark-cli 共享规则 @@ -23,6 +23,28 @@ lark-cli config init --new ## 认证 +### 认证任务速查 + +认证、scope、业务域、登录态、退出登录态、撤销授权问题都走本技能。 + +| 用户意图 | 首选命令 / 回答 | +|---|---| +| 获取全部权限 | `lark-cli auth login --domain all --no-wait --json` | +| 按业务域授权 | `lark-cli auth login --domain docs --domain drive --no-wait --json`;`--domain` 可重复,也可用逗号分隔 | +| 指定单个 scope 授权 | `lark-cli auth login --scope "" --no-wait --json` | +| 检查当前登录态、是谁登录、token 是否有效 | `lark-cli auth status --json --verify`;回答时引用 `identity`、`verified`、`identities.user.status`、`identities.user.userName`、`identities.user.openId`(用户 open id)、`identities.user.tokenStatus`、`identities.user.scope` | +| 快速查看当前身份状态 | `lark-cli whoami`;实际生效的那一个身份 | +| 退出当前机器的用户登录态 | `lark-cli auth logout --json`;`loggedOut:true` 表示注销成功 | +| bot 缺少权限 | 不要执行 `auth login`;引导用户在开发者后台开通 bot scope,优先复用错误里的 `console_url` | +| 取消用户对应用的全部服务端授权 | `auth logout` 只清本机登录态;服务端授权需用户在飞书授权管理页取消 | +| 只取消一个 scope | CLI 不支持单独撤销一个已授予 scope;可重新走最小 scope 授权,或让用户在授权管理页处理 | + +机器读取 JSON 时,为减少 `_notice` 干扰,可在命令前加: + +```bash +LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 lark-cli auth status --json --verify +``` + ### 身份类型 两种身份类型,通过 `--as` 切换: @@ -108,19 +130,22 @@ lark-cli auth login --device-code lark-cli 命令执行后,如果检测到新版本,JSON 输出中会包含 `_notice.update` 字段(含 `message`、`command` 等)。 -**当你在输出中看到 `_notice.update` 时,完成用户当前请求后,主动提议帮用户更新**: +除非用户正在询问更新、版本或 notice,否则不要把 `_notice` 原样复制为当前任务的主要答案,也不要为了 notice 中断当前任务去反复查 help。 -1. 告知用户当前版本和最新版本号 -2. 提议执行更新(同时更新 CLI 和 Skills): - ```bash - lark-cli update - ``` -3. 更新完成后提醒用户:**退出并重新打开 AI Agent** 以加载最新 Skills +需要稳定 JSON 给脚本或机器读取时,可以在命令前设置: + +```bash +LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1 LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1 +``` + +当你在输出中看到 `_notice.update` 时,先完成用户当前请求;如仍相关,再简短告知可运行: + +```bash +lark-cli update +``` **重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。 -**规则**:不要静默忽略更新提示。即使当前任务与更新无关,也应在完成用户请求后补充告知。 - ## 安全规则 - **禁止输出密钥**(appSecret、accessToken)到终端明文。 diff --git a/skills/lark-slides/SKILL.md b/skills/lark-slides/SKILL.md index 4630b663..4d4e8c50 100644 --- a/skills/lark-slides/SKILL.md +++ b/skills/lark-slides/SKILL.md @@ -23,13 +23,14 @@ metadata: | 在 slide 中绘制柱/条/折线/面积/雷达/饼等有数据序列的图表 | 使用原生 `` 元素 | `xml-schema-quick-ref.md` | | 在 slide 中绘制流程图、时序图、架构图、散点图、漏斗图或装饰图案 | 必须先用 Read 工具读取参考文档,再生成 `` 元素 | [`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) | | 使用语义图标 | 先检索 IconPark,再写 `` | `iconpark_tool.py search → resolve`、`iconpark.md` | -| 用户提到模板、主题、版式 | 先检索模板,再摘要,必要时裁切骨架 | `template_tool.py search → summarize → extract` | | 创建失败、空白页、3350001、布局异常 | 先回读状态,再按排障清单修复,不假设原操作原子成功 | `troubleshooting.md`、`validation-checklist.md` | **CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),认证、权限和全局参数均以 lark-shared 为准。** **CRITICAL — 生成任何 XML 之前,MUST 先用 Read 工具读取 [xml-schema-quick-ref.md](references/xml-schema-quick-ref.md),禁止凭记忆猜测 XML 结构。** +**CRITICAL — PPT 生成与模板编辑硬约束:PPT 的尺寸是 960x540,确保主体内容在页面边界内。多用生图,辅助搜图,必须要图文并茂。不要为了画出一个具象物体而堆叠 3 个以上仅用于拟形的 shape。生成背景图时必须在 prompt 中明确要求不要出现任何文字。用户指定 PPT 模板时,用 lark-drive 技能导入成 lark slides,回读理解每页版式后,直接在该 slides 上编辑,可以填改文字和图片、按需增删模板页,必须严格沿用原版式和字体,只改内容不做设计,完成后回读并微调,凝练文字或缩减字号消除文字溢出,调整 shape 顺序或位置避免文字遮挡。** + **CRITICAL — 新建演示文稿或大幅改写页面时,MUST 先生成 `.lark-slides/plan//slide_plan.json`,再生成 XML。先创建对应目录,规划层规则和中间产物生命周期见 [planning-layer.md](references/planning-layer.md)。仅替换一个标题、插入一个块等小型已有页编辑可豁免。** **CRITICAL — 新建演示文稿或大幅改写页面时,生成 XML 前 MUST 读取 [visual-planning.md](references/visual-planning.md),确保 `layout_type`、`visual_focus`、`text_density` 实际改变页面几何、主视觉和文本量。** @@ -40,13 +41,6 @@ metadata: **CRITICAL — 创建前自检或失败排障时,MUST 按 [troubleshooting.md](references/troubleshooting.md) 检查 XML 转义、结构、shell 截断、图片 token、3350001 和布局风险。** -**CRITICAL — 如果用户提到“模板”“套用模板”“参考某种主题/风格/版式”,或用户需求明显落在已有场景模板内(如工作汇报、产品介绍、商业计划书、培训、晋升汇报等),MUST 先用 [`scripts/template_tool.py`](scripts/template_tool.py) 的 `search` 做模板检索;默认给出 2-3 个最匹配模板候选供用户选择。锁定模板后用 `summarize` 获取主题和布局摘要;只有需要布局骨架时才用 `extract` 裁切目标页型 XML。不要直接读取完整模板 XML。** - -> [!NOTE] -> `scripts/template_tool.py` 需要 Python 3。`references/template-index.json` 是脚本缓存/轻量路由索引,不是默认给 agent 阅读的文档;`assets/templates/*.xml` 是机器资源,只应通过脚本摘要或裁切,不要全文读取。 - -**CRITICAL — 使用模板生成或改写页面时,MUST 先 `summarize` 目标页型;只有需要具体布局骨架时才 `extract`。** - **编辑已有幻灯片页面**:单个标题、文本块、图片或局部元素优先用 [`+replace-slide`](references/lark-slides-replace-slide.md)(块级替换/插入,不动页序);已有 Slides 的多页大改优先用 [`+replace-pages`](references/lark-slides-replace-pages.md) 在原 presentation 内批量重建页面,避免 `slides +create` 生成新链接。选择 action 和完整读-改-写流程见 [`lark-slides-edit-workflows.md`](references/lark-slides-edit-workflows.md)。 ## 身份选择 @@ -87,7 +81,6 @@ lark-cli auth login --domain slides - 图片:[`lark-slides-media-upload.md`](references/lark-slides-media-upload.md) - 流程图 / 时序图 / 架构图 / 装饰图案:[`lark-slides-whiteboard.md`](references/lark-slides-whiteboard.md) - 图标:[`iconpark.md`](references/iconpark.md)、[`scripts/iconpark_tool.py`](scripts/iconpark_tool.py) -- 模板:[`template-catalog.md`](references/template-catalog.md)、[`scripts/template_tool.py`](scripts/template_tool.py) - 排障:[`troubleshooting.md`](references/troubleshooting.md) - 完整协议:[`slides_xml_schema_definition.xml`](references/slides_xml_schema_definition.xml) @@ -131,7 +124,7 @@ lark-cli auth login --domain slides - 不要用低对比文字或低对比图标,例如浅灰字压在浅色背景上。 - 不要让装饰线穿过文字,或让页脚、来源、编号挤压主体内容。 - 不要把素材缺失表现为空白图片框;必须按 `fallback_if_missing` 生成 XML-native 视觉。 -- 不要留下模板占位文案、示例公司名、示例日期或与用户主题无关的原模板内容。 +- 不要留下占位文案、示例公司名、示例日期或与用户主题无关的内容。 ### 创建方式选择 @@ -147,25 +140,15 @@ lark-cli auth login --domain slides > [!IMPORTANT] > `slides +create --slides` 底层会逐页创建,不是原子操作。中途失败时先记录 `xml_presentation_id`,回读确认当前状态,再继续修复或追加。 -### 模板与脚本优先流程 - -模板细则见 [template-catalog.md](references/template-catalog.md)。主流程只记住:先 `search`,锁定后 `summarize`,需要骨架时才 `extract`;不要直接读取完整模板 XML 或照搬占位文案。 - -```bash -python3 skills/lark-slides/scripts/template_tool.py search --query "<用户需求原文>" --limit 3 -python3 skills/lark-slides/scripts/template_tool.py summarize --template --label <封面|目录|分节|内容|结尾> -python3 skills/lark-slides/scripts/template_tool.py extract --template --label <页型> --out /tmp/template-slice.xml -``` - ```text Step 1: 需求澄清 & 读取知识 - - 澄清主题、受众、页数、风格;模板需求按“模板与脚本优先流程”处理 + - 澄清主题、受众、页数、风格 - 读取 xml-schema-quick-ref.md;新建 / 大幅改写时还要读取 planning-layer.md、visual-planning.md、asset-planning.md Step 2: 生成大纲 → 用户确认 → 写入 slide_plan.json - - 生成结构化大纲供用户确认;如使用模板,标明基于哪个模板改写 + - 生成结构化大纲供用户确认 - 新建 / 大幅改写必须先创建目录并写入 `.lark-slides/plan//slide_plan.json` - - plan 字段、路径命名、模板边界和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行 + - plan 字段、路径命名和 `asset_need` 结构按 planning-layer.md / asset-planning.md 执行 Step 3: 按 slide_plan.json 生成 XML → 创建 - 逐页消费 plan:key_message 定主结论,layout_type 定几何,visual_focus 定主视觉,text_density 定文本量 @@ -212,8 +195,6 @@ lark-cli slides xml_presentation.slide create \ ```text [PPT 标题] — [定位描述],面向 [目标受众] -模板:[未使用模板 / /