mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 17:45:15 +08:00
* feat: add strict mode identity filter, profile management and credential extension Port changes from feat/strict-mode-identity-filter_3 branch: - Add strict mode for identity filtering and configuration - Add profile management commands (add/list/remove/rename/use) - Add credential extension framework (registry, env provider) - Add VFS abstraction layer - Refactor factory default and client options - Update shortcuts to use new credential and validation patterns Change-Id: I8c104c6b147e1901d94aefcefe35a174932c742b Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: go mod tidy Change-Id: I0f610ccea6bc874248e84c24770944a3071dcc57 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: fix test failures from credential provider migration - Remove unused TAT stub registrations in api and service tests (CredentialProvider manages tokens, SDK no longer calls TAT endpoint) - Update strict mode integration test: +chat-create now supports user identity, so it should succeed under strict mode user Change-Id: Iab51c2e12a97995e0b95dcd71df212d2d1f76570 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: migrate remaining os calls to internal/vfs Replace direct os.Stat/Open/MkdirAll/OpenFile/Remove/ReadDir/UserHomeDir with vfs equivalents in shortcuts/minutes, shortcuts/drive, and internal/keychain. Add ReadDir to the vfs interface and OsFs implementation. Change-Id: I8f97e5fb3e1731b4684d276644fcb10fae823067 * fix: resolve gofmt and goimports formatting issues Change-Id: If61578631f5698f7ca2d9a946ca59753651463fb * feat: add Flag.Input support for @file and stdin input sources Add framework-level support for reading flag values from files (@path) or stdin (-), solving the fundamental problem of passing complex text (markdown, multi-line content) via CLI arguments where shell escaping breaks content. Closes #239, fixes #163. - Add File/Stdin constants and Input field to Flag struct - Add resolveInputFlags() in runner pipeline (pre-Validate) - Support @@ escape for literal @ prefix - Guard against multiple stdin consumers - Auto-append "(supports @file, - for stdin)" to help text - Apply to: docs +create/+update --markdown, im +messages-send/+reply --text/--markdown/--content, task +comment --content, drive +add-comment --content Change-Id: I305a326d972417542aeadd70f37b74ea456461ef Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: fix pre-existing test failures in task, minutes, and registry - task/minutes: remove unused tenant_access_token httpmock stubs (TestFactory's testDefaultToken provides tokens directly, so the HTTP stub was never consumed and failed verification) - registry: fix hasEmbeddedData() to check for actual services instead of just byte length (meta_data_default.json has empty services array) Change-Id: Ic7b5fc7f9de09137a7254fe1ddf47d24ade40587 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: suppress nilerr lint for intentional nil returns Both cases intentionally return nil on error for graceful degradation: - profile list: show friendly message when config is not initialized - service: skip scope check when token resolution fails Change-Id: I7285c37277c9b0361a421ab00359244c2cd150b3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit review feedback - runner.go: fail fast when Input is used on non-string flags - remote_test.go: rename hasEmbeddedData → hasEmbeddedServices - profile/list.go: add omitempty to optional JSON fields - service.go: surface context cancellation errors in scope check Change-Id: I7072d41f8c711b4b37c542e32dfd8150f42b13c0 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: tighten credential resolution and profile flows Change-Id: I83f6d424540eab9b1708944b9b6e26e8477cc60d * refactor: centralize identity hint resolution Change-Id: I38d5f98160b92adb62dc929ae73697ae5b3d64f8 * fix: surface unverified extension identities Change-Id: Ia86d9bd19add9010176339ec4cc89deb033f5b4f * fix: honor runtime credential sources in config views Change-Id: I40b2ffedc5c1db5e08e86b9472ea2b84fa02bb29 * fix: prefer runtime values in config show commands Change-Id: I5663a53e147577f0f1f533f67d12bea504e6b839 * Revert "fix: prefer runtime values in config show commands" This reverts commit4f9db3a227. * Revert "fix: honor runtime credential sources in config views" This reverts commitb3bfd526c5. * fix: harden profile flows and credential boundaries Change-Id: Ica61cd2730a639f71516cb1b237a639cb6511f7a * fix: optimize profile and config inspection for agents Change-Id: I19c368102f19654952638180ab947788a6971563 * refactor: unify credential env contracts Change-Id: I0ff2c0a650ea53589a0626333e8f6e628ef10a54 * docs: expand AGENTS guidance Change-Id: I289027dfd364c92205012feef6f05037066c035b * fix: resolve regression bugs found during PR #252 review - im: fix double SafeInputPath in resolveLocalMedia → uploadImageToIM/ uploadFileToIM chain that rejected all local image/file uploads - credential: stop writing plain-text warnings to stderr, preserving JSON envelope contract for AI agent consumers - profile add: reject duplicate app-id to prevent keychain credential collisions across profiles - profile rename: exclude self when checking name uniqueness so renaming to own appId works correctly - config: replace bare fmt.Errorf with output.Errorf in save-failure paths (default_as, strict_mode ×2, profile add) - factory: remove unused resolveDefaultAs method (lint) Change-Id: I6aa0d064414016f367f1edb08dd0604adf7bf13d Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove flaky TestColdStart_UsesEmbedded (race in registry) The test triggers a data race: resetInit() writes package globals while a background goroutine from a previous test may still be reading them. The embedded-data path is covered by other tests. Change-Id: I7a0c3bf85a9fb337b9279c9053697f40a0c0a0d4 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor: type-strengthen Brand and DefaultAs across credential chain Replace raw string fields with typed enums for compile-time safety: - extension/credential: add Brand and Identity named types - internal/core: AppConfig.DefaultAs and CliConfig.DefaultAs → Identity - internal/credential: Account.DefaultAs and IdentityHint.DefaultAs → core.Identity The full data flow is now typed end-to-end: extcred.Brand → core.LarkBrand (named-type cast) extcred.Identity → core.Identity (named-type cast) No string intermediaries, no implicit conversions. Change-Id: I715b3b3f033fcb624010f1af9619e3562740ef08 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: fix gofmt alignment in extension/credential/types.go Change-Id: Ibfac0703a5a28f3c6ba4a47bf40696028d0f3b90 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove file/stdin input support from task comment content flag Change-Id: If49704ca4612465a23bd30b755d6e72a35fc2349 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(cmdutil): remove dead code autoDetectIdentity autoDetectIdentity() is only called from tests, never from production code. Remove it along with its 3 test cases to reduce surface area before the upcoming ctx propagation refactor. Change-Id: I35a188860f17656f3e1fe9874f87f284985ae196 * refactor(cmdutil): add ctx parameter to resolveIdentityHint Private method resolveIdentityHint now accepts context.Context and passes it to CredentialProvider.ResolveIdentityHint instead of using context.Background(). The caller (ResolveAs) still uses context.Background() temporarily until its own signature is updated. Change-Id: I14634a4e0dc1d657d56936ba61a7b7a206da8ac4 * refactor(cmdutil): add ctx parameter to ResolveStrictMode ResolveStrictMode now accepts context.Context and passes it to CredentialProvider.ResolveAccount instead of using context.Background(). Callers in cobra RunE pass cmd.Context(); callers outside RunE (cmd/root.go startup, tests) use context.Background() explicitly. Change-Id: I31be48e548ac5ac5640a65f3bfdde4a53ed1dc7e * refactor(cmdutil): add ctx parameter to CheckStrictMode CheckStrictMode now accepts context.Context and forwards it to ResolveStrictMode. Callers pass cmd.Context() (cobra RunE) or opts.Ctx (APIOptions/ServiceMethodOptions). Change-Id: I47888519d4cae8c94054771c32aff075565a8cdc * refactor(cmdutil): add ctx parameter to ResolveAs ResolveAs now accepts context.Context as first parameter and forwards it to ResolveStrictMode and resolveIdentityHint. This completes the ctx propagation chain: all Factory methods that call CredentialProvider now receive ctx from cobra cmd.Context(). No more context.Background() calls remain in factory.go for credential provider operations. Change-Id: I6d10b6350e3b149470660de3e7855614314e8b29 * test: fix gofmt in cmdutil factory tests Change-Id: I4a87d5a815b959f14cc4371b73dee4aae106932f * fix: remove file/stdin input support from im send/reply and drive comment The Input (file/stdin) feature is not yet ready for these flags: - im send/reply: --content, --text, --markdown - drive add-comment: --content Retained only in doc create/update where markdown from file is essential. Change-Id: I582b6349528fccb639ad9edc84650cca3b68535c Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: liushiyao <liushiyao.1206@bytedance.com>
416 lines
14 KiB
Go
416 lines
14 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package config
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/huh"
|
|
"github.com/spf13/cobra"
|
|
|
|
"github.com/larksuite/cli/internal/auth"
|
|
"github.com/larksuite/cli/internal/cmdutil"
|
|
"github.com/larksuite/cli/internal/core"
|
|
"github.com/larksuite/cli/internal/keychain"
|
|
"github.com/larksuite/cli/internal/output"
|
|
)
|
|
|
|
// ConfigInitOptions holds all inputs for config init.
|
|
type ConfigInitOptions struct {
|
|
Factory *cmdutil.Factory
|
|
Ctx context.Context
|
|
AppID string
|
|
appSecret string // internal only; populated from stdin, never from a CLI flag
|
|
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
|
|
Brand string
|
|
New bool
|
|
Lang string
|
|
langExplicit bool // true when --lang was explicitly passed
|
|
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
|
}
|
|
|
|
// NewCmdConfigInit creates the config init subcommand.
|
|
func NewCmdConfigInit(f *cmdutil.Factory, runF func(*ConfigInitOptions) error) *cobra.Command {
|
|
opts := &ConfigInitOptions{Factory: f}
|
|
|
|
cmd := &cobra.Command{
|
|
Use: "init",
|
|
Short: "Initialize configuration (app-id / app-secret-stdin / brand)",
|
|
Long: `Initialize configuration (app-id / app-secret-stdin / brand).
|
|
|
|
For AI agents: use --new to create a new app. The command blocks until the user
|
|
completes setup in the browser. Run it in the background and retrieve the
|
|
verification URL from its output.`,
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
opts.Ctx = cmd.Context()
|
|
opts.langExplicit = cmd.Flags().Changed("lang")
|
|
if runF != nil {
|
|
return runF(opts)
|
|
}
|
|
return configInitRun(opts)
|
|
},
|
|
}
|
|
|
|
cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)")
|
|
cmd.Flags().StringVar(&opts.AppID, "app-id", "", "App ID (non-interactive)")
|
|
cmd.Flags().BoolVar(&opts.AppSecretStdin, "app-secret-stdin", false, "Read App Secret from stdin to avoid process list exposure")
|
|
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
|
cmd.Flags().StringVar(&opts.Lang, "lang", "zh", "language for interactive prompts (zh or en)")
|
|
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
|
|
|
return cmd
|
|
}
|
|
|
|
// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
|
|
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
|
|
return o.New || o.AppID != "" || o.AppSecretStdin
|
|
}
|
|
|
|
// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
|
|
func cleanupOldConfig(existing *core.MultiAppConfig, f *cmdutil.Factory, skipAppID string) {
|
|
if existing == nil {
|
|
return
|
|
}
|
|
for _, app := range existing.Apps {
|
|
if app.AppId == skipAppID {
|
|
continue
|
|
}
|
|
core.RemoveSecretStore(app.AppSecret, f.Keychain)
|
|
for _, user := range app.Users {
|
|
auth.RemoveStoredToken(app.AppId, user.UserOpenId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// saveAsOnlyApp overwrites config.json with a single-app config.
|
|
func saveAsOnlyApp(appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
|
config := &core.MultiAppConfig{
|
|
Apps: []core.AppConfig{{
|
|
AppId: appId, AppSecret: secret, Brand: brand, Lang: lang, Users: []core.AppUser{},
|
|
}},
|
|
}
|
|
return core.SaveMultiAppConfig(config)
|
|
}
|
|
|
|
// saveInitConfig saves a new/updated app config, respecting --profile mode.
|
|
// With profileName: appends or updates the named profile (preserves other profiles).
|
|
// Without profileName: cleans up old config and saves as the only app.
|
|
func saveInitConfig(profileName string, existing *core.MultiAppConfig, f *cmdutil.Factory, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
|
if profileName != "" {
|
|
return saveAsProfile(existing, f.Keychain, profileName, appId, secret, brand, lang)
|
|
}
|
|
cleanupOldConfig(existing, f, appId)
|
|
return saveAsOnlyApp(appId, secret, brand, lang)
|
|
}
|
|
|
|
// saveAsProfile appends or updates a named profile in the config.
|
|
// If a profile with the same name exists, it updates it; otherwise appends.
|
|
// When updating, cleans up old keychain secrets if AppId changed.
|
|
func saveAsProfile(existing *core.MultiAppConfig, kc keychain.KeychainAccess, profileName, appId string, secret core.SecretInput, brand core.LarkBrand, lang string) error {
|
|
multi := existing
|
|
if multi == nil {
|
|
multi = &core.MultiAppConfig{}
|
|
}
|
|
|
|
if idx := findProfileIndexByName(multi, profileName); idx >= 0 {
|
|
// Clean up old keychain secret and user tokens if AppId changed
|
|
if multi.Apps[idx].AppId != appId {
|
|
core.RemoveSecretStore(multi.Apps[idx].AppSecret, kc)
|
|
for _, user := range multi.Apps[idx].Users {
|
|
auth.RemoveStoredToken(multi.Apps[idx].AppId, user.UserOpenId)
|
|
}
|
|
multi.Apps[idx].Users = []core.AppUser{}
|
|
}
|
|
// Update existing profile
|
|
multi.Apps[idx].AppId = appId
|
|
multi.Apps[idx].AppSecret = secret
|
|
multi.Apps[idx].Brand = brand
|
|
multi.Apps[idx].Lang = lang
|
|
} else {
|
|
if findAppIndexByAppID(multi, profileName) >= 0 {
|
|
return fmt.Errorf("profile name %q conflicts with existing appId", profileName)
|
|
}
|
|
// Append new profile
|
|
multi.Apps = append(multi.Apps, core.AppConfig{
|
|
Name: profileName,
|
|
AppId: appId,
|
|
AppSecret: secret,
|
|
Brand: brand,
|
|
Lang: lang,
|
|
Users: []core.AppUser{},
|
|
})
|
|
}
|
|
return core.SaveMultiAppConfig(multi)
|
|
}
|
|
|
|
func findProfileIndexByName(multi *core.MultiAppConfig, profileName string) int {
|
|
if multi == nil {
|
|
return -1
|
|
}
|
|
for i := range multi.Apps {
|
|
if multi.Apps[i].Name == profileName {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func findAppIndexByAppID(multi *core.MultiAppConfig, appID string) int {
|
|
if multi == nil {
|
|
return -1
|
|
}
|
|
for i := range multi.Apps {
|
|
if multi.Apps[i].AppId == appID {
|
|
return i
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileName, appID string, brand core.LarkBrand, lang string) error {
|
|
if existing == nil {
|
|
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
|
}
|
|
|
|
var app *core.AppConfig
|
|
if profileName != "" {
|
|
if idx := findProfileIndexByName(existing, profileName); idx >= 0 {
|
|
app = &existing.Apps[idx]
|
|
} else {
|
|
return output.ErrValidation("App Secret cannot be empty for new profile")
|
|
}
|
|
} else {
|
|
app = existing.CurrentAppConfig("")
|
|
if app == nil {
|
|
return output.ErrValidation("App Secret cannot be empty for new configuration")
|
|
}
|
|
}
|
|
|
|
if app.AppId != appID {
|
|
return output.ErrValidation("App Secret cannot be empty when changing App ID")
|
|
}
|
|
|
|
app.AppId = appID
|
|
app.Brand = brand
|
|
app.Lang = lang
|
|
return core.SaveMultiAppConfig(existing)
|
|
}
|
|
|
|
func configInitRun(opts *ConfigInitOptions) error {
|
|
f := opts.Factory
|
|
|
|
// Read secret from stdin if --app-secret-stdin is set
|
|
if opts.AppSecretStdin {
|
|
scanner := bufio.NewScanner(f.IOStreams.In)
|
|
if !scanner.Scan() {
|
|
if err := scanner.Err(); err != nil {
|
|
return output.ErrValidation("failed to read secret from stdin: %v", err)
|
|
}
|
|
return output.ErrValidation("stdin is empty, expected app secret")
|
|
}
|
|
opts.appSecret = strings.TrimSpace(scanner.Text())
|
|
if opts.appSecret == "" {
|
|
return output.ErrValidation("app secret read from stdin is empty")
|
|
}
|
|
}
|
|
|
|
existing, err := core.LoadMultiAppConfig()
|
|
if err != nil {
|
|
existing = nil // treat as empty
|
|
}
|
|
|
|
// Validate --profile name if set
|
|
if opts.ProfileName != "" {
|
|
if err := core.ValidateProfileName(opts.ProfileName); err != nil {
|
|
return output.ErrValidation("%v", err)
|
|
}
|
|
}
|
|
|
|
// Mode 1: Non-interactive
|
|
if opts.AppID != "" && opts.appSecret != "" {
|
|
brand := parseBrand(opts.Brand)
|
|
secret, err := core.ForStorage(opts.AppID, core.PlainSecret(opts.appSecret), f.Keychain)
|
|
if err != nil {
|
|
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
|
}
|
|
if err := saveInitConfig(opts.ProfileName, existing, f, opts.AppID, secret, brand, opts.Lang); err != nil {
|
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
|
}
|
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": opts.AppID, "appSecret": "****", "brand": brand})
|
|
return nil
|
|
}
|
|
|
|
// For interactive modes, prompt language selection if --lang was not explicitly set
|
|
if f.IOStreams.IsTerminal && !opts.langExplicit && !opts.hasAnyNonInteractiveFlag() {
|
|
savedLang := ""
|
|
if existing != nil {
|
|
if app := existing.CurrentAppConfig(""); app != nil {
|
|
savedLang = app.Lang
|
|
}
|
|
}
|
|
lang, err := promptLangSelection(savedLang)
|
|
if err != nil {
|
|
if err == huh.ErrUserAborted {
|
|
return output.ErrBare(1)
|
|
}
|
|
return err
|
|
}
|
|
opts.Lang = lang
|
|
}
|
|
|
|
msg := getInitMsg(opts.Lang)
|
|
|
|
// Mode 3: Create new app directly (--new)
|
|
if opts.New {
|
|
result, err := runCreateAppFlow(opts.Ctx, f, core.BrandFeishu, msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if result == nil {
|
|
return output.ErrValidation("app creation returned no result")
|
|
}
|
|
existing, _ := core.LoadMultiAppConfig()
|
|
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
|
if err != nil {
|
|
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
|
}
|
|
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
|
}
|
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
|
return nil
|
|
}
|
|
|
|
// Mode 4: Interactive TUI (terminal)
|
|
if !opts.hasAnyNonInteractiveFlag() && f.IOStreams.IsTerminal {
|
|
result, err := runInteractiveConfigInit(opts.Ctx, f, msg)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if result == nil {
|
|
return output.ErrValidation("App ID and App Secret cannot be empty")
|
|
}
|
|
|
|
existing, _ := core.LoadMultiAppConfig()
|
|
|
|
if result.AppSecret != "" {
|
|
// New secret provided (either from "create" or "existing" with input)
|
|
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
|
if err != nil {
|
|
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
|
}
|
|
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang); err != nil {
|
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
|
}
|
|
} else if result.Mode == "existing" && result.AppID != "" {
|
|
// Existing app with unchanged secret — update app ID and brand only
|
|
if err := updateExistingProfileWithoutSecret(existing, opts.ProfileName, result.AppID, result.Brand, opts.Lang); err != nil {
|
|
var exitErr *output.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
return err
|
|
}
|
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
|
}
|
|
} else {
|
|
return output.ErrValidation("App ID and App Secret cannot be empty")
|
|
}
|
|
|
|
if result.Mode == "existing" {
|
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.ConfigSaved, result.AppID))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Non-terminal: cannot run interactive mode, guide user to --new
|
|
if !f.IOStreams.IsTerminal {
|
|
return output.ErrValidation("config init requires a terminal for interactive mode. Run with --new to create a new app:\n lark-cli config init --new\nThis command blocks until setup is complete and outputs a verification URL. Run it in the background, then retrieve the URL from its output.")
|
|
}
|
|
|
|
// Mode 5: Legacy interactive (readline fallback)
|
|
firstApp := (*core.AppConfig)(nil)
|
|
if existing != nil {
|
|
firstApp = existing.CurrentAppConfig("")
|
|
}
|
|
|
|
reader := bufio.NewReader(f.IOStreams.In)
|
|
readLine := func(prompt string) (string, error) {
|
|
fmt.Fprintf(f.IOStreams.ErrOut, "%s: ", prompt)
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil && err != io.EOF {
|
|
return "", fmt.Errorf("failed to read input: %w", err)
|
|
}
|
|
if err == io.EOF && strings.TrimSpace(line) == "" {
|
|
return "", fmt.Errorf("input terminated unexpectedly (EOF)")
|
|
}
|
|
return strings.TrimSpace(line), nil
|
|
}
|
|
|
|
prompt := "App ID"
|
|
if firstApp != nil && firstApp.AppId != "" {
|
|
prompt += fmt.Sprintf(" [%s]", firstApp.AppId)
|
|
}
|
|
appIdInput, err := readLine(prompt)
|
|
if err != nil {
|
|
return output.ErrValidation("%s", err)
|
|
}
|
|
|
|
prompt = "App Secret"
|
|
if firstApp != nil && !firstApp.AppSecret.IsZero() {
|
|
prompt += " [****]"
|
|
}
|
|
appSecretInput, err := readLine(prompt)
|
|
if err != nil {
|
|
return output.ErrValidation("%s", err)
|
|
}
|
|
|
|
prompt = "Brand (lark/feishu)"
|
|
if firstApp != nil && firstApp.Brand != "" {
|
|
prompt += fmt.Sprintf(" [%s]", firstApp.Brand)
|
|
} else {
|
|
prompt += " [feishu]"
|
|
}
|
|
brandInput, err := readLine(prompt)
|
|
if err != nil {
|
|
return output.ErrValidation("%s", err)
|
|
}
|
|
|
|
resolvedAppId := appIdInput
|
|
if resolvedAppId == "" && firstApp != nil {
|
|
resolvedAppId = firstApp.AppId
|
|
}
|
|
var resolvedSecret core.SecretInput
|
|
if appSecretInput != "" {
|
|
resolvedSecret = core.PlainSecret(appSecretInput)
|
|
} else if firstApp != nil {
|
|
resolvedSecret = firstApp.AppSecret
|
|
}
|
|
resolvedBrand := brandInput
|
|
if resolvedBrand == "" && firstApp != nil {
|
|
resolvedBrand = string(firstApp.Brand)
|
|
}
|
|
if resolvedBrand == "" {
|
|
resolvedBrand = "feishu"
|
|
}
|
|
|
|
if resolvedAppId == "" || resolvedSecret.IsZero() {
|
|
return output.ErrValidation("App ID and App Secret cannot be empty")
|
|
}
|
|
|
|
storedSecret, err := core.ForStorage(resolvedAppId, resolvedSecret, f.Keychain)
|
|
if err != nil {
|
|
return output.Errorf(output.ExitInternal, "internal", "%v", err)
|
|
}
|
|
if err := saveInitConfig(opts.ProfileName, existing, f, resolvedAppId, storedSecret, parseBrand(resolvedBrand), opts.Lang); err != nil {
|
|
return output.Errorf(output.ExitInternal, "internal", "failed to save config: %v", err)
|
|
}
|
|
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf("Configuration saved to %s", core.GetConfigPath()))
|
|
return nil
|
|
}
|