|
|
|
|
@@ -41,6 +41,8 @@ type ConfigInitOptions struct {
|
|
|
|
|
|
|
|
|
|
ProfileName string // when set, create/update a named profile instead of replacing Apps[0]
|
|
|
|
|
|
|
|
|
|
Restore bool // Restore re-registers the app already in config to recover a lost credential
|
|
|
|
|
|
|
|
|
|
// ForceInit overrides the agent-workspace guard. Without it, running
|
|
|
|
|
// init under OPENCLAW_HOME / HERMES_HOME refuses and points the caller
|
|
|
|
|
// at config bind — which is what AI agents almost always want. Manual
|
|
|
|
|
@@ -89,6 +91,7 @@ if the user explicitly wants a separate app inside the Agent workspace.`,
|
|
|
|
|
cmd.Flags().StringVar(&opts.Brand, "brand", "feishu", "feishu or lark (non-interactive, default feishu)")
|
|
|
|
|
cmd.Flags().StringVar(&opts.Lang, "lang", "", "language preference (e.g. zh or zh_cn)")
|
|
|
|
|
cmd.Flags().StringVar(&opts.ProfileName, "name", "", "create or update a named profile (append instead of replace)")
|
|
|
|
|
cmd.Flags().BoolVar(&opts.Restore, "restore", false, "re-register the app already in config to recover a lost credential (keychain key / app secret); reuses the stored app ID and auth method")
|
|
|
|
|
cmd.Flags().BoolVar(&opts.ForceInit, "force-init", false, "allow init inside an Agent workspace (OPENCLAW_HOME / HERMES_HOME); use config bind instead unless you really want a separate app")
|
|
|
|
|
cmdutil.SetRisk(cmd, "write")
|
|
|
|
|
|
|
|
|
|
@@ -135,7 +138,7 @@ func guardAgentWorkspace(opts *ConfigInitOptions) error {
|
|
|
|
|
|
|
|
|
|
// 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.
|
|
|
|
|
@@ -347,6 +350,94 @@ func updateExistingProfileWithoutSecret(existing *core.MultiAppConfig, profileNa
|
|
|
|
|
return core.SaveMultiAppConfig(existing)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// persistAndProbeResult saves a registration/restore result into profileName and
|
|
|
|
|
// runs the post-registration probe. profileName == "" replaces the single app
|
|
|
|
|
// (legacy); a named profile is updated in place. Shared by --new and --restore.
|
|
|
|
|
func persistAndProbeResult(opts *ConfigInitOptions, f *cmdutil.Factory, profileName string, result *configInitResult) error {
|
|
|
|
|
existing, _ := core.LoadMultiAppConfig()
|
|
|
|
|
|
|
|
|
|
// private_key_jwt apps have no secret: persist auth method + TEE key ref.
|
|
|
|
|
// Registration success already validated the key (server bound the public
|
|
|
|
|
// key), so the app_secret probe is skipped.
|
|
|
|
|
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
|
|
|
|
|
if err := saveInitConfig(profileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil {
|
|
|
|
|
return wrapSaveConfigError(err)
|
|
|
|
|
}
|
|
|
|
|
removeStaleSecretForPKJWT(existing, profileName, result.AppID, f.Keychain)
|
|
|
|
|
printLangPreferenceConfirmation(opts)
|
|
|
|
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "authMethod": result.AuthMethod, "brand": result.Brand})
|
|
|
|
|
return runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
|
|
|
|
}
|
|
|
|
|
if err := saveInitConfig(profileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil {
|
|
|
|
|
return wrapSaveConfigError(err)
|
|
|
|
|
}
|
|
|
|
|
printLangPreferenceConfirmation(opts)
|
|
|
|
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
|
|
|
|
return runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// runRestoreFlow re-registers the app already in config to recover a lost
|
|
|
|
|
// credential (deleted keychain key / lost app secret). It reads the existing
|
|
|
|
|
// app id + auth method + brand from config (no secret needed — that's the lost
|
|
|
|
|
// part) and re-runs the device-flow registration with the app id sent on begin,
|
|
|
|
|
// so the server re-registers that app instead of creating a new one. The
|
|
|
|
|
// re-issued credential is written back to the same profile.
|
|
|
|
|
func runRestoreFlow(opts *ConfigInitOptions, existing *core.MultiAppConfig, f *cmdutil.Factory, msg *initMsg) error {
|
|
|
|
|
if existing == nil {
|
|
|
|
|
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no config found").
|
|
|
|
|
WithHint("run: lark-cli config init")
|
|
|
|
|
}
|
|
|
|
|
app := existing.CurrentAppConfig(opts.ProfileName)
|
|
|
|
|
if app == nil || app.AppId == "" {
|
|
|
|
|
return errs.NewConfigError(errs.SubtypeNotConfigured, "nothing to restore: no app id in config%s", profileSuffix(opts.ProfileName)).
|
|
|
|
|
WithHint("run: lark-cli config init")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
restoreAppID := app.AppId
|
|
|
|
|
// Reuse the stored auth method authoritatively — never prompt. Empty on disk
|
|
|
|
|
// means client_secret (omitempty back-compat); pass it explicitly so
|
|
|
|
|
// resolveRegisterAuthMethod doesn't fall through to the interactive picker.
|
|
|
|
|
authMethod := app.AuthMethod
|
|
|
|
|
if authMethod == "" {
|
|
|
|
|
authMethod = core.AuthMethodClientSecret
|
|
|
|
|
}
|
|
|
|
|
result, err := runCreateAppFlow(opts.Ctx, f, app.Brand, authMethod, msg, restoreAppID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if result == nil {
|
|
|
|
|
return errs.NewInternalError(errs.SubtypeSDKError, "app restore returned no result")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Safety: if the server did not honor app_id (e.g. not yet supported), it may
|
|
|
|
|
// have created a NEW app instead of restoring. Warn so the user is not silently
|
|
|
|
|
// switched to a different app id.
|
|
|
|
|
if result.AppID != restoreAppID {
|
|
|
|
|
fmt.Fprintf(f.IOStreams.ErrOut, "[lark-cli] [WARN] restore: server returned app %s, expected %s — it may have created a new app instead of restoring\n", result.AppID, restoreAppID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Write back to the profile we restored: an explicit --name, else the resolved
|
|
|
|
|
// app's own name. Empty name => legacy single-app replace.
|
|
|
|
|
saveProfile := opts.ProfileName
|
|
|
|
|
if saveProfile == "" {
|
|
|
|
|
saveProfile = app.Name
|
|
|
|
|
}
|
|
|
|
|
return persistAndProbeResult(opts, f, saveProfile, result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// profileSuffix renders " (profile %q)" for error messages, or "" when unnamed.
|
|
|
|
|
func profileSuffix(profileName string) string {
|
|
|
|
|
if profileName == "" {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return fmt.Sprintf(" (profile %q)", profileName)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func configInitRun(opts *ConfigInitOptions) error {
|
|
|
|
|
f := opts.Factory
|
|
|
|
|
|
|
|
|
|
@@ -377,6 +468,17 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --restore recovers an existing app; it is incompatible with creating a new
|
|
|
|
|
// app (--new) or importing one non-interactively (--app-id / stdin secret).
|
|
|
|
|
if opts.Restore {
|
|
|
|
|
if opts.New {
|
|
|
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --new").WithParam("--restore")
|
|
|
|
|
}
|
|
|
|
|
if opts.AppID != "" || opts.AppSecretStdin {
|
|
|
|
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--restore cannot be combined with --app-id / --app-secret-stdin").WithParam("--restore")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mode 1: Non-interactive
|
|
|
|
|
if opts.AppID != "" && opts.appSecret != "" {
|
|
|
|
|
brand := parseBrand(opts.Brand)
|
|
|
|
|
@@ -410,46 +512,21 @@ func configInitRun(opts *ConfigInitOptions) error {
|
|
|
|
|
|
|
|
|
|
msg := getInitMsg(opts.UILang)
|
|
|
|
|
|
|
|
|
|
// Mode: Restore (--restore) — re-register the app already in config.
|
|
|
|
|
if opts.Restore {
|
|
|
|
|
return runRestoreFlow(opts, existing, f, msg)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mode 3: Create new app directly (--new)
|
|
|
|
|
if opts.New {
|
|
|
|
|
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), opts.AuthMethod, msg)
|
|
|
|
|
result, err := runCreateAppFlow(opts.Ctx, f, parseBrand(opts.Brand), opts.AuthMethod, msg, "")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
if result == nil {
|
|
|
|
|
return errs.NewInternalError(errs.SubtypeSDKError, "app creation returned no result")
|
|
|
|
|
}
|
|
|
|
|
existing, _ := core.LoadMultiAppConfig()
|
|
|
|
|
|
|
|
|
|
// private_key_jwt apps have no secret: persist auth method + TEE key ref.
|
|
|
|
|
// Registration success already validated the key (server bound the public
|
|
|
|
|
// key), so the app_secret probe is skipped.
|
|
|
|
|
if result.AuthMethod == core.AuthMethodPrivateKeyJWT {
|
|
|
|
|
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, core.SecretInput{}, result.Brand, opts.Lang, result.AuthMethod, keyRefFromResult(result)); err != nil {
|
|
|
|
|
return wrapSaveConfigError(err)
|
|
|
|
|
}
|
|
|
|
|
removeStaleSecretForPKJWT(existing, opts.ProfileName, result.AppID, f.Keychain)
|
|
|
|
|
printLangPreferenceConfirmation(opts)
|
|
|
|
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "authMethod": result.AuthMethod, "brand": result.Brand})
|
|
|
|
|
if err := runProbePKJWT(opts.Ctx, f, result.Brand, result.AppID, keysigner.Active(), result.KeyLabel); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
secret, err := core.ForStorage(result.AppID, core.PlainSecret(result.AppSecret), f.Keychain)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return errs.NewInternalError(errs.SubtypeSDKError, "%v", err).WithCause(err)
|
|
|
|
|
}
|
|
|
|
|
if err := saveInitConfig(opts.ProfileName, existing, f, result.AppID, secret, result.Brand, opts.Lang, "", nil); err != nil {
|
|
|
|
|
return wrapSaveConfigError(err)
|
|
|
|
|
}
|
|
|
|
|
printLangPreferenceConfirmation(opts)
|
|
|
|
|
output.PrintJson(f.IOStreams.Out, map[string]interface{}{"appId": result.AppID, "appSecret": "****", "brand": result.Brand})
|
|
|
|
|
if err := runProbe(opts.Ctx, f, result.AppID, result.AppSecret, result.Brand); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
return nil
|
|
|
|
|
return persistAndProbeResult(opts, f, opts.ProfileName, result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mode 4: Interactive TUI (terminal)
|
|
|
|
|
|