Skip to content
18 changes: 9 additions & 9 deletions api/dashboard/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -544,34 +544,34 @@ func (c *Client) CreateAPIKey(
accessToken, appID string,
acl []string,
description string,
) (string, error) {
) (CreatedAPIKey, error) {
payload := CreateAPIKeyRequest{ACL: acl, Description: description}
body, err := json.Marshal(payload)
if err != nil {
return "", err
return CreatedAPIKey{}, err
}

endpoint := fmt.Sprintf("%s/1/applications/%s/api-keys", c.APIURL, url.PathEscape(appID))
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return "", err
return CreatedAPIKey{}, err
}
c.setAPIHeaders(req, accessToken)
req.Header.Set("Content-Type", "application/json")

resp, err := c.client.Do(req)
if err != nil {
return "", fmt.Errorf("create API key request failed: %w", err)
return CreatedAPIKey{}, fmt.Errorf("create API key request failed: %w", err)
}
defer resp.Body.Close()

respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read API key response: %w", err)
return CreatedAPIKey{}, fmt.Errorf("failed to read API key response: %w", err)
}

if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
return "", fmt.Errorf(
return CreatedAPIKey{}, fmt.Errorf(
"create API key failed with status %d: %s",
resp.StatusCode,
string(respBody),
Expand All @@ -580,7 +580,7 @@ func (c *Client) CreateAPIKey(

var keyResp CreateAPIKeyResponse
if err := json.Unmarshal(respBody, &keyResp); err != nil {
return "", fmt.Errorf(
return CreatedAPIKey{}, fmt.Errorf(
"failed to parse API key response: %w (body: %s)",
err,
string(respBody),
Expand All @@ -589,13 +589,13 @@ func (c *Client) CreateAPIKey(

key := keyResp.Data.Attributes.Value
if key == "" {
return "", fmt.Errorf(
return CreatedAPIKey{}, fmt.Errorf(
"API key creation succeeded but no key was returned in the response: %s",
string(respBody),
)
}

return key, nil
return CreatedAPIKey{Value: key, UUID: keyResp.Data.ID}, nil
}

// GetCrawlerUser gets the crawler API user data for the current authenticated user
Expand Down
45 changes: 45 additions & 0 deletions api/dashboard/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,51 @@ func TestCreateApplication_Success(t *testing.T) {
assert.Equal(t, "My App", app.Name)
}

func TestCreateAPIKey_ReturnsValueAndUUID(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, http.MethodPost, r.Method)
assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))

w.WriteHeader(http.StatusCreated)
require.NoError(t, json.NewEncoder(w).Encode(CreateAPIKeyResponse{
Data: APIKeyResource{
ID: "key-uuid-123",
Type: "api_key",
Attributes: APIKeyAttributes{Value: "secret-key"},
},
}))
})

ts, client := newTestClient(mux)
defer ts.Close()

created, err := client.CreateAPIKey("test-token", "APP1", WriteACL, "Algolia CLI")
require.NoError(t, err)
assert.Equal(t, "secret-key", created.Value)
assert.Equal(t, "key-uuid-123", created.UUID)
}

func TestCreateAPIKey_EmptyValueReturnsError(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/applications/APP1/api-keys", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusCreated)
require.NoError(t, json.NewEncoder(w).Encode(CreateAPIKeyResponse{
Data: APIKeyResource{
ID: "key-uuid-123",
Attributes: APIKeyAttributes{Value: ""},
},
}))
})

ts, client := newTestClient(mux)
defer ts.Close()

_, err := client.CreateAPIKey("test-token", "APP1", WriteACL, "Algolia CLI")
require.Error(t, err)
assert.Contains(t, err.Error(), "no key was returned")
}

