Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions cmd/auth/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ func authListRun(opts *ListOptions) error {
f := opts.Factory

multi, _ := core.LoadMultiAppConfig()
if err := cmdutil.ProjectProfileError(f.Invocation, multi); err != nil {
return err
}
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
Expand Down
26 changes: 26 additions & 0 deletions cmd/auth/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package auth

import (
"encoding/json"
"errors"
"strings"
"testing"

Expand Down Expand Up @@ -62,6 +63,31 @@ func TestAuthListRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
}
}

func TestAuthListRun_ProjectProfileMissingFailsClosedWhenNotConfigured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

projectPath := core.ProjectConfigPath(t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Invocation = cmdutil.InvocationContext{
Profile: "missing",
ProfileSource: core.ProfileSourceProject,
ProfileConfigPath: projectPath,
}
err := authListRun(&ListOptions{Factory: f, JSON: true})
if err == nil {
t.Fatal("authListRun() error = nil, want project profile not found")
}
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
wantMsg := `profile "missing" is configured by project but not found`
wantHint := "project config: " + projectPath + "; run: lark-cli profile list"
if cfgErr.Code != 3 || cfgErr.Type != "config" || cfgErr.Message != wantMsg || cfgErr.Hint != wantHint {
t.Fatalf("ConfigError = %#v", cfgErr)
}
}

// TestAuthListRun_NotConfigured_AgentWorkspace_RoutesToBindHelp covers the
// reason this hint exists workspace-aware in the first place: an AI agent
// in OpenClaw / Hermes that probes auth list before binding gets routed to
Expand Down
3 changes: 3 additions & 0 deletions cmd/auth/logout.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ func authLogoutRun(opts *LogoutOptions) error {
f := opts.Factory

multi, _ := core.LoadMultiAppConfig()
if err := cmdutil.ProjectProfileError(f.Invocation, multi); err != nil {
return err
}
if multi == nil || len(multi.Apps) == 0 {
if opts.JSON {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
Expand Down
26 changes: 26 additions & 0 deletions cmd/auth/logout_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package auth

import (
"encoding/json"
"errors"
"net/url"
"strings"
"testing"
Expand Down Expand Up @@ -59,6 +60,31 @@ func TestAuthLogoutRun_JSONMode_NotConfigured_WritesStdoutOnly(t *testing.T) {
}
}

func TestAuthLogoutRun_ProjectProfileMissingFailsClosedWhenNotConfigured(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())

projectPath := core.ProjectConfigPath(t.TempDir())
f, _, _, _ := cmdutil.TestFactory(t, nil)
f.Invocation = cmdutil.InvocationContext{
Profile: "missing",
ProfileSource: core.ProfileSourceProject,
ProfileConfigPath: projectPath,
}
err := authLogoutRun(&LogoutOptions{Factory: f, JSON: true})
if err == nil {
t.Fatal("authLogoutRun() error = nil, want project profile not found")
}
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
wantMsg := `profile "missing" is configured by project but not found`
wantHint := "project config: " + projectPath + "; run: lark-cli profile list"
if cfgErr.Code != 3 || cfgErr.Type != "config" || cfgErr.Message != wantMsg || cfgErr.Hint != wantHint {
t.Fatalf("ConfigError = %#v", cfgErr)
}
}

func TestAuthLogoutRun_JSONMode_NotLoggedIn_WritesStdoutOnly(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
writeLogoutConfig(t, nil)
Expand Down
44 changes: 43 additions & 1 deletion cmd/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"io"

"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/spf13/pflag"
)

Expand All @@ -26,5 +27,46 @@ func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error
if err := fs.Parse(args); err != nil && !errors.Is(err, pflag.ErrHelp) {
return cmdutil.InvocationContext{}, err
}
return cmdutil.InvocationContext{Profile: globals.Profile}, nil
if globals.Profile != "" {
return cmdutil.InvocationContext{
Profile: globals.Profile,
ProfileSource: core.ProfileSourceCLI,
}, nil
}
if skipProjectProfileLookup(args, fs.Args()) {
return cmdutil.InvocationContext{ProfileSource: core.ProfileSourceGlobal}, nil
}
project, err := core.ResolveProjectProfile()
if err != nil {
return cmdutil.InvocationContext{}, err
}
if project != nil {
return cmdutil.InvocationContext{
Profile: project.Profile,
ProfileSource: core.ProfileSourceProject,
ProfileConfigPath: project.Path,
}, nil
}
return cmdutil.InvocationContext{ProfileSource: core.ProfileSourceGlobal}, nil
}

func skipProjectProfileLookup(rawArgs, positionals []string) bool {
for _, arg := range rawArgs {
if arg == "-h" || arg == "--help" {
return true
}
}
if len(positionals) == 0 {
return false
}
switch positionals[0] {
case "completion", "__complete", "__completeNoDesc":
return true
case "profile":
return len(positionals) < 2 || positionals[1] != "current"
case "config":
return len(positionals) >= 2 && (positionals[1] == "bind" || positionals[1] == "init" || positionals[1] == "remove")
default:
return false
}
}
180 changes: 179 additions & 1 deletion cmd/bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,27 @@

package cmd

import "testing"
import (
"errors"
"os"
"path/filepath"
"testing"

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

func writeBootstrapProjectConfig(t *testing.T, dir, body string) string {
t.Helper()
path := core.ProjectConfigPath(dir)
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
t.Fatalf("MkdirAll(project config dir): %v", err)
}
if err := os.WriteFile(path, []byte(body), 0600); err != nil {
t.Fatalf("WriteFile(project config): %v", err)
}
return path
}
Comment on lines +16 to +26

func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) {
inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"})
Expand Down Expand Up @@ -70,3 +90,161 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) {
t.Fatalf("profile = %q, want %q", inv.Profile, "target")
}
}

