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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <format>` - Output format (supported: `json`)
- `--show-logs` - Show stdout and stderr from runtime commands (cannot be combined with `--output json`)
- `--storage <path>` - Storage directory for kortex-cli data (default: `$HOME/.kortex-cli`)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
14 changes: 14 additions & 0 deletions pkg/cmd/autocomplete.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
168 changes: 168 additions & 0 deletions pkg/cmd/autocomplete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
21 changes: 19 additions & 2 deletions pkg/cmd/workspace_remove.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type workspaceRemoveCmd struct {
nameOrID string
output string
showLogs bool
force bool
}

// preRun validates the parameters and flags
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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"}))

Expand Down
Loading
Loading