Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
cc8c7d8
feat(shortcuts): import typed shortcut framework from c07b64621
sang-neo03 Aug 11, 2026
c9e06c0
feat(extension): add public command contract
sang-neo03 Aug 11, 2026
096035e
feat(extension): compile and register business commands
sang-neo03 Aug 11, 2026
2959a30
feat(cmd): assemble business command sets
sang-neo03 Aug 11, 2026
25e8182
fix(auth): derive login domains from shortcuts
sang-neo03 Aug 11, 2026
c73cfdd
test(extension): add business command test runtime
sang-neo03 Aug 11, 2026
173d8de
ci: verify generated Go sources
sang-neo03 Aug 11, 2026
5b849b9
test(auth): match help and interactive domains
sang-neo03 Aug 11, 2026
a61b22d
fix(extension): validate command path segments
sang-neo03 Aug 11, 2026
c45fb38
fix(extension): satisfy generator and error guards
sang-neo03 Aug 11, 2026
b398604
fix(extension): follow generated domain naming contract
sang-neo03 Aug 11, 2026
02a2e73
fix(command): complete extension runtime contracts
sang-neo03 Aug 11, 2026
2e14a13
test(command): cover public extension surface
sang-neo03 Aug 11, 2026
1c206e8
fix(command): remove unused typed runtime APIs
sang-neo03 Aug 11, 2026
d2f226a
fix(command): address follow-up review findings
sang-neo03 Aug 11, 2026
42765e8
fix(command): enforce extension v1 contracts
sang-neo03 Aug 11, 2026
3c1aaed
fix(command): remove unreachable compatibility wrappers
sang-neo03 Aug 11, 2026
96063c9
fix(auth): keep scope-less domains addressable via --domain, matching…
sang-neo03 Aug 12, 2026
3e03ca6
feat(command): add PathSegment for user-provided path values
sang-neo03 Aug 12, 2026
6a04c8a
docs(command): add runnable chat-brief distribution example
sang-neo03 Aug 12, 2026
2421cfd
fix(command): normalize page envelopes and bound CollectAllPages
sang-neo03 Aug 12, 2026
9dd833e
feat(commandhost): note bounded repetition in Page dry-run previews
sang-neo03 Aug 12, 2026
1770b17
docs(command): generate domain constants with English comments
sang-neo03 Aug 12, 2026
292f1ae
docs(command): mark the host adapter read surface
sang-neo03 Aug 12, 2026
3cd9c9b
refactor(command): type CommandMetadata.Service as DomainName
sang-neo03 Aug 12, 2026
1d450cd
refactor(command): extract the command-set assembly steps
sang-neo03 Aug 12, 2026
f6d0583
perf(shortcuts): stop re-cloning an already-isolated snapshot
sang-neo03 Aug 12, 2026
a0d75d5
fix(command): align the commandtest page bound and escape wrapper paths
sang-neo03 Aug 12, 2026
30ee9b2
fix(command): deny network to the pre-confirmation hooks and four rev…
sang-neo03 Aug 12, 2026
def56b7
test(cmd): exercise the retained constructors instead of naming them
sang-neo03 Aug 12, 2026
74d49e8
docs(command): document the hook contract and pin dry-run note idempo…
sang-neo03 Aug 12, 2026
c962b0b
refactor(command): settle the dry-run constructor, tips, and domain enum
sang-neo03 Aug 13, 2026
66643d0
ci: stop generating extension/command
sang-neo03 Aug 13, 2026
b6b6bc5
refactor(command): drop DryRunE and let the preview render like a bui…
sang-neo03 Aug 13, 2026
4698b2a
refactor(command): drop the partial-failure outcome from the contract
sang-neo03 Aug 13, 2026
b9ed216
refactor(shortcuts): walk pages once for built-in and external commands
sang-neo03 Aug 13, 2026
ce56f50
refactor(shortcuts): make the external page walk PaginateInto's twin
sang-neo03 Aug 13, 2026
bb92ab4
fix(shortcuts): reject recursive Data and Args types during compilation
sang-neo03 Aug 17, 2026
e235a59
fix(command): share one result protocol between the host and commandtest
sang-neo03 Aug 17, 2026
ee931a5
feat(command): expose reusable download capabilities
liangshuo-1 Aug 17, 2026
6aca8f5
test(command): make the chat-brief example testable and cover its hooks
sang-neo03 Aug 17, 2026
644b8ee
test(commandtest): cover the ordered URL download script
sang-neo03 Aug 17, 2026
cfdc8a7
feat(command): accept @file input for external commands
sang-neo03 Aug 18, 2026
4bf72fe
revert(content): drop the default content exports from the extension …
sang-neo03 Aug 18, 2026
6bee2a7
fix(command): close the review findings on API surface and storage co…
sang-neo03 Aug 19, 2026
f450d4f
refactor(command): keep a single authoring contract
liangshuo-1 Aug 25, 2026
9953ceb
revert(schema): keep the schema command blind to shortcuts
sang-neo03 Aug 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ jobs:
echo "::error::Unformatted Go files detected — run 'gofmt -w .' and commit"
exit 1
fi
- name: Check generated Go files
run: |
go generate ./shortcuts/sheets/...
git diff --exit-code
- name: Check go.mod tidiness
run: |
go mod tidy
Expand Down
20 changes: 15 additions & 5 deletions cmd/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,30 @@
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/recovery"
"github.com/larksuite/cli/shortcuts"
shortcutcommon "github.com/larksuite/cli/shortcuts/common"
)

