diff --git a/README.md b/README.md index 6660348..80935ea 100644 --- a/README.md +++ b/README.md @@ -2504,6 +2504,7 @@ kortex-cli remove NAME|ID [flags] #### Flags +- `--force, -f` - Stop the workspace if it is running before removing it - `--output, -o ` - Output format (supported: `json`) - `--show-logs` - Show stdout and stderr from runtime commands (cannot be combined with `--output json`) - `--storage ` - Storage directory for kortex-cli data (default: `$HOME/.kortex-cli`) @@ -2536,6 +2537,12 @@ kortex-cli list kortex-cli remove my-project ``` +**Remove a running workspace (stops it first):** +```bash +kortex-cli workspace remove a1b2c3d4e5f6... --force +``` +Output: `a1b2c3d4e5f6...` (ID of removed workspace) + **JSON output:** ```bash kortex-cli workspace remove a1b2c3d4e5f6... --output json @@ -2580,12 +2587,27 @@ Output: } ``` +**Removing a running workspace without --force:** + +Attempting to remove a running workspace without `--force` will fail because the runtime refuses to remove a running instance. Stop the workspace first, or use `--force`: + +```bash +# Stop first, then remove +kortex-cli stop a1b2c3d4e5f6... +kortex-cli remove a1b2c3d4e5f6... + +# Or remove in one step +kortex-cli remove a1b2c3d4e5f6... --force +``` + #### Notes - You can specify the workspace using either its name or ID (both can be obtained using the `workspace list` or `list` command) - The command always outputs the workspace ID, even when removed by name - Removing a workspace only unregisters it from kortex-cli; it does not delete any files from the sources or configuration directories - If the workspace name or ID is not found, the command will fail with a helpful error message +- Use `--force` to automatically stop a running workspace before removing it; without this flag, removing a running workspace will fail +- Tab completion for this command suggests only non-running workspaces by default; when `--force` is specified, all workspaces are suggested - JSON output format is useful for scripting and automation - When using `--output json`, errors are also returned in JSON format for consistent parsing - **JSON error handling**: When `--output json` is used, errors are written to stdout (not stderr) in JSON format, and the CLI exits with code 1. Always check the exit code to determine success/failure diff --git a/pkg/cmd/autocomplete.go b/pkg/cmd/autocomplete.go index 087067c..76f7380 100644 --- a/pkg/cmd/autocomplete.go +++ b/pkg/cmd/autocomplete.go @@ -96,6 +96,20 @@ func completeRunningWorkspaceID(cmd *cobra.Command, args []string, toComplete st }) } +// completeRemoveWorkspaceID provides completion for the remove command. +// When --force is set, all workspaces are suggested; otherwise only non-running workspaces. +// The args and toComplete parameters are part of Cobra's ValidArgsFunction signature but are unused +// because Cobra's shell completion framework automatically filters results based on user input. +func completeRemoveWorkspaceID(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + force, _ := cmd.Flags().GetBool("force") + if force { + return getFilteredWorkspaceIDs(cmd, nil) + } + return getFilteredWorkspaceIDs(cmd, func(state api.WorkspaceState) bool { + return state != api.WorkspaceStateRunning + }) +} + // newOutputFlagCompletion creates a completion function for the --output flag // with the given list of valid output formats func newOutputFlagCompletion(validFormats []string) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { diff --git a/pkg/cmd/autocomplete_test.go b/pkg/cmd/autocomplete_test.go index b9dbc46..81cf022 100644 --- a/pkg/cmd/autocomplete_test.go +++ b/pkg/cmd/autocomplete_test.go @@ -451,6 +451,174 @@ func TestNewOutputFlagCompletion(t *testing.T) { }) } +func TestCompleteRemoveWorkspaceID(t *testing.T) { + t.Parallel() + + t.Run("without --force returns only non-running workspace IDs", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + storageDir := t.TempDir() + + manager, err := instances.NewManager(storageDir) + if err != nil { + t.Fatalf("failed to create manager: %v", err) + } + + if err := manager.RegisterRuntime(fake.New()); err != nil { + t.Fatalf("failed to register fake runtime: %v", err) + } + + sourceDir1 := t.TempDir() + instance1, err := instances.NewInstance(instances.NewInstanceParams{ + SourceDir: sourceDir1, + ConfigDir: filepath.Join(sourceDir1, ".kortex"), + }) + if err != nil { + t.Fatalf("failed to create instance1: %v", err) + } + addedInstance1, err := manager.Add(ctx, instances.AddOptions{Instance: instance1, RuntimeType: "fake"}) + if err != nil { + t.Fatalf("failed to add instance1: %v", err) + } + + sourceDir2 := t.TempDir() + instance2, err := instances.NewInstance(instances.NewInstanceParams{ + SourceDir: sourceDir2, + ConfigDir: filepath.Join(sourceDir2, ".kortex"), + }) + if err != nil { + t.Fatalf("failed to create instance2: %v", err) + } + addedInstance2, err := manager.Add(ctx, instances.AddOptions{Instance: instance2, RuntimeType: "fake"}) + if err != nil { + t.Fatalf("failed to add instance2: %v", err) + } + + // Start instance1 so it is running + if err := manager.Start(ctx, addedInstance1.GetID()); err != nil { + t.Fatalf("failed to start instance1: %v", err) + } + + cmd := &cobra.Command{} + cmd.Flags().String("storage", storageDir, "") + cmd.Flags().Bool("force", false, "") + + completions, directive := completeRemoveWorkspaceID(cmd, []string{}, "") + + // Only instance2 (stopped) should appear + if len(completions) != 2 { + t.Errorf("Expected 2 completions (ID and name for non-running), got %d: %v", len(completions), completions) + } + + for _, completion := range completions { + if completion == addedInstance1.GetID() || completion == addedInstance1.GetName() { + t.Errorf("Running instance should not appear in completions without --force, got %s", completion) + } + } + + foundID := false + foundName := false + for _, completion := range completions { + if completion == addedInstance2.GetID() { + foundID = true + } + if completion == addedInstance2.GetName() { + foundName = true + } + } + if !foundID { + t.Errorf("Expected stopped instance ID %s in completions, got %v", addedInstance2.GetID(), completions) + } + if !foundName { + t.Errorf("Expected stopped instance name %s in completions, got %v", addedInstance2.GetName(), completions) + } + + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("Expected ShellCompDirectiveNoFileComp, got %v", directive) + } + }) + + t.Run("with --force returns all workspace IDs including running", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + storageDir := t.TempDir() + + manager, err := instances.NewManager(storageDir) + if err != nil { + t.Fatalf("failed to create manager: %v", err) + } + + if err := manager.RegisterRuntime(fake.New()); err != nil { + t.Fatalf("failed to register fake runtime: %v", err) + } + + sourceDir1 := t.TempDir() + instance1, err := instances.NewInstance(instances.NewInstanceParams{ + SourceDir: sourceDir1, + ConfigDir: filepath.Join(sourceDir1, ".kortex"), + }) + if err != nil { + t.Fatalf("failed to create instance1: %v", err) + } + addedInstance1, err := manager.Add(ctx, instances.AddOptions{Instance: instance1, RuntimeType: "fake"}) + if err != nil { + t.Fatalf("failed to add instance1: %v", err) + } + + sourceDir2 := t.TempDir() + instance2, err := instances.NewInstance(instances.NewInstanceParams{ + SourceDir: sourceDir2, + ConfigDir: filepath.Join(sourceDir2, ".kortex"), + }) + if err != nil { + t.Fatalf("failed to create instance2: %v", err) + } + addedInstance2, err := manager.Add(ctx, instances.AddOptions{Instance: instance2, RuntimeType: "fake"}) + if err != nil { + t.Fatalf("failed to add instance2: %v", err) + } + + // Start instance1 so it is running + if err := manager.Start(ctx, addedInstance1.GetID()); err != nil { + t.Fatalf("failed to start instance1: %v", err) + } + + cmd := &cobra.Command{} + cmd.Flags().String("storage", storageDir, "") + cmd.Flags().Bool("force", false, "") + if err := cmd.Flags().Set("force", "true"); err != nil { + t.Fatalf("failed to set --force flag: %v", err) + } + + completions, directive := completeRemoveWorkspaceID(cmd, []string{}, "") + + // Both instances (ID + name each) should appear + if len(completions) != 4 { + t.Errorf("Expected 4 completions (ID and name for each instance), got %d: %v", len(completions), completions) + } + + expected := []string{ + addedInstance1.GetID(), addedInstance1.GetName(), + addedInstance2.GetID(), addedInstance2.GetName(), + } + completionSet := make(map[string]bool, len(completions)) + for _, c := range completions { + completionSet[c] = true + } + for _, e := range expected { + if !completionSet[e] { + t.Errorf("Expected %s in completions, got %v", e, completions) + } + } + + if directive != cobra.ShellCompDirectiveNoFileComp { + t.Errorf("Expected ShellCompDirectiveNoFileComp, got %v", directive) + } + }) +} + func TestCompleteRuntimeFlag(t *testing.T) { t.Parallel() diff --git a/pkg/cmd/workspace_remove.go b/pkg/cmd/workspace_remove.go index e5bfd8f..6f77e79 100644 --- a/pkg/cmd/workspace_remove.go +++ b/pkg/cmd/workspace_remove.go @@ -38,6 +38,7 @@ type workspaceRemoveCmd struct { nameOrID string output string showLogs bool + force bool } // preRun validates the parameters and flags @@ -125,6 +126,18 @@ func (w *workspaceRemoveCmd) run(cmd *cobra.Command, args []string) error { // Get the actual ID (in case user provided a name) instanceID := instance.GetID() + // Explicitly reject removing running workspace without --force for stable UX. + if !w.force && instance.GetRuntimeData().State == api.WorkspaceStateRunning { + return outputErrorIfJSON(cmd, w.output, fmt.Errorf("workspace is running; stop it first or use --force")) + } + + // If force flag is set and instance is running, stop it first + if w.force && instance.GetRuntimeData().State == api.WorkspaceStateRunning { + if err := w.manager.Stop(ctx, instanceID); err != nil { + return outputErrorIfJSON(cmd, w.output, fmt.Errorf("failed to stop running workspace: %w", err)) + } + } + // Delete the instance err = w.manager.Delete(ctx, instanceID) if err != nil { @@ -172,15 +185,19 @@ kortex-cli workspace remove abc123 kortex-cli workspace remove my-project # Remove workspace and show runtime command output -kortex-cli workspace remove abc123 --show-logs`, +kortex-cli workspace remove abc123 --show-logs + +# Remove a running workspace (stops it first) +kortex-cli workspace remove abc123 --force`, Args: cobra.ExactArgs(1), - ValidArgsFunction: completeNonRunningWorkspaceID, + ValidArgsFunction: completeRemoveWorkspaceID, PreRunE: c.preRun, RunE: c.run, } cmd.Flags().StringVarP(&c.output, "output", "o", "", "Output format (supported: json)") cmd.Flags().BoolVar(&c.showLogs, "show-logs", false, "Show stdout and stderr from runtime commands") + cmd.Flags().BoolVarP(&c.force, "force", "f", false, "Stop the workspace if it is running before removing it") cmd.RegisterFlagCompletionFunc("output", newOutputFlagCompletion([]string{"json"})) diff --git a/pkg/cmd/workspace_remove_test.go b/pkg/cmd/workspace_remove_test.go index 237dd01..785639e 100644 --- a/pkg/cmd/workspace_remove_test.go +++ b/pkg/cmd/workspace_remove_test.go @@ -727,6 +727,125 @@ func TestWorkspaceRemoveCmd_E2E(t *testing.T) { }) } +func TestWorkspaceRemoveCmd_Force(t *testing.T) { + t.Parallel() + + t.Run("removes running workspace with --force flag", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + storageDir := t.TempDir() + sourcesDir := t.TempDir() + + manager, err := instances.NewManager(storageDir) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + instance, err := instances.NewInstance(instances.NewInstanceParams{ + SourceDir: sourcesDir, + ConfigDir: filepath.Join(sourcesDir, ".kortex"), + }) + if err != nil { + t.Fatalf("Failed to create instance: %v", err) + } + + if err := manager.RegisterRuntime(fake.New()); err != nil { + t.Fatalf("Failed to register fake runtime: %v", err) + } + + addedInstance, err := manager.Add(ctx, instances.AddOptions{Instance: instance, RuntimeType: "fake"}) + if err != nil { + t.Fatalf("Failed to add instance: %v", err) + } + + // Start the instance so it is running + if err := manager.Start(ctx, addedInstance.GetID()); err != nil { + t.Fatalf("Failed to start instance: %v", err) + } + + // Remove with --force + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "remove", addedInstance.GetID(), "--storage", storageDir, "--force"}) + + var output bytes.Buffer + rootCmd.SetOut(&output) + + err = rootCmd.Execute() + if err != nil { + t.Fatalf("Expected no error with --force on running workspace, got %v", err) + } + + // Verify output is the instance ID + result := strings.TrimSpace(output.String()) + if result != addedInstance.GetID() { + t.Errorf("Expected output to be '%s', got: '%s'", addedInstance.GetID(), result) + } + + // Verify workspace is removed + instancesList, err := manager.List() + if err != nil { + t.Fatalf("Failed to list instances: %v", err) + } + if len(instancesList) != 0 { + t.Errorf("Expected 0 instances after removal, got %d", len(instancesList)) + } + }) + + t.Run("fails to remove running workspace without --force flag", func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + storageDir := t.TempDir() + sourcesDir := t.TempDir() + + manager, err := instances.NewManager(storageDir) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + instance, err := instances.NewInstance(instances.NewInstanceParams{ + SourceDir: sourcesDir, + ConfigDir: filepath.Join(sourcesDir, ".kortex"), + }) + if err != nil { + t.Fatalf("Failed to create instance: %v", err) + } + + if err := manager.RegisterRuntime(fake.New()); err != nil { + t.Fatalf("Failed to register fake runtime: %v", err) + } + + addedInstance, err := manager.Add(ctx, instances.AddOptions{Instance: instance, RuntimeType: "fake"}) + if err != nil { + t.Fatalf("Failed to add instance: %v", err) + } + + // Start the instance so it is running + if err := manager.Start(ctx, addedInstance.GetID()); err != nil { + t.Fatalf("Failed to start instance: %v", err) + } + + // Try to remove without --force + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "remove", addedInstance.GetID(), "--storage", storageDir}) + + err = rootCmd.Execute() + if err == nil { + t.Fatal("Expected error when removing running workspace without --force, got nil") + } + + // Verify workspace is still present + instancesList, err := manager.List() + if err != nil { + t.Fatalf("Failed to list instances: %v", err) + } + if len(instancesList) != 1 { + t.Errorf("Expected 1 instance (workspace should not be removed), got %d", len(instancesList)) + } + }) +} + func TestWorkspaceRemoveCmd_Examples(t *testing.T) { t.Parallel() @@ -745,7 +864,7 @@ func TestWorkspaceRemoveCmd_Examples(t *testing.T) { } // Verify we have the expected number of examples - expectedCount := 3 + expectedCount := 4 if len(commands) != expectedCount { t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) }