From b0c5d34d52defe1f82f9c6f9da1a7ce4706bbd49 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Mon, 9 Mar 2026 15:12:51 +0100 Subject: [PATCH] feat: stop command Signed-off-by: Philippe Martin Co-Authored-By: Claude Code (Claude Sonnet 4.5) --- pkg/cmd/remove.go | 43 ++++ pkg/cmd/remove_test.go | 42 ++++ pkg/cmd/root.go | 1 + pkg/cmd/workspace.go | 1 + pkg/cmd/workspace_remove.go | 84 ++++++++ pkg/cmd/workspace_remove_test.go | 338 +++++++++++++++++++++++++++++++ 6 files changed, 509 insertions(+) create mode 100644 pkg/cmd/remove.go create mode 100644 pkg/cmd/remove_test.go create mode 100644 pkg/cmd/workspace_remove.go create mode 100644 pkg/cmd/workspace_remove_test.go diff --git a/pkg/cmd/remove.go b/pkg/cmd/remove.go new file mode 100644 index 0000000..68bbaa9 --- /dev/null +++ b/pkg/cmd/remove.go @@ -0,0 +1,43 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +package cmd + +import ( + "github.com/spf13/cobra" +) + +func NewRemoveCmd() *cobra.Command { + // Create the workspace remove command + workspaceRemoveCmd := NewWorkspaceRemoveCmd() + + // Create an alias command that delegates to workspace remove + cmd := &cobra.Command{ + Use: "remove ID", + Short: workspaceRemoveCmd.Short, + Long: workspaceRemoveCmd.Long, + Args: workspaceRemoveCmd.Args, + PreRunE: workspaceRemoveCmd.PreRunE, + RunE: workspaceRemoveCmd.RunE, + } + + // Copy flags from workspace remove command + cmd.Flags().AddFlagSet(workspaceRemoveCmd.Flags()) + + return cmd +} diff --git a/pkg/cmd/remove_test.go b/pkg/cmd/remove_test.go new file mode 100644 index 0000000..74cde3a --- /dev/null +++ b/pkg/cmd/remove_test.go @@ -0,0 +1,42 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +package cmd + +import ( + "testing" +) + +func TestRemoveCmd(t *testing.T) { + t.Parallel() + + cmd := NewRemoveCmd() + if cmd == nil { + t.Fatal("NewRemoveCmd() returned nil") + } + + if cmd.Use != "remove ID" { + t.Errorf("Expected Use to be 'remove ID', got '%s'", cmd.Use) + } + + // Verify it has the same behavior as workspace remove + workspaceRemoveCmd := NewWorkspaceRemoveCmd() + if cmd.Short != workspaceRemoveCmd.Short { + t.Errorf("Expected Short to match workspace remove, got '%s'", cmd.Short) + } +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 8d9f923..49f5619 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -49,6 +49,7 @@ func NewRootCmd() *cobra.Command { rootCmd.AddCommand(NewInitCmd()) rootCmd.AddCommand(NewWorkspaceCmd()) rootCmd.AddCommand(NewListCmd()) + rootCmd.AddCommand(NewRemoveCmd()) // Global flags rootCmd.PersistentFlags().String("storage", defaultStoragePath, "Directory where kortex-cli will store all its files") diff --git a/pkg/cmd/workspace.go b/pkg/cmd/workspace.go index 3ca8c8f..4e1a8c1 100644 --- a/pkg/cmd/workspace.go +++ b/pkg/cmd/workspace.go @@ -32,6 +32,7 @@ func NewWorkspaceCmd() *cobra.Command { // Add subcommands cmd.AddCommand(NewWorkspaceListCmd()) + cmd.AddCommand(NewWorkspaceRemoveCmd()) return cmd } diff --git a/pkg/cmd/workspace_remove.go b/pkg/cmd/workspace_remove.go new file mode 100644 index 0000000..38d4aed --- /dev/null +++ b/pkg/cmd/workspace_remove.go @@ -0,0 +1,84 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +package cmd + +import ( + "errors" + "fmt" + + "github.com/kortex-hub/kortex-cli/pkg/instances" + "github.com/spf13/cobra" +) + +// workspaceRemoveCmd contains the configuration for the workspace remove command +type workspaceRemoveCmd struct { + manager instances.Manager + id string +} + +// preRun validates the parameters and flags +func (w *workspaceRemoveCmd) preRun(cmd *cobra.Command, args []string) error { + w.id = args[0] + + // Get storage directory from global flag + storageDir, err := cmd.Flags().GetString("storage") + if err != nil { + return fmt.Errorf("failed to read --storage flag: %w", err) + } + + // Create manager + manager, err := instances.NewManager(storageDir) + if err != nil { + return fmt.Errorf("failed to create manager: %w", err) + } + w.manager = manager + + return nil +} + +// run executes the workspace remove command logic +func (w *workspaceRemoveCmd) run(cmd *cobra.Command, args []string) error { + // Delete the instance + err := w.manager.Delete(w.id) + if err != nil { + if errors.Is(err, instances.ErrInstanceNotFound) { + return fmt.Errorf("workspace not found: %s\nUse 'workspace list' to see available workspaces", w.id) + } + return fmt.Errorf("failed to remove workspace: %w", err) + } + + // Output only the ID + cmd.Println(w.id) + return nil +} + +func NewWorkspaceRemoveCmd() *cobra.Command { + c := &workspaceRemoveCmd{} + + cmd := &cobra.Command{ + Use: "remove ID", + Short: "Remove a workspace", + Long: "Remove a workspace by its ID", + Args: cobra.ExactArgs(1), + PreRunE: c.preRun, + RunE: c.run, + } + + return cmd +} diff --git a/pkg/cmd/workspace_remove_test.go b/pkg/cmd/workspace_remove_test.go new file mode 100644 index 0000000..284a491 --- /dev/null +++ b/pkg/cmd/workspace_remove_test.go @@ -0,0 +1,338 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + **********************************************************************/ + +package cmd + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/kortex-hub/kortex-cli/pkg/instances" +) + +func TestWorkspaceRemoveCmd(t *testing.T) { + t.Parallel() + + cmd := NewWorkspaceRemoveCmd() + if cmd == nil { + t.Fatal("NewWorkspaceRemoveCmd() returned nil") + } + + if cmd.Use != "remove ID" { + t.Errorf("Expected Use to be 'remove ID', got '%s'", cmd.Use) + } +} + +func TestWorkspaceRemoveCmd_PreRun(t *testing.T) { + t.Parallel() + + t.Run("requires ID argument", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "remove", "--storage", storageDir}) + + err := rootCmd.Execute() + if err == nil { + t.Fatal("Expected error when ID argument is missing, got nil") + } + + if !strings.Contains(err.Error(), "accepts 1 arg(s), received 0") { + t.Errorf("Expected error to contain 'accepts 1 arg(s), received 0', got: %v", err) + } + }) + + t.Run("rejects multiple arguments", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "remove", "id1", "id2", "--storage", storageDir}) + + err := rootCmd.Execute() + if err == nil { + t.Fatal("Expected error when multiple arguments provided, got nil") + } + + if !strings.Contains(err.Error(), "accepts 1 arg(s), received 2") { + t.Errorf("Expected error to contain 'accepts 1 arg(s), received 2', got: %v", err) + } + }) + + t.Run("creates manager from storage flag", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + sourcesDir := t.TempDir() + + // Create a workspace first + 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) + } + + addedInstance, err := manager.Add(instance) + if err != nil { + t.Fatalf("Failed to add instance: %v", err) + } + + // Now remove it + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "remove", addedInstance.GetID(), "--storage", storageDir}) + + err = rootCmd.Execute() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) +} + +func TestWorkspaceRemoveCmd_E2E(t *testing.T) { + t.Parallel() + + t.Run("removes existing workspace by ID", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + sourcesDir := t.TempDir() + + // Create a workspace + 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) + } + + addedInstance, err := manager.Add(instance) + if err != nil { + t.Fatalf("Failed to add instance: %v", err) + } + + instanceID := addedInstance.GetID() + + // Remove the workspace + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "remove", instanceID, "--storage", storageDir}) + + var output bytes.Buffer + rootCmd.SetOut(&output) + + err = rootCmd.Execute() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify output is only the ID + result := strings.TrimSpace(output.String()) + if result != instanceID { + t.Errorf("Expected output to be '%s', got: '%s'", instanceID, result) + } + + // Verify workspace is removed from storage + 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)) + } + + // Verify Get returns not found + _, err = manager.Get(instanceID) + if err == nil { + t.Error("Expected error when getting removed instance, got nil") + } + if err != instances.ErrInstanceNotFound { + t.Errorf("Expected ErrInstanceNotFound, got: %v", err) + } + }) + + t.Run("returns error for non-existent workspace ID", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "remove", "nonexistent-id", "--storage", storageDir}) + + err := rootCmd.Execute() + if err == nil { + t.Fatal("Expected error for non-existent ID, got nil") + } + + if !strings.Contains(err.Error(), "workspace not found") { + t.Errorf("Expected error to contain 'workspace not found', got: %v", err) + } + if !strings.Contains(err.Error(), "workspace list") { + t.Errorf("Expected error to contain 'workspace list', got: %v", err) + } + }) + + t.Run("removes only specified workspace when multiple exist", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + sourcesDir1 := t.TempDir() + sourcesDir2 := t.TempDir() + + // Create two workspaces + manager, err := instances.NewManager(storageDir) + if err != nil { + t.Fatalf("Failed to create manager: %v", err) + } + + instance1, err := instances.NewInstance(instances.NewInstanceParams{ + SourceDir: sourcesDir1, + ConfigDir: filepath.Join(sourcesDir1, ".kortex"), + }) + if err != nil { + t.Fatalf("Failed to create instance 1: %v", err) + } + + instance2, err := instances.NewInstance(instances.NewInstanceParams{ + SourceDir: sourcesDir2, + ConfigDir: filepath.Join(sourcesDir2, ".kortex"), + }) + if err != nil { + t.Fatalf("Failed to create instance 2: %v", err) + } + + addedInstance1, err := manager.Add(instance1) + if err != nil { + t.Fatalf("Failed to add instance 1: %v", err) + } + + addedInstance2, err := manager.Add(instance2) + if err != nil { + t.Fatalf("Failed to add instance 2: %v", err) + } + + // Remove the first workspace + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "remove", addedInstance1.GetID(), "--storage", storageDir}) + + err = rootCmd.Execute() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify only one workspace remains + instancesList, err := manager.List() + if err != nil { + t.Fatalf("Failed to list instances: %v", err) + } + + if len(instancesList) != 1 { + t.Fatalf("Expected 1 instance after removal, got %d", len(instancesList)) + } + + // Verify the remaining workspace is instance2 + if instancesList[0].GetID() != addedInstance2.GetID() { + t.Errorf("Expected remaining instance ID %s, got %s", addedInstance2.GetID(), instancesList[0].GetID()) + } + + // Verify instance1 is removed + _, err = manager.Get(addedInstance1.GetID()) + if err != instances.ErrInstanceNotFound { + t.Errorf("Expected ErrInstanceNotFound for removed instance, got: %v", err) + } + + // Verify instance2 still exists + retrievedInstance, err := manager.Get(addedInstance2.GetID()) + if err != nil { + t.Fatalf("Expected no error getting instance 2, got %v", err) + } + if retrievedInstance.GetID() != addedInstance2.GetID() { + t.Errorf("Expected ID %s, got %s", addedInstance2.GetID(), retrievedInstance.GetID()) + } + }) + + t.Run("remove command alias works", func(t *testing.T) { + t.Parallel() + + storageDir := t.TempDir() + sourcesDir := t.TempDir() + + // Create a workspace + 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) + } + + addedInstance, err := manager.Add(instance) + if err != nil { + t.Fatalf("Failed to add instance: %v", err) + } + + instanceID := addedInstance.GetID() + + // Use the alias command 'remove' instead of 'workspace remove' + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"remove", instanceID, "--storage", storageDir}) + + var output bytes.Buffer + rootCmd.SetOut(&output) + + err = rootCmd.Execute() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + // Verify output is only the ID + result := strings.TrimSpace(output.String()) + if result != instanceID { + t.Errorf("Expected output to be '%s', got: '%s'", instanceID, 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)) + } + }) +}