From 071b603f5997ee2cd7b72101cd2f25d6cf0060c2 Mon Sep 17 00:00:00 2001 From: David Moore Date: Fri, 4 Apr 2025 15:34:46 +1100 Subject: [PATCH 1/8] feat: add website command not supported in non-interactive mode --- cmd/add.go | 111 ++++ pkg/project/config.go | 9 +- pkg/view/tui/commands/website/new.go | 544 ++++++++++++++++++++ pkg/view/tui/commands/website/toolsetup.go | 188 +++++++ pkg/view/tui/commands/website/validators.go | 84 +++ 5 files changed, 933 insertions(+), 3 deletions(-) create mode 100644 cmd/add.go create mode 100644 pkg/view/tui/commands/website/new.go create mode 100644 pkg/view/tui/commands/website/toolsetup.go create mode 100644 pkg/view/tui/commands/website/validators.go diff --git a/cmd/add.go b/cmd/add.go new file mode 100644 index 00000000..ee2fd4af --- /dev/null +++ b/cmd/add.go @@ -0,0 +1,111 @@ +// 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") + } + + // get base url path for the website + 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), +} + +// init registers the 'add' command with the root command +func init() { + rootCmd.AddCommand(addCmd) + + // Add subcommands under 'add' + addCmd.AddCommand(addWebsiteCmd) + + // Copy and add the stack new command under 'add' + 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) + + // Add flag for --path, the base url path for the website + 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..114c3382 --- /dev/null +++ b/pkg/view/tui/commands/website/new.go @@ -0,0 +1,544 @@ +// 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" + "regexp" + "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 + StepRunningToolCommand + StepDone +) + +// Args holds the arguments required for the website project creation +type Args struct { + WebsiteName string + WebsitePath string + ToolName string +} + +var packageManagers = []string{"npm", "yarn", "pnpm"} // We will filter this based on what's installed + +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 + + 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) + + // collect existing website names + existingNames := []string{} + for _, website := range config.Websites { + existingNames = append(existingNames, website.Basedir) + } + + // If website name is provided in args, validate it first + if args.WebsiteName != "" { + // Normalize the provided name + normalizedName := strings.TrimPrefix(args.WebsiteName, "./") + + // Check if it's a duplicate + for _, name := range existingNames { + existingName := strings.TrimPrefix(name, "./") + if existingName == normalizedName { + return Model{}, fmt.Errorf("website name '%s' already exists", normalizedName) + } + } + + // Validate the format + if !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(normalizedName) { + return Model{}, fmt.Errorf("website name can only contain letters, numbers, underscores and hyphens") + } + + // Check if directory already exists + 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, + }) + + // collect existing paths + 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 != "" { + // check if the path is already in use + if lo.Contains(existingPaths, args.WebsitePath) { + return Model{}, fmt.Errorf("path %s is already in use", args.WebsitePath) + } + // check if the path is valid + 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 + } + } + + 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, + 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(), + ) +} + +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) // make room for the exit message + 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 + } + + 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 + } + + // Command completed successfully, update config + 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 the tool is not package manager based, we need to run the command + if tool.SkipPackageManagerPrompt { + m.step = StepRunningToolCommand + m.packagePrompt.SetChoice(tool.Value) + + // Run the command directly + return m, m.runCommand() + } + + m.step = StepPackageManager + } + case StepPackageManager: + m.packagePrompt, cmd = m.packagePrompt.UpdateListPrompt(msg) + + if m.packagePrompt.Choice() != "" { + m.step = StepRunningToolCommand + + // Run the command directly + return m, m.runCommand() + } + } + + 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 == 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() +} + +// help to get the selected tool +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 the tool has skip package manager prompt, check the tool exists and print the install guide + 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 a command that will execute the process + return tea.ExecProcess(c, func(err error) tea.Msg { + // If there was an error running the command + if err != nil { + return commandResultMsg{err: fmt.Errorf("failed to run website command: %w", err), msg: "Failed to create website"} + } + + // Check if the website directory was created + 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"} + } + + // If we get here, the website was created successfully + return commandResultMsg{msg: "Website created successfully"} + }) +} + +// update the nitric.yaml config file with website +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() + + 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(), path), + URL: tool.GetDevURL(), + }, + Path: path, + } + + m.config.Websites = append(m.config.Websites, website) + + return configUpdatedResultMsg{ + err: m.config.ToFile(m.fs, ""), + } + } +} + +// getAvailablePackageManagers filters package managers that exist on the system +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..3c4e03ab --- /dev/null +++ b/pkg/view/tui/commands/website/toolsetup.go @@ -0,0 +1,188 @@ +// 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" + "strings" +) + +type Tool struct { + Name string + Value string + Description string + buildCommand CommandTemplate + buildCommandSubSite CommandTemplate + devCommand CommandTemplate + devCommandSubSite 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 int + BaseURL string +} + +func (f Tool) GetDevCommand(packageManager string, path string) string { + // if packageManager is npm, we need to add run to the command + if packageManager == "npm" { + packageManager = "npm run" + } + + vars := CommandVars{ + PackageManager: packageManager, + Path: path, + Port: 3000, + BaseURL: path, + } + + if path != "" && f.devCommandSubSite != "" { + return f.devCommandSubSite.Format(vars) + } + + return f.devCommand.Format(vars) +} + +func (f Tool) GetBuildCommand(packageManager string, path string) string { + // if packageManager is npm, we need to add run to the command + 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() string { + vars := CommandVars{ + Port: 3000, + } + + return f.devURL.Format(vars) +} + +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}", fmt.Sprintf("%d", vars.Port)) + cmd = strings.ReplaceAll(cmd, "{baseURL}", vars.BaseURL) + + // if the package manager is npm and using a run command, + // we need to add a " -- " before the flags if it does not already exist + if vars.PackageManager == "npm run" { + // Find the first flag (starts with --) + parts := strings.Split(cmd, " ") + for i, part := range parts { + if part == "--" { + break // already has -- + } + + if strings.HasPrefix(part, "--") { + // Insert " -- " before the first flag + 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}", + devCommandSubSite: "{packageManager} dev --base {baseURL} --port {port}", + 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}", + devCommandSubSite: "{packageManager} dev --base {baseURL} --port {port}", + 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}", + devCommandSubSite: "hugo server --baseURL {baseURL} --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..be6d1a18 --- /dev/null +++ b/pkg/view/tui/commands/website/validators.go @@ -0,0 +1,84 @@ +// 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" + "strings" + + "github.com/nitrictech/cli/pkg/view/tui/components/validation" +) + +var ( + pathPrefixRegex = regexp.MustCompile(`^/`) + pathRegex = regexp.MustCompile(`^/[a-zA-Z0-9-]*$`) +) + +func WebsiteNameInFlightValidators() []validation.StringValidator { + return []validation.StringValidator{ + validation.NotBlankValidator("Website name is required"), + validation.RegexValidator(regexp.MustCompile(`^[a-zA-Z0-9_-]*$`), "Website name can only contain letters, numbers, underscores and hyphens"), + } +} + +func WebsiteNameValidators(existingNames []string) []validation.StringValidator { + return append([]validation.StringValidator{ + validation.NotBlankValidator("Website name is required"), + validation.RegexValidator(regexp.MustCompile(`^[a-zA-Z0-9_-]+$`), "Website name can only contain letters, numbers, underscores and hyphens"), + }, 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)...) +} From 41e8c23eb255b36bac8690f2ce7ecf2d3b7b9dd5 Mon Sep 17 00:00:00 2001 From: David Moore Date: Mon, 7 Apr 2025 10:19:39 +1000 Subject: [PATCH 2/8] fmt --- cmd/add.go | 1 - 1 file changed, 1 deletion(-) diff --git a/cmd/add.go b/cmd/add.go index ee2fd4af..fe25178d 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -107,5 +107,4 @@ func init() { // Add flag for --path, the base url path for the website addWebsiteCmd.Flags().StringP("path", "p", "", "base url path for the website, e.g. /my-site") - } From 1dc1a6e366994afaab47db80230fed23224b90e1 Mon Sep 17 00:00:00 2001 From: David Moore Date: Mon, 7 Apr 2025 12:54:49 +1000 Subject: [PATCH 3/8] ensure website name starts and ends with correct chars --- pkg/view/tui/commands/website/new.go | 13 +++++++++++-- pkg/view/tui/commands/website/validators.go | 14 ++++++++++++-- 2 files changed, 23 insertions(+), 4 deletions(-) diff --git a/pkg/view/tui/commands/website/new.go b/pkg/view/tui/commands/website/new.go index 114c3382..8083ee1a 100644 --- a/pkg/view/tui/commands/website/new.go +++ b/pkg/view/tui/commands/website/new.go @@ -20,7 +20,6 @@ import ( "fmt" "os" "os/exec" - "regexp" "strings" tea "github.com/charmbracelet/bubbletea" @@ -108,10 +107,20 @@ func New(fs afero.Fs, args Args) (Model, error) { } // Validate the format - if !regexp.MustCompile(`^[a-zA-Z0-9_-]+$`).MatchString(normalizedName) { + if !WebsiteNameRegex.MatchString(normalizedName) { return Model{}, fmt.Errorf("website name can only contain letters, numbers, underscores and hyphens") } + // Check if name starts with a valid character + if !WebsiteNameStartRegex.MatchString(normalizedName) { + return Model{}, fmt.Errorf("website name must start with a letter or number") + } + + // Check if name ends with a valid character + if !WebsiteNameEndRegex.MatchString(normalizedName) { + return Model{}, fmt.Errorf("website name cannot end with a hyphen") + } + // Check if directory already exists if _, err := fs.Stat(normalizedName); err == nil { return Model{}, fmt.Errorf("website directory '%s' already exists", normalizedName) diff --git a/pkg/view/tui/commands/website/validators.go b/pkg/view/tui/commands/website/validators.go index be6d1a18..45dd9cb9 100644 --- a/pkg/view/tui/commands/website/validators.go +++ b/pkg/view/tui/commands/website/validators.go @@ -28,19 +28,29 @@ import ( 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(regexp.MustCompile(`^[a-zA-Z0-9_-]*$`), "Website name can only contain letters, numbers, underscores and hyphens"), + 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(regexp.MustCompile(`^[a-zA-Z0-9_-]+$`), "Website name can only contain letters, numbers, underscores and hyphens"), + 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 From d33c19523277ba8674dedf4d8aedd4f266b1ad4f Mon Sep 17 00:00:00 2001 From: David Moore Date: Mon, 7 Apr 2025 14:42:06 +1000 Subject: [PATCH 4/8] add port as a question --- pkg/view/tui/commands/website/new.go | 48 +++++++++++++++------ pkg/view/tui/commands/website/toolsetup.go | 22 ++++++---- pkg/view/tui/commands/website/validators.go | 41 ++++++++++++++++++ 3 files changed, 91 insertions(+), 20 deletions(-) diff --git a/pkg/view/tui/commands/website/new.go b/pkg/view/tui/commands/website/new.go index 8083ee1a..ce14bea6 100644 --- a/pkg/view/tui/commands/website/new.go +++ b/pkg/view/tui/commands/website/new.go @@ -45,6 +45,7 @@ const ( StepPath StepTool StepPackageManager + StepPort StepRunningToolCommand StepDone ) @@ -76,6 +77,7 @@ type Model struct { err error namePrompt textprompt.TextPrompt pathPrompt textprompt.TextPrompt + portPrompt textprompt.TextPrompt config *project.ProjectConfiguration existingPaths []string @@ -226,6 +228,17 @@ func New(fs afero.Fs, args Args) (Model, error) { } } + 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, @@ -236,6 +249,7 @@ func New(fs afero.Fs, args Args) (Model, error) { Items: list.StringsToListItems(getAvailablePackageManagers()), }), pathPrompt: pathPrompt, + portPrompt: portPrompt, config: config, fs: fs, existingPaths: existingPaths, @@ -254,6 +268,7 @@ func (m Model) Init() tea.Cmd { m.pathPrompt.Init(), m.toolPrompt.Init(), m.packagePrompt.Init(), + m.portPrompt.Init(), ) } @@ -296,6 +311,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } 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 + + // Run the command directly + return m, m.runCommand() } return m, nil @@ -340,24 +361,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // if the tool is not package manager based, we need to run the command if tool.SkipPackageManagerPrompt { - m.step = StepRunningToolCommand m.packagePrompt.SetChoice(tool.Value) - - // Run the command directly - return m, m.runCommand() + m.step = StepPort + m.portPrompt.Focus() + } else { + m.step = StepPackageManager } - - m.step = StepPackageManager } case StepPackageManager: m.packagePrompt, cmd = m.packagePrompt.UpdateListPrompt(msg) if m.packagePrompt.Choice() != "" { - m.step = StepRunningToolCommand - - // Run the command directly - return m, m.runCommand() + m.step = StepPort + m.portPrompt.Focus() } + case StepPort: + m.portPrompt, cmd = m.portPrompt.UpdateTextPrompt(msg) } return m, cmd @@ -410,6 +429,10 @@ func (m Model) View() string { } } + if m.step >= StepPort { + v.Addln(m.portPrompt.View()) + } + if m.step == StepRunningToolCommand { v.Break() @@ -517,6 +540,7 @@ func (m Model) updateConfig() tea.Cmd { } path := m.pathPrompt.Value() + port := m.portPrompt.Value() website := project.WebsiteConfiguration{ Basedir: fmt.Sprintf("./%s", m.namePrompt.Value()), @@ -525,8 +549,8 @@ func (m Model) updateConfig() tea.Cmd { Output: tool.OutputDir, }, Dev: project.Dev{ - Command: tool.GetDevCommand(m.packagePrompt.Choice(), path), - URL: tool.GetDevURL(), + Command: tool.GetDevCommand(m.packagePrompt.Choice(), path, port), + URL: tool.GetDevURL(port, path), }, Path: path, } diff --git a/pkg/view/tui/commands/website/toolsetup.go b/pkg/view/tui/commands/website/toolsetup.go index 3c4e03ab..47e82e3f 100644 --- a/pkg/view/tui/commands/website/toolsetup.go +++ b/pkg/view/tui/commands/website/toolsetup.go @@ -17,7 +17,6 @@ package add_website import ( - "fmt" "strings" ) @@ -48,11 +47,11 @@ func (f Tool) GetItemDescription() string { type CommandVars struct { PackageManager string Path string - Port int + Port string BaseURL string } -func (f Tool) GetDevCommand(packageManager string, path string) string { +func (f Tool) GetDevCommand(packageManager string, path string, port string) string { // if packageManager is npm, we need to add run to the command if packageManager == "npm" { packageManager = "npm run" @@ -61,7 +60,7 @@ func (f Tool) GetDevCommand(packageManager string, path string) string { vars := CommandVars{ PackageManager: packageManager, Path: path, - Port: 3000, + Port: port, BaseURL: path, } @@ -104,12 +103,19 @@ func (f Tool) GetCreateCommand(packageManager string, path string) string { return f.createCommand.Format(vars) } -func (f Tool) GetDevURL() string { +func (f Tool) GetDevURL(port string, path string) string { vars := CommandVars{ - Port: 3000, + Port: port, } - return f.devURL.Format(vars) + url := f.devURL.Format(vars) + + // append the subsite path if it exists + if path != "" && path != "/" { + url = url + path + } + + return url } type CommandTemplate string @@ -118,7 +124,7 @@ 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}", fmt.Sprintf("%d", vars.Port)) + cmd = strings.ReplaceAll(cmd, "{port}", vars.Port) cmd = strings.ReplaceAll(cmd, "{baseURL}", vars.BaseURL) // if the package manager is npm and using a run command, diff --git a/pkg/view/tui/commands/website/validators.go b/pkg/view/tui/commands/website/validators.go index 45dd9cb9..8f1c3c39 100644 --- a/pkg/view/tui/commands/website/validators.go +++ b/pkg/view/tui/commands/website/validators.go @@ -20,6 +20,7 @@ import ( "fmt" "regexp" "slices" + "strconv" "strings" "github.com/nitrictech/cli/pkg/view/tui/components/validation" @@ -92,3 +93,43 @@ func WebsiteURLPathValidators(disallowedPaths []string) []validation.StringValid 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 + }, + } +} From b41fba0031c5072c80173095996fe7ebfbda84ba Mon Sep 17 00:00:00 2001 From: David Moore Date: Mon, 7 Apr 2025 16:08:00 +1000 Subject: [PATCH 5/8] chore: adds strict port flag for dev commands Adds the `--strictPort` flag to the dev commands for Astro and Vite. This ensures that the development server fails to start if the specified port is already in use, preventing unexpected behavior and making debugging easier. --- pkg/view/tui/commands/website/toolsetup.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/view/tui/commands/website/toolsetup.go b/pkg/view/tui/commands/website/toolsetup.go index 47e82e3f..45efba70 100644 --- a/pkg/view/tui/commands/website/toolsetup.go +++ b/pkg/view/tui/commands/website/toolsetup.go @@ -157,8 +157,8 @@ var tools = []Tool{ Value: "astro", buildCommand: "{packageManager} build", buildCommandSubSite: "{packageManager} build --base {baseURL}", - devCommand: "{packageManager} dev --port {port}", - devCommandSubSite: "{packageManager} dev --base {baseURL} --port {port}", + devCommand: "{packageManager} dev --port {port} --strictPort", + devCommandSubSite: "{packageManager} dev --base {baseURL} --port {port} --strictPort", devURL: "http://localhost:{port}", createCommand: "{packageManager} create astro {path} --no-git", npmCreateCommand: "npm create astro@latest {path} -- --no-git", @@ -170,8 +170,8 @@ var tools = []Tool{ Value: "vite", buildCommand: "{packageManager} build", buildCommandSubSite: "{packageManager} build --base {baseURL}", - devCommand: "{packageManager} dev --port {port}", - devCommandSubSite: "{packageManager} dev --base {baseURL} --port {port}", + devCommand: "{packageManager} dev --port {port} --strictPort", + devCommandSubSite: "{packageManager} dev --base {baseURL} --port {port} --strictPort", devURL: "http://localhost:{port}", OutputDir: "dist", createCommand: "{packageManager} create vite {path}", From 6c314e702991d33b1a7be9a80b97149ce7b703ce Mon Sep 17 00:00:00 2001 From: David Moore Date: Tue, 8 Apr 2025 16:57:05 +1000 Subject: [PATCH 6/8] remove base path from dev command and url --- pkg/view/tui/commands/website/new.go | 4 ++-- pkg/view/tui/commands/website/toolsetup.go | 19 ++----------------- 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/pkg/view/tui/commands/website/new.go b/pkg/view/tui/commands/website/new.go index ce14bea6..2fc71972 100644 --- a/pkg/view/tui/commands/website/new.go +++ b/pkg/view/tui/commands/website/new.go @@ -549,8 +549,8 @@ func (m Model) updateConfig() tea.Cmd { Output: tool.OutputDir, }, Dev: project.Dev{ - Command: tool.GetDevCommand(m.packagePrompt.Choice(), path, port), - URL: tool.GetDevURL(port, path), + Command: tool.GetDevCommand(m.packagePrompt.Choice(), port), + URL: tool.GetDevURL(port), }, Path: path, } diff --git a/pkg/view/tui/commands/website/toolsetup.go b/pkg/view/tui/commands/website/toolsetup.go index 45efba70..536fd708 100644 --- a/pkg/view/tui/commands/website/toolsetup.go +++ b/pkg/view/tui/commands/website/toolsetup.go @@ -27,7 +27,6 @@ type Tool struct { buildCommand CommandTemplate buildCommandSubSite CommandTemplate devCommand CommandTemplate - devCommandSubSite CommandTemplate devURL CommandTemplate OutputDir string createCommand CommandTemplate @@ -51,7 +50,7 @@ type CommandVars struct { BaseURL string } -func (f Tool) GetDevCommand(packageManager string, path string, port string) string { +func (f Tool) GetDevCommand(packageManager string, port string) string { // if packageManager is npm, we need to add run to the command if packageManager == "npm" { packageManager = "npm run" @@ -59,13 +58,7 @@ func (f Tool) GetDevCommand(packageManager string, path string, port string) str vars := CommandVars{ PackageManager: packageManager, - Path: path, Port: port, - BaseURL: path, - } - - if path != "" && f.devCommandSubSite != "" { - return f.devCommandSubSite.Format(vars) } return f.devCommand.Format(vars) @@ -103,18 +96,13 @@ func (f Tool) GetCreateCommand(packageManager string, path string) string { return f.createCommand.Format(vars) } -func (f Tool) GetDevURL(port string, path string) string { +func (f Tool) GetDevURL(port string) string { vars := CommandVars{ Port: port, } url := f.devURL.Format(vars) - // append the subsite path if it exists - if path != "" && path != "/" { - url = url + path - } - return url } @@ -158,7 +146,6 @@ var tools = []Tool{ buildCommand: "{packageManager} build", buildCommandSubSite: "{packageManager} build --base {baseURL}", devCommand: "{packageManager} dev --port {port} --strictPort", - devCommandSubSite: "{packageManager} dev --base {baseURL} --port {port} --strictPort", devURL: "http://localhost:{port}", createCommand: "{packageManager} create astro {path} --no-git", npmCreateCommand: "npm create astro@latest {path} -- --no-git", @@ -171,7 +158,6 @@ var tools = []Tool{ buildCommand: "{packageManager} build", buildCommandSubSite: "{packageManager} build --base {baseURL}", devCommand: "{packageManager} dev --port {port} --strictPort", - devCommandSubSite: "{packageManager} dev --base {baseURL} --port {port} --strictPort", devURL: "http://localhost:{port}", OutputDir: "dist", createCommand: "{packageManager} create vite {path}", @@ -184,7 +170,6 @@ var tools = []Tool{ buildCommand: "hugo", buildCommandSubSite: "hugo --baseURL {baseURL}", devCommand: "hugo server --port {port}", - devCommandSubSite: "hugo server --baseURL {baseURL} --port {port}", devURL: "http://localhost:{port}", OutputDir: "public", createCommand: "{packageManager} new site {path}", From c1d2559a9b3dd2fe1c89a98ea952f21ba27adcc2 Mon Sep 17 00:00:00 2001 From: David Moore <4121492+davemooreuws@users.noreply.github.com> Date: Wed, 30 Apr 2025 13:57:30 +1000 Subject: [PATCH 7/8] Update pkg/view/tui/commands/website/new.go Co-authored-by: Jye Cusch --- pkg/view/tui/commands/website/new.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/view/tui/commands/website/new.go b/pkg/view/tui/commands/website/new.go index 2fc71972..1f7bc4a1 100644 --- a/pkg/view/tui/commands/website/new.go +++ b/pkg/view/tui/commands/website/new.go @@ -183,8 +183,8 @@ func New(fs afero.Fs, args Args) (Model, error) { } if args.WebsitePath != "" { - // check if the path is already in use - if lo.Contains(existingPaths, args.WebsitePath) { + pathInUse := lo.Contains(existingPaths, args.WebsitePath) + if pathInUse { return Model{}, fmt.Errorf("path %s is already in use", args.WebsitePath) } // check if the path is valid From c8cb6a8b4c0dde5f37c092875be6ff555d7c3107 Mon Sep 17 00:00:00 2001 From: David Moore Date: Wed, 30 Apr 2025 14:16:32 +1000 Subject: [PATCH 8/8] clean comments --- cmd/add.go | 5 ----- pkg/view/tui/commands/website/new.go | 26 ++-------------------- pkg/view/tui/commands/website/toolsetup.go | 8 +------ 3 files changed, 3 insertions(+), 36 deletions(-) diff --git a/cmd/add.go b/cmd/add.go index fe25178d..3199c8e2 100644 --- a/cmd/add.go +++ b/cmd/add.go @@ -65,7 +65,6 @@ nitric add website my-site astro`, return fmt.Errorf("non-interactive mode is not supported by this command") } - // get base url path for the website websitePath, err := cmd.Flags().GetString("path") if err != nil { return fmt.Errorf("failed to get path flag: %w", err) @@ -87,14 +86,11 @@ nitric add website my-site astro`, Args: cobra.MaximumNArgs(2), } -// init registers the 'add' command with the root command func init() { rootCmd.AddCommand(addCmd) - // Add subcommands under 'add' addCmd.AddCommand(addWebsiteCmd) - // Copy and add the stack new command under 'add' addStackCmd := &cobra.Command{ Use: "stack [stackName] [providerName]", Short: newStackCmd.Short, @@ -105,6 +101,5 @@ func init() { addStackCmd.Flags().AddFlagSet(newStackCmd.Flags()) addCmd.AddCommand(addStackCmd) - // Add flag for --path, the base url path for the website addWebsiteCmd.Flags().StringP("path", "p", "", "base url path for the website, e.g. /my-site") } diff --git a/pkg/view/tui/commands/website/new.go b/pkg/view/tui/commands/website/new.go index 1f7bc4a1..32ea4be1 100644 --- a/pkg/view/tui/commands/website/new.go +++ b/pkg/view/tui/commands/website/new.go @@ -50,14 +50,13 @@ const ( StepDone ) -// Args holds the arguments required for the website project creation type Args struct { WebsiteName string WebsitePath string ToolName string } -var packageManagers = []string{"npm", "yarn", "pnpm"} // We will filter this based on what's installed +var packageManagers = []string{"npm", "yarn", "pnpm"} type configUpdatedResultMsg struct { err error @@ -89,18 +88,14 @@ func New(fs afero.Fs, args Args) (Model, error) { config, err := project.ConfigurationFromFile(fs, "") tui.CheckErr(err) - // collect existing website names existingNames := []string{} for _, website := range config.Websites { existingNames = append(existingNames, website.Basedir) } - // If website name is provided in args, validate it first if args.WebsiteName != "" { - // Normalize the provided name normalizedName := strings.TrimPrefix(args.WebsiteName, "./") - // Check if it's a duplicate for _, name := range existingNames { existingName := strings.TrimPrefix(name, "./") if existingName == normalizedName { @@ -108,22 +103,18 @@ func New(fs afero.Fs, args Args) (Model, error) { } } - // Validate the format if !WebsiteNameRegex.MatchString(normalizedName) { return Model{}, fmt.Errorf("website name can only contain letters, numbers, underscores and hyphens") } - // Check if name starts with a valid character if !WebsiteNameStartRegex.MatchString(normalizedName) { return Model{}, fmt.Errorf("website name must start with a letter or number") } - // Check if name ends with a valid character if !WebsiteNameEndRegex.MatchString(normalizedName) { return Model{}, fmt.Errorf("website name cannot end with a hyphen") } - // Check if directory already exists if _, err := fs.Stat(normalizedName); err == nil { return Model{}, fmt.Errorf("website directory '%s' already exists", normalizedName) } else if !os.IsNotExist(err) { @@ -142,7 +133,6 @@ func New(fs afero.Fs, args Args) (Model, error) { InFlightValidator: nameInFlightValidator, }) - // collect existing paths existingPaths := []string{} for _, website := range config.Websites { @@ -187,7 +177,6 @@ func New(fs afero.Fs, args Args) (Model, error) { if pathInUse { return Model{}, fmt.Errorf("path %s is already in use", args.WebsitePath) } - // check if the path is valid if err := pathValidator(args.WebsitePath); err != nil { return Model{}, fmt.Errorf("path %s is invalid: %w", args.WebsitePath, err) } @@ -286,7 +275,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.packagePrompt.SetMaxDisplayedItems(m.windowSize.Height - 1) } else { m.toolPrompt.SetMinimized(false) - maxItems := ((m.windowSize.Height - 3) / 3) // make room for the exit message + maxItems := ((m.windowSize.Height - 3) / 3) m.toolPrompt.SetMaxDisplayedItems(maxItems) m.packagePrompt.SetMinimized(false) m.packagePrompt.SetMaxDisplayedItems(maxItems) @@ -315,7 +304,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.portPrompt.Blur() m.step = StepRunningToolCommand - // Run the command directly return m, m.runCommand() } @@ -339,7 +327,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, teax.Quit } - // Command completed successfully, update config return m, m.updateConfig() } @@ -359,7 +346,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, teax.Quit } - // if the tool is not package manager based, we need to run the command if tool.SkipPackageManagerPrompt { m.packagePrompt.SetChoice(tool.Value) m.step = StepPort @@ -467,7 +453,6 @@ func (m Model) View() string { return v.Render() } -// help to get the selected tool func (m Model) getSelectedTool() (Tool, error) { tool, ok := lo.Find(tools, func(f Tool) bool { return f.Name == m.toolPrompt.Choice() @@ -486,7 +471,6 @@ func (m Model) runCommand() tea.Cmd { return teax.Quit } - // if the tool has skip package manager prompt, check the tool exists and print the install guide if tool.SkipPackageManagerPrompt { if _, err := exec.LookPath(tool.Value); err != nil { return func() tea.Msg { @@ -505,14 +489,11 @@ func (m Model) runCommand() tea.Cmd { c := exec.Command(command, args...) - // Return a command that will execute the process return tea.ExecProcess(c, func(err error) tea.Msg { - // If there was an error running the command if err != nil { return commandResultMsg{err: fmt.Errorf("failed to run website command: %w", err), msg: "Failed to create website"} } - // Check if the website directory was created websiteDir := m.namePrompt.Value() if _, err := m.fs.Stat(websiteDir); err != nil { if os.IsNotExist(err) { @@ -522,12 +503,10 @@ func (m Model) runCommand() tea.Cmd { return commandResultMsg{err: fmt.Errorf("failed to check website directory: %w", err), msg: "Failed to verify website creation"} } - // If we get here, the website was created successfully return commandResultMsg{msg: "Website created successfully"} }) } -// update the nitric.yaml config file with website func (m Model) updateConfig() tea.Cmd { return func() tea.Msg { var tool Tool @@ -563,7 +542,6 @@ func (m Model) updateConfig() tea.Cmd { } } -// getAvailablePackageManagers filters package managers that exist on the system func getAvailablePackageManagers() []string { available := []string{} diff --git a/pkg/view/tui/commands/website/toolsetup.go b/pkg/view/tui/commands/website/toolsetup.go index 536fd708..56e77c38 100644 --- a/pkg/view/tui/commands/website/toolsetup.go +++ b/pkg/view/tui/commands/website/toolsetup.go @@ -51,7 +51,6 @@ type CommandVars struct { } func (f Tool) GetDevCommand(packageManager string, port string) string { - // if packageManager is npm, we need to add run to the command if packageManager == "npm" { packageManager = "npm run" } @@ -65,7 +64,6 @@ func (f Tool) GetDevCommand(packageManager string, port string) string { } func (f Tool) GetBuildCommand(packageManager string, path string) string { - // if packageManager is npm, we need to add run to the command if packageManager == "npm" { packageManager = "npm run" } @@ -115,18 +113,14 @@ func (t CommandTemplate) Format(vars CommandVars) string { cmd = strings.ReplaceAll(cmd, "{port}", vars.Port) cmd = strings.ReplaceAll(cmd, "{baseURL}", vars.BaseURL) - // if the package manager is npm and using a run command, - // we need to add a " -- " before the flags if it does not already exist if vars.PackageManager == "npm run" { - // Find the first flag (starts with --) parts := strings.Split(cmd, " ") for i, part := range parts { if part == "--" { - break // already has -- + break } if strings.HasPrefix(part, "--") { - // Insert " -- " before the first flag parts = append(parts[:i], append([]string{"--"}, parts[i:]...)...) cmd = strings.Join(parts, " ")