From 502bb07b38ed68ba04b8fd4271b3a5c8ad33fd28 Mon Sep 17 00:00:00 2001 From: Manuel Dewald Date: Wed, 4 Jun 2025 16:17:26 +0200 Subject: [PATCH 1/3] feat(create-workspace,list-plans): Add commands to CLI * Adds create workspace command * Adds list plans command Signed-off-by: Manuel Dewald --- api/workspace.go | 14 +-- cli/cmd/client.go | 2 + cli/cmd/create-workspace.go | 149 +++++++++++++++++++++++++++++++ cli/cmd/create.go | 24 +++++ cli/cmd/create_workspace_test.go | 110 +++++++++++++++++++++++ cli/cmd/list-plans.go | 80 +++++++++++++++++ cli/cmd/list.go | 1 + cli/cmd/mocks.go | 111 +++++++++++++++++++++++ cli/cmd/root.go | 1 + pkg/cs/util.go | 9 ++ pkg/out/out.go | 9 +- 11 files changed, 504 insertions(+), 6 deletions(-) create mode 100644 cli/cmd/create-workspace.go create mode 100644 cli/cmd/create.go create mode 100644 cli/cmd/create_workspace_test.go create mode 100644 cli/cmd/list-plans.go diff --git a/api/workspace.go b/api/workspace.go index 895bdad..cd93009 100644 --- a/api/workspace.go +++ b/api/workspace.go @@ -95,7 +95,11 @@ type DeployWorkspaceArgs struct { PlanId int Name string EnvVars map[string]string - VpnConfigName string + VpnConfigName *string //must be nil to use default + + IsPrivateRepo bool + GitUrl *string //must be nil to use default + Branch *string //must be nil to use default Timeout time.Duration } @@ -108,13 +112,13 @@ func (client Client) DeployWorkspace(args DeployWorkspaceArgs) (*Workspace, erro TeamId: args.TeamId, Name: args.Name, PlanId: args.PlanId, - IsPrivateRepo: true, - GitUrl: nil, - InitialBranch: nil, + IsPrivateRepo: args.IsPrivateRepo, + GitUrl: args.GitUrl, + InitialBranch: args.Branch, SourceWorkspaceId: nil, WelcomeMessage: nil, Replicas: 1, - VpnConfig: &args.VpnConfigName, + VpnConfig: args.VpnConfigName, }) if err != nil { return nil, err diff --git a/cli/cmd/client.go b/cli/cmd/client.go index 662ce7f..e5803f6 100644 --- a/cli/cmd/client.go +++ b/cli/cmd/client.go @@ -19,6 +19,8 @@ type Client interface { GetWorkspace(workspaceId int) (api.Workspace, error) SetEnvVarOnWorkspace(workspaceId int, vars map[string]string) error ExecCommand(workspaceId int, command string, workdir string, env map[string]string) (string, string, error) + ListWorkspacePlans() ([]api.WorkspacePlan, error) + DeployWorkspace(args api.DeployWorkspaceArgs) (*api.Workspace, error) } func NewClient(opts GlobalOptions) (Client, error) { diff --git a/cli/cmd/create-workspace.go b/cli/cmd/create-workspace.go new file mode 100644 index 0000000..2239a2a --- /dev/null +++ b/cli/cmd/create-workspace.go @@ -0,0 +1,149 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "time" + + "github.com/codesphere-cloud/cs-go/api" + "github.com/codesphere-cloud/cs-go/pkg/cs" + "github.com/codesphere-cloud/cs-go/pkg/out" + "github.com/spf13/cobra" +) + +// CreateWorkspaceCmd represents the workspace command +type CreateWorkspaceCmd struct { + cmd *cobra.Command + Opts CreateWorkspaceOpts +} + +type CreateWorkspaceOpts struct { + GlobalOptions + Repo *string + Vpn *string + Env *[]string + Plan *int + Private *bool + Timeout *time.Duration + Branch *string +} + +func (c *CreateWorkspaceCmd) RunE(_ *cobra.Command, args []string) error { + client, err := NewClient(c.Opts.GlobalOptions) + if err != nil { + return fmt.Errorf("failed to create Codesphere client: %w", err) + } + + teamId, err := c.Opts.GetTeamId() + if err != nil { + return fmt.Errorf("failed to get team ID: %w", err) + } + + if len(args) != 1 { + return errors.New("workspace name not set") + } + wsName := args[0] + + ws, err := c.CreateWorkspace(client, teamId, wsName) + if err != nil { + return fmt.Errorf("failed to create workspace: %w", err) + } + + giturl := "" + if ws.GitUrl.Get() != nil { + giturl = *ws.GitUrl.Get() + } + branch := "" + if ws.InitialBranch.Get() != nil { + branch = *ws.InitialBranch.Get() + } + fmt.Println("Workspace created:") + fmt.Printf("\nID: %d\n", ws.Id) + fmt.Printf("Name: %s\n", ws.Name) + fmt.Printf("Team ID: %d\n", ws.TeamId) + fmt.Printf("Git Repository: %s\n", giturl) + fmt.Printf("Branch: %s\n", branch) + fmt.Printf("To open it in the Codesphere IDE run '%s open workspace -w %d'", os.Args[0], ws.Id) + + return nil +} + +func AddCreateWorkspaceCmd(create *cobra.Command, opts GlobalOptions) { + workspace := CreateWorkspaceCmd{ + cmd: &cobra.Command{ + Use: "workspace", + Short: "Create a workspace", + Args: cobra.RangeArgs(1, 1), + Long: out.Long(`Create a workspace in Codesphere. + + Specify a (private) git repository or start an empty workspace. + Environment variables can be set to initialize the workspace with a specific environment. + The command will wait for the workspace to become running or a timeout is reached. + + To decide which plan suits your needs, run 'cs list plans' + `), + Example: out.FormatExampleCommands("create workspace my-workspace", []out.Example{ + {Cmd: "-p 20", Desc: "Create an empty workspace, using plan 20"}, + {Cmd: "-r https://github.com/codesphere-cloud/landingpage-temp.git", Desc: "Create a workspace from a git repository"}, + {Cmd: "-r https://github.com/codesphere-cloud/landingpage-temp.git -e FOO=BAR -e A=B", Desc: "Create a workspace and set environment variables"}, + {Cmd: "-r https://github.com/codesphere-cloud/landingpage-temp.git --vpn myVpn", Desc: "Create a workspace and connect to VPN myVpn"}, + {Cmd: "-r https://github.com/codesphere-cloud/landingpage-temp.git --timeout 30", Desc: "Create a workspace and wait 30 seconds for it to become running"}, + {Cmd: "-r https://github.com/codesphere-cloud/landingpage-temp.git -b staging", Desc: "Create a workspace from branch 'staging'"}, + {Cmd: "-r https://github.com/my-org/my-private-project.git -P", Desc: "Create a workspace from a private git repository"}, + }), + }, + Opts: CreateWorkspaceOpts{GlobalOptions: opts}, + } + workspace.Opts.Repo = workspace.cmd.Flags().StringP("repository", "r", "", "Git repository to create the workspace from") + workspace.Opts.Vpn = workspace.cmd.Flags().String("vpn", "", "Vpn config to use") + workspace.Opts.Env = workspace.cmd.Flags().StringArrayP("env", "e", []string{}, "Environment variables to set in the workspace") + workspace.Opts.Plan = workspace.cmd.Flags().IntP("plan", "p", 8, "Plan ID for the workspace") + workspace.Opts.Private = workspace.cmd.Flags().BoolP("private", "P", false, "Use private repository") + workspace.Opts.Timeout = workspace.cmd.Flags().Duration("timeout", 60*time.Second, "Time to wait for the workspace to start") + workspace.Opts.Branch = workspace.cmd.Flags().StringP("branch", "b", "", "branch to check out") + + create.AddCommand(workspace.cmd) + workspace.cmd.RunE = workspace.RunE +} + +func (c *CreateWorkspaceCmd) CreateWorkspace(client Client, teamId int, wsName string) (*api.Workspace, error) { + envVars, err := cs.ArgToEnvVarMap(*c.Opts.Env) + if err != nil { + return nil, fmt.Errorf("failed to parse environment variables: %w", err) + } + + args := api.DeployWorkspaceArgs{ + TeamId: teamId, + PlanId: *c.Opts.Plan, + Name: wsName, + EnvVars: envVars, + + IsPrivateRepo: *c.Opts.Private, + + Timeout: *c.Opts.Timeout, + } + + if c.Opts.Repo != nil && *c.Opts.Repo != "" { + validatedUrl, err := cs.ValidateUrl(*c.Opts.Repo) + if err != nil { + return nil, fmt.Errorf("validation of repository URL failed: %w", err) + } + args.GitUrl = &validatedUrl + } + + if c.Opts.Vpn != nil && *c.Opts.Vpn != "" { + args.VpnConfigName = c.Opts.Vpn + } + + if c.Opts.Branch != nil && *c.Opts.Branch != "" { + args.Branch = c.Opts.Branch + } + + ws, err := client.DeployWorkspace(args) + if err != nil { + return nil, fmt.Errorf("failed to create workspace: %w", err) + } + + return ws, nil +} diff --git a/cli/cmd/create.go b/cli/cmd/create.go new file mode 100644 index 0000000..eee4716 --- /dev/null +++ b/cli/cmd/create.go @@ -0,0 +1,24 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// CreateCmd represents the create command +type CreateCmd struct { + cmd *cobra.Command +} + +func AddCreateCmd(rootCmd *cobra.Command, opts GlobalOptions) { + create := CreateCmd{ + cmd: &cobra.Command{ + Use: "create", + Short: "Create codesphere resource", + Long: `Create codesphere resources like workspaces.`, + }, + } + rootCmd.AddCommand(create.cmd) + + // Add child commands here + AddCreateWorkspaceCmd(create.cmd, opts) +} diff --git a/cli/cmd/create_workspace_test.go b/cli/cmd/create_workspace_test.go new file mode 100644 index 0000000..41d6ba6 --- /dev/null +++ b/cli/cmd/create_workspace_test.go @@ -0,0 +1,110 @@ +package cmd_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/codesphere-cloud/cs-go/api" + "github.com/codesphere-cloud/cs-go/cli/cmd" + "github.com/codesphere-cloud/cs-go/pkg/cs" +) + +var _ = Describe("CreateWorkspace", func() { + var ( + mockEnv *cmd.MockEnv + mockClient *cmd.MockClient + c *cmd.CreateWorkspaceCmd + teamId int + wsName string + env []string + repoStr string + repo *string + vpn *string + vpnStr string + plan int + private bool + timeout time.Duration + branchStr string + branch *string + deployArgs api.DeployWorkspaceArgs + ) + + BeforeEach(func() { + env = []string{} + repoStr = "https://fake-git.com/my/repo.git" + repo = nil + vpnStr = "MyVpn" + vpn = nil + plan = 8 + private = false + timeout = 30 * time.Second + branchStr = "fake-branch" + branch = nil + }) + + JustBeforeEach(func() { + mockClient = cmd.NewMockClient(GinkgoT()) + mockEnv = cmd.NewMockEnv(GinkgoT()) + wsName = "foo-workspace" + teamId = 21 + c = &cmd.CreateWorkspaceCmd{ + Opts: cmd.CreateWorkspaceOpts{ + GlobalOptions: cmd.GlobalOptions{ + Env: mockEnv, + TeamId: &teamId, + }, + Env: &env, + Repo: repo, + Vpn: vpn, + Plan: &plan, + Private: &private, + Timeout: &timeout, + Branch: branch, + }, + } + envMap, err := cs.ArgToEnvVarMap(env) + Expect(err).ToNot(HaveOccurred()) + deployArgs = api.DeployWorkspaceArgs{ + Name: wsName, + TeamId: teamId, + EnvVars: envMap, + GitUrl: repo, + VpnConfigName: vpn, + PlanId: plan, + IsPrivateRepo: private, + Timeout: timeout, + Branch: branch, + } + }) + + Context("Minimal values are set", func() { + + It("Creates the workspace", func() { + mockClient.EXPECT().DeployWorkspace(deployArgs).Return(&api.Workspace{Name: wsName}, nil) + ws, err := c.CreateWorkspace(mockClient, teamId, wsName) + Expect(err).ToNot(HaveOccurred()) + Expect(ws.Name).To(Equal(wsName)) + }) + }) + + Context("All values are set", func() { + BeforeEach(func() { + env = []string{"foo=bla", "blib=blub"} + repo = &repoStr + vpn = &vpnStr + private = true + timeout = 120 * time.Second + branch = &branchStr + wsName = "different-name" + }) + It("Creates the workspace and passes expected arguments", func() { + mockClient.EXPECT().DeployWorkspace(deployArgs).Return(&api.Workspace{Name: wsName}, nil) + ws, err := c.CreateWorkspace(mockClient, teamId, wsName) + Expect(err).ToNot(HaveOccurred()) + Expect(ws.Name).To(Equal(wsName)) + }) + }) + +}) diff --git a/cli/cmd/list-plans.go b/cli/cmd/list-plans.go new file mode 100644 index 0000000..cf6409e --- /dev/null +++ b/cli/cmd/list-plans.go @@ -0,0 +1,80 @@ +package cmd + +import ( + "fmt" + + "github.com/codesphere-cloud/cs-go/pkg/out" + + "github.com/jedib0t/go-pretty/v6/table" + "github.com/jedib0t/go-pretty/v6/text" + "github.com/spf13/cobra" +) + +// ListPlansCmd represents the plans command +type ListPlansCmd struct { + cmd *cobra.Command + Opts GlobalOptions +} + +func (c *ListPlansCmd) RunE(_ *cobra.Command, args []string) error { + + client, err := NewClient(c.Opts) + if err != nil { + return fmt.Errorf("failed to create Codesphere client: %w", err) + } + + plans, err := client.ListWorkspacePlans() + if err != nil { + return fmt.Errorf("failed to list plans: %s", err) + } + + t := out.GetTableWriter() + t.AppendHeader(table.Row{"ID", "Name", "On Demand", "CPU", "RAM(GiB)", "SSD(GiB)", "Price(USD)", "Max Replicas"}) + t.SetColumnConfigs([]table.ColumnConfig{ + {Name: "Price(USD)", Align: text.AlignRight}, + {Name: "RAM(GiB)", Align: text.AlignRight}, + {Name: "SSD(GiB)", Align: text.AlignRight}, + }) + for _, plan := range plans { + if plan.Deprecated { + continue + } + onDemand := "" + if plan.Characteristics.OnDemand { + onDemand = "*" + } + t.AppendRow(table.Row{ + plan.Id, + plan.Title, + onDemand, + plan.Characteristics.CPU, + formatBytesAsGib(plan.Characteristics.RAM), + formatBytesAsGib(plan.Characteristics.SSD), + fmt.Sprintf("%.2f", plan.PriceUsd), + plan.MaxReplicas, + }) + } + t.Render() + + return nil +} + +func formatBytesAsGib(in int) string { + return fmt.Sprintf("%.2f", float32(in)/1024/1024/1024) + +} + +func AddListPlansCmd(list *cobra.Command, opts GlobalOptions) { + plans := ListPlansCmd{ + cmd: &cobra.Command{ + Use: "plans", + Short: "List available plans", + Long: out.Long(`List available workpace plans. + + When creating new workspaces you need to select a specific plan.`), + }, + Opts: opts, + } + list.AddCommand(plans.cmd) + plans.cmd.RunE = plans.RunE +} diff --git a/cli/cmd/list.go b/cli/cmd/list.go index 91644fc..f30c63d 100644 --- a/cli/cmd/list.go +++ b/cli/cmd/list.go @@ -26,4 +26,5 @@ func AddListCmd(rootCmd *cobra.Command, opts GlobalOptions) { rootCmd.AddCommand(l.cmd) addListWorkspacesCmd(l.cmd, opts) addListTeamsCmd(l.cmd, opts) + AddListPlansCmd(l.cmd, opts) } diff --git a/cli/cmd/mocks.go b/cli/cmd/mocks.go index 1a645df..fa848e7 100644 --- a/cli/cmd/mocks.go +++ b/cli/cmd/mocks.go @@ -36,6 +36,62 @@ func (_m *MockClient) EXPECT() *MockClient_Expecter { return &MockClient_Expecter{mock: &_m.Mock} } +// DeployWorkspace provides a mock function for the type MockClient +func (_mock *MockClient) DeployWorkspace(args api.DeployWorkspaceArgs) (*api.Workspace, error) { + ret := _mock.Called(args) + + if len(ret) == 0 { + panic("no return value specified for DeployWorkspace") + } + + var r0 *api.Workspace + var r1 error + if returnFunc, ok := ret.Get(0).(func(api.DeployWorkspaceArgs) (*api.Workspace, error)); ok { + return returnFunc(args) + } + if returnFunc, ok := ret.Get(0).(func(api.DeployWorkspaceArgs) *api.Workspace); ok { + r0 = returnFunc(args) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*api.Workspace) + } + } + if returnFunc, ok := ret.Get(1).(func(api.DeployWorkspaceArgs) error); ok { + r1 = returnFunc(args) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_DeployWorkspace_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeployWorkspace' +type MockClient_DeployWorkspace_Call struct { + *mock.Call +} + +// DeployWorkspace is a helper method to define mock.On call +// - args +func (_e *MockClient_Expecter) DeployWorkspace(args interface{}) *MockClient_DeployWorkspace_Call { + return &MockClient_DeployWorkspace_Call{Call: _e.mock.On("DeployWorkspace", args)} +} + +func (_c *MockClient_DeployWorkspace_Call) Run(run func(args api.DeployWorkspaceArgs)) *MockClient_DeployWorkspace_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(api.DeployWorkspaceArgs)) + }) + return _c +} + +func (_c *MockClient_DeployWorkspace_Call) Return(v *api.Workspace, err error) *MockClient_DeployWorkspace_Call { + _c.Call.Return(v, err) + return _c +} + +func (_c *MockClient_DeployWorkspace_Call) RunAndReturn(run func(args api.DeployWorkspaceArgs) (*api.Workspace, error)) *MockClient_DeployWorkspace_Call { + _c.Call.Return(run) + return _c +} + // ExecCommand provides a mock function for the type MockClient func (_mock *MockClient) ExecCommand(workspaceId int, command string, workdir string, env map[string]string) (string, string, error) { ret := _mock.Called(workspaceId, command, workdir, env) @@ -208,6 +264,61 @@ func (_c *MockClient_ListTeams_Call) RunAndReturn(run func() ([]api.Team, error) return _c } +// ListWorkspacePlans provides a mock function for the type MockClient +func (_mock *MockClient) ListWorkspacePlans() ([]api.WorkspacePlan, error) { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for ListWorkspacePlans") + } + + var r0 []api.WorkspacePlan + var r1 error + if returnFunc, ok := ret.Get(0).(func() ([]api.WorkspacePlan, error)); ok { + return returnFunc() + } + if returnFunc, ok := ret.Get(0).(func() []api.WorkspacePlan); ok { + r0 = returnFunc() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]api.WorkspacePlan) + } + } + if returnFunc, ok := ret.Get(1).(func() error); ok { + r1 = returnFunc() + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// MockClient_ListWorkspacePlans_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListWorkspacePlans' +type MockClient_ListWorkspacePlans_Call struct { + *mock.Call +} + +// ListWorkspacePlans is a helper method to define mock.On call +func (_e *MockClient_Expecter) ListWorkspacePlans() *MockClient_ListWorkspacePlans_Call { + return &MockClient_ListWorkspacePlans_Call{Call: _e.mock.On("ListWorkspacePlans")} +} + +func (_c *MockClient_ListWorkspacePlans_Call) Run(run func()) *MockClient_ListWorkspacePlans_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockClient_ListWorkspacePlans_Call) Return(vs []api.WorkspacePlan, err error) *MockClient_ListWorkspacePlans_Call { + _c.Call.Return(vs, err) + return _c +} + +func (_c *MockClient_ListWorkspacePlans_Call) RunAndReturn(run func() ([]api.WorkspacePlan, error)) *MockClient_ListWorkspacePlans_Call { + _c.Call.Return(run) + return _c +} + // ListWorkspaces provides a mock function for the type MockClient func (_mock *MockClient) ListWorkspaces(teamId int) ([]api.Workspace, error) { ret := _mock.Called(teamId) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index dc7b72f..c729a9b 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -80,6 +80,7 @@ func GetRootCmd() *cobra.Command { AddVersionCmd(rootCmd) AddLicensesCmd(rootCmd) AddOpenCmd(rootCmd, opts) + AddCreateCmd(rootCmd, opts) return rootCmd } diff --git a/pkg/cs/util.go b/pkg/cs/util.go index 893ae4d..562b98b 100644 --- a/pkg/cs/util.go +++ b/pkg/cs/util.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "net/http" + "net/url" "strings" ) @@ -89,3 +90,11 @@ func ArgToEnvVarMap(input []string) (map[string]string, error) { } return res, nil } + +func ValidateUrl(urlIn string) (string, error) { + urlParsed, err := url.Parse(urlIn) + if err != nil { + return "", fmt.Errorf("failed to parse URL %s: %w", urlIn, err) + } + return urlParsed.String(), nil +} diff --git a/pkg/out/out.go b/pkg/out/out.go index c80ada7..8d73547 100644 --- a/pkg/out/out.go +++ b/pkg/out/out.go @@ -5,8 +5,10 @@ package out import ( "fmt" - "github.com/jedib0t/go-pretty/v6/table" "os" + "regexp" + + "github.com/jedib0t/go-pretty/v6/table" ) func GetTableWriter() table.Writer { @@ -35,3 +37,8 @@ func FormatExampleCommands(command string, examples []Example) (res string) { } return } + +func Long(in string) string { + re := regexp.MustCompile("\n\t+") + return re.ReplaceAllString(in, "\n") +} From a003f835af8eb16c20e2ef3c22f47c9df89fd2a6 Mon Sep 17 00:00:00 2001 From: Manuel Dewald Date: Thu, 5 Jun 2025 11:31:58 +0200 Subject: [PATCH 2/3] docs(usage): Consistent formatting of short descriptions Signed-off-by: Manuel Dewald --- cli/cmd/exec.go | 4 ++-- cli/cmd/list-teams.go | 4 ++-- cli/cmd/list-workspaces.go | 4 ++-- cli/cmd/list.go | 4 ++-- cli/cmd/log.go | 2 +- cli/cmd/open-workspace.go | 4 ++-- cli/cmd/open.go | 4 ++-- cli/cmd/root.go | 2 +- cli/cmd/set-env-vars.go | 4 ++-- 9 files changed, 16 insertions(+), 16 deletions(-) diff --git a/cli/cmd/exec.go b/cli/cmd/exec.go index bf404ed..17a53f4 100644 --- a/cli/cmd/exec.go +++ b/cli/cmd/exec.go @@ -44,8 +44,8 @@ func AddExecCmd(rootCmd *cobra.Command, opts GlobalOptions) { Use: "exec", Args: cobra.MinimumNArgs(1), Short: "Run a command in Codesphere workspace", - Long: `Run a command in a Codesphere workspace. - Output will be printed to STDOUT, errors to STDERR.`, + Long: out.Long(`Run a command in a Codesphere workspace. + Output will be printed to STDOUT, errors to STDERR.`), Example: out.FormatExampleCommands("exec", []out.Example{ {Cmd: "-- echo hello world", Desc: "Print `hello world`"}, {Cmd: "-- find .", Desc: "List all files in workspace"}, diff --git a/cli/cmd/list-teams.go b/cli/cmd/list-teams.go index 827e896..036e83c 100644 --- a/cli/cmd/list-teams.go +++ b/cli/cmd/list-teams.go @@ -22,8 +22,8 @@ func addListTeamsCmd(p *cobra.Command, opts GlobalOptions) { l := ListTeamsCmd{ cmd: &cobra.Command{ Use: "teams", - Short: "list teams", - Long: `list teams available in Codesphere`, + Short: "List teams", + Long: `List teams available in Codesphere`, Example: out.FormatExampleCommands("list teams", []out.Example{ {Desc: "List all teams"}, }), diff --git a/cli/cmd/list-workspaces.go b/cli/cmd/list-workspaces.go index a44e9ae..ed8d085 100644 --- a/cli/cmd/list-workspaces.go +++ b/cli/cmd/list-workspaces.go @@ -22,8 +22,8 @@ func addListWorkspacesCmd(p *cobra.Command, opts GlobalOptions) { l := ListWorkspacesCmd{ cmd: &cobra.Command{ Use: "workspaces", - Short: "list workspaces", - Long: `list workspaces available in Codesphere`, + Short: "List workspaces", + Long: `List workspaces available in Codesphere`, Example: out.FormatExampleCommands("list workspaces", []out.Example{ {Cmd: "--team-id ", Desc: "List all workspaces"}, }), diff --git a/cli/cmd/list.go b/cli/cmd/list.go index f30c63d..523da5a 100644 --- a/cli/cmd/list.go +++ b/cli/cmd/list.go @@ -16,8 +16,8 @@ func AddListCmd(rootCmd *cobra.Command, opts GlobalOptions) { l := ListCmd{ cmd: &cobra.Command{ Use: "list", - Short: "list resources", - Long: `list resources available in Codesphere`, + Short: "List resources", + Long: `List resources available in Codesphere`, Example: out.FormatExampleCommands("list", []out.Example{ {Cmd: "workspaces", Desc: "List all workspaces"}, }), diff --git a/cli/cmd/log.go b/cli/cmd/log.go index 595806a..aca4801 100644 --- a/cli/cmd/log.go +++ b/cli/cmd/log.go @@ -57,7 +57,7 @@ func AddLogCmd(rootCmd *cobra.Command, opts GlobalOptions) { logCmd := LogCmd{ cmd: &cobra.Command{ Use: "log", - Short: "Retrieve Run logs from services", + Short: "Retrieve run logs from services", Long: `You can retrieve logs based on the given scope. If you provide the step number and server, it returns all logs from diff --git a/cli/cmd/open-workspace.go b/cli/cmd/open-workspace.go index 8a79690..1dcfb06 100644 --- a/cli/cmd/open-workspace.go +++ b/cli/cmd/open-workspace.go @@ -39,8 +39,8 @@ func AddOpenWorkspaceCmd(open *cobra.Command, opts GlobalOptions) { workspace := OpenWorkspaceCmd{ cmd: &cobra.Command{ Use: "workspace", - Short: "Open workspace in web browser", - Long: `Open workspace in the Codesphere IDE.`, + Short: "Open workspace in the Codesphere IDE", + Long: `Open workspace in the Codesphere IDE in your web browser.`, Example: out.FormatExampleCommands("open workspace", []out.Example{ {Cmd: "-w 42", Desc: "open workspace 42 in web browser"}, {Cmd: "", Desc: "open workspace set by environment variable CS_WORKSPACE_ID"}, diff --git a/cli/cmd/open.go b/cli/cmd/open.go index 9163289..aca84b7 100644 --- a/cli/cmd/open.go +++ b/cli/cmd/open.go @@ -27,8 +27,8 @@ func AddOpenCmd(rootCmd *cobra.Command, opts GlobalOptions) { open := OpenCmd{ cmd: &cobra.Command{ Use: "open", - Short: "Open the codesphere IDE", - Long: `Open the codesphere IDE.`, + Short: "Open the Codesphere IDE", + Long: `Open the Codesphere IDE.`, }, } rootCmd.AddCommand(open.cmd) diff --git a/cli/cmd/root.go b/cli/cmd/root.go index c729a9b..64b080a 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -63,7 +63,7 @@ func (o GlobalOptions) GetWorkspaceId() (int, error) { func GetRootCmd() *cobra.Command { var rootCmd = &cobra.Command{ Use: "cs", - Short: "The codesphere CLI", + Short: "The Codesphere CLI", Long: `Manage and debug resources deployed in Codesphere via command line.`, } diff --git a/cli/cmd/set-env-vars.go b/cli/cmd/set-env-vars.go index 5235a96..f86aeef 100644 --- a/cli/cmd/set-env-vars.go +++ b/cli/cmd/set-env-vars.go @@ -25,8 +25,8 @@ func AddSetEnvVarCmd(p *cobra.Command, opts GlobalOptions) { l := SetEnvVarCmd{ cmd: &cobra.Command{ Use: "set-env", - Short: "set env vars", - Long: `set environment variables for your workspace`, + Short: "Set environment varariables", + Long: `Set environment variables in a workspace`, Example: out.FormatExampleCommands("set-env", []out.Example{ {Cmd: "--workspace --env-var foo=bar", Desc: "Set single environment variable"}, {Cmd: "--workspace --env-var foo=bar --env-var hello=world", Desc: "Set multiple environment variables"}, From be7dd68043c2c0607620c55c8acb93cd1a450616 Mon Sep 17 00:00:00 2001 From: Manuel Dewald Date: Thu, 5 Jun 2025 16:06:12 +0200 Subject: [PATCH 3/3] Address comments Signed-off-by: Manuel Dewald --- cli/cmd/create-workspace.go | 8 ++++---- cli/cmd/exec.go | 2 +- cli/cmd/list-plans.go | 1 - pkg/cs/cs_suite_test.go | 13 +++++++++++++ pkg/cs/util.go | 4 ++++ pkg/cs/util_test.go | 33 +++++++++++++++++++++++++++++++++ pkg/out/out.go | 2 ++ 7 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 pkg/cs/cs_suite_test.go create mode 100644 pkg/cs/util_test.go diff --git a/cli/cmd/create-workspace.go b/cli/cmd/create-workspace.go index 2239a2a..3694588 100644 --- a/cli/cmd/create-workspace.go +++ b/cli/cmd/create-workspace.go @@ -86,9 +86,9 @@ func AddCreateWorkspaceCmd(create *cobra.Command, opts GlobalOptions) { Example: out.FormatExampleCommands("create workspace my-workspace", []out.Example{ {Cmd: "-p 20", Desc: "Create an empty workspace, using plan 20"}, {Cmd: "-r https://github.com/codesphere-cloud/landingpage-temp.git", Desc: "Create a workspace from a git repository"}, - {Cmd: "-r https://github.com/codesphere-cloud/landingpage-temp.git -e FOO=BAR -e A=B", Desc: "Create a workspace and set environment variables"}, + {Cmd: "-r https://github.com/codesphere-cloud/landingpage-temp.git -e DEPLOYMENT=prod -e A=B", Desc: "Create a workspace and set environment variables"}, {Cmd: "-r https://github.com/codesphere-cloud/landingpage-temp.git --vpn myVpn", Desc: "Create a workspace and connect to VPN myVpn"}, - {Cmd: "-r https://github.com/codesphere-cloud/landingpage-temp.git --timeout 30", Desc: "Create a workspace and wait 30 seconds for it to become running"}, + {Cmd: "-r https://github.com/codesphere-cloud/landingpage-temp.git --timeout 30s", Desc: "Create a workspace and wait 30 seconds for it to become running"}, {Cmd: "-r https://github.com/codesphere-cloud/landingpage-temp.git -b staging", Desc: "Create a workspace from branch 'staging'"}, {Cmd: "-r https://github.com/my-org/my-private-project.git -P", Desc: "Create a workspace from a private git repository"}, }), @@ -97,10 +97,10 @@ func AddCreateWorkspaceCmd(create *cobra.Command, opts GlobalOptions) { } workspace.Opts.Repo = workspace.cmd.Flags().StringP("repository", "r", "", "Git repository to create the workspace from") workspace.Opts.Vpn = workspace.cmd.Flags().String("vpn", "", "Vpn config to use") - workspace.Opts.Env = workspace.cmd.Flags().StringArrayP("env", "e", []string{}, "Environment variables to set in the workspace") + workspace.Opts.Env = workspace.cmd.Flags().StringArrayP("env", "e", []string{}, "Environment variables to set in the workspace in key=value form (e.g. --env DEPLOYMENT=prod)") workspace.Opts.Plan = workspace.cmd.Flags().IntP("plan", "p", 8, "Plan ID for the workspace") workspace.Opts.Private = workspace.cmd.Flags().BoolP("private", "P", false, "Use private repository") - workspace.Opts.Timeout = workspace.cmd.Flags().Duration("timeout", 60*time.Second, "Time to wait for the workspace to start") + workspace.Opts.Timeout = workspace.cmd.Flags().Duration("timeout", 10*time.Minute, "Time to wait for the workspace to start (e.g. 5m for 5 minutes)") workspace.Opts.Branch = workspace.cmd.Flags().StringP("branch", "b", "", "branch to check out") create.AddCommand(workspace.cmd) diff --git a/cli/cmd/exec.go b/cli/cmd/exec.go index 17a53f4..1390ce5 100644 --- a/cli/cmd/exec.go +++ b/cli/cmd/exec.go @@ -45,7 +45,7 @@ func AddExecCmd(rootCmd *cobra.Command, opts GlobalOptions) { Args: cobra.MinimumNArgs(1), Short: "Run a command in Codesphere workspace", Long: out.Long(`Run a command in a Codesphere workspace. - Output will be printed to STDOUT, errors to STDERR.`), + Output will be printed to STDOUT, errors to STDERR.`), Example: out.FormatExampleCommands("exec", []out.Example{ {Cmd: "-- echo hello world", Desc: "Print `hello world`"}, {Cmd: "-- find .", Desc: "List all files in workspace"}, diff --git a/cli/cmd/list-plans.go b/cli/cmd/list-plans.go index cf6409e..5e7fc86 100644 --- a/cli/cmd/list-plans.go +++ b/cli/cmd/list-plans.go @@ -61,7 +61,6 @@ func (c *ListPlansCmd) RunE(_ *cobra.Command, args []string) error { func formatBytesAsGib(in int) string { return fmt.Sprintf("%.2f", float32(in)/1024/1024/1024) - } func AddListPlansCmd(list *cobra.Command, opts GlobalOptions) { diff --git a/pkg/cs/cs_suite_test.go b/pkg/cs/cs_suite_test.go new file mode 100644 index 0000000..353fe83 --- /dev/null +++ b/pkg/cs/cs_suite_test.go @@ -0,0 +1,13 @@ +package cs_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCs(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Cs Suite") +} diff --git a/pkg/cs/util.go b/pkg/cs/util.go index 562b98b..d766f2a 100644 --- a/pkg/cs/util.go +++ b/pkg/cs/util.go @@ -91,10 +91,14 @@ func ArgToEnvVarMap(input []string) (map[string]string, error) { return res, nil } +// Validates a URL is a http/https URL func ValidateUrl(urlIn string) (string, error) { urlParsed, err := url.Parse(urlIn) if err != nil { return "", fmt.Errorf("failed to parse URL %s: %w", urlIn, err) } + if urlParsed.Scheme != "http" && urlParsed.Scheme != "https" { + return "", fmt.Errorf("unsupported URL scheme: %s. Only http and https are supported", urlParsed.Scheme) + } return urlParsed.String(), nil } diff --git a/pkg/cs/util_test.go b/pkg/cs/util_test.go new file mode 100644 index 0000000..fb23c32 --- /dev/null +++ b/pkg/cs/util_test.go @@ -0,0 +1,33 @@ +package cs_test + +import ( + "github.com/codesphere-cloud/cs-go/pkg/cs" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ValidateUrl", func() { + + It("succeeds on a github repo URL", func() { + urlIn := "https://github.com/codesphere-cloud/codesphere-monorepofails" + url, err := cs.ValidateUrl(urlIn) + Expect(url).To(Equal(urlIn)) + Expect(err).NotTo(HaveOccurred()) + + }) + + It("fails on unsupported scheme", func() { + urlIn := "sftp://my-server/my-stuff" + url, err := cs.ValidateUrl(urlIn) + Expect(url).To(Equal("")) + Expect(err).To(MatchError("unsupported URL scheme: sftp. Only http and https are supported")) + }) + + It("fails on invalid URL", func() { + urlIn := "\nhttps:\n" + url, err := cs.ValidateUrl(urlIn) + Expect(url).To(Equal("")) + Expect(err).To(MatchError("failed to parse URL \nhttps:\n: parse \"\\nhttps:\\n\": net/url: invalid control character in URL")) + }) + +}) diff --git a/pkg/out/out.go b/pkg/out/out.go index 8d73547..e5f8bec 100644 --- a/pkg/out/out.go +++ b/pkg/out/out.go @@ -38,6 +38,8 @@ func FormatExampleCommands(command string, examples []Example) (res string) { return } +// Remove tabs to allow formatted multi-line descriptions in Code without cluttering +// the help output func Long(in string) string { re := regexp.MustCompile("\n\t+") return re.ReplaceAllString(in, "\n")