Skip to content
Open
11 changes: 2 additions & 9 deletions pkg/cmd/application/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,13 @@ func runListCmd(opts *ListOptions) error {
return nil
}

configuredProfiles := opts.Config.ConfiguredProfiles()
configuredAppIDs := make(map[string]string)
for _, p := range configuredProfiles {
configuredAppIDs[p.ApplicationID] = p.Name
}

fmt.Fprintf(opts.IO.Out, "\nYour applications:\n\n")
unconfigured := make([]dashboard.Application, 0)

for _, app := range apps {
profileName, configured := configuredAppIDs[app.ID]
label := fmt.Sprintf(" %s %s", app.ID, app.Name)
if configured {
fmt.Fprintf(opts.IO.Out, "%s %s\n", label, cs.Greenf("(configured: %s)", profileName))
if apputil.ApplicationConfigured(opts.Config, app.ID) {
fmt.Fprintf(opts.IO.Out, "%s %s\n", label, cs.Green("(configured)"))
} else {
fmt.Fprintf(opts.IO.Out, "%s %s\n", label, cs.Gray("(not configured)"))
unconfigured = append(unconfigured, app)
Expand Down
13 changes: 3 additions & 10 deletions pkg/cmd/application/selectapp/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,21 +152,14 @@ func pickApplication(
return nil, fmt.Errorf("--app-name is required in non-interactive mode")
}

configuredProfiles := opts.Config.ConfiguredProfiles()
configuredAppIDs := make(map[string]string)
for _, p := range configuredProfiles {
configuredAppIDs[p.ApplicationID] = p.Name
}

cs := opts.IO.ColorScheme()
appOptions := make([]string, len(apps))
for i, app := range apps {
label := fmt.Sprintf("%s (%s)", app.ID, app.Name)
if profileName, ok := configuredAppIDs[app.ID]; ok {
appOptions[i] = fmt.Sprintf("%s %s", label, cs.Greenf("profile: %s", profileName))
} else {
appOptions[i] = label
if apputil.ApplicationConfigured(opts.Config, app.ID) {
label = fmt.Sprintf("%s %s", label, cs.Green("(configured)"))
}
appOptions[i] = label
}

var selected int
Expand Down
5 changes: 5 additions & 0 deletions pkg/cmd/describe/describe.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ func NewDescribeCmd(f *cmdutil.Factory) *cobra.Command {
Aliases: []string{"schema"},
Args: cobra.ArbitraryArgs,
Short: "Describe commands and flags as JSON.",
// Describe only walks the command tree; it needs no credentials and
// must work on a machine with nothing configured.
Annotations: map[string]string{
"skipAuthCheck": "true",
},
Long: heredoc.Doc(`
Describe the CLI's command tree in a machine-readable format.
With no arguments, this command describes the root command.
Expand Down
8 changes: 8 additions & 0 deletions pkg/cmd/root/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,14 @@ func Execute() exitCode {
cmdFactory := factory.New(version.Version, &cfg)
stderr := cmdFactory.IOStreams.ErrOut

// One-time config.toml → state.toml + keychain migration (GROUT-363). Must
// run before credential resolution, which caches state.toml per command.
if cfg.ShouldMigrate() {
if err := cfg.Migrate(); err != nil && hasDebug {
fmt.Fprintf(stderr, "config migration failed (will retry on next run): %s\n", err)
}
}

// Set up the update notifier.
updateMessageChan := make(chan *update.ReleaseInfo)
go func() {
Expand Down
14 changes: 14 additions & 0 deletions pkg/cmd/shared/apputil/configured.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package apputil

import "github.com/algolia/cli/pkg/config"

// ApplicationConfigured reports whether an application is already known to the
// CLI. state.toml is the source of truth; the legacy config.toml profiles are
// a fallback while config.toml is still supported (remove once it's gone).
func ApplicationConfigured(cfg config.IConfig, appID string) bool {
if cfg.ApplicationInState(appID) {
return true
}
exists, _ := cfg.ApplicationIDExists(appID)
return exists
}
29 changes: 29 additions & 0 deletions pkg/cmd/shared/apputil/configured_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package apputil

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/algolia/cli/test"
)

func TestApplicationConfigured(t *testing.T) {
cfg := test.NewDefaultConfigStub()
cfg.SavedApps = map[string]test.SavedApplication{
"STATE_APP": {Alias: "prod"},
}
// "default" is the config.toml profile's application ID (legacy fallback).

t.Run("in state.toml", func(t *testing.T) {
assert.True(t, ApplicationConfigured(cfg, "STATE_APP"))
})

t.Run("only in legacy config.toml", func(t *testing.T) {
assert.True(t, ApplicationConfigured(cfg, "default"))
})

t.Run("unknown application", func(t *testing.T) {
assert.False(t, ApplicationConfigured(cfg, "UNKNOWN"))
})
}
9 changes: 9 additions & 0 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type IConfig interface {

// New model (state.toml + OS keychain).
ActiveApplicationID() string
ApplicationInState(appID string) bool
ApplicationIDByAlias(alias string) (string, bool)
SaveApplication(appID, alias, apiKeyUUID, apiKey string, setCurrent bool) error
SetCrawlerAPIKey(appID, crawlerAPIKey string) error
Expand Down Expand Up @@ -125,6 +126,14 @@ func (c *Config) loadState() *State {
return c.state
}

// ApplicationInState reports whether state.toml already holds an entry for the
// given application, i.e. the application has been configured under the new
// storage model.
func (c *Config) ApplicationInState(appID string) bool {
_, ok := c.loadState().Applications[appID]
return ok
}

// StateFileExists reports whether state.toml exists on disk, i.e. the new
// storage model (state.toml + OS keychain) is already in use on this machine.
func (c *Config) StateFileExists() bool {
Expand Down
117 changes: 117 additions & 0 deletions pkg/config/migrate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
package config

import (
"os"
"sort"

log "github.com/sirupsen/logrus"
"github.com/spf13/viper"

"github.com/algolia/cli/pkg/keychain"
)

// ShouldMigrate reports whether the one-time config.toml → state.toml +
// keychain migration still has to run: config.toml exists and state.toml
// (only written on success, so doubling as the "migrated" marker) does not.
func (c *Config) ShouldMigrate() bool {
if c.File == "" {
return false
}
if _, err := os.Stat(c.File); err != nil {
return false
}
return !c.StateFileExists()
}

// Migrate moves the legacy config.toml profiles into the new model (state.toml
// + OS keychain); config.toml is never modified. Keychain first, state.toml
// last (atomic): a failure leaves state.toml absent, so the migration retries
// on the next run.
func (c *Config) Migrate() error {
// An unparseable config.toml must not mark the migration as done: abort
// before writing state.toml so it retries once the file is fixed.
if err := viper.ReadInConfig(); err != nil {
return err
}

state := &State{Applications: map[string]ApplicationState{}}

for _, profile := range c.migratableProfiles() {
secrets := keychain.AppSecrets{
APIKey: profile.APIKey,
CrawlerAPIKey: viper.GetString(profile.GetFieldName("crawler_api_key")),
}
if err := keychain.SaveAppSecrets(profile.ApplicationID, secrets); err != nil {
return err
}

state.UpsertApplication(profile.ApplicationID, ApplicationState{
Alias: profile.Name,
SearchHosts: profile.SearchHosts,
CrawlerUserID: viper.GetString(profile.GetFieldName("crawler_user_id")),
})
if profile.Default {
state.SetCurrentApplication(profile.ApplicationID)
}
}

return state.Save(c.StateFile)
}

// migratableProfiles applies the skip rules: profiles without application_id
// or api_key are dropped, admin_api_key never migrates, and the default
// profile wins when several share an application_id. Name order keeps the
// conflict resolution deterministic (ConfiguredProfiles iterates a map).
func (c *Config) migratableProfiles() []*Profile {
// Decode the profiles here rather than through ConfiguredProfiles, whose
// log.Fatalf on an undecodable entry would brick every command at startup.
configs := viper.AllSettings()
profiles := make([]*Profile, 0, len(configs))
for name := range configs {
profile := &Profile{Name: name}
if err := viper.UnmarshalKey(name, profile); err != nil {
log.Warnf("config migration: skipping profile %q: %s", name, err)
continue
}
profiles = append(profiles, profile)
}
sort.Slice(profiles, func(i, j int) bool { return profiles[i].Name < profiles[j].Name })

selected := make([]*Profile, 0, len(profiles))
owner := map[string]int{} // application ID → index in selected

for _, profile := range profiles {
if profile.AdminAPIKey != "" {
log.Warnf(
"config migration: profile %q: admin_api_key is not migrated, use ALGOLIA_ADMIN_API_KEY or --api-key instead",
profile.Name,
)
}
if profile.ApplicationID == "" {
log.Warnf("config migration: skipping profile %q: no application_id", profile.Name)
continue
}
if profile.APIKey == "" {
log.Warnf("config migration: skipping profile %q: empty api_key", profile.Name)
continue
}
if i, ok := owner[profile.ApplicationID]; ok {
kept, dropped := selected[i], profile
if profile.Default && !kept.Default {
selected[i] = profile
kept, dropped = profile, kept
}
log.Warnf(
"config migration: skipping profile %q: application %q already migrated from profile %q",
dropped.Name,
dropped.ApplicationID,
kept.Name,
)
continue
}
owner[profile.ApplicationID] = len(selected)
selected = append(selected, profile)
}

return selected
}
Loading
Loading