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
18 changes: 16 additions & 2 deletions cmd/config/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
AppSecretStdin bool // read app-secret from stdin (avoids process list exposure)
Brand string
New bool
Restore bool

Lang string // raw --lang (string for cobra); normalized to canonical/"" in validateInitLang
langExplicit bool // true when --lang was explicitly passed
Expand Down Expand Up @@ -90,6 +91,9 @@
RunE: func(cmd *cobra.Command, args []string) error {
opts.Ctx = cmd.Context()
opts.langExplicit = cmd.Flags().Changed("lang")
if err := validateRestoreFlags(cmd, opts); err != nil {
return err

Check warning on line 95 in cmd/config/init.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/init.go#L95

Added line #L95 was not covered by tests
}
if err := validateInitLang(opts); err != nil {
return err
}
Expand All @@ -104,6 +108,8 @@
}

cmd.Flags().BoolVar(&opts.New, "new", false, "create a new app directly (skip mode selection)")
cmd.Flags().BoolVar(&opts.Restore, "restore", false,
"re-register the app already in config to recover a lost app secret")
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)")
Expand Down Expand Up @@ -178,7 +184,7 @@

// hasAnyNonInteractiveFlag returns true if any non-interactive flag is set.
func (o *ConfigInitOptions) hasAnyNonInteractiveFlag() bool {
return o.New || o.AppID != "" || o.AppSecretStdin
return o.New || o.Restore || o.AppID != "" || o.AppSecretStdin
}

// cleanupOldConfig clears keychain entries (AppSecret + UAT) for all apps in existing config except the app whose AppId equals skipAppID.
Expand Down Expand Up @@ -369,6 +375,14 @@
}
}

if opts.Restore {
existing, err := core.LoadOrNotConfigured()
if err != nil {
return err

Check warning on line 381 in cmd/config/init.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/init.go#L381

Added line #L381 was not covered by tests
}
return runRestoreFlow(opts, existing, f, getInitMsg(opts.UILang))
}

existing, err := core.LoadMultiAppConfig()
if err != nil {
existing = nil // treat as empty
Expand Down Expand Up @@ -416,7 +430,7 @@

// Mode 3: Create new app directly (--new)
if opts.New {
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg)
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), msg, "")

Check warning on line 433 in cmd/config/init.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/init.go#L433

Added line #L433 was not covered by tests
if err != nil {
return err
}
Expand Down
16 changes: 12 additions & 4 deletions cmd/config/init_interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
return runExistingAppForm(f, msg)
}

return runCreateAppFlow(ctx, f, "", msg)
return runCreateAppFlow(ctx, f, "", msg, "")

Check warning on line 59 in cmd/config/init_interactive.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/init_interactive.go#L59

Added line #L59 was not covered by tests
}

// runExistingAppForm shows a huh form for manually entering App ID / App Secret / Brand.
Expand Down Expand Up @@ -150,7 +150,13 @@

// runCreateAppFlow runs the "create new app" flow via OpenClaw device flow.
// If brandOverride is non-empty, skip the interactive brand selection.
func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride core.LarkBrand, msg *initMsg) (*configInitResult, error) {
func runCreateAppFlow(
ctx context.Context,
f *cmdutil.Factory,
brandOverride core.LarkBrand,
msg *initMsg,
appID string,
) (*configInitResult, error) {
var larkBrand core.LarkBrand
if brandOverride != "" {
larkBrand = brandOverride
Expand Down Expand Up @@ -182,7 +188,7 @@
// Registration is platform traffic, so it must use the provider-aware
// transport as well as the shared proxy configuration.
httpClient := transport.NewHTTPClient(0)
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, f.IOStreams.ErrOut)
authResp, err := larkauth.RequestAppRegistration(ctx, httpClient, larkBrand, appID, f.IOStreams.ErrOut)
if err != nil {
return nil, classifyRegistrationBeginError(err)
}
Expand Down Expand Up @@ -222,7 +228,9 @@
}

fmt.Fprintln(f.IOStreams.ErrOut)
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))
if appID == "" {
output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, result.ClientID))

Check warning on line 232 in cmd/config/init_interactive.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/init_interactive.go#L232

Added line #L232 was not covered by tests
}