func TestBootstrapInvocationContext_ProjectProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
repo := t.TempDir()
if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil {
t.Fatalf("Mkdir(.git): %v", err)
}
writeBootstrapProjectConfig(t, repo, `{"profile":"bytedance"}`)
sub := filepath.Join(repo, "sub")
if err := os.MkdirAll(sub, 0700); err != nil {
t.Fatalf("MkdirAll(sub): %v", err)
}
cmdutil.TestChdir(t, sub)

inv, err := BootstrapInvocationContext([]string{"auth", "status"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.Profile != "bytedance" {
t.Fatalf("profile = %q, want bytedance", inv.Profile)
}
if inv.ProfileSource != core.ProfileSourceProject {
t.Fatalf("ProfileSource = %q, want project", inv.ProfileSource)
}
wantRepo, err := filepath.EvalSymlinks(repo)
if err != nil {
t.Fatalf("EvalSymlinks(repo): %v", err)
}
if inv.ProfileConfigPath != core.ProjectConfigPath(wantRepo) {
t.Fatalf("ProfileConfigPath = %q", inv.ProfileConfigPath)
}
}

func TestBootstrapInvocationContext_ProfileFlagOverridesProjectProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
repo := t.TempDir()
if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil {
t.Fatalf("Mkdir(.git): %v", err)
}
writeBootstrapProjectConfig(t, repo, `{"profile":"project"}`)
cmdutil.TestChdir(t, repo)

inv, err := BootstrapInvocationContext([]string{"--profile", "cli", "auth", "status"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.Profile != "cli" {
t.Fatalf("profile = %q, want cli", inv.Profile)
}
if inv.ProfileSource != core.ProfileSourceCLI {
t.Fatalf("ProfileSource = %q, want cli", inv.ProfileSource)
}
if inv.ProfileConfigPath != "" {
t.Fatalf("ProfileConfigPath = %q, want empty", inv.ProfileConfigPath)
}
}

func TestBootstrapInvocationContext_ProfileBindSkipsMalformedProjectConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
repo := t.TempDir()
if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil {
t.Fatalf("Mkdir(.git): %v", err)
}
writeBootstrapProjectConfig(t, repo, `{`)
cmdutil.TestChdir(t, repo)

inv, err := BootstrapInvocationContext([]string{"profile", "bind", "bytedance"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.Profile != "" || inv.ProfileSource != core.ProfileSourceGlobal {
t.Fatalf("invocation = %#v, want global without profile", inv)
}
}

func TestBootstrapInvocationContext_ProfileCurrentReadsMalformedProjectConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
repo := t.TempDir()
if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil {
t.Fatalf("Mkdir(.git): %v", err)
}
writeBootstrapProjectConfig(t, repo, `{`)
cmdutil.TestChdir(t, repo)

_, err := BootstrapInvocationContext([]string{"profile", "current"})
if err == nil {
t.Fatal("BootstrapInvocationContext() error = nil, want malformed project config error")
}
var cfgErr *core.ConfigError
if !errors.As(err, &cfgErr) {
t.Fatalf("error type = %T, want *core.ConfigError", err)
}
if cfgErr.Code != 3 || cfgErr.Type != "config" {
t.Fatalf("ConfigError metadata = code:%d type:%q", cfgErr.Code, cfgErr.Type)
}
wantRepo, err := filepath.EvalSymlinks(repo)
if err != nil {
t.Fatalf("EvalSymlinks(repo): %v", err)
}
wantMsg := "invalid project config " + core.ProjectConfigPath(wantRepo) + ": unexpected end of JSON input"
if cfgErr.Message != wantMsg {
t.Fatalf("ConfigError.Message = %q, want %q", cfgErr.Message, wantMsg)
}
}

func TestBootstrapInvocationContext_CompletionValueDoesNotSkipProjectProfile(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
repo := t.TempDir()
if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil {
t.Fatalf("Mkdir(.git): %v", err)
}
writeBootstrapProjectConfig(t, repo, `{"profile":"bytedance"}`)
cmdutil.TestChdir(t, repo)

inv, err := BootstrapInvocationContext([]string{"auth", "status", "completion"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.ProfileSource != core.ProfileSourceProject || inv.Profile != "bytedance" {
t.Fatalf("invocation = %#v, want project profile", inv)
}
}

func TestBootstrapInvocationContext_CompletionCommandSkipsMalformedProjectConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
repo := t.TempDir()
if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil {
t.Fatalf("Mkdir(.git): %v", err)
}
writeBootstrapProjectConfig(t, repo, `{`)
cmdutil.TestChdir(t, repo)

inv, err := BootstrapInvocationContext([]string{"completion"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.Profile != "" || inv.ProfileSource != core.ProfileSourceGlobal {
t.Fatalf("invocation = %#v, want global without profile", inv)
}
}

func TestBootstrapInvocationContext_CompletionAfterUnknownValueFlagSkipsMalformedProjectConfig(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
repo := t.TempDir()
if err := os.Mkdir(filepath.Join(repo, ".git"), 0700); err != nil {
t.Fatalf("Mkdir(.git): %v", err)
}
writeBootstrapProjectConfig(t, repo, `{`)
cmdutil.TestChdir(t, repo)

inv, err := BootstrapInvocationContext([]string{"--config-dir", t.TempDir(), "completion"})
if err != nil {
t.Fatalf("BootstrapInvocationContext() error = %v", err)
}
if inv.Profile != "" || inv.ProfileSource != core.ProfileSourceGlobal {
t.Fatalf("invocation = %#v, want global without profile", inv)
}
}
13 changes: 13 additions & 0 deletions cmd/config/active_profile.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package config

import (
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
)

func noActiveProfileError(f *cmdutil.Factory, multi *core.MultiAppConfig) error {
return cmdutil.ActiveProfileError(f.Invocation, multi)
}
2 changes: 1 addition & 1 deletion cmd/config/default_as.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func NewCmdConfigDefaultAs(f *cmdutil.Factory) *cobra.Command {

app := multi.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return core.NoActiveProfileError()
return noActiveProfileError(f, multi)
}

if len(args) == 0 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/config/show.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ func configShowRun(opts *ConfigShowOptions) error {
}
app := config.CurrentAppConfig(f.Invocation.Profile)
if app == nil {
return errs.NewConfigError(errs.SubtypeNotConfigured, "no active profile").WithHint("run: lark-cli profile list")
return noActiveProfileError(f, config)
}
users := "(no logged-in users)"
if len(app.Users) > 0 {
Expand Down
Loading
Loading