diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 841a8840..68c738be 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -6,14 +6,21 @@ package cmd import ( "errors" "io" + "os" + "strings" "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/envvars" "github.com/spf13/pflag" ) // BootstrapInvocationContext extracts global invocation options before // the real command tree is built, so provider-backed config resolution sees // the correct profile from the start. +// +// Profile resolution: --profile flag > CliRuntimeAppID env > "" (defers to +// MultiAppConfig.CurrentApp). The env value flows through FindApp which +// matches by Name first, then by AppId — so callers can pass either form. func BootstrapInvocationContext(args []string) (cmdutil.InvocationContext, error) { var globals GlobalOptions @@ -26,5 +33,9 @@ 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 + profile := globals.Profile + if profile == "" { + profile = strings.TrimSpace(os.Getenv(envvars.CliRuntimeAppID)) + } + return cmdutil.InvocationContext{Profile: profile}, nil } diff --git a/cmd/bootstrap_test.go b/cmd/bootstrap_test.go index aa5fd3de..7d8cf738 100644 --- a/cmd/bootstrap_test.go +++ b/cmd/bootstrap_test.go @@ -3,7 +3,11 @@ package cmd -import "testing" +import ( + "testing" + + "github.com/larksuite/cli/internal/envvars" +) func TestBootstrapInvocationContext_ProfileFlag(t *testing.T) { inv, err := BootstrapInvocationContext([]string{"--profile", "target", "auth", "status"}) @@ -70,3 +74,25 @@ func TestBootstrapInvocationContext_HelpWithProfile(t *testing.T) { t.Fatalf("profile = %q, want %q", inv.Profile, "target") } } + +func TestBootstrapInvocationContext_RuntimeAppIDEnv(t *testing.T) { + t.Setenv(envvars.CliRuntimeAppID, " env-app ") + inv, err := BootstrapInvocationContext([]string{"auth", "status"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext() error = %v", err) + } + if inv.Profile != "env-app" { + t.Fatalf("profile = %q, want %q (env value, trimmed)", inv.Profile, "env-app") + } +} + +func TestBootstrapInvocationContext_ProfileFlagBeatsRuntimeAppIDEnv(t *testing.T) { + t.Setenv(envvars.CliRuntimeAppID, "env-app") + inv, err := BootstrapInvocationContext([]string{"--profile", "flag-app", "auth", "status"}) + if err != nil { + t.Fatalf("BootstrapInvocationContext() error = %v", err) + } + if inv.Profile != "flag-app" { + t.Fatalf("profile = %q, want %q (--profile wins over env)", inv.Profile, "flag-app") + } +} diff --git a/cmd/config/bind.go b/cmd/config/bind.go index 2ec7843f..fc6b3e27 100644 --- a/cmd/config/bind.go +++ b/cmd/config/bind.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "os" + "sort" "strings" "github.com/charmbracelet/huh" @@ -55,6 +56,11 @@ type BindOptions struct { // configBindRun; downstream branches read this instead of rechecking // IOStreams.IsTerminal. Do not set from outside — it is overwritten. IsTUI bool + + // All binds every account exposed by the agent source as a multi-app + // config. Hidden flag, currently openclaw source only, mutually + // exclusive with --app-id. + All bool } // NewCmdConfigBind creates the config bind subcommand. @@ -107,6 +113,8 @@ Interactive terminal use: run with no flags to enter the TUI form.`, cmd.Flags().StringVar(&opts.Identity, "identity", "", "identity preset (bot-only|user-default); defaults to bot-only in flag mode (safer: no impersonation)") cmd.Flags().BoolVar(&opts.Force, "force", false, "confirm a risky transition (currently: bot-only → user-default identity change in flag mode)") cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)") + cmd.Flags().BoolVar(&opts.All, "all", false, "bind every account from the agent source as a multi-app config (currently openclaw source only)") + _ = cmd.Flags().MarkHidden("all") cmdutil.SetRisk(cmd, "write") return cmd @@ -124,6 +132,10 @@ func configBindRun(opts *BindOptions) error { // opts.IsTUI instead of re-checking IOStreams.IsTerminal. opts.IsTUI = opts.Source == "" && opts.Factory.IOStreams.IsTerminal + if opts.All { + return configBindAllRun(opts) + } + source, err := finalizeSource(opts) if err != nil { return err @@ -632,6 +644,15 @@ func validateBindFlags(opts *BindOptions) error { return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --identity %q; valid values: bot-only, user-default", opts.Identity).WithParam("--identity") } } + if opts.All { + if opts.AppID != "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--all is mutually exclusive with --app-id").WithParam("--all") + } + src := strings.TrimSpace(strings.ToLower(opts.Source)) + if src != "" && src != "openclaw" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--all only supports --source openclaw").WithParam("--all") + } + } lang, err := cmdutil.ParseLangFlag(opts.Lang) if err != nil { return err @@ -677,3 +698,173 @@ func tuiSelectIdentity(opts *BindOptions) (string, error) { } return value, nil } + +// configBindAllRun binds every account exposed by the agent source as a +// multi-app config. Flag-mode only; bot-only identity by default; +// silently overwrites prior bindings. +func configBindAllRun(opts *BindOptions) error { + // --all is flag-mode only: resolve the source through the same flag/env + // reconciliation as a single bind (including the explicit-vs-detected + // mismatch guard), but never prompt. + opts.IsTUI = false + source, err := finalizeSource(opts) + if err != nil { + return err + } + if source != "openclaw" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, + "--all only supports --source openclaw").WithParam("--all") + } + core.SetCurrentWorkspace(core.Workspace(source)) + targetConfigPath := core.GetConfigPath() + previousConfigBytes, _ := vfs.ReadFile(targetConfigPath) + + binder, err := newBinder(source, opts) + if err != nil { + return err + } + candidates, err := binder.ListCandidates() + if err != nil { + return err + } + if len(candidates) == 0 { + return errs.NewConfigError(errs.SubtypeNotConfigured, + "no Feishu accounts found in openclaw.json"). + WithHint("configure channels.feishu.accounts first") + } + + if opts.Identity == "" { + opts.Identity = "bot-only" + } + if err := warnIdentityEscalation(opts, previousConfigBytes); err != nil { + return err + } + noticeUserDefaultRisk(opts) + prior := priorLang(previousConfigBytes) + + oc, ok := binder.(*openclawBinder) + if !ok { + return errs.NewInternalError(errs.SubtypeSDKError, "internal: --all requires openclawBinder, got %T", binder) + } + apps, err := oc.BuildAll() + if err != nil { + return err + } + for i := range apps { + applyPreferences(&apps[i], opts, prior) + } + sortAppsDefaultFirst(apps) + apps = dedupAndOrderApps(apps) + + return commitMultiAppBinding(opts, apps, previousConfigBytes, source, targetConfigPath) +} + +// sortAppsDefaultFirst orders the bound apps deterministically: the "default" +// alias first, then named accounts alphabetically. Account enumeration +// upstream follows Go map order, and CurrentApp is taken from the first +// entry — without a stable order the selected default app would vary +// between runs of the same bind. +func sortAppsDefaultFirst(apps []core.AppConfig) { + sort.SliceStable(apps, func(i, j int) bool { + di, dj := strings.EqualFold(apps[i].Name, "default"), strings.EqualFold(apps[j].Name, "default") + if di != dj { + return di + } + return apps[i].Name < apps[j].Name + }) +} + +// dedupAndOrderApps collapses entries that share an AppId. Per OpenClaw +// semantics, "default" is an implicit fallback alias that inherits the +// top-level appId; when an explicit named account exposes the same appId, +// "default" is redundant and discarded. Insertion order is preserved. +func dedupAndOrderApps(apps []core.AppConfig) []core.AppConfig { + byID := make(map[string]int, len(apps)) + out := make([]core.AppConfig, 0, len(apps)) + for _, a := range apps { + if idx, seen := byID[a.AppId]; seen { + if strings.EqualFold(out[idx].Name, "default") && !strings.EqualFold(a.Name, "default") { + out[idx] = a + } + continue + } + byID[a.AppId] = len(out) + out = append(out, a) + } + return out +} + +// commitMultiAppBinding writes a MultiAppConfig containing every bound +// account. CurrentApp defaults to the first entry so existing single-app +// callers keep their behaviour; callers select another profile with +// --profile or LARKSUITE_CLI_RUNTIME_APP_ID. +func commitMultiAppBinding(opts *BindOptions, apps []core.AppConfig, previousConfigBytes []byte, source, configPath string) error { + multi := &core.MultiAppConfig{ + CurrentApp: apps[0].ProfileName(), + Apps: apps, + } + + if err := vfs.MkdirAll(core.GetConfigDir(), 0700); err != nil { + return errs.NewInternalError(errs.SubtypeFileIO, "failed to create workspace directory: %v", err).WithCause(err) + } + data, err := json.MarshalIndent(multi, "", " ") + if err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "failed to marshal config: %v", err).WithCause(err) + } + if err := validate.AtomicWrite(configPath, append(data, '\n'), 0600); err != nil { + return errs.NewInternalError(errs.SubtypeStorage, "failed to write config %s: %v", configPath, err).WithCause(err) + } + + replaced := previousConfigBytes != nil + if replaced { + cleanupKeychainFromDataMulti(opts.Factory.Keychain, previousConfigBytes, apps) + } + + appIDs := make([]string, len(apps)) + for i := range apps { + appIDs[i] = apps[i].AppId + } + + uiMsg := getBindMsg(opts.UILang) + display := sourceDisplayName(source) + fmt.Fprintln(opts.Factory.IOStreams.ErrOut, + fmt.Sprintf(uiMsg.BindSuccessHeader, display)+"\n"+ + fmt.Sprintf("bound %d Feishu apps: %s", len(appIDs), strings.Join(appIDs, ", "))) + + envelope := map[string]interface{}{ + "ok": true, + "workspace": source, + "app_ids": appIDs, + "config_path": configPath, + "replaced": replaced, + "identity": opts.Identity, + "all": true, + } + resultJSON, _ := json.Marshal(envelope) + fmt.Fprintln(opts.Factory.IOStreams.Out, string(resultJSON)) + return nil +} + +// cleanupKeychainFromDataMulti is cleanupKeychainFromData with a keep set, +// used when re-binding multiple apps in one pass. +func cleanupKeychainFromDataMulti(kc keychain.KeychainAccess, data []byte, keep []core.AppConfig) { + var multi core.MultiAppConfig + if err := json.Unmarshal(data, &multi); err != nil { + return + } + keepIDs := make(map[string]struct{}, len(keep)) + for i := range keep { + ref := keep[i].AppSecret.Ref + if ref != nil && ref.Source == "keychain" { + keepIDs[ref.ID] = struct{}{} + } + } + for _, app := range multi.Apps { + if app.AppSecret.Ref != nil && app.AppSecret.Ref.Source == "keychain" { + if _, ok := keepIDs[app.AppSecret.Ref.ID]; ok { + continue + } + } + core.RemoveSecretStore(app.AppSecret, kc) + } +} diff --git a/cmd/config/bind_test.go b/cmd/config/bind_test.go index 0711e105..00dd0333 100644 --- a/cmd/config/bind_test.go +++ b/cmd/config/bind_test.go @@ -1907,3 +1907,270 @@ func TestConfigBindRun_LangExplicit_PrintsConfirmation(t *testing.T) { t.Errorf("stderr = %q, want it to contain confirmation %q", got, want) } } + +// ── --all flag tests (hidden multi-account bind) ── + +func TestConfigBindCmd_AllFlagParsing(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + + var gotOpts *BindOptions + cmd := NewCmdConfigBind(f, func(opts *BindOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs([]string{"--source", "openclaw", "--all"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !gotOpts.All { + t.Error("expected All=true") + } + + // --all is hidden but still parseable. + flag := cmd.Flags().Lookup("all") + if flag == nil { + t.Fatal("--all flag must be registered") + } + if !flag.Hidden { + t.Error("--all must be hidden") + } +} + +// requireAllValidationParam asserts err is a typed validation error of +// subtype invalid_argument carrying the named param, locking the contract +// beyond the prose message. +func requireAllValidationParam(t *testing.T, err error, wantParam string) { + t.Helper() + var ve *errs.ValidationError + if !errors.As(err, &ve) { + t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err) + } + if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("expected validation/invalid_argument, got %s/%s", ve.Category, ve.Subtype) + } + if ve.Param != wantParam { + t.Fatalf("param = %q, want %q", ve.Param, wantParam) + } +} + +func TestConfigBindRun_AllRejectsAppID(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{ + Factory: f, + Source: "openclaw", + AppID: "cli_x", + All: true, + }) + if err == nil { + t.Fatal("expected validation error") + } + if !strings.Contains(err.Error(), "--all is mutually exclusive with --app-id") { + t.Errorf("unexpected error: %v", err) + } + requireAllValidationParam(t, err, "--all") +} + +func TestDedupAndOrderApps_NamedWinsOverDefault(t *testing.T) { + // OpenClaw semantics: "default" is an implicit fallback alias; when a + // named account exposes the same appId, default is dropped. + apps := []core.AppConfig{ + {Name: "acct-a", AppId: "cli_main"}, + {Name: "acct-b", AppId: "cli_product"}, + {Name: "default", AppId: "cli_main"}, + } + got := dedupAndOrderApps(apps) + if len(got) != 2 { + t.Fatalf("len=%d, want 2", len(got)) + } + if got[0].Name != "acct-a" || got[0].AppId != "cli_main" { + t.Errorf("apps[0]=%+v, want acct-a cli_main", got[0]) + } + if got[1].Name != "acct-b" { + t.Errorf("apps[1]=%+v, want acct-b", got[1]) + } +} + +func TestDedupAndOrderApps_DefaultFirstThenNamedOverwrites(t *testing.T) { + // default arrives before its named alias — named still wins. + apps := []core.AppConfig{ + {Name: "default", AppId: "cli_main"}, + {Name: "acct-a", AppId: "cli_main"}, + } + got := dedupAndOrderApps(apps) + if len(got) != 1 { + t.Fatalf("len=%d, want 1", len(got)) + } + if got[0].Name != "acct-a" { + t.Errorf("apps[0]=%+v, want acct-a", got[0]) + } +} + +func TestDedupAndOrderApps_NoDuplicatesPreservesOrder(t *testing.T) { + apps := []core.AppConfig{ + {Name: "acct-a", AppId: "cli_main"}, + {Name: "acct-b", AppId: "cli_product"}, + {Name: "acct-c", AppId: "cli_extra"}, + } + got := dedupAndOrderApps(apps) + if len(got) != 3 { + t.Fatalf("len=%d, want 3", len(got)) + } + want := []string{"acct-a", "acct-b", "acct-c"} + for i := range got { + if got[i].Name != want[i] { + t.Errorf("apps[%d]=%q, want %q", i, got[i].Name, want[i]) + } + } +} + +func TestDedupAndOrderApps_DefaultAlreadyFirst(t *testing.T) { + apps := []core.AppConfig{ + {Name: "default", AppId: "cli_main"}, + {Name: "acct-b", AppId: "cli_product"}, + } + got := dedupAndOrderApps(apps) + if len(got) != 2 || got[0].Name != "default" { + t.Fatalf("got %+v, want default first", got) + } +} + +func TestConfigBindRun_AllRejectsNonOpenClawSource(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{ + Factory: f, + Source: "hermes", + All: true, + }) + if err == nil { + t.Fatal("expected validation error") + } + if !strings.Contains(err.Error(), "--all only supports --source openclaw") { + t.Errorf("unexpected error: %v", err) + } + requireAllValidationParam(t, err, "--all") +} + +func TestConfigBindRun_AllRejectsSourceEnvMismatch(t *testing.T) { + saveWorkspace(t) + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + clearAgentEnv(t) + t.Setenv("HERMES_HOME", t.TempDir()) // env says hermes, flag says openclaw + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{Factory: f, Source: "openclaw", All: true}) + if err == nil { + t.Fatal("expected source/env mismatch error") + } + if !strings.Contains(err.Error(), "does not match detected Agent environment") { + t.Errorf("unexpected error: %v", err) + } + requireAllValidationParam(t, err, "--source") +} + +func TestConfigBindRun_AllBindsEveryAccount(t *testing.T) { + saveWorkspace(t) + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + clearAgentEnv(t) + + openclawHome := t.TempDir() + t.Setenv("OPENCLAW_HOME", openclawHome) + openclawDir := filepath.Join(openclawHome, ".openclaw") + if err := os.MkdirAll(openclawDir, 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + // Top-level credentials surface as a "default" candidate aliasing the + // "work" account; dedup must collapse them and keep the named entry. + cfg := `{"channels":{"feishu":{"appId":"cli_main","appSecret":"sec_main","domain":"feishu","accounts":{"work":{"appId":"cli_main","appSecret":"sec_main"},"personal":{"appId":"cli_personal","appSecret":"sec_personal"}}}}}` + if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(cfg), 0600); err != nil { + t.Fatalf("write: %v", err) + } + + f, stdout, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{Factory: f, All: true}) + if err != nil { + t.Fatalf("expected success, got %v", err) + } + + configPath := filepath.Join(configDir, "openclaw", "config.json") + assertEnvelope(t, stdout.Bytes(), map[string]any{ + "ok": true, + "workspace": "openclaw", + "app_ids": []any{"cli_main", "cli_personal"}, + "config_path": configPath, + "replaced": false, + "identity": "bot-only", + "all": true, + }) + + data, err := os.ReadFile(configPath) + if err != nil { + t.Fatalf("read written config: %v", err) + } + var multi core.MultiAppConfig + if err := json.Unmarshal(data, &multi); err != nil { + t.Fatalf("written config is not a MultiAppConfig: %v", err) + } + if len(multi.Apps) != 2 { + t.Fatalf("apps = %d, want 2", len(multi.Apps)) + } + // The account aliased by top-level "default" stays first and becomes + // the initial CurrentApp. + if multi.CurrentApp != "work" { + t.Errorf("currentApp = %q, want %q", multi.CurrentApp, "work") + } + if multi.Apps[0].Name != "work" || multi.Apps[1].Name != "personal" { + t.Errorf("app order = [%q, %q], want [work, personal]", multi.Apps[0].Name, multi.Apps[1].Name) + } +} + +func TestConfigBindRun_AllEscalationRequiresForce(t *testing.T) { + saveWorkspace(t) + configDir := t.TempDir() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir) + clearAgentEnv(t) + + openclawHome := t.TempDir() + t.Setenv("OPENCLAW_HOME", openclawHome) + openclawDir := filepath.Join(openclawHome, ".openclaw") + if err := os.MkdirAll(openclawDir, 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + cfg := `{"channels":{"feishu":{"appId":"cli_main","appSecret":"sec_main","domain":"feishu"}}}` + if err := os.WriteFile(filepath.Join(openclawDir, "openclaw.json"), []byte(cfg), 0600); err != nil { + t.Fatalf("write: %v", err) + } + + // Pre-existing binding locked to bot-only. + ocDir := filepath.Join(configDir, "openclaw") + if err := os.MkdirAll(ocDir, 0700); err != nil { + t.Fatalf("mkdir: %v", err) + } + locked := `{"apps":[{"appId":"cli_old","strictMode":"bot"}]}` + if err := os.WriteFile(filepath.Join(ocDir, "config.json"), []byte(locked), 0600); err != nil { + t.Fatalf("write: %v", err) + } + + f, _, _, _ := cmdutil.TestFactory(t, nil) + err := configBindRun(&BindOptions{Factory: f, All: true, Identity: "user-default"}) + if err == nil { + t.Fatal("expected confirmation-required error without --force") + } + var ce *errs.ConfirmationRequiredError + if !errors.As(err, &ce) { + t.Fatalf("expected *errs.ConfirmationRequiredError, got %T (%v)", err, err) + } + + // Same escalation with --force proceeds. + f2, stdout, _, _ := cmdutil.TestFactory(t, nil) + if err := configBindRun(&BindOptions{Factory: f2, All: true, Identity: "user-default", Force: true}); err != nil { + t.Fatalf("expected --force to allow escalation, got %v", err) + } + envelope := map[string]any{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("invalid JSON output: %v", err) + } + if envelope["replaced"] != true || envelope["identity"] != "user-default" { + t.Errorf("envelope = %v, want replaced=true identity=user-default", envelope) + } +} diff --git a/cmd/config/binder.go b/cmd/config/binder.go index 6b04086d..024839d5 100644 --- a/cmd/config/binder.go +++ b/cmd/config/binder.go @@ -172,40 +172,54 @@ func (b *openclawBinder) Build(appID string) (*core.AppConfig, error) { if b.cfg == nil { return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: Build called before ListCandidates") } - - var selected *binding.CandidateApp for i := range b.rawApps { if b.rawApps[i].AppID == appID { - selected = &b.rawApps[i] - break + return b.buildFromCandidate(&b.rawApps[i]) } } - if selected == nil { - return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID) - } + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: appID %q not in candidates", appID) +} - if selected.AppSecret.IsZero() { - return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", selected.AppID, b.path). +// BuildAll builds every candidate without appID lookup so duplicate-appID +// entries (e.g. OpenClaw's "default" alias of a named account) keep their own +// Label. --all callers must use this; Build(appID) collapses duplicates. +func (b *openclawBinder) BuildAll() ([]core.AppConfig, error) { + if b.cfg == nil { + return nil, errs.NewInternalError(errs.SubtypeSDKError, "internal: BuildAll called before ListCandidates") + } + out := make([]core.AppConfig, 0, len(b.rawApps)) + for i := range b.rawApps { + cfg, err := b.buildFromCandidate(&b.rawApps[i]) + if err != nil { + return nil, err + } + out = append(out, *cfg) + } + return out, nil +} + +func (b *openclawBinder) buildFromCandidate(c *binding.CandidateApp) (*core.AppConfig, error) { + if c.AppSecret.IsZero() { + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "appSecret is empty for app %s in %s", c.AppID, b.path). WithHint("configure channels.feishu.appSecret in openclaw.json") } - secret, err := binding.ResolveSecretInput(selected.AppSecret, b.cfg.Secrets, os.Getenv) + secret, err := binding.ResolveSecretInput(c.AppSecret, b.cfg.Secrets, os.Getenv) if err != nil { - return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", selected.AppID, err). + return nil, errs.NewConfigError(errs.SubtypeInvalidClient, "failed to resolve appSecret for %s: %v", c.AppID, err). WithHint("check appSecret configuration in %s", b.path). WithCause(err) } - - stored, err := core.ForStorage(selected.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain) + stored, err := core.ForStorage(c.AppID, core.PlainSecret(secret), b.opts.Factory.Keychain) if err != nil { return nil, errs.NewInternalError(errs.SubtypeStorage, "keychain unavailable: %v", err). WithHint("use file: reference in config to bypass keychain"). WithCause(err) } - return &core.AppConfig{ - AppId: selected.AppID, + Name: c.Label, + AppId: c.AppID, AppSecret: stored, - Brand: core.LarkBrand(normalizeBrand(selected.Brand)), + Brand: core.LarkBrand(normalizeBrand(c.Brand)), }, nil } diff --git a/internal/credential/default_provider.go b/internal/credential/default_provider.go index 9482b284..3bb967ea 100644 --- a/internal/credential/default_provider.go +++ b/internal/credential/default_provider.go @@ -68,6 +68,8 @@ func (p *DefaultAccountProvider) ResolveAccount(ctx context.Context) (*Account, return nil, core.NotConfiguredError() } + // p.profile already incorporates --profile / CliRuntimeAppID precedence + // (resolved in cmd/bootstrap.go before Factory construction). cfg, err := core.ResolveConfigFromMulti(multi, p.keychain(), p.profile) if err != nil { return nil, err diff --git a/internal/envvars/envvars.go b/internal/envvars/envvars.go index 7b4a2346..181ad014 100644 --- a/internal/envvars/envvars.go +++ b/internal/envvars/envvars.go @@ -4,8 +4,11 @@ package envvars const ( - CliAppID = "LARKSUITE_CLI_APP_ID" - CliAppSecret = "LARKSUITE_CLI_APP_SECRET" + CliAppID = "LARKSUITE_CLI_APP_ID" + CliAppSecret = "LARKSUITE_CLI_APP_SECRET" + // CliRuntimeAppID pins a profile by appId when --profile is unset. + // Selector only; credentials still come from config.json / keychain. + CliRuntimeAppID = "LARKSUITE_CLI_RUNTIME_APP_ID" CliBrand = "LARKSUITE_CLI_BRAND" CliUserAccessToken = "LARKSUITE_CLI_USER_ACCESS_TOKEN" CliTenantAccessToken = "LARKSUITE_CLI_TENANT_ACCESS_TOKEN"