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
5 changes: 4 additions & 1 deletion ai/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>'"`

---
Expand Down Expand Up @@ -293,6 +294,8 @@ type CtxInfo struct {

**Command behaviours:**
- `context set <name> --project-id <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 <name>` — validates the name exists, then sets `CurrentContext`.
- `context delete <name>` — removes the context; clears `CurrentContext` if it was the active one.
- `context list` — prints all contexts with `*` marking the current one.
Expand Down
27 changes: 23 additions & 4 deletions cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -652,9 +655,11 @@ var configProfileDeleteCmd = &cobra.Command{
var configProfileUseCmd = &cobra.Command{
Use: "use <profile-name>",
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 *.
Expand Down Expand Up @@ -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()
Expand All @@ -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
}
84 changes: 84 additions & 0 deletions cmd/config_profile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion cmd/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -105,14 +110,22 @@ 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 {
return "", err
}

if ctx.CurrentContext == "" {
if len(ctx.Contexts) == 1 {
for _, info := range ctx.Contexts {
return info.ProjectID, nil
}
}
return "", fmt.Errorf("no current context set")
}

Expand Down
38 changes: 36 additions & 2 deletions cmd/context_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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)
Expand Down
14 changes: 14 additions & 0 deletions cmd/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 8 additions & 1 deletion docs/website/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>`.

### 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <nome>`.

### 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
Expand Down
Loading