return &configInitResult{
Mode: "create",
Expand Down
70 changes: 70 additions & 0 deletions cmd/config/init_restore.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package config

import (
"fmt"

"github.com/spf13/cobra"

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

func validateRestoreFlags(cmd *cobra.Command, opts *ConfigInitOptions) error {
if !opts.Restore {
return nil
}
for _, name := range []string{"new", "app-id", "app-secret-stdin", "brand", "lang", "name"} {
if cmd.Flags().Changed(name) {
flag := "--" + name
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s cannot be used with --restore", flag).WithParam(flag)

Check warning on line 25 in cmd/config/init_restore.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/init_restore.go#L23-L25

Added lines #L23 - L25 were not covered by tests
}
}
return nil
}

func runRestoreFlow(
opts *ConfigInitOptions,
existing *core.MultiAppConfig,
f *cmdutil.Factory,
msg *initMsg,
) error {
app, err := existing.RequireAppConfig(f.Invocation.Profile, f.Invocation.ProfileSource)
if err != nil {
return err

Check warning on line 39 in cmd/config/init_restore.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/init_restore.go#L39

Added line #L39 was not covered by tests
}
if app.AppId == "" {
return errs.NewConfigError(errs.SubtypeInvalidConfig,
"app selected for restore has an empty app ID")

Check warning on line 43 in cmd/config/init_restore.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/init_restore.go#L42-L43

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

result, err := runCreateAppFlow(opts.Ctx, f, core.ParseBrand(string(app.Brand)), msg, app.AppId)
if err != nil {
return err

Check warning on line 48 in cmd/config/init_restore.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/init_restore.go#L48

Added line #L48 was not covered by tests
}
if result == nil || result.AppID != app.AppId || result.AppSecret == "" {
return errs.NewConfigError(errs.SubtypeInvalidClient,
"app restore returned invalid credentials for the configured app")
}

secret, err := core.ForStorage(app.AppId, core.PlainSecret(result.AppSecret), f.Keychain)
if err != nil {
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)

Check warning on line 57 in cmd/config/init_restore.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/init_restore.go#L57

Added line #L57 was not covered by tests
}
app.AppSecret = secret
app.Brand = result.Brand
if err := core.SaveMultiAppConfig(existing); err != nil {
return wrapSaveConfigError(err)

Check warning on line 62 in cmd/config/init_restore.go

View check run for this annotation

Codecov / codecov/patch

cmd/config/init_restore.go#L62

Added line #L62 was not covered by tests
}

output.PrintSuccess(f.IOStreams.ErrOut, fmt.Sprintf(msg.AppCreated, app.AppId))
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"appId": app.AppId, "appSecret": "****", "brand": app.Brand,
})
Comment on lines +65 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report restore success instead of app creation.

The restore path updates an existing application. msg.AppCreated reports the wrong operation. Use msg.ConfigSaved or add a restore-specific message. Capture stderr in the success test and assert that it does not report application creation.

  • cmd/config/init_restore.go#L65-L68: replace msg.AppCreated with a restore-appropriate success message.
  • cmd/config/init_restore_test.go#L102-L108: capture stderr from cmdutil.TestFactory and assert the restore success message.

As per coding guidelines: “Preserve established CLI behavior, tests, lint, CI, output contracts, and public APIs unless a breaking change is explicitly requested.”

📍 Affects 2 files
  • cmd/config/init_restore.go#L65-L68 (this comment)
  • cmd/config/init_restore_test.go#L102-L108
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/config/init_restore.go` around lines 65 - 68, Update the restore success
output in cmd/config/init_restore.go lines 65-68 to use msg.ConfigSaved or a
restore-specific message instead of msg.AppCreated. In
cmd/config/init_restore_test.go lines 102-108, capture stderr from
cmdutil.TestFactory and assert that it contains the restore success message and
does not report application creation.

Source: Coding guidelines

return runProbe(opts.Ctx, f, app.AppId, result.AppSecret, app.Brand)
}
166 changes: 166 additions & 0 deletions cmd/config/init_restore_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package config

import (
"context"
"encoding/json"
"io"
"net/http"
"reflect"
"strings"
"testing"

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

type restoreRoundTripFunc func(*http.Request) (*http.Response, error)

func (f restoreRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

type recordingKeychain struct {
setCalls int
account string
value string
}

func (k *recordingKeychain) Get(_, _ string) (string, error) { return "", nil }
func (k *recordingKeychain) Remove(_, _ string) error { return nil }
func (k *recordingKeychain) Set(_, account, value string) error {
k.setCalls++
k.account = account
k.value = value
return nil
}

func TestConfigInitRestoreUpdatesSelectedProfile(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARK_CLI_NO_PROXY", "")
original := &core.MultiAppConfig{
CurrentApp: "other",
Apps: []core.AppConfig{
{Name: "target", AppId: "cli_target", AppSecret: core.PlainSecret("old-secret"), Brand: core.BrandFeishu,
Users: []core.AppUser{{UserOpenId: "ou_target", UserName: "Target"}}},
{Name: "other", AppId: "cli_other", AppSecret: core.PlainSecret("other-secret"), Brand: core.BrandLark},
},
}
if err := core.SaveMultiAppConfig(original); err != nil {
t.Fatal(err)
}

var beginAppID string
replaceRestoreDefaultTransport(t, restoreRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if err := req.ParseForm(); err != nil {
t.Fatal(err)
}
switch req.Form.Get("action") {
case "begin":
beginAppID = req.Form.Get("app_id")
return restoreJSONResponse(`{"device_code":"device","user_code":"TEST-CODE","expire_in":30,"interval":0}`), nil
case "poll":
return restoreJSONResponse(`{"client_id":"cli_target","client_secret":"restored-secret"}`), nil
default:
t.Fatalf("unexpected action %q", req.Form.Get("action"))
return nil, nil
}
}))

kc := &recordingKeychain{}
rt := &fakeRT{}
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
f.Keychain = kc
f.Invocation = cmdutil.InvocationContext{Profile: "target", ProfileSource: core.ProfileFromFlag}
f.HttpClient = func() (*http.Client, error) { return &http.Client{Transport: rt}, nil }
cmd := NewCmdConfigInit(f, nil)
cmd.SetArgs([]string{"--restore"})
if err := cmd.ExecuteContext(context.Background()); err != nil {
t.Fatal(err)
}

if beginAppID != "cli_target" {
t.Fatalf("begin app_id = %q, want cli_target", beginAppID)
}
if kc.setCalls != 1 || kc.account != "appsecret:cli_target" || kc.value != "restored-secret" {
t.Fatalf("keychain set = calls:%d account:%q value:%q", kc.setCalls, kc.account, kc.value)
}
after, err := core.LoadMultiAppConfig()
if err != nil {
t.Fatal(err)
}
if after.Apps[0].AppSecret.Ref == nil || after.Apps[0].AppSecret.Ref.ID != "appsecret:cli_target" {
t.Fatalf("target secret = %#v", after.Apps[0].AppSecret)
}
if !reflect.DeepEqual(after.Apps[0].Users, original.Apps[0].Users) || !reflect.DeepEqual(after.Apps[1], original.Apps[1]) {
t.Fatalf("restore changed unrelated config: %#v", after)
}
var output map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatalf("stdout is not JSON: %v", err)
}
if output["appSecret"] != "****" || strings.Contains(stdout.String(), "restored-secret") {
t.Fatalf("stdout leaked restored secret: %q", stdout.String())
}
}

func TestConfigInitRestoreRejectsMismatchedAppIDWithoutPersisting(t *testing.T) {
clearAgentEnv(t)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARK_CLI_NO_PROXY", "")
original := &core.MultiAppConfig{Apps: []core.AppConfig{{
AppId: "cli_target", AppSecret: core.PlainSecret("old-secret"), Brand: core.BrandFeishu,
}}}
if err := core.SaveMultiAppConfig(original); err != nil {
t.Fatal(err)
}
replaceRestoreDefaultTransport(t, restoreRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if err := req.ParseForm(); err != nil {
t.Fatal(err)
}
if req.Form.Get("action") == "begin" {
return restoreJSONResponse(`{"device_code":"device","user_code":"TEST-CODE","expire_in":30,"interval":0}`), nil
}
return restoreJSONResponse(`{"client_id":"cli_other","client_secret":"returned-secret"}`), nil
}))

kc := &recordingKeychain{}
f, stdout, _, _ := cmdutil.TestFactory(t, nil)
f.Keychain = kc
cmd := NewCmdConfigInit(f, nil)
cmd.SetArgs([]string{"--restore"})
err := cmd.ExecuteContext(context.Background())
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryConfig || problem.Subtype != errs.SubtypeInvalidClient {
t.Fatalf("problem = %#v, err = %v", problem, err)
}
if kc.setCalls != 0 || stdout.Len() != 0 {
t.Fatalf("restore persisted mismatched credentials: keychain calls=%d stdout=%q", kc.setCalls, stdout.String())
}
after, loadErr := core.LoadMultiAppConfig()
if loadErr != nil {
t.Fatal(loadErr)
}
if !reflect.DeepEqual(after, original) {
t.Fatalf("config changed: got %#v, want %#v", after, original)
}
}

func restoreJSONResponse(body string) *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(body)),
Header: make(http.Header),
}
}

func replaceRestoreDefaultTransport(t *testing.T, rt http.RoundTripper) {
t.Helper()
original := http.DefaultTransport
http.DefaultTransport = rt
t.Cleanup(func() { http.DefaultTransport = original })
}
5 changes: 4 additions & 1 deletion internal/auth/app_registration.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ func appRegistrationEndpoint(brand core.LarkBrand) string {
// RequestAppRegistration initiates the device flow. The registration protocol
// always bootstraps on Feishu; brand selects the user-facing verification host.
// The request is bounded by ctx and a begin timeout.
func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, errOut io.Writer) (*AppRegistrationResponse, error) {
func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand core.LarkBrand, appID string, errOut io.Writer) (*AppRegistrationResponse, error) {
if errOut == nil {
errOut = io.Discard
}
Expand All @@ -104,6 +104,9 @@ func RequestAppRegistration(ctx context.Context, httpClient *http.Client, brand
form.Set("archetype", "PersonalAgent")
form.Set("auth_method", "client_secret")
form.Set("request_user_info", "open_id tenant_brand")
if appID != "" {
form.Set("app_id", appID)
}

req, err := http.NewRequestWithContext(ctx, "POST", endpoint, strings.NewReader(form.Encode()))
if err != nil {
Expand Down
Loading
Loading