Skip to content
Merged
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
12 changes: 3 additions & 9 deletions pkg/cmd/application/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,20 +104,14 @@ 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)

profileApps := apputil.ProfileApplicationIDs(opts.Config.ConfiguredProfiles())
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, profileApps, 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
14 changes: 4 additions & 10 deletions pkg/cmd/application/selectapp/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,21 +152,15 @@ 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()
profileApps := apputil.ProfileApplicationIDs(opts.Config.ConfiguredProfiles())
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, profileApps, 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
24 changes: 24 additions & 0 deletions pkg/cmd/shared/apputil/configured.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package apputil

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

// ProfileApplicationIDs returns the set of application IDs backed by a legacy
// config.toml profile. Built once by the caller so a per-application loop tests
// membership in O(1) instead of re-parsing config.toml on every iteration.
func ProfileApplicationIDs(profiles []*config.Profile) map[string]bool {
ids := make(map[string]bool, len(profiles))
for _, p := range profiles {
if p.ApplicationID != "" {
ids[p.ApplicationID] = true
}
}
return ids
}

// ApplicationConfigured reports whether an application is already known to the
// CLI. state.toml is the source of truth (an O(1) cached lookup); profileApps
// is the legacy config.toml fallback while config.toml is still supported
// (remove once it's gone).
func ApplicationConfigured(cfg config.IConfig, profileApps map[string]bool, appID string) bool {
return cfg.ApplicationInState(appID) || profileApps[appID]
}
46 changes: 46 additions & 0 deletions pkg/cmd/shared/apputil/configured_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package apputil

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/algolia/cli/pkg/config"
"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).
profileApps := ProfileApplicationIDs(cfg.ConfiguredProfiles())

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

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

t.Run("unknown application", func(t *testing.T) {
assert.False(t, ApplicationConfigured(cfg, profileApps, "UNKNOWN"))
})
}

func TestProfileApplicationIDs(t *testing.T) {
profiles := []*config.Profile{
{Name: "prod", ApplicationID: "APP1"},
{Name: "dev", ApplicationID: "APP2"},
{Name: "broken", ApplicationID: ""}, // skipped: no app ID
}

ids := ProfileApplicationIDs(profiles)

assert.True(t, ids["APP1"])
assert.True(t, ids["APP2"])
assert.False(t, ids[""]) // empty IDs never become a member
assert.Len(t, ids, 2)
}
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
119 changes: 119 additions & 0 deletions pkg/config/migrate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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.
//
// The state.toml check comes first so an already-migrated machine — the
// steady state, hit on every command — settles in a single stat instead of
// also stat-ing config.toml.
func (c *Config) ShouldMigrate() bool {
if c.File == "" || c.StateFileExists() {
return false
}
_, err := os.Stat(c.File)
return err == nil
}

// 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