diff --git a/ai/ARCHITECTURE.md b/ai/ARCHITECTURE.md index 0313487..fcd4058 100644 --- a/ai/ARCHITECTURE.md +++ b/ai/ARCHITECTURE.md @@ -250,7 +250,8 @@ Completion functions that run interactively may keep `context.Background()`. `GetProjectID(cmd)` in `cmd/root.go` resolves in order: 1. `--project-id` flag value (if non-empty) -2. `GetCurrentProjectID()` → reads `CurrentContext` from `~/.acloud-context.yaml`, returns its `ProjectID` +2. `GetCurrentProjectID()` → reads `CurrentContext` from `~/.acloud-context.yaml`, returns its `ProjectID`; + if `CurrentContext` is unset but exactly one context exists, that context is auto-selected 3. Returns error: `"project ID not specified. Use --project-id flag or set a context with 'acloud context use '"` --- @@ -293,6 +294,8 @@ type CtxInfo struct { **Command behaviours:** - `context set --project-id ` — creates/updates a named context but does **not** switch to it automatically. + When only one context exists and `CurrentContext` is empty, `GetCurrentProjectID()` auto-selects it, + so `acloud context use` is not required in the single-context case. - `context use ` — validates the name exists, then sets `CurrentContext`. - `context delete ` — removes the context; clears `CurrentContext` if it was the active one. - `context list` — prints all contexts with `*` marking the current one. diff --git a/cmd/config.go b/cmd/config.go index 39f624f..4ec415a 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -94,6 +94,9 @@ func init() { configProfileSetCmd.Flags().String("client-secret", "", "Aruba Cloud API client secret (or use ACLOUD_CLIENT_SECRET)") configProfileSetCmd.Flags().String("base-url", "", "Base URL for Aruba Cloud API") configProfileSetCmd.Flags().String("token-issuer-url", "", "Token issuer URL for authentication") + + // Flags for config profile use + configProfileUseCmd.Flags().Bool("clear-contexts", false, "Clear all saved contexts after switching profiles (skips the interactive prompt)") } // xdgConfigDir returns the XDG config home directory (#176). @@ -652,9 +655,11 @@ var configProfileDeleteCmd = &cobra.Command{ var configProfileUseCmd = &cobra.Command{ Use: "use ", Short: "Switch the active profile", - Long: `Switch the active credential profile. The selection is persisted in the config file and overridden by --profile flag or ACLOUD_PROFILE env var.`, - Args: cobra.ExactArgs(1), - RunE: ConfigProfileUseRun, + Long: `Switch the active credential profile. The selection is persisted in the config file and overridden by --profile flag or ACLOUD_PROFILE env var. + +Contexts (saved project IDs) belong to a specific tenant and may not be valid after switching profiles. The CLI will ask whether to clear them when run interactively. Pass --clear-contexts to clear without prompting.`, + Args: cobra.ExactArgs(1), + RunE: ConfigProfileUseRun, } // ConfigProfileListRun lists all profiles, marking the active one with *. @@ -761,7 +766,7 @@ func ConfigProfileDeleteRun(_ *cobra.Command, args []string) error { } // ConfigProfileUseRun switches the active profile persisted in the config file. -func ConfigProfileUseRun(_ *cobra.Command, args []string) error { +func ConfigProfileUseRun(cmd *cobra.Command, args []string) error { profileName := args[0] profiles, err := loadAllProfiles() @@ -776,5 +781,19 @@ func ConfigProfileUseRun(_ *cobra.Command, args []string) error { return fmt.Errorf("switching profile: %w", err) } fmt.Printf("Switched to profile %q.\n", profileName) + + clearContexts := false + if cmd != nil { + clearContexts, _ = cmd.Flags().GetBool("clear-contexts") + } + if !clearContexts { + clearContexts = promptConfirmOptional("Contexts may not be valid for the new profile. Clear all contexts?") + } + if clearContexts { + if err := ClearAllContexts(); err != nil { + return fmt.Errorf("clearing contexts: %w", err) + } + fmt.Println("All contexts cleared.") + } return nil } diff --git a/cmd/config_profile_test.go b/cmd/config_profile_test.go index 442e9ba..c6cb950 100644 --- a/cmd/config_profile_test.go +++ b/cmd/config_profile_test.go @@ -558,6 +558,90 @@ func TestConfigProfileUse_NotFound(t *testing.T) { } } +func TestConfigProfileUse_ClearContextsFlag(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("ACLOUD_PROFILE", "") + resetCmdFlags(rootCmd) + t.Cleanup(func() { resetCmdFlags(rootCmd) }) + + if err := saveAllProfiles(map[string]configFile{ + "default": {ClientID: "id-default", ClientSecret: "sec"}, + "prod": {ClientID: "id-prod", ClientSecret: "sec"}, + }); err != nil { + t.Fatal(err) + } + + // Pre-populate a context to verify it gets cleared. + if err := SaveContext(&Context{ + CurrentContext: "my-ctx", + Contexts: map[string]CtxInfo{"my-ctx": {ProjectID: "proj-123"}}, + }); err != nil { + t.Fatal(err) + } + + cmd := configProfileUseCmd + cmd.Flags().Set("clear-contexts", "true") + t.Cleanup(func() { cmd.Flags().Set("clear-contexts", "false") }) + + out := captureStdout(func() { + if err := ConfigProfileUseRun(cmd, []string{"prod"}); err != nil { + t.Errorf("ConfigProfileUseRun() error: %v", err) + } + }) + + if !strings.Contains(out, "cleared") { + t.Errorf("expected 'cleared' in output, got: %s", out) + } + + loaded, err := LoadContext() + if err != nil { + t.Fatalf("LoadContext() error: %v", err) + } + if len(loaded.Contexts) != 0 { + t.Errorf("expected no contexts after --clear-contexts, got: %v", loaded.Contexts) + } + if loaded.CurrentContext != "" { + t.Errorf("expected no current context after --clear-contexts, got: %q", loaded.CurrentContext) + } +} + +func TestConfigProfileUse_KeepsContextsInNonInteractive(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + t.Setenv("ACLOUD_PROFILE", "") + resetCmdFlags(rootCmd) + t.Cleanup(func() { resetCmdFlags(rootCmd) }) + + if err := saveAllProfiles(map[string]configFile{ + "default": {ClientID: "id-default", ClientSecret: "sec"}, + "prod": {ClientID: "id-prod", ClientSecret: "sec"}, + }); err != nil { + t.Fatal(err) + } + + if err := SaveContext(&Context{ + CurrentContext: "my-ctx", + Contexts: map[string]CtxInfo{"my-ctx": {ProjectID: "proj-123"}}, + }); err != nil { + t.Fatal(err) + } + + // Call with nil cmd and non-interactive stdin (test environment). + // Contexts must be preserved. + if err := ConfigProfileUseRun(nil, []string{"prod"}); err != nil { + t.Fatalf("ConfigProfileUseRun() error: %v", err) + } + + loaded, err := LoadContext() + if err != nil { + t.Fatalf("LoadContext() error: %v", err) + } + if len(loaded.Contexts) != 1 { + t.Errorf("expected contexts to be preserved in non-interactive mode, got: %v", loaded.Contexts) + } +} + // ─── loadAllProfiles edge cases ─────────────────────────────────────────────── func TestLoadAllProfiles_FileNotFound(t *testing.T) { diff --git a/cmd/context.go b/cmd/context.go index 8a6dfbb..67d64be 100644 --- a/cmd/context.go +++ b/cmd/context.go @@ -84,6 +84,11 @@ func LoadContext() (*Context, error) { return &ctx, nil } +// ClearAllContexts removes all saved contexts and unsets the current context. +func ClearAllContexts() error { + return SaveContext(&Context{Contexts: make(map[string]CtxInfo)}) +} + // SaveContext saves the context configuration func SaveContext(ctx *Context) error { contextFile, err := getContextFilePath() @@ -105,7 +110,10 @@ func SaveContext(ctx *Context) error { return os.WriteFile(contextFile, data, FilePermConfig) } -// GetCurrentProjectID returns the project ID from the current context +// GetCurrentProjectID returns the project ID from the current context. +// When no current context is set but exactly one context exists, that context +// is used automatically — so `acloud context set` is enough without a follow-up +// `acloud context use`. func GetCurrentProjectID() (string, error) { ctx, err := LoadContext() if err != nil { @@ -113,6 +121,11 @@ func GetCurrentProjectID() (string, error) { } if ctx.CurrentContext == "" { + if len(ctx.Contexts) == 1 { + for _, info := range ctx.Contexts { + return info.ProjectID, nil + } + } return "", fmt.Errorf("no current context set") } diff --git a/cmd/context_test.go b/cmd/context_test.go index fd61e67..3f83979 100644 --- a/cmd/context_test.go +++ b/cmd/context_test.go @@ -175,7 +175,7 @@ func TestSaveContext_EmptyContext(t *testing.T) { } } -func TestGetCurrentProjectID_NoCurrentContext(t *testing.T) { +func TestGetCurrentProjectID_NoCurrentContext_SingleContext(t *testing.T) { originalHome := os.Getenv("HOME") if originalHome == "" { originalHome = os.Getenv("USERPROFILE") @@ -199,9 +199,43 @@ func TestGetCurrentProjectID_NoCurrentContext(t *testing.T) { t.Fatalf("SaveContext() error = %v", err) } + // Single context with no CurrentContext set: auto-selected. + projectID, err := GetCurrentProjectID() + if err != nil { + t.Errorf("GetCurrentProjectID() should auto-select the only context, got error: %v", err) + } + if projectID != "test-project-id" { + t.Errorf("GetCurrentProjectID() = %v, want test-project-id", projectID) + } +} + +func TestGetCurrentProjectID_NoCurrentContext_MultipleContexts(t *testing.T) { + originalHome := os.Getenv("HOME") + if originalHome == "" { + originalHome = os.Getenv("USERPROFILE") + } + + tmpDir := t.TempDir() + + os.Setenv("HOME", tmpDir) + defer os.Setenv("HOME", originalHome) + + testContext := &Context{ + Contexts: map[string]CtxInfo{ + "ctx-a": {ProjectID: "pid-a"}, + "ctx-b": {ProjectID: "pid-b"}, + }, + } + + err := SaveContext(testContext) + if err != nil { + t.Fatalf("SaveContext() error = %v", err) + } + + // Multiple contexts with no CurrentContext set: must still error. projectID, err := GetCurrentProjectID() if err == nil { - t.Error("GetCurrentProjectID() should return error when no current context is set") + t.Error("GetCurrentProjectID() should return error when no current context is set and multiple contexts exist") } if projectID != "" { t.Errorf("GetCurrentProjectID() = %v, want empty string", projectID) diff --git a/cmd/prompt.go b/cmd/prompt.go index d098931..17a6d37 100644 --- a/cmd/prompt.go +++ b/cmd/prompt.go @@ -24,6 +24,20 @@ func confirmDelete(resourceType, id string) (bool, error) { return true, nil } +// promptConfirmOptional asks a yes/no question when stdin is an interactive +// terminal. Returns false silently in non-interactive mode — the caller treats +// that as "no" and moves on without erroring. +func promptConfirmOptional(question string) bool { + fi, err := os.Stdin.Stat() + if err != nil || (fi.Mode()&os.ModeCharDevice) == 0 { + return false + } + fmt.Printf("%s (y/N): ", question) + var response string + fmt.Scanln(&response) + return response == "yes" || response == "y" +} + // readSecret prompts the user for a secret value with echo disabled. // Returns an error if stdin is not an interactive terminal. func readSecret(prompt string) (string, error) { diff --git a/docs/website/docs/configuration.md b/docs/website/docs/configuration.md index fc790b5..7bf6f65 100644 --- a/docs/website/docs/configuration.md +++ b/docs/website/docs/configuration.md @@ -21,14 +21,21 @@ Create a context with a project ID: acloud context set my-prod --project-id "66a10244f62b99c686572a9f" ``` +> **Single-context shortcut**: When only one context is configured, the CLI uses it automatically — you do not need to run `acloud context use` first. If you add a second context later, you will need to explicitly switch with `acloud context use `. + ### Using a Context -Switch to a saved context: +Switch to a saved context when you have more than one: ```bash acloud context use my-prod ``` +> **Switching profiles**: Contexts store project IDs that belong to a specific tenant. When you switch credential profiles with `acloud config profile use`, the CLI will ask whether to clear all contexts (since the saved project IDs are likely invalid for the new profile). Pass `--clear-contexts` to clear them without being prompted: +> ```bash +> acloud config profile use staging --clear-contexts +> ``` + Once a context is active, you can run commands without specifying `--project-id`: ```bash diff --git a/docs/website/i18n/it/docusaurus-plugin-content-docs/current/configuration.md b/docs/website/i18n/it/docusaurus-plugin-content-docs/current/configuration.md index 8ccc3f3..cf49dcc 100644 --- a/docs/website/i18n/it/docusaurus-plugin-content-docs/current/configuration.md +++ b/docs/website/i18n/it/docusaurus-plugin-content-docs/current/configuration.md @@ -21,14 +21,21 @@ Crea un contesto con un ID progetto: acloud context set my-prod --project-id "66a10244f62b99c686572a9f" ``` +> **Scorciatoia per contesto singolo**: Quando è configurato un solo contesto, la CLI lo utilizza automaticamente — non è necessario eseguire prima `acloud context use`. Se in seguito si aggiunge un secondo contesto, sarà necessario selezionarlo esplicitamente con `acloud context use `. + ### Utilizzo di un Contesto -Passa a un contesto salvato: +Passa a un contesto salvato quando ne hai più di uno: ```bash acloud context use my-prod ``` +> **Cambio di profilo**: I contesti memorizzano gli ID progetto che appartengono a un tenant specifico. Quando si cambia profilo con `acloud config profile use`, la CLI chiederà se cancellare tutti i contesti (poiché gli ID progetto salvati potrebbero non essere validi per il nuovo profilo). Usa `--clear-contexts` per cancellarli senza essere interrogato: +> ```bash +> acloud config profile use staging --clear-contexts +> ``` + Una volta che un contesto è attivo, puoi eseguire comandi senza specificare `--project-id`: ```bash