diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 00000000..3199c8e2 --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,105 @@ +// Copyright Nitric Pty Ltd. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +package cmd + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/afero" + "github.com/spf13/cobra" + + "github.com/nitrictech/cli/pkg/view/tui" + add_website "github.com/nitrictech/cli/pkg/view/tui/commands/website" + "github.com/nitrictech/cli/pkg/view/tui/teax" +) + +// addCmd acts as a parent command for adding different types of resources +// e.g., websites or other components in the future. +var addCmd = &cobra.Command{ + Use: "add", + Short: "Add new resources to your Nitric project", + Long: `Add new components such as websites to an existing Nitric project. +Run 'nitric add website' to add a new website.`, + Example: `# Add a new website interactively +nitric add website`, +} + +var addWebsiteCmd = &cobra.Command{ + Use: "website [websiteName] [toolName]", + Short: "Add a new website to your Nitric project", + Long: `Add a new website to your Nitric project, with optional tool selection.`, + Example: `# Interactive website addition +nitric add website + +# Add a new website with a specific tool +nitric add website my-site astro`, + RunE: func(cmd *cobra.Command, args []string) error { + fs := afero.NewOsFs() + + websiteName := "" + if len(args) >= 1 { + websiteName = args[0] + } + + toolName := "" + if len(args) >= 2 { + toolName = args[1] + } + + if !tui.IsTerminal() { + return fmt.Errorf("non-interactive mode is not supported by this command") + } + + websitePath, err := cmd.Flags().GetString("path") + if err != nil { + return fmt.Errorf("failed to get path flag: %w", err) + } + + websiteModel, err := add_website.New(fs, add_website.Args{ + WebsiteName: websiteName, + ToolName: toolName, + WebsitePath: websitePath, + }) + tui.CheckErr(err) + + if _, err := teax.NewProgram(websiteModel, tea.WithANSICompressor()).Run(); err != nil { + return err + } + + return nil + }, + Args: cobra.MaximumNArgs(2), +} + +func init() { + rootCmd.AddCommand(addCmd) + + addCmd.AddCommand(addWebsiteCmd) + + addStackCmd := &cobra.Command{ + Use: "stack [stackName] [providerName]", + Short: newStackCmd.Short, + Long: newStackCmd.Long, + RunE: newStackCmd.RunE, + Args: newStackCmd.Args, + } + addStackCmd.Flags().AddFlagSet(newStackCmd.Flags()) + addCmd.AddCommand(addStackCmd) + + addWebsiteCmd.Flags().StringP("path", "p", "", "base url path for the website, e.g. /my-site") +} diff --git a/pkg/project/config.go b/pkg/project/config.go index cb8e8b77..79f1f112 100644 --- a/pkg/project/config.go +++ b/pkg/project/config.go @@ -95,15 +95,18 @@ type Dev struct { } type WebsiteConfiguration struct { - BaseServiceConfiguration `yaml:",inline"` - + Basedir string `yaml:"basedir"` Build Build `yaml:"build"` Dev Dev `yaml:"dev"` - Path string `yaml:"path"` + Path string `yaml:"path,omitempty"` IndexPage string `yaml:"index,omitempty"` ErrorPage string `yaml:"error,omitempty"` } +func (w WebsiteConfiguration) GetBasedir() string { + return w.Basedir +} + type ProjectConfiguration struct { Name string `yaml:"name"` Directory string `yaml:"-"` diff --git a/pkg/view/tui/commands/website/new.go b/pkg/view/tui/commands/website/new.go new file mode 100644 index 00000000..32ea4be1 --- /dev/null +++ b/pkg/view/tui/commands/website/new.go @@ -0,0 +1,555 @@ +// Copyright Nitric Pty Ltd. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +package add_website + +import ( + "fmt" + "os" + "os/exec" + "strings" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/charmbracelet/lipgloss" + "github.com/samber/lo" + "github.com/spf13/afero" + + "github.com/nitrictech/cli/pkg/project" + "github.com/nitrictech/cli/pkg/view/tui" + "github.com/nitrictech/cli/pkg/view/tui/components/list" + "github.com/nitrictech/cli/pkg/view/tui/components/listprompt" + "github.com/nitrictech/cli/pkg/view/tui/components/textprompt" + "github.com/nitrictech/cli/pkg/view/tui/components/validation" + "github.com/nitrictech/cli/pkg/view/tui/components/view" + "github.com/nitrictech/cli/pkg/view/tui/teax" +) + +type step int + +const ( + StepName step = iota + StepPath + StepTool + StepPackageManager + StepPort + StepRunningToolCommand + StepDone +) + +type Args struct { + WebsiteName string + WebsitePath string + ToolName string +} + +var packageManagers = []string{"npm", "yarn", "pnpm"} + +type configUpdatedResultMsg struct { + err error +} + +type commandResultMsg struct { + err error + exited bool + msg string +} + +type Model struct { + windowSize tea.WindowSizeMsg + step step + toolPrompt listprompt.ListPrompt + packagePrompt listprompt.ListPrompt + err error + namePrompt textprompt.TextPrompt + pathPrompt textprompt.TextPrompt + portPrompt textprompt.TextPrompt + + config *project.ProjectConfiguration + existingPaths []string + + fs afero.Fs +} + +func New(fs afero.Fs, args Args) (Model, error) { + config, err := project.ConfigurationFromFile(fs, "") + tui.CheckErr(err) + + existingNames := []string{} + for _, website := range config.Websites { + existingNames = append(existingNames, website.Basedir) + } + + if args.WebsiteName != "" { + normalizedName := strings.TrimPrefix(args.WebsiteName, "./") + + for _, name := range existingNames { + existingName := strings.TrimPrefix(name, "./") + if existingName == normalizedName { + return Model{}, fmt.Errorf("website name '%s' already exists", normalizedName) + } + } + + if !WebsiteNameRegex.MatchString(normalizedName) { + return Model{}, fmt.Errorf("website name can only contain letters, numbers, underscores and hyphens") + } + + if !WebsiteNameStartRegex.MatchString(normalizedName) { + return Model{}, fmt.Errorf("website name must start with a letter or number") + } + + if !WebsiteNameEndRegex.MatchString(normalizedName) { + return Model{}, fmt.Errorf("website name cannot end with a hyphen") + } + + if _, err := fs.Stat(normalizedName); err == nil { + return Model{}, fmt.Errorf("website directory '%s' already exists", normalizedName) + } else if !os.IsNotExist(err) { + return Model{}, fmt.Errorf("failed to check website directory: %w", err) + } + } + + nameValidator := validation.ComposeValidators(WebsiteNameValidators(existingNames)...) + nameInFlightValidator := validation.ComposeValidators(WebsiteNameInFlightValidators()...) + + namePrompt := textprompt.NewTextPrompt("websiteName", textprompt.TextPromptArgs{ + Prompt: "What would you like to name your website?", + Tag: "name", + Validator: nameValidator, + Placeholder: "", + InFlightValidator: nameInFlightValidator, + }) + + existingPaths := []string{} + + for _, website := range config.Websites { + if website.Path == "" { + existingPaths = append(existingPaths, "/") + continue + } + + existingPaths = append(existingPaths, website.Path) + } + + pathValidator := validation.ComposeValidators(WebsiteURLPathValidators(existingPaths)...) + pathInFlightValidator := validation.ComposeValidators(WebsiteURLPathInFlightValidators(existingPaths)...) + + pathPrompt := textprompt.NewTextPrompt("path", textprompt.TextPromptArgs{ + Prompt: "What path would you like to use?", + Tag: "path", + Validator: pathValidator, + Placeholder: "", + InFlightValidator: pathInFlightValidator, + }) + + step := StepName + + namePrompt.Focus() + + if args.WebsiteName != "" { + namePrompt.SetValue(args.WebsiteName) + namePrompt.Blur() + + if len(existingPaths) > 0 { + step = StepPath + + pathPrompt.Focus() + } else { + step = StepTool + } + } + + if args.WebsitePath != "" { + pathInUse := lo.Contains(existingPaths, args.WebsitePath) + if pathInUse { + return Model{}, fmt.Errorf("path %s is already in use", args.WebsitePath) + } + if err := pathValidator(args.WebsitePath); err != nil { + return Model{}, fmt.Errorf("path %s is invalid: %w", args.WebsitePath, err) + } + + pathPrompt.SetValue(args.WebsitePath) + pathPrompt.Blur() + + step = StepTool + } + + toolItems := []list.ListItem{} + for _, tool := range tools { + toolItems = append(toolItems, &tool) + } + + toolPrompt := listprompt.NewListPrompt(listprompt.ListPromptArgs{ + Prompt: "Choose your site setup:", + Tag: "setup", + Items: toolItems, + }) + + if args.ToolName != "" { + tool, found := lo.Find(tools, func(f Tool) bool { return f.Value == args.ToolName }) + if !found { + return Model{}, fmt.Errorf("tool '%s' not found", args.ToolName) + } + + toolPrompt.SetChoice(tool.Name) + + if args.WebsitePath != "" { + if !tool.SkipPackageManagerPrompt { + step = StepPackageManager + } else { + step = StepTool + } + } else { + step = StepPath + } + } + + portValidator := validation.ComposeValidators(PortValidators()...) + portInFlightValidator := validation.ComposeValidators(PortInFlightValidators()...) + + portPrompt := textprompt.NewTextPrompt("port", textprompt.TextPromptArgs{ + Prompt: "What port would you like to use for development?", + Tag: "port", + Validator: portValidator, + Placeholder: "3000", + InFlightValidator: portInFlightValidator, + }) + + return Model{ + step: step, + namePrompt: namePrompt, + toolPrompt: toolPrompt, + packagePrompt: listprompt.NewListPrompt(listprompt.ListPromptArgs{ + Prompt: "Which package manager would you like to use?", + Tag: "pkgm", + Items: list.StringsToListItems(getAvailablePackageManagers()), + }), + pathPrompt: pathPrompt, + portPrompt: portPrompt, + config: config, + fs: fs, + existingPaths: existingPaths, + err: nil, + }, nil +} + +func (m Model) Init() tea.Cmd { + if m.err != nil { + return teax.Quit + } + + return tea.Batch( + tea.ClearScreen, + m.namePrompt.Init(), + m.pathPrompt.Init(), + m.toolPrompt.Init(), + m.packagePrompt.Init(), + m.portPrompt.Init(), + ) +} + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmd tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.windowSize = msg + + if m.windowSize.Height < 15 { + m.toolPrompt.SetMinimized(true) + m.toolPrompt.SetMaxDisplayedItems(m.windowSize.Height - 1) + m.packagePrompt.SetMinimized(true) + m.packagePrompt.SetMaxDisplayedItems(m.windowSize.Height - 1) + } else { + m.toolPrompt.SetMinimized(false) + maxItems := ((m.windowSize.Height - 3) / 3) + m.toolPrompt.SetMaxDisplayedItems(maxItems) + m.packagePrompt.SetMinimized(false) + m.packagePrompt.SetMaxDisplayedItems(maxItems) + } + + return m, nil + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + return m, teax.Quit + } + case textprompt.CompleteMsg: + if msg.ID == m.namePrompt.ID { + m.namePrompt.Blur() + + if len(m.existingPaths) > 0 { + m.step = StepPath + m.pathPrompt.Focus() + } else { + m.step = StepTool + } + } else if msg.ID == m.pathPrompt.ID { + m.pathPrompt.Blur() + m.step = StepTool + } else if msg.ID == m.portPrompt.ID { + m.portPrompt.Blur() + m.step = StepRunningToolCommand + + return m, m.runCommand() + } + + return m, nil + case configUpdatedResultMsg: + if msg.err == nil { + m.step = StepDone + } else { + m.step = StepDone + m.err = msg.err + } + + return m, teax.Quit + case commandResultMsg: + if msg.err != nil { + m.err = msg.err + return m, teax.Quit + } + + if msg.exited { + return m, teax.Quit + } + + return m, m.updateConfig() + } + + switch m.step { + case StepName: + m.namePrompt, cmd = m.namePrompt.UpdateTextPrompt(msg) + case StepPath: + m.pathPrompt, cmd = m.pathPrompt.UpdateTextPrompt(msg) + case StepTool: + m.toolPrompt, cmd = m.toolPrompt.UpdateListPrompt(msg) + + if m.toolPrompt.Choice() != "" { + tool, err := m.getSelectedTool() + if err != nil { + m.err = err + + return m, teax.Quit + } + + if tool.SkipPackageManagerPrompt { + m.packagePrompt.SetChoice(tool.Value) + m.step = StepPort + m.portPrompt.Focus() + } else { + m.step = StepPackageManager + } + } + case StepPackageManager: + m.packagePrompt, cmd = m.packagePrompt.UpdateListPrompt(msg) + + if m.packagePrompt.Choice() != "" { + m.step = StepPort + m.portPrompt.Focus() + } + case StepPort: + m.portPrompt, cmd = m.portPrompt.UpdateTextPrompt(msg) + } + + return m, cmd +} + +var ( + errorTagStyle = lipgloss.NewStyle().Background(tui.Colors.Red).Foreground(tui.Colors.White).PaddingLeft(2).PaddingRight(2).Align(lipgloss.Center) + errorTextStyle = lipgloss.NewStyle().PaddingLeft(2).Foreground(tui.Colors.Red) + tagStyle = lipgloss.NewStyle().Width(8).Background(tui.Colors.Purple).Foreground(tui.Colors.White).Align(lipgloss.Center) + leftMarginStyle = lipgloss.NewStyle().MarginLeft(2) + createdHeadingStyle = lipgloss.NewStyle().Bold(true).MarginLeft(2) + highlightStyle = lipgloss.NewStyle().Foreground(tui.Colors.TextHighlight) +) + +func (m Model) View() string { + v := view.New(view.WithStyle(lipgloss.NewStyle())) + + // clear the screen manually with a print, fixes a bug where the screen is not cleared on exit due to ExecProcess + if m.step == StepDone { + fmt.Println("\033c") + } + + if m.err != nil { + v.Add("error").WithStyle(errorTagStyle) + v.Addln(m.err.Error()).WithStyle(errorTextStyle) + + v.Break() + + return v.Render() + } + + v.Addln(m.namePrompt.View()) + + if m.step >= StepPath && len(m.existingPaths) > 0 { + v.Addln(m.pathPrompt.View()) + v.Break() + } + + if m.step >= StepTool { + v.Addln(m.toolPrompt.View()) + v.Break() + } + + if m.step >= StepPackageManager { + tool, _ := m.getSelectedTool() + + if !tool.SkipPackageManagerPrompt { + v.Addln(m.packagePrompt.View()) + v.Break() + } + } + + if m.step >= StepPort { + v.Addln(m.portPrompt.View()) + } + + if m.step == StepRunningToolCommand { + v.Break() + + v.Add("site").WithStyle(tagStyle) + v.Addln("Running site setup 👇").WithStyle(leftMarginStyle) + v.Break() + } + + if m.step == StepDone { + v.Break() + v.Add("site").WithStyle(tagStyle) + v.Addln("Website Created!").WithStyle(createdHeadingStyle) + v.Break() + + indent := view.New(view.WithStyle(lipgloss.NewStyle().MarginLeft(10))) + + indent.Add("Navigate to your website with ") + indent.Addln("cd ./%s", m.namePrompt.Value()).WithStyle(highlightStyle) + + indent.Break() + + indent.Add("Need help? Come and chat ") + indent.Addln("https://nitric.io/chat").WithStyle(highlightStyle) + + v.Addln(indent.Render()) + } else if m.windowSize.Height > 10 { + v.Break() + v.Break() + v.Add("(esc to quit)").WithStyle(lipgloss.NewStyle().Foreground(tui.Colors.TextMuted)) + } + + return v.Render() +} + +func (m Model) getSelectedTool() (Tool, error) { + tool, ok := lo.Find(tools, func(f Tool) bool { + return f.Name == m.toolPrompt.Choice() + }) + if !ok { + return Tool{}, fmt.Errorf("tool %s not found", m.toolPrompt.Choice()) + } + + return tool, nil +} + +func (m Model) runCommand() tea.Cmd { + tool, err := m.getSelectedTool() + if err != nil { + m.err = err + return teax.Quit + } + + if tool.SkipPackageManagerPrompt { + if _, err := exec.LookPath(tool.Value); err != nil { + return func() tea.Msg { + return commandResultMsg{ + err: fmt.Errorf("tool %s not found, please install it using the following guide: %s", tool.Value, tool.InstallLink), + msg: "Tool not found", + } + } + } + } + + cmd := tool.GetCreateCommand(m.packagePrompt.Choice(), m.namePrompt.Value()) + parts := strings.Fields(cmd) + command := parts[0] + args := parts[1:] + + c := exec.Command(command, args...) + + return tea.ExecProcess(c, func(err error) tea.Msg { + if err != nil { + return commandResultMsg{err: fmt.Errorf("failed to run website command: %w", err), msg: "Failed to create website"} + } + + websiteDir := m.namePrompt.Value() + if _, err := m.fs.Stat(websiteDir); err != nil { + if os.IsNotExist(err) { + return commandResultMsg{exited: true, msg: fmt.Sprintf("website directory '%s' was not created", websiteDir)} + } + + return commandResultMsg{err: fmt.Errorf("failed to check website directory: %w", err), msg: "Failed to verify website creation"} + } + + return commandResultMsg{msg: "Website created successfully"} + }) +} + +func (m Model) updateConfig() tea.Cmd { + return func() tea.Msg { + var tool Tool + + for _, f := range tools { + if f.Name == m.toolPrompt.Choice() { + tool = f + break + } + } + + path := m.pathPrompt.Value() + port := m.portPrompt.Value() + + website := project.WebsiteConfiguration{ + Basedir: fmt.Sprintf("./%s", m.namePrompt.Value()), + Build: project.Build{ + Command: tool.GetBuildCommand(m.packagePrompt.Choice(), path), + Output: tool.OutputDir, + }, + Dev: project.Dev{ + Command: tool.GetDevCommand(m.packagePrompt.Choice(), port), + URL: tool.GetDevURL(port), + }, + Path: path, + } + + m.config.Websites = append(m.config.Websites, website) + + return configUpdatedResultMsg{ + err: m.config.ToFile(m.fs, ""), + } + } +} + +func getAvailablePackageManagers() []string { + available := []string{} + + for _, pm := range packageManagers { + if _, err := exec.LookPath(pm); err == nil { + available = append(available, pm) + } + } + + return available +} diff --git a/pkg/view/tui/commands/website/toolsetup.go b/pkg/view/tui/commands/website/toolsetup.go new file mode 100644 index 00000000..56e77c38 --- /dev/null +++ b/pkg/view/tui/commands/website/toolsetup.go @@ -0,0 +1,173 @@ +// Copyright Nitric Pty Ltd. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +package add_website + +import ( + "strings" +) + +type Tool struct { + Name string + Value string + Description string + buildCommand CommandTemplate + buildCommandSubSite CommandTemplate + devCommand CommandTemplate + devURL CommandTemplate + OutputDir string + createCommand CommandTemplate + npmCreateCommand CommandTemplate + InstallLink string + SkipPackageManagerPrompt bool +} + +func (f Tool) GetItemValue() string { + return f.Name +} + +func (f Tool) GetItemDescription() string { + return f.Description +} + +type CommandVars struct { + PackageManager string + Path string + Port string + BaseURL string +} + +func (f Tool) GetDevCommand(packageManager string, port string) string { + if packageManager == "npm" { + packageManager = "npm run" + } + + vars := CommandVars{ + PackageManager: packageManager, + Port: port, + } + + return f.devCommand.Format(vars) +} + +func (f Tool) GetBuildCommand(packageManager string, path string) string { + if packageManager == "npm" { + packageManager = "npm run" + } + + vars := CommandVars{ + PackageManager: packageManager, + Path: path, + BaseURL: path, + } + + if path != "" && f.buildCommandSubSite != "" { + return f.buildCommandSubSite.Format(vars) + } + + return f.buildCommand.Format(vars) +} + +func (f Tool) GetCreateCommand(packageManager string, path string) string { + vars := CommandVars{ + PackageManager: packageManager, + Path: path, + } + + if packageManager == "npm" { + return f.npmCreateCommand.Format(vars) + } + + return f.createCommand.Format(vars) +} + +func (f Tool) GetDevURL(port string) string { + vars := CommandVars{ + Port: port, + } + + url := f.devURL.Format(vars) + + return url +} + +type CommandTemplate string + +func (t CommandTemplate) Format(vars CommandVars) string { + cmd := string(t) + cmd = strings.ReplaceAll(cmd, "{packageManager}", vars.PackageManager) + cmd = strings.ReplaceAll(cmd, "{path}", vars.Path) + cmd = strings.ReplaceAll(cmd, "{port}", vars.Port) + cmd = strings.ReplaceAll(cmd, "{baseURL}", vars.BaseURL) + + if vars.PackageManager == "npm run" { + parts := strings.Split(cmd, " ") + for i, part := range parts { + if part == "--" { + break + } + + if strings.HasPrefix(part, "--") { + parts = append(parts[:i], append([]string{"--"}, parts[i:]...)...) + cmd = strings.Join(parts, " ") + + break + } + } + } + + return cmd +} + +var tools = []Tool{ + { + Name: "Astro", + Description: "Static Site Generator (JS) — React, Vue, Markdown, and more", + Value: "astro", + buildCommand: "{packageManager} build", + buildCommandSubSite: "{packageManager} build --base {baseURL}", + devCommand: "{packageManager} dev --port {port} --strictPort", + devURL: "http://localhost:{port}", + createCommand: "{packageManager} create astro {path} --no-git", + npmCreateCommand: "npm create astro@latest {path} -- --no-git", + OutputDir: "dist", + }, + { + Name: "Vite", + Description: "Build Tool (JS) — React, Vue, Svelte, and more", + Value: "vite", + buildCommand: "{packageManager} build", + buildCommandSubSite: "{packageManager} build --base {baseURL}", + devCommand: "{packageManager} dev --port {port} --strictPort", + devURL: "http://localhost:{port}", + OutputDir: "dist", + createCommand: "{packageManager} create vite {path}", + npmCreateCommand: "npm create vite@latest {path}", + }, + { + Name: "Hugo", + Description: "Static Site Generator (Go) — Markdown, HTML, and more", + Value: "hugo", + buildCommand: "hugo", + buildCommandSubSite: "hugo --baseURL {baseURL}", + devCommand: "hugo server --port {port}", + devURL: "http://localhost:{port}", + OutputDir: "public", + createCommand: "{packageManager} new site {path}", + InstallLink: "https://gohugo.io/installation", + SkipPackageManagerPrompt: true, + }, +} diff --git a/pkg/view/tui/commands/website/validators.go b/pkg/view/tui/commands/website/validators.go new file mode 100644 index 00000000..8f1c3c39 --- /dev/null +++ b/pkg/view/tui/commands/website/validators.go @@ -0,0 +1,135 @@ +// Copyright Nitric Pty Ltd. +// +// SPDX-License-Identifier: Apache-2.0 +// +// 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. + +package add_website + +import ( + "fmt" + "regexp" + "slices" + "strconv" + "strings" + + "github.com/nitrictech/cli/pkg/view/tui/components/validation" +) + +var ( + pathPrefixRegex = regexp.MustCompile(`^/`) + pathRegex = regexp.MustCompile(`^/[a-zA-Z0-9-]*$`) + // WebsiteNameRegex matches valid characters for a website name + WebsiteNameRegex = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + // WebsiteNameStartRegex ensures the name starts with a letter or number + WebsiteNameStartRegex = regexp.MustCompile(`^[a-zA-Z0-9]`) + // WebsiteNameEndRegex ensures the name doesn't end with a hyphen + WebsiteNameEndRegex = regexp.MustCompile(`[a-zA-Z0-9]$`) +) + +func WebsiteNameInFlightValidators() []validation.StringValidator { + return []validation.StringValidator{ + validation.NotBlankValidator("Website name is required"), + validation.RegexValidator(WebsiteNameRegex, "Website name can only contain letters, numbers, underscores and hyphens"), + validation.RegexValidator(WebsiteNameStartRegex, "Website name must start with a letter or number"), + validation.RegexValidator(WebsiteNameEndRegex, "Website name cannot end with a hyphen"), + } +} + +func WebsiteNameValidators(existingNames []string) []validation.StringValidator { + return append([]validation.StringValidator{ + validation.NotBlankValidator("Website name is required"), + validation.RegexValidator(WebsiteNameRegex, "Website name can only contain letters, numbers, underscores and hyphens"), + validation.RegexValidator(WebsiteNameStartRegex, "Website name must start with a letter or number"), + validation.RegexValidator(WebsiteNameEndRegex, "Website name cannot end with a hyphen"), + }, func(existingNames []string) validation.StringValidator { + return func(value string) error { + // Normalize the new value + newName := strings.TrimPrefix(value, "./") + + for _, name := range existingNames { + // Normalize the existing basedir + existingName := strings.TrimPrefix(name, "./") + if existingName == newName { + return fmt.Errorf("website name already exists") + } + } + + return nil + } + }(existingNames)) +} + +func DisallowedPathsValidator(disallowedPaths []string) validation.StringValidator { + return func(value string) error { + if slices.Contains(disallowedPaths, value) { + return fmt.Errorf("duplicate path '%s' is not allowed", value) + } + + return nil + } +} + +func WebsiteURLPathInFlightValidators(disallowedPaths []string) []validation.StringValidator { + return []validation.StringValidator{ + validation.RegexValidator(pathPrefixRegex, "path must start with a slash"), + validation.RegexValidator(pathRegex, "path must only contain letters, numbers, and dashes after the initial slash"), + DisallowedPathsValidator(disallowedPaths), + } +} + +func WebsiteURLPathValidators(disallowedPaths []string) []validation.StringValidator { + return append([]validation.StringValidator{ + validation.NotBlankValidator("path can't be blank"), + }, WebsiteURLPathInFlightValidators(disallowedPaths)...) +} + +// PortValidators returns a list of validators for the port field +func PortValidators() []validation.StringValidator { + return []validation.StringValidator{ + func(value string) error { + if value == "" { + return fmt.Errorf("port cannot be empty") + } + + port, err := strconv.Atoi(value) + if err != nil { + return fmt.Errorf("port must be a number") + } + + if port < 1 || port > 65535 { + return fmt.Errorf("port must be between 1 and 65535") + } + + return nil + }, + } +} + +// PortInFlightValidators returns a list of in-flight validators for the port field +func PortInFlightValidators() []validation.StringValidator { + return []validation.StringValidator{ + func(value string) error { + if value == "" { + return nil // Allow empty during typing + } + + // Check if it's a number + if _, err := strconv.Atoi(value); err != nil { + return fmt.Errorf("port must be a number") + } + + return nil + }, + } +}