// NewCmdAuth creates the auth command with subcommands.
func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
return newCmdAuth(f, nil)
return newCmdAuth(f, nil, shortcuts.AllShortcuts())
}

// NewCmdAuthWithRecovery creates the auth command with a build-local recovery
// presenter while preserving NewCmdAuth's established function signature.
// presenter, resolving domains from the registered shortcut set. Retained at its
// established signature: callers outside this module cannot name
// *recovery.Projector, but they can pass nil for it, so dropping this would
// break them at compile time.
func NewCmdAuthWithRecovery(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
return newCmdAuth(f, projector)
return NewCmdAuthWithRecoveryAndShortcuts(f, projector, shortcuts.AllShortcuts())

Check warning on line 37 in cmd/auth/auth.go

View check run for this annotation

Codecov / codecov/patch

cmd/auth/auth.go#L37

Added line #L37 was not covered by tests
}

func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector) *cobra.Command {
// NewCmdAuthWithRecoveryAndShortcuts creates auth commands from one build-local shortcut snapshot.
func NewCmdAuthWithRecoveryAndShortcuts(f *cmdutil.Factory, projector *recovery.Projector, registered []shortcutcommon.Shortcut) *cobra.Command {
return newCmdAuth(f, projector, registered)

Check warning on line 42 in cmd/auth/auth.go

View check run for this annotation

Codecov / codecov/patch

cmd/auth/auth.go#L41-L42

Added lines #L41 - L42 were not covered by tests
}

func newCmdAuth(f *cmdutil.Factory, projector *recovery.Projector, registered []shortcutcommon.Shortcut) *cobra.Command {
cmd := &cobra.Command{
Use: "auth",
Short: "OAuth credentials and authorization management",
Expand All @@ -49,7 +59,7 @@
}
cmdutil.DisableAuthCheck(cmd)

cmd.AddCommand(NewCmdAuthLogin(f, nil))
cmd.AddCommand(newCmdAuthLogin(f, nil, registered))
cmd.AddCommand(NewCmdAuthLogout(f, nil))
cmd.AddCommand(newCmdAuthStatus(f, nil, projector))
cmd.AddCommand(NewCmdAuthScopes(f, nil))
Expand Down
2 changes: 1 addition & 1 deletion cmd/auth/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ func TestDomainFlagCompletion(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
comps := completeDomain(tt.toComplete)
comps := builtinResolver().complete(tt.toComplete, "")
sort.Strings(comps)

for _, want := range tt.wantContains {
Expand Down
102 changes: 84 additions & 18 deletions cmd/auth/login.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,16 @@

// NewCmdAuthLogin creates the auth login subcommand.
func NewCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command {
return newCmdAuthLogin(f, runF, shortcuts.AllShortcuts())
}

// newCmdAuthLogin resolves domains from one build's shortcut snapshot. The
// snapshot stays a closure capture rather than a LoginOptions field: LoginOptions
// is part of the exported runF signature, and an unexported field on it would
// end positional literals for every caller outside this module.
func newCmdAuthLogin(f *cmdutil.Factory, runF func(*LoginOptions) error, registered []common.Shortcut) *cobra.Command {
opts := &LoginOptions{Factory: f}
resolver := newDomainResolver(registered)

cmd := &cobra.Command{
Use: "login",
Expand All @@ -65,7 +74,7 @@
if runF != nil {
return runF(opts)
}
return authLoginRun(opts)
return authLoginRun(opts, resolver)

Check warning on line 77 in cmd/auth/login.go

View check run for this annotation

Codecov / codecov/patch

cmd/auth/login.go#L77

Added line #L77 was not covered by tests
},
}
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
Expand All @@ -79,7 +88,7 @@
helpBrand = cfg.Brand
}
}
available := sortedKnownDomains(helpBrand)
available := resolver.sorted(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,
Expand All @@ -89,15 +98,15 @@
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")

cmdutil.RegisterFlagCompletion(cmd, "domain", func(_ *cobra.Command, _ []string, toComplete string) ([]string, cobra.ShellCompDirective) {
return completeDomain(toComplete), cobra.ShellCompDirectiveNoFileComp
return resolver.complete(toComplete, helpBrand), cobra.ShellCompDirectiveNoFileComp

Check warning on line 101 in cmd/auth/login.go

View check run for this annotation

Codecov / codecov/patch

cmd/auth/login.go#L101

Added line #L101 was not covered by tests
})

return cmd
}

// completeDomain returns completions for comma-separated domain values.
func completeDomain(toComplete string) []string {
allDomains := registry.ListFromMetaProjects()
// complete returns completions for comma-separated domain values.
func (r domainResolver) complete(toComplete string, brand core.LarkBrand) []string {
allDomains := r.sorted(brand)
parts := strings.Split(toComplete, ",")
prefix := parts[len(parts)-1]
base := strings.Join(parts[:len(parts)-1], ",")
Expand All @@ -116,7 +125,7 @@
}

// authLoginRun executes the login command logic.
func authLoginRun(opts *LoginOptions) error {
func authLoginRun(opts *LoginOptions, resolver domainResolver) error {
f := opts.Factory

config, err := f.Config()
Expand Down Expand Up @@ -151,14 +160,14 @@
// Expand --domain all to all available domains (from_meta projects + shortcut services)
for _, d := range selectedDomains {
if strings.EqualFold(d, "all") {
selectedDomains = sortedKnownDomains(config.Brand)
selectedDomains = resolver.sorted(config.Brand)

Check warning on line 163 in cmd/auth/login.go

View check run for this annotation

Codecov / codecov/patch

cmd/auth/login.go#L163

Added line #L163 was not covered by tests
break
}
}

// Validate domain names and suggest corrections for unknown ones
if len(selectedDomains) > 0 {
knownDomains := allKnownDomains(config.Brand)
knownDomains := resolver.allKnown(config.Brand)

Check warning on line 170 in cmd/auth/login.go

View check run for this annotation

Codecov / codecov/patch

cmd/auth/login.go#L170

Added line #L170 was not covered by tests
for _, d := range selectedDomains {
if !knownDomains[d] {
if suggestion := suggestDomain(d, knownDomains); suggestion != "" {
Expand All @@ -182,7 +191,7 @@

if !hasAnyOption {
if !opts.JSON && f.IOStreams.IsTerminal {
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand)
result, err := runInteractiveLogin(f.IOStreams, lang.Base(), msg, config.Brand, resolver)

Check warning on line 194 in cmd/auth/login.go

View check run for this annotation

Codecov / codecov/patch

cmd/auth/login.go#L194

Added line #L194 was not covered by tests
if err != nil {
return err
}
Expand Down Expand Up @@ -220,10 +229,10 @@
if len(selectedDomains) > 0 || opts.Recommend {
var candidateScopes []string
if len(selectedDomains) > 0 {
candidateScopes = collectScopesForDomains(selectedDomains, "user", config.Brand)
candidateScopes = resolver.scopesFor(selectedDomains, "user", config.Brand)

Check warning on line 232 in cmd/auth/login.go

View check run for this annotation

Codecov / codecov/patch

cmd/auth/login.go#L232

Added line #L232 was not covered by tests
} else {
// --recommend without --domain: all domains
candidateScopes = collectScopesForDomains(sortedKnownDomains(config.Brand), "user", config.Brand)
candidateScopes = resolver.scopesFor(resolver.sorted(config.Brand), "user", config.Brand)
}

// Filter to auto-approve scopes if --recommend or interactive "common"
Expand Down Expand Up @@ -508,7 +517,25 @@
// 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, brand core.LarkBrand) []string {
// domainResolver answers auth domain and scope questions against one build's
// shortcut snapshot. The snapshot is a build-local input rather than a constant:
// a distribution assembled with cmd.WithCommandSets contributes business
// commands whose declared scopes must participate in --domain resolution, so
// every method here reads the snapshot it was constructed with instead of the
// built-in set.
type domainResolver struct {
registered []common.Shortcut
}

func newDomainResolver(registered []common.Shortcut) domainResolver {
return domainResolver{registered: registered}
}

// scopesFor collects API scopes (from from_meta projects) and shortcut scopes
// for the given domain names.
// Domains with auth_domain children are automatically expanded to include
// their children's scopes.
func (r domainResolver) scopesFor(domains []string, identity string, brand core.LarkBrand) []string {
scopeSet := make(map[string]bool)

// 1. API scopes from from_meta projects
Expand All @@ -526,7 +553,7 @@
}

// 3. Shortcut scopes matching by Service (only include shortcuts supporting the identity)
for _, sc := range shortcuts.AllShortcuts() {
for _, sc := range r.registered {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
Expand All @@ -549,27 +576,66 @@
// 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(brand core.LarkBrand) map[string]bool {
func (r domainResolver) allKnown(brand core.LarkBrand) map[string]bool {
domains := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
if !registry.HasAuthDomain(p) {
domains[p] = true
}
}
for _, sc := range shortcuts.AllShortcuts() {
for _, sc := range r.registered {
if !shortcuts.IsShortcutServiceAvailable(sc.Service, brand) {
continue
}
// No scope filter here: matching main, a scope-less domain (e.g.
// event) stays addressable via --domain and the --help list, and
// fails later with "no matching scopes found". Only the interactive
// selector hides it (see domainResolver.metadata).
if !registry.HasAuthDomain(sc.Service) {
domains[sc.Service] = true
}
}
return domains
}

func shortcutHasDeclaredScopes(shortcut common.Shortcut) bool {
for _, identity := range []string{"user", "bot"} {
if len(shortcut.DeclaredScopesForIdentity(identity)) > 0 {
return true
}
}
return false
}

// scopelessShortcutOnlyDomains returns shortcut-only domains none of whose
// shortcuts declare any scope (e.g. event). The interactive selector hides
// them — picking one can only end in "no matching scopes found" — while
// --domain and the help list keep accepting them, matching main.
func (r domainResolver) scopeless() map[string]bool {
fromMeta := make(map[string]bool)
for _, p := range registry.ListFromMetaProjects() {
fromMeta[p] = true
}
hasScopes := make(map[string]bool)
seen := make(map[string]bool)
for _, sc := range r.registered {
seen[sc.Service] = true
if shortcutHasDeclaredScopes(sc) {
hasScopes[sc.Service] = true
}
}
scopeless := make(map[string]bool)
for service := range seen {
if !fromMeta[service] && !hasScopes[service] {
scopeless[service] = true
}
}
return scopeless
}

// sortedKnownDomains returns all valid domain names sorted alphabetically.
func sortedKnownDomains(brand core.LarkBrand) []string {
m := allKnownDomains(brand)
func (r domainResolver) sorted(brand core.LarkBrand) []string {
m := r.allKnown(brand)
domains := make([]string, 0, len(m))
for d := range m {
domains = append(domains, d)
Expand Down
31 changes: 27 additions & 4 deletions cmd/auth/login_brand_filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,26 +7,49 @@ import (
"testing"

"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts"
)

func TestBrandFilter_AppsExcludedOnLark(t *testing.T) {
feishuDomains := allKnownDomains(core.BrandFeishu)
feishuDomains := builtinResolver().allKnown(core.BrandFeishu)
if !feishuDomains["apps"] {
t.Errorf("expected apps domain to be known on Feishu brand")
}

larkDomains := allKnownDomains(core.BrandLark)
larkDomains := builtinResolver().allKnown(core.BrandLark)
if larkDomains["apps"] {
t.Errorf("expected apps domain to be EXCLUDED on Lark brand")
}

feishuScopes := collectScopesForDomains([]string{"apps"}, "user", core.BrandFeishu)
feishuScopes := builtinResolver().scopesFor([]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)
larkScopes := builtinResolver().scopesFor([]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)
}
}

func TestInteractiveDomainMetadataUsesActiveBrand(t *testing.T) {
registered := shortcuts.AllShortcuts()
feishuDomains := newDomainResolver(registered).metadata("en", core.BrandFeishu)
if !containsDomainMetadata(feishuDomains, "apps") {
t.Fatal("apps domain is missing for Feishu interactive login")
}

larkDomains := newDomainResolver(registered).metadata("en", core.BrandLark)
if containsDomainMetadata(larkDomains, "apps") {
t.Fatal("apps domain is present for Lark interactive login")
}
}

func containsDomainMetadata(domains []domainMeta, name string) bool {
for _, domain := range domains {
if domain.Name == name {
return true
}
}
return false
}
Loading
Loading