func TestUpdateApplication_Success(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/1/applications/APP1", func(w http.ResponseWriter, r *http.Request) {
Expand Down
16 changes: 12 additions & 4 deletions api/dashboard/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,11 @@ type ApplicationPlan struct {

// Application is a flattened view of an Algolia application for CLI consumption.
type Application struct {
ID string `json:"id"`
Name string `json:"name"`
APIKey string `json:"api_key,omitempty"`
PlanLabel string `json:"plan_label,omitempty"` // current plan label, e.g. "Grow Plus"
ID string `json:"id"`
Name string `json:"name"`
APIKey string `json:"api_key,omitempty"`
APIKeyUUID string `json:"api_key_uuid,omitempty"`
PlanLabel string `json:"plan_label,omitempty"` // current plan label, e.g. "Grow Plus"
}

// PaginationMeta contains page-based pagination metadata.
Expand Down Expand Up @@ -145,6 +146,13 @@ type CreateAPIKeyResponse struct {
Data APIKeyResource `json:"data"`
}

// CreatedAPIKey is the result of creating an API key: its secret value and its
// UUID, used to reference the key when persisting or managing it later.
type CreatedAPIKey struct {
Value string
UUID string
}

// DashboardCrawlerUserData contains the user information from the crawler API
type DashboardCrawlerUserData struct {
ID string `json:"id"`
Expand Down
2 changes: 1 addition & 1 deletion pkg/auth/auth_check.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func DisableAuthCheck(cmd *cobra.Command) {
cmd.Annotations["skipAuthCheck"] = "true"
}

func CheckAuth(cfg config.Config) error {
func CheckAuth(cfg *config.Config) error {
if cfg.Profile().Name == "" {
cfg.Profile().LoadDefault()
}
Expand Down
8 changes: 4 additions & 4 deletions pkg/cmd/application/create/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command {
Use: "create",
Short: "Create a new Algolia application",
Long: heredoc.Doc(`
Create a new Algolia application and optionally configure it as a CLI profile.
Create a new Algolia application and optionally set it as the current application.
Requires an active session (run "algolia auth login" first).`),
Example: heredoc.Doc(`
# Create an application interactively (prompts for name, plan, and terms)
Expand All @@ -66,7 +66,7 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command {
# Create on a paid plan (requires a payment method on file)
$ algolia application create --name "My App" --region CA --plan grow --accept-terms

# Create and set the new profile as the default
# Create and set the new application as the current one
$ algolia application create --name "My App" --region CA --accept-terms --default

# Preview what would be created without actually creating it
Expand All @@ -85,10 +85,10 @@ func NewCreateCmd(f *cmdutil.Factory) *cobra.Command {
cmd.Flags().StringVar(&opts.Name, "name", "My First Application", "Name for the application")
cmd.Flags().StringVar(&opts.Region, "region", "", "Region code (e.g. EU, UK, USC, USE, USW)")
cmd.Flags().
StringVar(&opts.ProfileName, "profile-name", "", "Name for the CLI profile (defaults to app name)")
StringVar(&opts.ProfileName, "profile-name", "", "Alias for the application (defaults to the app name)")
cmd.Flags().
StringVar(&opts.Plan, "plan", "", "Self-serve plan to create the application on (free, grow, grow-plus)")
cmd.Flags().BoolVar(&opts.Default, "default", false, "Set the new profile as the default")
cmd.Flags().BoolVar(&opts.Default, "default", false, "Set the new application as the current one")
cmd.Flags().
BoolVar(&opts.DryRun, "dry-run", false, "Preview the create request without sending it")
cmd.Flags().
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/application/downgrade/downgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func NewDowngradeCmd(f *cmdutil.Factory) *cobra.Command {
Use: "downgrade",
Short: "Downgrade the current application to a lower-tier plan",
Long: heredoc.Doc(`
Change the application associated with the current CLI profile to a
Change the current application to a
lower-tier self-serve plan.

You must accept the target plan's terms of service before the change
Expand Down
12 changes: 6 additions & 6 deletions pkg/cmd/application/list/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ func NewListCmd(f *cmdutil.Factory) *cobra.Command {
Long: heredoc.Doc(`
List all Algolia applications associated with your account.
Requires an active session (run "algolia auth login" first).
Applications that already have a local CLI profile are marked.
You can select an unconfigured application to add it as a CLI profile.
Applications already configured on this machine are marked.
You can select an unconfigured application to configure it.
`),
Example: heredoc.Doc(`
# List applications
Expand Down Expand Up @@ -117,7 +117,7 @@ func runListCmd(opts *ListOptions) error {
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("(profile: %s)", profileName))
fmt.Fprintf(opts.IO.Out, "%s %s\n", label, cs.Greenf("(configured: %s)", profileName))
} else {
fmt.Fprintf(opts.IO.Out, "%s %s\n", label, cs.Gray("(not configured)"))
unconfigured = append(unconfigured, app)
Expand All @@ -127,7 +127,7 @@ func runListCmd(opts *ListOptions) error {
fmt.Fprintln(opts.IO.Out)

if len(unconfigured) == 0 {
fmt.Fprintf(opts.IO.Out, "%s All applications are already configured as CLI profiles.\n", cs.SuccessIcon())
fmt.Fprintf(opts.IO.Out, "%s All applications are already configured.\n", cs.SuccessIcon())
return nil
}

Expand All @@ -138,7 +138,7 @@ func runListCmd(opts *ListOptions) error {
var wantConfigure bool
err = prompt.SurveyAskOne(
&survey.Confirm{
Message: "Would you like to configure an unconfigured application as a CLI profile?",
Message: "Would you like to configure one of the unconfigured applications?",
Default: true,
},
&wantConfigure,
Expand Down Expand Up @@ -173,7 +173,7 @@ func runListCmd(opts *ListOptions) error {
var setDefault bool
err = prompt.SurveyAskOne(
&survey.Confirm{
Message: "Set as the default profile?",
Message: "Set as the current application?",
Default: false,
},
&setDefault,
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/application/planchange/planchange.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func Run(opts *Options) error {
appID, err := opts.Config.Profile().GetApplicationID()
if err != nil {
return fmt.Errorf(
"no current application configured; configure a profile with \"algolia profile add\" or \"algolia application select\" first: %w",
"no current application configured; run \"algolia auth login\" or \"algolia application select\" first: %w",
err,
)
}
Expand Down
52 changes: 9 additions & 43 deletions pkg/cmd/application/selectapp/select.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,11 @@ func NewSelectCmd(f *cmdutil.Factory) *cobra.Command {

cmd := &cobra.Command{
Use: "select",
Short: "Select an application to use as the active profile",
Short: "Select the current application",
Long: heredoc.Doc(`
Select an Algolia application to use as the default CLI profile.
Fetches your applications from the API and lets you pick one.

If the selected application already has a local profile, it is set
as the default. Otherwise, a new profile is created and set as default.
Select an Algolia application to use as the current application for
all CLI commands. Fetches your applications from the API and lets
you pick one.
`),
Example: heredoc.Doc(`
# Select interactively
Expand Down Expand Up @@ -122,44 +120,12 @@ func runSelectCmd(opts *SelectOptions) (*dashboard.Application, error) {
return nil, err
}

// If a profile already exists for this app, switch the default
// and ensure it has an API key.
if exists, profileName := opts.Config.ApplicationIDExists(chosen.ID); exists {
// Read the profile BEFORE SetDefaultProfile, because viper.Set() calls
// inside SetDefaultProfile pollute the override map and cause
// UnmarshalKey to return empty fields (known viper issue).
var existingProfile *config.Profile
for _, p := range opts.Config.ConfiguredProfiles() {
if p.Name == profileName {
existingProfile = p
break
}
}

if err := opts.Config.SetDefaultProfile(profileName); err != nil {
return nil, fmt.Errorf("failed to set default profile: %w", err)
}
fmt.Fprintf(opts.IO.Out, "%s Switched to profile %q (application %s).\n",
cs.SuccessIcon(), profileName, cs.Bold(chosen.ID))

if existingProfile != nil && existingProfile.APIKey == "" {
app := &dashboard.Application{ID: chosen.ID, Name: chosen.Name}
if err := apputil.EnsureAPIKey(opts.IO, client, accessToken, app); err != nil {
return nil, err
}
existingProfile.ApplicationID = chosen.ID
existingProfile.APIKey = app.APIKey
if err := existingProfile.Add(); err != nil {
return nil, err
}
fmt.Fprintf(opts.IO.Out, "%s Profile %q updated with API key.\n",
cs.SuccessIcon(), profileName)
// Reuse a key already stored for this application (keychain, then legacy
// config.toml) before creating a new one on the dashboard.
if !apputil.ReuseExistingAPIKey(opts.Config, chosen) {
if err := apputil.EnsureAPIKey(opts.IO, client, accessToken, chosen); err != nil {
return nil, err
}
return chosen, nil
}

if err := apputil.EnsureAPIKey(opts.IO, client, accessToken, chosen); err != nil {
return nil, err
}

if err := apputil.ConfigureProfile(opts.IO, opts.Config, chosen, "", true); err != nil {
Expand Down
4 changes: 2 additions & 2 deletions pkg/cmd/application/update/update.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func NewUpdateCmd(f *cmdutil.Factory) *cobra.Command {
Use: "update",
Short: "Rename the current Algolia application",
Long: heredoc.Doc(`
Rename the application associated with the current CLI profile.
Rename the current application.
Requires an active application to be selected (run "algolia application select" first).
`),
Example: heredoc.Doc(`
Expand Down Expand Up @@ -69,7 +69,7 @@ func runUpdateCmd(opts *UpdateOptions) error {
appID, err := opts.Config.Profile().GetApplicationID()
if err != nil {
return fmt.Errorf(
"no current application configured; configure a profile with \"algolia profile add\" or \"algolia application select\" first: %w",
"no current application configured; run \"algolia auth login\" or \"algolia application select\" first: %w",
err,
)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/application/upgrade/upgrade.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ func NewUpgradeCmd(f *cmdutil.Factory) *cobra.Command {
Use: "upgrade",
Short: "Upgrade the current application to a higher-tier plan",
Long: heredoc.Doc(`
Change the application associated with the current CLI profile to a
Change the current application to a
higher-tier self-serve plan.

Paid plans require a payment method on your account; the CLI can't
Expand Down
27 changes: 12 additions & 15 deletions pkg/cmd/auth/crawler/crawler.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func NewCrawlerCmd(f *cmdutil.Factory) *cobra.Command {

cmd := &cobra.Command{
Use: "crawler",
Short: "Load crawler auth details for the current profile",
Short: "Configure the crawler API key for the current application",
Args: validators.NoArgs(),
RunE: func(cmd *cobra.Command, args []string) error {
return runCrawlerCmd(opts)
Expand All @@ -45,6 +45,14 @@ func NewCrawlerCmd(f *cmdutil.Factory) *cobra.Command {

func runCrawlerCmd(opts *CrawlerOptions) error {
cs := opts.IO.ColorScheme()

appID := opts.config.ActiveApplicationID()
if appID == "" {
return fmt.Errorf(
"no application configured: run `algolia auth login` or `algolia application select` first",
)
}

dashboardClient := opts.NewDashboardClient(opts.OAuthClientID())

accessToken, err := opts.GetValidToken(dashboardClient)
Expand All @@ -59,24 +67,13 @@ func runCrawlerCmd(opts *CrawlerOptions) error {
return err
}

currentProfileName := opts.config.Profile().Name
if currentProfileName == "" {
defaultProfile := opts.config.Default()
if defaultProfile != nil {
currentProfileName = defaultProfile.Name
opts.config.Profile().Name = currentProfileName
}
}
if currentProfileName == "" {
return fmt.Errorf("no profile selected and no default profile configured")
}

if err = opts.config.SetCrawlerAuth(currentProfileName, crawlerUserData.ID, crawlerUserData.APIKey); err != nil {
if err := opts.config.SetCrawlerAPIKey(appID, crawlerUserData.APIKey); err != nil {
return err
}

if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.Out, "%s Crawler API auth credentials configured for profile: %s\n", cs.SuccessIcon(), currentProfileName)
fmt.Fprintf(opts.IO.Out, "%s Crawler API key configured for application: %s\n",
cs.SuccessIcon(), appID)
}

return nil
Expand Down
Loading
Loading