diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index 8d9f923..3163bfd 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(NewStopCmd()) // Global flags rootCmd.PersistentFlags().String("storage", defaultStoragePath, "Directory where kortex-cli will store all its files") diff --git a/pkg/cmd/stop.go b/pkg/cmd/stop.go new file mode 100644 index 0000000..e96a17f --- /dev/null +++ b/pkg/cmd/stop.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 NewStopCmd() *cobra.Command { + // Create the workspace stop command + workspaceStopCmd := NewWorkspaceStopCmd() + + // Create an alias command that delegates to workspace stop + cmd := &cobra.Command{ + Use: "stop ID", + Short: workspaceStopCmd.Short, + Long: workspaceStopCmd.Long, + Args: workspaceStopCmd.Args, + PreRunE: workspaceStopCmd.PreRunE, + RunE: workspaceStopCmd.RunE, + } + + // Copy flags from workspace stop command + cmd.Flags().AddFlagSet(workspaceStopCmd.Flags()) + + return cmd +} diff --git a/pkg/cmd/stop_test.go b/pkg/cmd/stop_test.go new file mode 100644 index 0000000..90952b4 --- /dev/null +++ b/pkg/cmd/stop_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 TestStopCmd(t *testing.T) { + t.Parallel() + + cmd := NewStopCmd() + if cmd == nil { + t.Fatal("NewStopCmd() returned nil") + } + + if cmd.Use != "stop ID" { + t.Errorf("Expected Use to be 'stop ID', got '%s'", cmd.Use) + } + + // Verify it has the same behavior as workspace stop + workspaceStopCmd := NewWorkspaceStopCmd() + if cmd.Short != workspaceStopCmd.Short { + t.Errorf("Expected Short to match workspace stop, got '%s'", cmd.Short) + } +} diff --git a/pkg/cmd/workspace.go b/pkg/cmd/workspace.go index 3ca8c8f..c1f6e91 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(NewWorkspaceStopCmd()) return cmd } diff --git a/pkg/cmd/workspace_stop.go b/pkg/cmd/workspace_stop.go new file mode 100644 index 0000000..82c6a3d --- /dev/null +++ b/pkg/cmd/workspace_stop.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" +) + +// workspaceStopCmd contains the configuration for the workspace stop command +type workspaceStopCmd struct { + manager instances.Manager + id string +} + +// preRun validates the parameters and flags +func (w *workspaceStopCmd) 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 stop command logic +func (w *workspaceStopCmd) 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 stop workspace: %w", err) + } + + // Output only the ID + cmd.Println(w.id) + return nil +} + +func NewWorkspaceStopCmd() *cobra.Command { + c := &workspaceStopCmd{} + + cmd := &cobra.Command{ + Use: "stop ID", + Short: "Stop a workspace", + Long: "Stop a workspace by its ID", + Args: cobra.ExactArgs(1), + PreRunE: c.preRun, + RunE: c.run, + } + + return cmd +} diff --git a/pkg/cmd/workspace_stop_test.go b/pkg/cmd/workspace_stop_test.go new file mode 100644 index 0000000..b522bb1 --- /dev/null +++ b/pkg/cmd/workspace_stop_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 TestWorkspaceStopCmd(t *testing.T) { + t.Parallel() + + cmd := NewWorkspaceStopCmd() + if cmd == nil { + t.Fatal("NewWorkspaceStopCmd() returned nil") + } + + if cmd.Use != "stop ID" { + t.Errorf("Expected Use to be 'stop ID', got '%s'", cmd.Use) + } +} + +func TestWorkspaceStopCmd_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", "stop", "--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", "stop", "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 stop it + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "stop", addedInstance.GetID(), "--storage", storageDir}) + + err = rootCmd.Execute() + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + }) +} + +func TestWorkspaceStopCmd_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() + + // Stop the workspace + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "stop", 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", "stop", "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) + } + + // Stop the first workspace + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"workspace", "stop", 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("stop 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 'stop' instead of 'workspace stop' + rootCmd := NewRootCmd() + rootCmd.SetArgs([]string{"stop", 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)) + } + }) +}