From daba3c9afdfee3a91860bdec929a23b4c86bf5e9 Mon Sep 17 00:00:00 2001 From: liangshuo-1 Date: Fri, 22 May 2026 03:03:41 +0800 Subject: [PATCH] feat(apps): gate apps domain off on Lark brand (#1025) * feat(apps): gate apps domain off on Lark brand The Miaoda apps OpenAPI is Feishu-only. On Lark brand: - shortcut subtree is registered + hidden, RunE returns a structured brand-restriction error so users see a clear message instead of cobra's generic "unknown command" - auth login `--domain apps` is treated as unknown; `--domain all` skips apps; help text omits it - scope collection skips apps shortcuts so spark:* scopes are never requested The leaf-stub pattern mirrors internal/cmdpolicy/apply.go::installDenyStub (DisableFlagParsing + ArbitraryArgs + leaf-level PersistentPreRunE override) so cobra can't short-circuit the stub with a missing-flag or parent-PreRunE detour. Change-Id: I5817e87ae6fedabdb5faf05d0d32ea988f7effc9 --- cmd/auth/login.go | 32 +++++-- cmd/auth/login_brand_filter_test.go | 32 +++++++ cmd/auth/login_interactive.go | 5 +- cmd/auth/login_test.go | 14 +-- shortcuts/register.go | 70 ++++++++++++++ shortcuts/register_brand_guard_test.go | 122 +++++++++++++++++++++++++ skills/lark-apps/SKILL.md | 8 +- 7 files changed, 262 insertions(+), 21 deletions(-) create mode 100644 cmd/auth/login_brand_filter_test.go create mode 100644 shortcuts/register_brand_guard_test.go diff --git a/cmd/auth/login.go b/cmd/auth/login.go index 02888c98..b1332f3e 100644 --- a/cmd/auth/login.go +++ b/cmd/auth/login.go @@ -68,7 +68,13 @@ run --device-code in a later step after the user confirms authorization.`, cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend") cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes") - available := sortedKnownDomains() + var helpBrand core.LarkBrand + if f != nil && f.Config != nil { + if cfg, err := f.Config(); err == nil && cfg != nil { + helpBrand = cfg.Brand + } + } + available := sortedKnownDomains(helpBrand) cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil, fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", "))) cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil, @@ -139,14 +145,14 @@ func authLoginRun(opts *LoginOptions) error { // Expand --domain all to all available domains (from_meta projects + shortcut services) for _, d := range selectedDomains { if strings.EqualFold(d, "all") { - selectedDomains = sortedKnownDomains() + selectedDomains = sortedKnownDomains(config.Brand) break } } // Validate domain names and suggest corrections for unknown ones if len(selectedDomains) > 0 { - knownDomains := allKnownDomains() + knownDomains := allKnownDomains(config.Brand) for _, d := range selectedDomains { if !knownDomains[d] { if suggestion := suggestDomain(d, knownDomains); suggestion != "" { @@ -170,7 +176,7 @@ func authLoginRun(opts *LoginOptions) error { if !hasAnyOption { if !opts.JSON && f.IOStreams.IsTerminal { - result, err := runInteractiveLogin(f.IOStreams, lang, msg) + result, err := runInteractiveLogin(f.IOStreams, lang, msg, config.Brand) if err != nil { return err } @@ -208,10 +214,10 @@ func authLoginRun(opts *LoginOptions) error { if len(selectedDomains) > 0 || opts.Recommend { var candidateScopes []string if len(selectedDomains) > 0 { - candidateScopes = collectScopesForDomains(selectedDomains, "user") + candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand) } else { // --recommend without --domain: all domains - candidateScopes = collectScopesForDomains(sortedKnownDomains(), "user") + candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand) } // Filter to auto-approve scopes if --recommend or interactive "common" @@ -490,7 +496,7 @@ func findProfileByName(multi *core.MultiAppConfig, profileName string) *core.App // shortcut scopes for the given domain names. // Domains with auth_domain children are automatically expanded to include // their children's scopes. -func collectScopesForDomains(domains []string, identity string) []string { +func collectScopesForDomains(domains []string, identity string, brand core.LarkBrand) []string { scopeSet := make(map[string]bool) // 1. API scopes from from_meta projects @@ -509,6 +515,9 @@ func collectScopesForDomains(domains []string, identity string) []string { // 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity) for _, sc := range shortcuts.AllShortcuts() { + if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) { + continue + } if domainSet[sc.Service] && shortcutSupportsIdentity(sc, identity) { for _, s := range sc.DeclaredScopesForIdentity(identity) { scopeSet[s] = true @@ -528,7 +537,7 @@ func collectScopesForDomains(domains []string, identity string) []string { // allKnownDomains returns all valid auth domain names (from_meta projects + // shortcut services), excluding domains that have auth_domain set (they are // folded into their parent domain). -func allKnownDomains() map[string]bool { +func allKnownDomains(brand core.LarkBrand) map[string]bool { domains := make(map[string]bool) for _, p := range registry.ListFromMetaProjects() { if !registry.HasAuthDomain(p) { @@ -536,6 +545,9 @@ func allKnownDomains() map[string]bool { } } for _, sc := range shortcuts.AllShortcuts() { + if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) { + continue + } if !registry.HasAuthDomain(sc.Service) { domains[sc.Service] = true } @@ -544,8 +556,8 @@ func allKnownDomains() map[string]bool { } // sortedKnownDomains returns all valid domain names sorted alphabetically. -func sortedKnownDomains() []string { - m := allKnownDomains() +func sortedKnownDomains(brand core.LarkBrand) []string { + m := allKnownDomains(brand) domains := make([]string, 0, len(m)) for d := range m { domains = append(domains, d) diff --git a/cmd/auth/login_brand_filter_test.go b/cmd/auth/login_brand_filter_test.go new file mode 100644 index 00000000..b8eae24e --- /dev/null +++ b/cmd/auth/login_brand_filter_test.go @@ -0,0 +1,32 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package auth + +import ( + "testing" + + "github.com/larksuite/cli/internal/core" +) + +func TestBrandFilter_AppsExcludedOnLark(t *testing.T) { + feishuDomains := allKnownDomains(core.BrandFeishu) + if !feishuDomains["apps"] { + t.Errorf("expected apps domain to be known on Feishu brand") + } + + larkDomains := allKnownDomains(core.BrandLark) + if larkDomains["apps"] { + t.Errorf("expected apps domain to be EXCLUDED on Lark brand") + } + + feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu) + if len(feishuScopes) == 0 { + t.Errorf("expected non-empty scopes for apps on Feishu brand, got %d", len(feishuScopes)) + } + + larkScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandLark) + if len(larkScopes) != 0 { + t.Errorf("expected empty scopes for apps on Lark brand, got %d: %v", len(larkScopes), larkScopes) + } +} diff --git a/cmd/auth/login_interactive.go b/cmd/auth/login_interactive.go index 364d839f..0510f82c 100644 --- a/cmd/auth/login_interactive.go +++ b/cmd/auth/login_interactive.go @@ -11,6 +11,7 @@ import ( "github.com/charmbracelet/huh" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts" @@ -105,7 +106,7 @@ func buildDomainMeta(name, lang string) domainMeta { } // runInteractiveLogin shows an interactive TUI form for domain and permission selection. -func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*interactiveResult, error) { +func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg, brand core.LarkBrand) (*interactiveResult, error) { allDomains := getDomainMetadata(lang) // Build multi-select options @@ -165,7 +166,7 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i } // Compute scope summary - scopes := collectScopesForDomains(selectedDomains, "user") + scopes := collectScopesForDomains(selectedDomains, "user", brand) if permLevel == "common" { scopes = registry.FilterAutoApproveScopes(scopes) } diff --git a/cmd/auth/login_test.go b/cmd/auth/login_test.go index 51ebdb9d..eef776ab 100644 --- a/cmd/auth/login_test.go +++ b/cmd/auth/login_test.go @@ -171,7 +171,7 @@ func TestCompleteDomain_CommaSeparated(t *testing.T) { } func TestAllKnownDomains(t *testing.T) { - domains := allKnownDomains() + domains := allKnownDomains("") if len(domains) == 0 { t.Fatal("expected non-empty known domains") } @@ -185,7 +185,7 @@ func TestAllKnownDomains(t *testing.T) { } func TestSortedKnownDomains(t *testing.T) { - sorted := sortedKnownDomains() + sorted := sortedKnownDomains("") if len(sorted) == 0 { t.Fatal("expected non-empty sorted domains") } @@ -195,7 +195,7 @@ func TestSortedKnownDomains(t *testing.T) { } // Should match allKnownDomains - known := allKnownDomains() + known := allKnownDomains("") if len(sorted) != len(known) { t.Errorf("sorted (%d) and known (%d) length mismatch", len(sorted), len(known)) } @@ -220,7 +220,7 @@ func TestCollectScopesForDomains(t *testing.T) { t.Skip("no from_meta data available") } - scopes := collectScopesForDomains([]string{"calendar"}, "user") + scopes := collectScopesForDomains([]string{"calendar"}, "user", "") if len(scopes) == 0 { t.Fatal("expected non-empty scopes for calendar domain") } @@ -247,7 +247,7 @@ func TestCollectScopesForDomains(t *testing.T) { } func TestCollectScopesForDomains_NonexistentDomain(t *testing.T) { - scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user") + scopes := collectScopesForDomains([]string{"nonexistent_domain_xyz"}, "user", "") if len(scopes) != 0 { t.Errorf("expected empty scopes for nonexistent domain, got %d", len(scopes)) } @@ -1077,7 +1077,7 @@ func TestGetDomainMetadata_ExcludesEvent(t *testing.T) { } func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) { - domains := allKnownDomains() + domains := allKnownDomains("") if domains["whiteboard"] { t.Error("whiteboard should not appear in known auth domains (it has auth_domain=docs)") } @@ -1087,7 +1087,7 @@ func TestAllKnownDomains_ExcludesAuthDomainChildren(t *testing.T) { } func TestCollectScopesForDomains_ExpandsAuthDomainChildren(t *testing.T) { - scopes := collectScopesForDomains([]string{"docs"}, "user") + scopes := collectScopesForDomains([]string{"docs"}, "user", "") // docs domain should include whiteboard shortcut scopes (board:whiteboard:*) found := false for _, s := range scopes { diff --git a/shortcuts/register.go b/shortcuts/register.go index 3a0350c1..39599028 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -5,12 +5,16 @@ package shortcuts import ( "context" + "fmt" + "slices" "github.com/larksuite/cli/shortcuts/okr" "github.com/spf13/cobra" "github.com/larksuite/cli/internal/cmdmeta" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/shortcuts/apps" "github.com/larksuite/cli/shortcuts/base" @@ -32,6 +36,23 @@ import ( "github.com/larksuite/cli/shortcuts/wiki" ) +// Empty brand (no config loaded) is treated as no-restriction so bootstrap +// paths and tests without config still see the full service list. +var brandRestrictedServices = map[string][]core.LarkBrand{ + "apps": {core.BrandFeishu}, +} + +func IsShortcutServiceAvailable(service string, brand core.LarkBrand) bool { + allowed, ok := brandRestrictedServices[service] + if !ok { + return true + } + if brand == "" { + return true + } + return slices.Contains(allowed, brand) +} + // allShortcuts aggregates shortcuts from all domain packages. var allShortcuts []common.Shortcut @@ -69,6 +90,14 @@ func RegisterShortcuts(program *cobra.Command, f *cmdutil.Factory) { } func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f *cmdutil.Factory) { + // Factory.Config may be nil in tests that pass a zero-value factory. + var brand core.LarkBrand + if f != nil && f.Config != nil { + if cfg, err := f.Config(); err == nil && cfg != nil { + brand = cfg.Brand + } + } + // Group by service byService := make(map[string][]common.Shortcut) for _, s := range allShortcuts { @@ -117,5 +146,46 @@ func RegisterShortcutsWithContext(ctx context.Context, program *cobra.Command, f if service == "mail" { mail.InstallOnMail(svc) } + + if !IsShortcutServiceAvailable(service, brand) { + installBrandRestrictionGuard(svc, service, brand) + } } } + +// Mirrors internal/cmdpolicy/apply.go::installDenyStub: DisableFlagParsing + +// ArbitraryArgs keep cobra from short-circuiting with "missing required flag" +// before our RunE runs; leaf-level PersistentPreRunE defeats cobra's "first +// PreRunE wins" walk-up that would otherwise shadow the stub. +func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core.LarkBrand) { + stub := func(c *cobra.Command, _ []string) error { + c.SilenceUsage = true + return output.ErrValidation( + "the %q feature is not yet supported on the %s brand", + service, brand, + ) + } + noopPreRun := func(c *cobra.Command, _ []string) error { + c.SilenceUsage = true + return nil + } + var walk func(c *cobra.Command) + walk = func(c *cobra.Command) { + c.Hidden = true + c.DisableFlagParsing = true + c.Args = cobra.ArbitraryArgs + c.PreRunE = nil + c.PreRun = nil + c.PersistentPreRunE = noopPreRun + c.PersistentPreRun = nil + c.RunE = stub + c.Run = nil + for _, child := range c.Commands() { + walk(child) + } + } + walk(svc) + + // --help bypasses RunE, so surface the restriction in Long too. + svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand) +} diff --git a/shortcuts/register_brand_guard_test.go b/shortcuts/register_brand_guard_test.go new file mode 100644 index 00000000..d3a67e19 --- /dev/null +++ b/shortcuts/register_brand_guard_test.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package shortcuts + +import ( + "context" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/output" +) + +func newFactoryWithBrand(brand core.LarkBrand) *cmdutil.Factory { + return &cmdutil.Factory{ + Config: func() (*core.CliConfig, error) { + return &core.CliConfig{Brand: brand}, nil + }, + } +} + +func findChild(root *cobra.Command, name string) *cobra.Command { + for _, c := range root.Commands() { + if c.Name() == name { + return c + } + } + return nil +} + +func TestBrandGuard_AppsStaysRegisteredOnLark(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark)) + + apps := findChild(program, "apps") + if apps == nil { + t.Fatal("apps service command should be registered on Lark brand (so users see a clear brand error, not 'unknown command')") + } + if !apps.Hidden { + t.Error("apps service command should be Hidden on Lark brand") + } + if len(apps.Commands()) == 0 { + t.Error("apps subcommands should still be mounted (so children also hit the brand-restriction stub)") + } + for _, child := range apps.Commands() { + if !child.Hidden { + t.Errorf("apps child %q should be Hidden on Lark brand", child.Name()) + } + } +} + +func TestBrandGuard_AppsExecuteReturnsBrandError(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark)) + + apps := findChild(program, "apps") + if apps == nil { + t.Fatal("apps should be registered") + } + create := findChild(apps, "+create") + if create == nil { + t.Fatal("apps +create should be registered") + } + + err := create.RunE(create, []string{"--name", "x"}) + if err == nil { + t.Fatal("expected brand-restriction error, got nil") + } + exitErr, ok := err.(*output.ExitError) + if !ok { + t.Fatalf("expected *output.ExitError, got %T: %v", err, err) + } + if exitErr.Code != output.ExitValidation { + t.Errorf("expected ExitValidation (%d), got %d", output.ExitValidation, exitErr.Code) + } + if !strings.Contains(exitErr.Error(), "apps") || !strings.Contains(exitErr.Error(), "lark") { + t.Errorf("expected error to mention apps + lark, got: %s", exitErr.Error()) + } +} + +func TestBrandGuard_AppsExecutableOnFeishu(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, newFactoryWithBrand(core.BrandFeishu)) + + apps := findChild(program, "apps") + if apps == nil { + t.Fatal("apps should be registered on Feishu brand") + } + if apps.Hidden { + t.Error("apps should NOT be Hidden on Feishu brand") + } + create := findChild(apps, "+create") + if create == nil { + t.Fatal("apps +create should be registered on Feishu brand") + } + if create.DisableFlagParsing { + t.Error("apps +create should not have DisableFlagParsing on Feishu (the guard must not have run)") + } +} + +func TestBrandGuard_DispatchHitsStubViaCobra(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, newFactoryWithBrand(core.BrandLark)) + + program.SetArgs([]string{"apps", "+create", "--name", "x", "--app-type", "HTML"}) + program.SetContext(context.Background()) + err := program.Execute() + if err == nil { + t.Fatal("expected error from dispatching apps +create on Lark brand") + } + exitErr, ok := err.(*output.ExitError) + if !ok { + t.Fatalf("expected *output.ExitError from cobra dispatch, got %T: %v", err, err) + } + if !strings.Contains(exitErr.Error(), "lark") { + t.Errorf("dispatched error should mention lark brand, got: %s", exitErr.Error()) + } +} diff --git a/skills/lark-apps/SKILL.md b/skills/lark-apps/SKILL.md index f0cfc938..5d7048a0 100644 --- a/skills/lark-apps/SKILL.md +++ b/skills/lark-apps/SKILL.md @@ -1,10 +1,10 @@ --- name: lark-apps -description: "飞书妙搭应用(lark-cli apps):把本地 HTML 文件或目录部署为可访问、可分享的妙搭应用(静态网站 / Web 页面),返回访问 URL;并提供应用创建、更新、列出、设置可用范围(specific 指定可见 / public 互联网公开 / tenant 企业全员)等管理能力。当用户说『用 HTML / 网页开发 PPT / 幻灯片 / 演示文稿 / 可演示的 demo』、『部署 / 发布 HTML / 静态网站 / 网页 / dist 目录』、『把 /xxx 中的 HTML 文件用 lark-cli 部署 / 发到妙搭』、『开发一个 xxx 并部署成可以分享的网站 / 可访问的链接 / 可分享 URL』、『生成一个可以发给别人看的 PPT / 页面 / demo』,或提到 妙搭 / miaoda / apps / app_id / 可用范围 / open-to-tenant / open-to-public 等关键词时使用。**部署策略:用户明示『部署 / 发布 / 分享 / 可访问 / 可分享 URL』时直接走 `apps +html-publish` 自动部署并返回 URL;用户只说『可演示 / 写一个 PPT / 做个 demo』等模糊意图时,HTML 写完后先询问『要部署到妙搭以便分享吗?』再决定。**" +description: "把本地 HTML 文件或目录部署到飞书妙搭(Miaoda),生成可分享访问的 Web 页面并返回 URL;管理应用的创建、更新、列表和访问范围。当用户要把 HTML、静态网站或 Web demo 发布成可分享链接,或提到妙搭 / Miaoda 时使用。不用于:上传普通文件到云空间(用 lark-drive)、编辑飞书云文档内容(用 lark-doc)、创建飞书原生幻灯片 / 演示文稿(用 lark-slides)。" metadata: requires: bins: ["lark-cli"] - cliHelp: "lark-cli apps --help; lark-cli apps +create --help; lark-cli apps +html-publish --help; lark-cli apps +access-scope-set --help; lark-cli apps +update --help" + cliHelp: "lark-cli apps --help" --- # apps (v1) @@ -16,6 +16,10 @@ lark-cli apps +html-publish --app-id app_xxx --path ./dist lark-cli apps +access-scope-set --app-id app_xxx --scope tenant ``` +## 品牌可用性(先做) + +跑 `lark-cli apps --help`;若提示暂未支持,告诉用户敬请期待并停止。 + ## 前置条件 — 执行操作前必读 **CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**