diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 4e3cd28..1acad72 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,4 +1,4 @@ -# DEVOPS: PULL REQUEST +# HACKSTACK: PULL REQUEST ## Description diff --git a/.gitignore b/.gitignore index 7c13c63..7793903 100644 --- a/.gitignore +++ b/.gitignore @@ -8,9 +8,12 @@ wheels/ .python-version # Testing -.coverage +coverage/ htmlcov/ -.pytest_cache/ +*.test +*.out +*.log +dump/ # Virtual environments *venv diff --git a/cli/command/cmds/build.go b/cli/command/cmds/build.go new file mode 100644 index 0000000..7f77728 --- /dev/null +++ b/cli/command/cmds/build.go @@ -0,0 +1,187 @@ +package cmds + +import ( + "context" + "fmt" + "io/fs" + "os" + "runtime" + "strings" + "time" + + "github.com/fatih/color" + "github.com/jgfranco17/hackstack/cli/internal/errorhandling" + "github.com/jgfranco17/hackstack/cli/internal/fileutils" + "github.com/jgfranco17/hackstack/cli/internal/logging" + "github.com/jgfranco17/hackstack/cli/internal/templating" + "github.com/jgfranco17/hackstack/cli/internal/tui" + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +type buildCommand struct { + // Injectable dependencies for easier testing + engineFactory templaterFactory + loader loaderFunc + dataSource dataSourceFunc + + // Flags + outputPath string + sourceFile string + forceOverwrite bool +} + +// NewBuildCommand constructs the 'build' command with default dependencies. +func NewBuildCommand() *cobra.Command { + cmd := &buildCommand{ + engineFactory: func(files fs.FS, data templating.CLIProject) renderer { + return templating.NewEngine(files, data) + }, + loader: templating.Load, + dataSource: defaultDataSource, + } + return cmd.invoke() +} + +func (c *buildCommand) invoke() *cobra.Command { + cmd := &cobra.Command{ + Use: "build", + Short: "Build a new project from a template", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + logger := logging.FromContext(ctx).WithField("command", "build") + category := args[0] + + if !fileutils.IsDir(c.outputPath) { + return &errorhandling.CommandError{ + Err: fmt.Errorf("output path %q is not a directory", c.outputPath), + ExitCode: errorhandling.ExitInputError, + HelpText: "The specified output path is not a directory, please provide a valid directory path.", + } + } + + empty, err := fileutils.IsDirEmpty(c.outputPath) + if err != nil { + return &errorhandling.CommandError{ + Err: fmt.Errorf("failed to check output directory %q: %w", c.outputPath, err), + ExitCode: errorhandling.ExitInputError, + HelpText: "The output directory must be accessible before building into it.", + } + } + if !empty { + if c.forceOverwrite { + logger.Warn("Proceeding with overwrite due to force flag") + } else { + return &errorhandling.CommandError{ + Err: fmt.Errorf("output path %q is not empty", c.outputPath), + ExitCode: errorhandling.ExitInputError, + HelpText: "The output directory must be empty before building into it.", + } + } + } + + files, err := c.loader(ctx, category) + if err != nil { + return &errorhandling.CommandError{ + Err: fmt.Errorf("failed to load template resources for category %q: %w", category, err), + ExitCode: errorhandling.ExitGenericError, + HelpText: "Failed to load template resources. Please check the category name and try again.", + } + } + + data, err := c.dataSource(c.sourceFile) + if err != nil { + return &errorhandling.CommandError{ + Err: fmt.Errorf("failed to resolve template data: %w", err), + ExitCode: errorhandling.ExitInputError, + HelpText: "Provide a valid --source YAML file or complete the interactive prompt.", + } + } + logger.WithFields(logrus.Fields{ + "category": category, + "output": c.outputPath, + }).Trace("Beginning template render") + startTime := time.Now() + templater := c.engineFactory(files, data) + if err := templater.Render(ctx, c.outputPath); err != nil { + return &errorhandling.CommandError{ + Err: fmt.Errorf("failed to render template %q: %w", category, err), + ExitCode: errorhandling.ExitGenericError, + HelpText: "Please check the template and try again.", + } + } + logger.WithFields(logrus.Fields{ + "category": category, + "output": c.outputPath, + "duration": time.Since(startTime).String(), + }).Info("Created full project from template") + + greenFmt := color.New(color.FgGreen).FprintlnFunc() + greenFmt(cmd.OutOrStdout(), "Built new templated project, happy hacking!") + + return nil + }, + } + + cmd.Flags().StringVarP(&c.outputPath, "output", "o", ".", "Output directory for the generated project") + cmd.Flags().StringVarP(&c.sourceFile, "source", "s", "", "Path to a YAML file supplying template data (name, username, author, go-version)") + cmd.Flags().BoolVarP(&c.forceOverwrite, "force", "f", false, "Force overwrite of existing files in the output directory") + return cmd +} + +// renderer is the minimal interface required by the build command to execute a +// template render. *templating.Engine satisfies this interface. +type renderer interface { + Render(ctx context.Context, outputPath string) error +} + +// loaderFunc loads the embedded FS for a given category. +// Matches templating.Load so it can be swapped in tests. +type loaderFunc func(ctx context.Context, category string) (fs.FS, error) + +// templaterFactory constructs a renderer from a loaded FS and template data. +type templaterFactory func(files fs.FS, data templating.CLIProject) renderer + +// dataSourceFunc resolves TemplateData from a source file path. When sourceFile +// is empty the implementation should fall back to interactive input. +type dataSourceFunc func(sourceFile string) (templating.CLIProject, error) + +// defaultDataSource is the production dataSourceFunc. It reads from a YAML file +// when sourceFile is non-empty, otherwise launches the interactive TUI. +func defaultDataSource(sourceFile string) (templating.CLIProject, error) { + if sourceFile != "" { + return loadFromYAMLFile(sourceFile) + } + return tui.PromptForCLI() +} + +// loadFromYAMLFile reads a YAML file at path and decodes it into CLIProject. +// GoVersion is always overridden with the host runtime version after decoding. +func loadFromYAMLFile(path string) (templating.CLIProject, error) { + f, err := os.Open(path) + if err != nil { + return templating.CLIProject{}, fmt.Errorf("open source file %q: %w", path, err) + } + defer f.Close() + + data, err := templating.DataFromSource[templating.CLIProject](f) + if err != nil { + return templating.CLIProject{}, fmt.Errorf("parse source file %q: %w", path, err) + } + + if data.GoVersion == "" { + data.GoVersion = runtimeGoVersion() + } + + if err := data.Validate(); err != nil { + return templating.CLIProject{}, err + } + return *data, nil +} + +// runtimeGoVersion returns the host Go version string without the leading "go" +// prefix (e.g. "1.24.0"). +func runtimeGoVersion() string { + return strings.TrimPrefix(runtime.Version(), "go") +} diff --git a/cli/command/cmds/build_test.go b/cli/command/cmds/build_test.go new file mode 100644 index 0000000..70ce01c --- /dev/null +++ b/cli/command/cmds/build_test.go @@ -0,0 +1,419 @@ +package cmds + +import ( + "context" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" + + "github.com/jgfranco17/hackstack/cli/internal/errorhandling" + "github.com/jgfranco17/hackstack/cli/internal/logging" + "github.com/jgfranco17/hackstack/cli/internal/templating" + "github.com/jgfranco17/hackstack/cli/internal/testutils" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildCommand(t *testing.T) { + tests := []struct { + name string + args func(t *testing.T) []string + cmd func(t *testing.T) *buildCommand + wantErr bool + wantExit errorhandling.ExitCode + wantStdout string + wantNoErr bool + }{ + { + name: "output path does not exist", + args: func(t *testing.T) []string { + return []string{"cli", "--output", filepath.Join(t.TempDir(), "no-such-dir")} + }, + cmd: func(t *testing.T) *buildCommand { + return newTestBuildCommand(successLoader, nil) + }, + wantErr: true, + wantExit: errorhandling.ExitInputError, + }, + { + name: "output path is a file not a directory", + args: func(t *testing.T) []string { + path := filepath.Join(t.TempDir(), "file.txt") + require.NoError(t, os.WriteFile(path, []byte("x"), 0644)) + return []string{"cli", "--output", path} + }, + cmd: func(t *testing.T) *buildCommand { + return newTestBuildCommand(successLoader, nil) + }, + wantErr: true, + wantExit: errorhandling.ExitInputError, + }, + { + name: "non-empty output dir without force flag returns error", + args: func(t *testing.T) []string { + dir := t.TempDir() + touchFile(t, dir) + return []string{"cli", "--output", dir} + }, + cmd: func(t *testing.T) *buildCommand { + return newTestBuildCommand(successLoader, nil) + }, + wantErr: true, + wantExit: errorhandling.ExitInputError, + }, + { + name: "non-empty output dir with force flag proceeds", + args: func(t *testing.T) []string { + dir := t.TempDir() + touchFile(t, dir) + return []string{"cli", "--output", dir, "--force"} + }, + cmd: func(t *testing.T) *buildCommand { + return newTestBuildCommand(successLoader, nil) + }, + wantNoErr: true, + wantStdout: "happy hacking", + }, + { + name: "loader failure returns generic error", + args: func(t *testing.T) []string { + return []string{"cli", "--output", t.TempDir()} + }, + cmd: func(t *testing.T) *buildCommand { + return newTestBuildCommand(errorLoader, nil) + }, + wantErr: true, + wantExit: errorhandling.ExitGenericError, + }, + { + name: "renderer failure returns generic error", + args: func(t *testing.T) []string { + return []string{"cli", "--output", t.TempDir()} + }, + cmd: func(t *testing.T) *buildCommand { + return newTestBuildCommand(successLoader, errors.New("render failure")) + }, + wantErr: true, + wantExit: errorhandling.ExitGenericError, + }, + { + name: "successful render on empty dir prints success message", + args: func(t *testing.T) []string { + return []string{"cli", "--output", t.TempDir()} + }, + cmd: func(t *testing.T) *buildCommand { + return newTestBuildCommand(successLoader, nil) + }, + wantNoErr: true, + wantStdout: "happy hacking", + }, + { + name: "category argument is forwarded to loader", + args: func(t *testing.T) []string { + return []string{"mycat", "--output", t.TempDir()} + }, + cmd: func(t *testing.T) *buildCommand { + var gotCategory string + loader := func(_ context.Context, category string) (fs.FS, error) { + gotCategory = category + return fstest.MapFS{}, nil + } + cmd := newTestBuildCommand(loader, nil) + t.Cleanup(func() { assert.Equal(t, "mycat", gotCategory) }) + return cmd + }, + wantNoErr: true, + }, + { + name: "missing category argument returns error", + args: func(t *testing.T) []string { return []string{} }, + cmd: func(t *testing.T) *buildCommand { + return newTestBuildCommand(successLoader, nil) + }, + wantErr: true, + }, + { + name: "valid --source YAML file is used for template data", + args: func(t *testing.T) []string { + src := filepath.Join(t.TempDir(), "data.yaml") + require.NoError(t, os.WriteFile(src, []byte( + "name: myapp\nusername: jgfranco17\nauthor: Jorge\n", + ), 0644)) + return []string{"cli", "--output", t.TempDir(), "--source", src} + }, + cmd: func(t *testing.T) *buildCommand { + cmd := newTestBuildCommand(successLoader, nil) + // Use the real YAML loader so the flag is exercised end-to-end. + cmd.dataSource = defaultDataSource + return cmd + }, + wantNoErr: true, + wantStdout: "happy hacking", + }, + { + name: "--source pointing to non-existent file returns input error", + args: func(t *testing.T) []string { + return []string{"cli", "--output", t.TempDir(), "--source", "/no/such/file.yaml"} + }, + cmd: func(t *testing.T) *buildCommand { + cmd := newTestBuildCommand(successLoader, nil) + cmd.dataSource = defaultDataSource + return cmd + }, + wantErr: true, + wantExit: errorhandling.ExitInputError, + }, + { + name: "dataSource failure returns input error", + args: func(t *testing.T) []string { + return []string{"cli", "--output", t.TempDir()} + }, + cmd: func(t *testing.T) *buildCommand { + cmd := newTestBuildCommand(successLoader, nil) + cmd.dataSource = func(_ string) (templating.CLIProject, error) { + return templating.CLIProject{}, errors.New("data source failure") + } + return cmd + }, + wantErr: true, + wantExit: errorhandling.ExitInputError, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := runBuild(t, tc.cmd(t), tc.args(t)...) + + if tc.wantNoErr { + require.NoError(t, result.RunErr) + } + + if tc.wantErr { + require.Error(t, result.RunErr) + } + + if tc.wantExit != 0 { + var cmdErr *errorhandling.CommandError + require.True(t, errors.As(result.RunErr, &cmdErr), "expected a CommandError, got: %v", result.RunErr) + assert.Equal(t, tc.wantExit, cmdErr.ExitCode) + } + + if tc.wantStdout != "" { + assert.True(t, strings.Contains(result.Stdout, tc.wantStdout), + "expected stdout to contain %q, got: %q", tc.wantStdout, result.Stdout) + } + }) + } +} + +func TestLoadFromYAMLFile(t *testing.T) { + tests := []struct { + name string + content string + filePath func(t *testing.T) string + wantName string + wantUser string + wantAuth string + wantErr string + }{ + { + name: "valid full YAML", + content: "name: myapp\nusername: jgfranco17\nauthor: Jorge\n", + wantName: "myapp", + wantUser: "jgfranco17", + wantAuth: "Jorge", + }, + { + name: "missing name field", + content: "username: jgfranco17\nauthor: Jorge\n", + wantErr: "name", + }, + { + name: "missing username field", + content: "name: myapp\nauthor: Jorge\n", + wantErr: "username", + }, + { + name: "missing author field", + content: "name: myapp\nusername: jgfranco17\n", + wantErr: "author", + }, + { + name: "multiple missing fields", + content: "name: myapp\n", + wantErr: "username", + }, + { + name: "malformed YAML", + content: "name: [unclosed bracket\n", + wantErr: "parse source file", + }, + { + name: "non-existent file path", + filePath: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "no-such-file.yaml") + }, + wantErr: "open source file", + }, + { + name: "empty file produces parse error", + content: "", + wantErr: "parse source file", + }, + { + name: "go-version field from YAML is ignored in favour of runtime", + content: "name: myapp\nusername: user\nauthor: author\ngo-version: 0.0.0\n", + wantName: "myapp", + wantUser: "user", + wantAuth: "author", + // GoVersion is always set from runtime, so 0.0.0 should be overwritten. + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var path string + if tc.filePath != nil { + path = tc.filePath(t) + } else { + path = filepath.Join(t.TempDir(), "data.yaml") + require.NoError(t, os.WriteFile(path, []byte(tc.content), 0644)) + } + + data, err := loadFromYAMLFile(path) + + if tc.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.wantName, data.Name) + assert.Equal(t, tc.wantUser, data.Username) + assert.Equal(t, tc.wantAuth, data.Author) + }) + } +} + +func TestValidateTemplateData(t *testing.T) { + tests := []struct { + name string + input string // YAML content to decode into TemplateData + wantErr bool + missing []string + }{ + { + name: "all fields present", + input: "name: a\nusername: b\nauthor: c\n", + }, + { + name: "empty name", + input: "username: b\nauthor: c\n", + wantErr: true, + missing: []string{"name"}, + }, + { + name: "empty username", + input: "name: a\nauthor: c\n", + wantErr: true, + missing: []string{"username"}, + }, + { + name: "empty author", + input: "name: a\nusername: b\n", + wantErr: true, + missing: []string{"author"}, + }, + { + name: "all fields empty strings", + input: "name:\nusername:\nauthor:\n", + wantErr: true, + missing: []string{"name", "username", "author"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "data.yaml") + require.NoError(t, os.WriteFile(path, []byte(tc.input), 0644)) + + imported, err := loadFromYAMLFile(path) + if tc.wantErr { + require.Error(t, err) + for _, field := range tc.missing { + assert.Contains(t, err.Error(), field) + } + // Zero-value struct on error + assert.Empty(t, imported.Name) + return + } + require.NoError(t, err) + }) + } +} + +// mockRenderer implements renderer, returning a fixed error (nil = success). +type mockRenderer struct { + err error +} + +func (m *mockRenderer) Render(_ context.Context, _ string) error { + return m.err +} + +// newTestBuildCommand wires up a buildCommand with injectable dependencies. +// dataSource defaults to successDataSource when nil. +func newTestBuildCommand(loader loaderFunc, renderErr error) *buildCommand { + return &buildCommand{ + loader: loader, + dataSource: func(_ string) (templating.CLIProject, error) { + return templating.CLIProject{Name: "test", Username: "user", Author: "author"}, nil + }, + engineFactory: func(_ fs.FS, _ templating.CLIProject) renderer { + return &mockRenderer{err: renderErr} + }, + } +} + +// buildContext returns a context carrying a silent logger, satisfying +// logging.FromContext without cluttering test output. +func buildContext(t *testing.T) context.Context { + t.Helper() + logger := logrus.New() + logger.SetOutput(io.Discard) + return logging.AddToContext(context.Background(), logger) +} + +// runBuild executes the build cobra command with the supplied args, +// after injecting a logger-carrying context. +func runBuild(t *testing.T, cmd *buildCommand, args ...string) testutils.ExecResult { + t.Helper() + cobraCmd := cmd.invoke() + cobraCmd.SetContext(buildContext(t)) + return testutils.RunCommand(t, cobraCmd, args...) +} + +// successLoader is a no-op loader that always returns an empty FS and no error. +func successLoader(_ context.Context, _ string) (fs.FS, error) { + return fstest.MapFS{}, nil +} + +// errorLoader always returns an error. +func errorLoader(_ context.Context, _ string) (fs.FS, error) { + return nil, errors.New("loader failure") +} + +// touchFile creates a file inside dir to make it non-empty. +func touchFile(t *testing.T, dir string) { + t.Helper() + f, err := os.Create(filepath.Join(dir, "existing.txt")) + require.NoError(t, err) + require.NoError(t, f.Close()) +} diff --git a/cli/command/registry.go b/cli/command/registry.go index 872a1ec..96e5e8a 100644 --- a/cli/command/registry.go +++ b/cli/command/registry.go @@ -3,13 +3,11 @@ package command import ( "context" "errors" - "fmt" "os" "os/signal" "syscall" "github.com/jgfranco17/hackstack/cli/command/cmds" - "github.com/jgfranco17/hackstack/cli/internal/errorhandling" "github.com/jgfranco17/hackstack/cli/internal/logging" "github.com/sirupsen/logrus" "github.com/spf13/cobra" @@ -82,10 +80,19 @@ func New(options RootCommandOptions) (*CLI, error) { logger := logging.New(cmd.ErrOrStderr(), level) ctx := logging.AddToContext(cmd.Context(), logger) - // Create a signal-aware context for the entire CLI lifecycle once here. - // This ensures signal handling is never duplicated regardless of how many - // commands are executed or how many times PersistentPreRunE fires. - ctx, _ = signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT, os.Interrupt) + + ctx, cancel := context.WithCancel(ctx) + c := make(chan os.Signal, 1) + signal.Notify(c, syscall.SIGINT, syscall.SIGTERM) + go func() { + select { + case sig := <-c: + logger.WithField("signal", sig).Infof("Received signal, exiting") + cancel() + case <-ctx.Done(): + // Context was canceled, exit goroutine. + } + }() cmd.SetContext(ctx) return nil @@ -124,16 +131,9 @@ func (cr *CLI) Cleanup() { } // Execute executes the root command. -func (cr *CLI) Execute() int { +func (cr *CLI) Execute() error { if err := cr.root.Execute(); err != nil { - var cmdErr *errorhandling.CommandError - if ok := errors.As(err, &cmdErr); ok { - fmt.Println(cmdErr.String()) - return cmdErr.ExitCode - } else { - fmt.Printf("An unexpected error occurred: %v\n", err) - return 1 - } + return err } - return 0 + return nil } diff --git a/cli/command/registry_test.go b/cli/command/registry_test.go index 10d7dd3..df141c7 100644 --- a/cli/command/registry_test.go +++ b/cli/command/registry_test.go @@ -179,8 +179,8 @@ func TestVerbosityLevels(t *testing.T) { cli.root.SetErr(&buf) cli.root.SetArgs(tt.args) - exitCode := cli.Execute() - assert.Equal(t, 0, exitCode) + err = cli.Execute() + require.NoError(t, err) assert.Equal(t, tt.expectedLevel, actualLevel) cli.Cleanup() @@ -204,8 +204,8 @@ func TestExecute_HelpFlag(t *testing.T) { cli.root.SetErr(&buf) cli.root.SetArgs([]string{"--help"}) - exitCode := cli.Execute() - assert.Equal(t, 0, exitCode) + err = cli.Execute() + require.NoError(t, err) output := buf.String() assert.Contains(t, output, "A test CLI application") @@ -226,8 +226,8 @@ func TestExecute_VersionFlag(t *testing.T) { cli.root.SetErr(&buf) cli.root.SetArgs([]string{"--version"}) - exitCode := cli.Execute() - assert.Equal(t, 0, exitCode) + err = cli.Execute() + require.NoError(t, err) output := buf.String() assert.Contains(t, output, "1.0.0") diff --git a/cli/entrypoint/entrypoint.go b/cli/entrypoint/entrypoint.go index 65e7007..0891c81 100644 --- a/cli/entrypoint/entrypoint.go +++ b/cli/entrypoint/entrypoint.go @@ -2,10 +2,18 @@ package entrypoint import ( "encoding/json" + "errors" "fmt" "os" + "github.com/fatih/color" + "github.com/jgfranco17/hackstack/cli/command" + "github.com/jgfranco17/hackstack/cli/internal/errorhandling" +) + +const ( + commandName = "hackstack" ) type ProjectMetadata struct { @@ -14,23 +22,60 @@ type ProjectMetadata struct { Version string `json:"version"` } -func Run(metadata []byte) { +func readMetadata(rawData []byte) (ProjectMetadata, error) { var projectMetadata ProjectMetadata - if err := json.Unmarshal(metadata, &projectMetadata); err != nil { - fmt.Printf("Error unmarshaling metadata: %v\n", err) - panic("failed to unmarshal metadata") + if err := json.Unmarshal(rawData, &projectMetadata); err != nil { + return ProjectMetadata{}, fmt.Errorf("Error unmarshaling metadata: %v\n", err) + } + return projectMetadata, nil +} + +func Run(metadata []byte) { + defer func() { + if r := recover(); r != nil { + printError("Application %s crashed: %v", commandName, r) + os.Exit(errorhandling.ExitPanicError.Int()) + } + }() + + projectMetadata, err := readMetadata(metadata) + if err != nil { + printError("Error reading metadata: %v", err) + os.Exit(errorhandling.ExitInputError.Int()) } command, err := command.New(command.RootCommandOptions{ - Name: "hackstack", + Name: commandName, Description: projectMetadata.Description, Version: projectMetadata.Version, }) if err != nil { - fmt.Printf("Error creating command: %v\n", err) - os.Exit(1) + printError("Error creating command: %v", err) + os.Exit(errorhandling.ExitGenericError.Int()) + } + + var exitCode int + if err := command.Execute(); err != nil { + exitCode = handlerExecError(err) + } else { + exitCode = 0 } - exitCode := command.Execute() os.Exit(exitCode) } + +func handlerExecError(err error) int { + var cmdErr *errorhandling.CommandError + if ok := errors.As(err, &cmdErr); ok { + fmt.Println(cmdErr.String()) + return cmdErr.ExitCode.Int() + } + fmt.Printf("An unexpected error occurred: %v\n", err) + return errorhandling.ExitGenericError.Int() +} + +func printError(format string, args ...any) { + redBoldFmt := color.New(color.FgRed).Add(color.Bold).FprintfFunc() + message := fmt.Sprintf(format, args...) + redBoldFmt(os.Stderr, "[FATAL] %s\n", message) +} diff --git a/cli/internal/errorhandling/errors.go b/cli/internal/errorhandling/errors.go index 7fd7796..6055bea 100644 --- a/cli/internal/errorhandling/errors.go +++ b/cli/internal/errorhandling/errors.go @@ -7,9 +7,23 @@ import ( "github.com/fatih/color" ) +type ExitCode int + +func (e ExitCode) Int() int { + return int(e) +} + +const ( + ExitSuccess ExitCode = 0 + ExitGenericError ExitCode = 1 + ExitPanicError ExitCode = 2 + ExitTemplateError ExitCode = 3 + ExitInputError ExitCode = 4 +) + type CommandError struct { Err error - ExitCode int + ExitCode ExitCode HelpText string } @@ -25,7 +39,6 @@ func (e *CommandError) String() string { redBoldFmt := color.New(color.FgRed).Add(color.Bold).SprintfFunc() var renderedMessage strings.Builder - renderedMessage.WriteString("\n") renderedMessage.WriteString(redBoldFmt("[ERROR] Hackstack internal error: %s\n", e.Err.Error())) if e.HelpText != "" { renderedMessage.WriteString(fmt.Sprintf("[HELP] %s", e.HelpText)) diff --git a/cli/internal/errorhandling/errors_test.go b/cli/internal/errorhandling/errors_test.go new file mode 100644 index 0000000..9bf9381 --- /dev/null +++ b/cli/internal/errorhandling/errors_test.go @@ -0,0 +1,24 @@ +package errorhandling + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCommandError(t *testing.T) { + mockErr := CommandError{ + Err: errors.New("mock error"), + ExitCode: ExitGenericError, + HelpText: "This is some help text.", + } + + require.Error(t, &mockErr, "CommandError should be accepted as error") + assert.Error(t, mockErr.Unwrap()) + assert.Equal(t, "mock error", mockErr.Error()) + assert.Equal(t, "This is some help text.", mockErr.HelpText) + assert.Contains(t, mockErr.String(), "[ERROR]") + assert.Contains(t, mockErr.String(), "[HELP]") +} diff --git a/cli/internal/fileutils/directory.go b/cli/internal/fileutils/directory.go index 72793f5..a3fa76c 100644 --- a/cli/internal/fileutils/directory.go +++ b/cli/internal/fileutils/directory.go @@ -9,3 +9,12 @@ func IsDir(path string) bool { } return fileInfo.IsDir() } + +// IsDirEmpty reports whether the directory at path contains no entries. +func IsDirEmpty(path string) (bool, error) { + entries, err := os.ReadDir(path) + if err != nil { + return false, err + } + return len(entries) == 0, nil +} diff --git a/cli/internal/fileutils/directory_test.go b/cli/internal/fileutils/directory_test.go index ca827d8..034b47a 100644 --- a/cli/internal/fileutils/directory_test.go +++ b/cli/internal/fileutils/directory_test.go @@ -48,3 +48,56 @@ func TestIsDir(t *testing.T) { }) } } + +func TestIsDirEmpty(t *testing.T) { + testCases := []struct { + description string + setup func(t *testing.T) string + wantEmpty bool + wantErr bool + }{ + { + description: "empty directory", + setup: func(t *testing.T) string { return t.TempDir() }, + wantEmpty: true, + }, + { + description: "directory with a file", + setup: func(t *testing.T) string { + dir := t.TempDir() + f, err := os.Create(path.Join(dir, "file.txt")) + require.NoError(t, err) + f.Close() + return dir + }, + wantEmpty: false, + }, + { + description: "directory with a subdirectory", + setup: func(t *testing.T) string { + dir := t.TempDir() + require.NoError(t, os.Mkdir(path.Join(dir, "sub"), 0755)) + return dir + }, + wantEmpty: false, + }, + { + description: "non-existent path returns error", + setup: func(t *testing.T) string { return path.Join(t.TempDir(), "does-not-exist") }, + wantErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + dir := tc.setup(t) + got, err := IsDirEmpty(dir) + if tc.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantEmpty, got) + }) + } +} diff --git a/cli/internal/templating/data.go b/cli/internal/templating/data.go new file mode 100644 index 0000000..f58e905 --- /dev/null +++ b/cli/internal/templating/data.go @@ -0,0 +1,15 @@ +package templating + +import ( + "io" + + "gopkg.in/yaml.v3" +) + +func DataFromSource[T any](r io.Reader) (*T, error) { + var data T + if err := yaml.NewDecoder(r).Decode(&data); err != nil { + return nil, err + } + return &data, nil +} diff --git a/cli/internal/templating/data_test.go b/cli/internal/templating/data_test.go new file mode 100644 index 0000000..302b251 --- /dev/null +++ b/cli/internal/templating/data_test.go @@ -0,0 +1,70 @@ +package templating + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDataFromSource(t *testing.T) { + tests := []struct { + name string + input string + want CLIProject + wantErr bool + }{ + { + name: "all fields decoded correctly", + input: "name: myapp\nusername: jgfranco17\nauthor: John Doe\ngo-version: 1.24.0\n", + want: CLIProject{ + Name: "myapp", + Username: "jgfranco17", + Author: "John Doe", + GoVersion: "1.24.0", + }, + }, + { + name: "partial fields decoded, missing fields are zero values", + input: "name: myapp\n", + want: CLIProject{Name: "myapp"}, + }, + { + name: "unknown fields are silently ignored", + input: "name: myapp\nunknown-key: ignored\n", + want: CLIProject{Name: "myapp"}, + }, + { + name: "empty reader returns error", + input: "", + wantErr: true, + }, + { + name: "malformed YAML returns error", + input: "name: [unclosed\n", + wantErr: true, + }, + { + name: "non-YAML content returns error", + input: "{ this is not > yaml : at all", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := DataFromSource[CLIProject](strings.NewReader(tc.input)) + + if tc.wantErr { + require.Error(t, err) + assert.Nil(t, got) + return + } + + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, tc.want, *got) + }) + } +} diff --git a/cli/internal/templating/embed.go b/cli/internal/templating/embed.go new file mode 100644 index 0000000..f4e09eb --- /dev/null +++ b/cli/internal/templating/embed.go @@ -0,0 +1,56 @@ +package templating + +import ( + "context" + "embed" + "fmt" + "io/fs" + "path/filepath" + "strings" + + "github.com/jgfranco17/hackstack/cli/internal/logging" +) + +//go:embed resources +var embeddedResources embed.FS + +// CLIProject holds the variables required to render the CLI project templates. +// Fields map 1:1 to {{ .Field }} placeholders in `.j2` files. +// YAML tags use kebab-case to match the expected source file format. +type CLIProject struct { + Name string `yaml:"name"` + Username string `yaml:"username"` + GoVersion string `yaml:"go-version"` + Author string `yaml:"author"` +} + +func (d *CLIProject) Validate() error { + var missing []string + if d.Name == "" { + missing = append(missing, "name") + } + if d.Username == "" { + missing = append(missing, "username") + } + if d.Author == "" { + missing = append(missing, "author") + } + if len(missing) > 0 { + return fmt.Errorf("missing required fields in source data: %s", strings.Join(missing, ", ")) + } + return nil +} + +func Load(ctx context.Context, category string) (fs.FS, error) { + logger := logging.FromContext(ctx).WithField("module", "templating") + + category = strings.ToLower(category) + subDirPath := filepath.Join("resources", category) + sub, err := fs.Sub(embeddedResources, subDirPath) + if err != nil { + return nil, fmt.Errorf("templating failed to load category %q: %w", category, err) + } + + logger.WithField("category", category).Trace("Loaded template resources") + return sub, nil +} diff --git a/cli/internal/templating/embed_test.go b/cli/internal/templating/embed_test.go new file mode 100644 index 0000000..53a7b67 --- /dev/null +++ b/cli/internal/templating/embed_test.go @@ -0,0 +1,102 @@ +package templating + +import ( + "io/fs" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCLIProject_Validate(t *testing.T) { + tests := []struct { + name string + input CLIProject + wantErr bool + wantMissing []string + }{ + { + name: "all fields present", + input: CLIProject{Name: "myapp", Username: "user", Author: "Author"}, + }, + { + name: "missing name", + input: CLIProject{Username: "user", Author: "Author"}, + wantErr: true, + wantMissing: []string{"name"}, + }, + { + name: "missing username", + input: CLIProject{Name: "myapp", Author: "Author"}, + wantErr: true, + wantMissing: []string{"username"}, + }, + { + name: "missing author", + input: CLIProject{Name: "myapp", Username: "user"}, + wantErr: true, + wantMissing: []string{"author"}, + }, + { + name: "all required fields missing", + input: CLIProject{}, + wantErr: true, + wantMissing: []string{"name", "username", "author"}, + }, + { + name: "GoVersion missing does not affect validation", + input: CLIProject{Name: "myapp", Username: "user", Author: "Author"}, + wantErr: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.input.Validate() + + if tc.wantErr { + require.Error(t, err) + for _, field := range tc.wantMissing { + assert.Contains(t, err.Error(), field) + } + return + } + require.NoError(t, err) + }) + } +} + +func TestLoad_ValidCategory(t *testing.T) { + ctx := testContext(t) + sub, err := Load(ctx, "cli") + + require.NoError(t, err) + require.NotNil(t, sub) + + // Confirm the returned FS is non-empty. + entries, err := fs.ReadDir(sub, ".") + require.NoError(t, err) + assert.NotEmpty(t, entries, "loaded FS should contain template files") +} + +func TestLoad_CategoryIsCaseInsensitive(t *testing.T) { + ctx := testContext(t) + + lower, err := Load(ctx, "cli") + require.NoError(t, err) + + upper, err := Load(ctx, "CLI") + require.NoError(t, err) + + // Both should resolve to the same set of top-level entries. + lowerEntries, err := fs.ReadDir(lower, ".") + require.NoError(t, err) + + upperEntries, err := fs.ReadDir(upper, ".") + require.NoError(t, err) + + require.Len(t, upperEntries, len(lowerEntries)) + for i := range lowerEntries { + assert.Equal(t, lowerEntries[i].Name(), upperEntries[i].Name()) + } +} diff --git a/cli/internal/templating/engine.go b/cli/internal/templating/engine.go new file mode 100644 index 0000000..64d32a4 --- /dev/null +++ b/cli/internal/templating/engine.go @@ -0,0 +1,95 @@ +package templating + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "text/template" + + "github.com/jgfranco17/hackstack/cli/internal/errorhandling" + "github.com/jgfranco17/hackstack/cli/internal/fileutils" + "github.com/jgfranco17/hackstack/cli/internal/logging" +) + +type Engine struct { + Files fs.FS + Data CLIProject +} + +func NewEngine(files fs.FS, data CLIProject) *Engine { + return &Engine{ + Files: files, + Data: data, + } +} + +func (e *Engine) Render(ctx context.Context, outputPath string) error { + logger := logging.FromContext(ctx).WithField("module", "templating") + + count := 0 + walker := func(path string, d fs.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("walk error at %q: %w", path, err) + } + if d.IsDir() { + return nil + } + + destPath := filepath.Join(outputPath, filepath.FromSlash(path)) + + switch { + case strings.HasSuffix(path, ".j2"): + destPath = strings.TrimSuffix(destPath, ".j2") + logger.WithField("file", path).Trace("Rendering from template") + count++ + return renderTemplate(e.Files, path, destPath, e.Data) + case strings.HasSuffix(path, ".copy"): + destPath = strings.TrimSuffix(destPath, ".copy") + logger.WithField("file", path).Trace("Copying file") + count++ + return fileutils.CopyFile(e.Files, path, destPath) + default: + return fmt.Errorf("unrecognized resource extension for %q: expected .j2 or .copy", path) + } + } + + if err := fs.WalkDir(e.Files, ".", walker); err != nil { + return &errorhandling.CommandError{ + Err: fmt.Errorf("failed to render templates: %w", err), + ExitCode: errorhandling.ExitTemplateError, + HelpText: "Check template resources and verify the contents.", + } + } + logger.WithField("fileCount", count).Debug("Completed render") + return nil +} + +func renderTemplate(fsys fs.FS, srcPath, destPath string, data CLIProject) error { + content, err := fs.ReadFile(fsys, srcPath) + if err != nil { + return fmt.Errorf("read template %q: %w", srcPath, err) + } + + tmpl, err := template.New(srcPath).Parse(string(content)) + if err != nil { + return fmt.Errorf("parse template %q: %w", srcPath, err) + } + + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("create dirs for %q: %w", destPath, err) + } + + f, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("create file %q: %w", destPath, err) + } + defer f.Close() + + if err := tmpl.Execute(f, data); err != nil { + return fmt.Errorf("execute template %q: %w", srcPath, err) + } + return nil +} diff --git a/cli/internal/templating/engine_test.go b/cli/internal/templating/engine_test.go new file mode 100644 index 0000000..2c52a14 --- /dev/null +++ b/cli/internal/templating/engine_test.go @@ -0,0 +1,128 @@ +package templating + +import ( + "context" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/jgfranco17/hackstack/cli/internal/logging" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func testContext(t *testing.T) context.Context { + t.Helper() + logger := logrus.New() + logger.SetOutput(os.Stderr) + logger.SetLevel(logrus.TraceLevel) + return logging.AddToContext(t.Context(), logger) +} + +func TestEngine_Render(t *testing.T) { + tests := []struct { + name string + files fstest.MapFS + data CLIProject + wantFiles map[string]string // relative output path -> expected content + unwantFiles []string // relative output paths that must NOT exist + wantErr bool + }{ + { + name: "copy file is copied verbatim and extension stripped", + files: fstest.MapFS{ + "README.md.copy": {Data: []byte("# Hello world")}, + }, + data: CLIProject{}, + wantFiles: map[string]string{ + "README.md": "# Hello world", + }, + unwantFiles: []string{"README.md.copy"}, + }, + { + name: "j2 file is rendered and extension stripped", + files: fstest.MapFS{ + "main.go.j2": {Data: []byte("package {{ .Name }}")}, + }, + data: CLIProject{Name: "mypkg"}, + wantFiles: map[string]string{ + "main.go": "package mypkg", + }, + unwantFiles: []string{"main.go.j2"}, + }, + { + name: "nested directory structure is preserved", + files: fstest.MapFS{ + "cli/cmd/root.go.j2": {Data: []byte("// {{ .Name }}")}, + "cli/main.go.copy": {Data: []byte("package main")}, + }, + data: CLIProject{Name: "myapp"}, + wantFiles: map[string]string{ + "cli/cmd/root.go": "// myapp", + "cli/main.go": "package main", + }, + unwantFiles: []string{"cli/cmd/root.go.j2", "cli/main.go.copy"}, + }, + { + name: "multiple template variables are substituted", + files: fstest.MapFS{ + "go.mod.j2": {Data: []byte("module github.com/{{ .Username }}/{{ .Name }}\n\ngo {{ .GoVersion }}")}, + }, + data: CLIProject{ + Username: "jgfranco17", + Name: "myapp", + GoVersion: "1.24.0", + }, + wantFiles: map[string]string{ + "go.mod": "module github.com/jgfranco17/myapp\n\ngo 1.24.0", + }, + }, + { + name: "invalid template syntax returns error", + files: fstest.MapFS{ + "broken.go.j2": {Data: []byte("package {{ .Name")}, + }, + data: CLIProject{}, + wantErr: true, + }, + { + name: "unrecognized extension returns error", + files: fstest.MapFS{ + "README.md": {Data: []byte("# Hello")}, + }, + data: CLIProject{}, + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + outDir := t.TempDir() + ctx := testContext(t) + + engine := NewEngine(tc.files, tc.data) + err := engine.Render(ctx, outDir) + + if tc.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + + for relPath, wantContent := range tc.wantFiles { + fullPath := filepath.Join(outDir, filepath.FromSlash(relPath)) + got, readErr := os.ReadFile(fullPath) + require.NoError(t, readErr, "expected output file %q to exist", relPath) + assert.Equal(t, wantContent, string(got), "content mismatch for %q", relPath) + } + + for _, relPath := range tc.unwantFiles { + fullPath := filepath.Join(outDir, filepath.FromSlash(relPath)) + assert.NoFileExists(t, fullPath) + } + }) + } +} diff --git a/cli/internal/templating/resources/cli/.github/actions/setup-workspace/action.yaml.copy b/cli/internal/templating/resources/cli/.github/actions/setup-workspace/action.yaml.copy new file mode 100644 index 0000000..a3aafee --- /dev/null +++ b/cli/internal/templating/resources/cli/.github/actions/setup-workspace/action.yaml.copy @@ -0,0 +1,19 @@ +--- +name: "Setup Go Workspace" +description: "Configure Go and prepare the workspace" + +runs: + using: composite + steps: + - name: Set up Golang + uses: actions/setup-go@v5 + with: + cache: false + + - name: Install Just + uses: extractions/setup-just@v2 + + - name: Install Go modules + shell: bash + run: | + go mod tidy diff --git a/cli/internal/templating/resources/cli/.github/workflows/compliance.yaml.copy b/cli/internal/templating/resources/cli/.github/workflows/compliance.yaml.copy new file mode 100644 index 0000000..dfa70c4 --- /dev/null +++ b/cli/internal/templating/resources/cli/.github/workflows/compliance.yaml.copy @@ -0,0 +1,36 @@ +--- +name: Compliance Checks + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + lint: + name: Run linters + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup workspace + uses: ./.github/actions/setup-workspace + + - name: Install linters + run: | + pip install --upgrade pip + pip install pre-commit==3.5.0 + + - name: Install dependencies + run: | + just tidy + go install golang.org/x/tools/cmd/goimports@latest + go install github.com/fzipp/gocyclo/cmd/gocyclo@latest + + - name: Lint + run: pre-commit run --all-files diff --git a/cli/internal/templating/resources/cli/.github/workflows/main.yaml.copy b/cli/internal/templating/resources/cli/.github/workflows/main.yaml.copy new file mode 100644 index 0000000..fe08178 --- /dev/null +++ b/cli/internal/templating/resources/cli/.github/workflows/main.yaml.copy @@ -0,0 +1,41 @@ +--- +name: CICD + +on: + push: + branches: + - main + pull_request: + branches: + - main + +jobs: + test: + name: Run unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Run setup + uses: ./.github/actions/setup-workspace + + - name: Run tests + run: | + go clean -testcache + go test -v -cover -race ./... + + build: + name: Build + runs-on: ubuntu-latest + needs: test + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Run setup + uses: ./.github/actions/setup-workspace + + - name: Build binary + run: | + go build diff --git a/cli/internal/templating/resources/cli/.gitignore.copy b/cli/internal/templating/resources/cli/.gitignore.copy new file mode 100644 index 0000000..df9dea0 --- /dev/null +++ b/cli/internal/templating/resources/cli/.gitignore.copy @@ -0,0 +1,18 @@ +# Local development +.vscode +.idea +.DS_Store +node_modules + +# Testing +coverage/ +*.test +*.log +*.out + +# Documentation +site/ + +# Go workspace +go.work +go.work.sum diff --git a/cli/internal/templating/resources/cli/.pre-commit-config.yaml.copy b/cli/internal/templating/resources/cli/.pre-commit-config.yaml.copy new file mode 100644 index 0000000..47114b2 --- /dev/null +++ b/cli/internal/templating/resources/cli/.pre-commit-config.yaml.copy @@ -0,0 +1,42 @@ +--- +fail_fast: false + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + - id: detect-private-key + + # Go standards and linting + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-vet + - id: go-imports + - id: go-cyclo + - id: go-unit-tests + + - repo: https://github.com/codespell-project/codespell.git + rev: v2.4.1 + hooks: + - id: codespell + args: [-w] + files: ^.*\.(md|py|jinja|yaml|yml|sh|feature)$ + + # Security + - repo: https://github.com/Yelp/detect-secrets + rev: v1.5.0 + hooks: + - id: detect-secrets + + # Ensure version sync + - repo: https://github.com/zricethezav/gitleaks + rev: v8.30.0 + hooks: + - id: gitleaks diff --git a/cli/internal/templating/resources/cli/README.md.j2 b/cli/internal/templating/resources/cli/README.md.j2 new file mode 100644 index 0000000..37c1c29 --- /dev/null +++ b/cli/internal/templating/resources/cli/README.md.j2 @@ -0,0 +1,6 @@ +# {{ .Name }} CLI + +--- + +> [!NOTE] +> This CLI is still a beta prototype. diff --git a/cli/command/cmds/commands.go b/cli/internal/templating/resources/cli/cli/command/cmds/commands.go.j2 similarity index 83% rename from cli/command/cmds/commands.go rename to cli/internal/templating/resources/cli/cli/command/cmds/commands.go.j2 index 0c51f6c..14a5cd4 100644 --- a/cli/command/cmds/commands.go +++ b/cli/internal/templating/resources/cli/cli/command/cmds/commands.go.j2 @@ -3,9 +3,9 @@ package cmds import ( "fmt" - "github.com/jgfranco17/hackstack/cli/internal/errorhandling" - "github.com/jgfranco17/hackstack/cli/internal/logging" "github.com/spf13/cobra" + "github.com/{{ .Username }}/{{ .Name }}/cli/internal/errorhandling" + "github.com/{{ .Username }}/{{ .Name }}/cli/internal/logging" ) type buildCommand struct{} diff --git a/cli/internal/templating/resources/cli/cli/command/registry.go.j2 b/cli/internal/templating/resources/cli/cli/command/registry.go.j2 new file mode 100644 index 0000000..c4b02b0 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/command/registry.go.j2 @@ -0,0 +1,139 @@ +package command + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "syscall" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/{{ .Username }}/{{ .Name }}/cli/command/cmds" + "github.com/{{ .Username }}/{{ .Name }}/cli/internal/errorhandling" + "github.com/{{ .Username }}/{{ .Name }}/cli/internal/logging" +) + +// CLI is a struct that represents the command-line interface of the application. +type CLI struct { + root *cobra.Command + cleanups []func() +} + +// ContextModifiers is a function type that takes a context and returns +// a modified context. This can be used to add additional values to the +// context for downstream consumption. +type ContextModifiers func(ctx context.Context) context.Context + +// RootCommandOptions defines the options for creating a new CLI instance. +type RootCommandOptions struct { + // Name is the name of the root command, i.e. the namespace used to invoke the CLI. + Name string + + // Description is a brief description of the root command. + // This will be displayed in the help message. + Description string + + // Version is the version of the root command. + // This will be displayed in the --version flag. + Version string + + // CleanupFuncs are functions that will be called when the CLI is cleaned up. + // This can be used to clean up resources, such as closing database connections + // or stopping background goroutines. + CleanupFuncs []func() +} + +// validate checks if the required fields in RootCommandOptions are set. +func (options RootCommandOptions) validate() error { + if options.Name == "" { + return errors.New("root command must have name") + } + if options.Version == "" { + return errors.New("root command must have version") + } + return nil +} + +// New creates a new instance of CLI with the provided options. +func New(options RootCommandOptions) (*CLI, error) { + if err := options.validate(); err != nil { + return nil, err + } + + var verbosity int + root := &cobra.Command{ + Use: options.Name, + Version: options.Version, + Short: options.Description, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + var level logrus.Level + switch verbosity { + case 1: + level = logrus.InfoLevel + case 2: + level = logrus.DebugLevel + case 3: + level = logrus.TraceLevel + default: + level = logrus.WarnLevel + } + + logger := logging.New(cmd.ErrOrStderr(), level) + ctx := logging.AddToContext(cmd.Context(), logger) + // Create a signal-aware context for the entire CLI lifecycle once here. + // This ensures signal handling is never duplicated regardless of how many + // commands are executed or how many times PersistentPreRunE fires. + ctx, _ = signal.NotifyContext(ctx, syscall.SIGTERM, syscall.SIGINT, os.Interrupt) + + cmd.SetContext(ctx) + return nil + }, + SilenceErrors: true, + SilenceUsage: true, + } + + root.PersistentFlags().CountVarP(&verbosity, "verbose", "v", "Increase verbosity (up to -vvv)") + + // Internal cleanup (stop) is prepended so signal notifications are released + // before user-provided cleanup functions run. + allCleanups := make([]func(), 0, 1+len(options.CleanupFuncs)) + allCleanups = append(allCleanups, options.CleanupFuncs...) + + root.AddCommand(cmds.NewBuildCommand()) + + return &CLI{ + root: root, + cleanups: allCleanups, + }, nil +} + +// RegisterCommands registers new commands with the CLI. +func (cr *CLI) RegisterCommands(commands []*cobra.Command) { + cr.root.AddCommand(commands...) +} + +// Cleanup releases resources held by the CLI. +func (cr *CLI) Cleanup() { + if len(cr.cleanups) > 0 { + for _, cleanup := range cr.cleanups { + cleanup() + } + } +} + +// Execute executes the root command. +func (cr *CLI) Execute() int { + if err := cr.root.Execute(); err != nil { + var cmdErr *errorhandling.CommandError + if ok := errors.As(err, &cmdErr); ok { + fmt.Println(cmdErr.String()) + return cmdErr.ExitCode + } else { + fmt.Printf("An unexpected error occurred: %v\n", err) + return 1 + } + } + return 0 +} diff --git a/cli/internal/templating/resources/cli/cli/command/registry_test.go.j2 b/cli/internal/templating/resources/cli/cli/command/registry_test.go.j2 new file mode 100644 index 0000000..ac81b40 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/command/registry_test.go.j2 @@ -0,0 +1,276 @@ +package command + +import ( + "bytes" + "testing" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/{{ .Username }}/{{ .Name }}/cli/internal/logging" +) + +func TestRootCommandOptions_validate(t *testing.T) { + tests := []struct { + name string + options RootCommandOptions + wantErr bool + }{ + { + name: "valid options", + options: RootCommandOptions{ + Name: "testcli", + Description: "A test CLI application", + Version: "1.0.0", + }, + wantErr: false, + }, + { + name: "empty name", + options: RootCommandOptions{ + Name: "", + Description: "A test CLI application", + Version: "1.0.0", + }, + wantErr: true, + }, + { + name: "empty version", + options: RootCommandOptions{ + Name: "testcli", + Description: "A test CLI application", + Version: "", + }, + wantErr: true, + }, + { + name: "empty description allowed", + options: RootCommandOptions{ + Name: "testcli", + Description: "", + Version: "1.0.0", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.options.validate() + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +func TestNew(t *testing.T) { + tests := []struct { + name string + options RootCommandOptions + wantErr bool + }{ + { + name: "valid options", + options: RootCommandOptions{ + Name: "testcli", + Description: "A test CLI application", + Version: "1.0.0", + }, + wantErr: false, + }, + { + name: "invalid options - no name", + options: RootCommandOptions{ + Name: "", + Description: "A test CLI application", + Version: "1.0.0", + }, + wantErr: true, + }, + { + name: "invalid options - no version", + options: RootCommandOptions{ + Name: "testcli", + Description: "A test CLI application", + Version: "", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cli, err := New(tt.options) + if tt.wantErr { + assert.Error(t, err) + assert.Nil(t, cli) + } else { + assert.NoError(t, err) + assert.NotNil(t, cli) + assert.NotNil(t, cli.root) + assert.Equal(t, tt.options.Name, cli.root.Use) + assert.Equal(t, tt.options.Version, cli.root.Version) + assert.Equal(t, tt.options.Description, cli.root.Short) + } + }) + } +} + +func TestVerbosityLevels(t *testing.T) { + tests := []struct { + name string + args []string + expectedLevel logrus.Level + }{ + { + name: "no verbosity flag", + args: []string{"test"}, + expectedLevel: logrus.WarnLevel, + }, + { + name: "single verbose flag", + args: []string{"-v", "test"}, + expectedLevel: logrus.InfoLevel, + }, + { + name: "double verbose flag", + args: []string{"-vv", "test"}, + expectedLevel: logrus.DebugLevel, + }, + { + name: "triple verbose flag", + args: []string{"-vvv", "test"}, + expectedLevel: logrus.TraceLevel, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cleanupCalled bool + + options := RootCommandOptions{ + Name: "testcli", + Description: "A test CLI application", + Version: "1.0.0", + CleanupFuncs: []func(){ + func() { cleanupCalled = true }, + }, + } + + cli, err := New(options) + require.NoError(t, err) + + var actualLevel logrus.Level + testCmd := &cobra.Command{ + Use: "test", + Run: func(cmd *cobra.Command, args []string) { + logger := logging.FromContext(cmd.Context()) + actualLevel = logger.GetLevel() + }, + } + cli.RegisterCommands([]*cobra.Command{testCmd}) + + var buf bytes.Buffer + cli.root.SetOut(&buf) + cli.root.SetErr(&buf) + cli.root.SetArgs(tt.args) + + exitCode := cli.Execute() + assert.Equal(t, 0, exitCode) + assert.Equal(t, tt.expectedLevel, actualLevel) + + cli.Cleanup() + assert.True(t, cleanupCalled) + }) + } +} + +func TestExecute_HelpFlag(t *testing.T) { + options := RootCommandOptions{ + Name: "testcli", + Description: "A test CLI application", + Version: "1.0.0", + } + + cli, err := New(options) + require.NoError(t, err) + + var buf bytes.Buffer + cli.root.SetOut(&buf) + cli.root.SetErr(&buf) + cli.root.SetArgs([]string{"--help"}) + + exitCode := cli.Execute() + assert.Equal(t, 0, exitCode) + + output := buf.String() + assert.Contains(t, output, "A test CLI application") +} + +func TestExecute_VersionFlag(t *testing.T) { + options := RootCommandOptions{ + Name: "testcli", + Description: "A test CLI application", + Version: "1.0.0", + } + + cli, err := New(options) + require.NoError(t, err) + + var buf bytes.Buffer + cli.root.SetOut(&buf) + cli.root.SetErr(&buf) + cli.root.SetArgs([]string{"--version"}) + + exitCode := cli.Execute() + assert.Equal(t, 0, exitCode) + + output := buf.String() + assert.Contains(t, output, "1.0.0") +} + +func TestCleanup_WithoutExecute(t *testing.T) { + var cleanupCalled bool + options := RootCommandOptions{ + Name: "testcli", + Version: "1.0.0", + CleanupFuncs: []func(){ + func() { cleanupCalled = true }, + }, + } + + cli, err := New(options) + require.NoError(t, err) + + assert.NotPanics(t, func() { cli.Cleanup() }) + assert.True(t, cleanupCalled) +} + +func TestCleanup_Order(t *testing.T) { + var order []string + + options := RootCommandOptions{ + Name: "testcli", + Version: "1.0.0", + CleanupFuncs: []func(){ + func() { order = append(order, "user1") }, + func() { order = append(order, "user2") }, + }, + } + + cli, err := New(options) + require.NoError(t, err) + + original := cli.cleanups[0] + cli.cleanups[0] = func() { + order = append(order, "internal") + original() + } + + cli.Cleanup() + assert.Equal(t, []string{"internal", "user1", "user2"}, order) +} diff --git a/cli/internal/templating/resources/cli/cli/entrypoint/entrypoint.go.j2 b/cli/internal/templating/resources/cli/cli/entrypoint/entrypoint.go.j2 new file mode 100644 index 0000000..0956cac --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/entrypoint/entrypoint.go.j2 @@ -0,0 +1,36 @@ +package entrypoint + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/{{ .Username }}/{{ .Name }}/cli/command" +) + +type ProjectMetadata struct { + Author string `json:"author"` + Description string `json:"description"` + Version string `json:"version"` +} + +func Run(metadata []byte) { + var projectMetadata ProjectMetadata + if err := json.Unmarshal(metadata, &projectMetadata); err != nil { + fmt.Printf("Error unmarshaling metadata: %v\n", err) + panic("failed to unmarshal metadata") + } + + command, err := command.New(command.RootCommandOptions{ + Name: "hackstack", + Description: projectMetadata.Description, + Version: projectMetadata.Version, + }) + if err != nil { + fmt.Printf("Error creating command: %v\n", err) + os.Exit(1) + } + + exitCode := command.Execute() + os.Exit(exitCode) +} diff --git a/cli/internal/templating/resources/cli/cli/internal/environment/environment.go.copy b/cli/internal/templating/resources/cli/cli/internal/environment/environment.go.copy new file mode 100644 index 0000000..c448616 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/environment/environment.go.copy @@ -0,0 +1,23 @@ +// Package environment provides tooling for interacting with the environment. +package environment + +import "os" + +var ciVariables = []string{ + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "NODE_NAME", +} + +// IsRunningInCI checks if the current environment is running in a CI +// environment. It checks for the presence of the CI environment variables +// from known providers. +func IsRunningInCI() bool { + for _, variable := range ciVariables { + if os.Getenv(variable) != "" { + return true + } + } + return false +} diff --git a/cli/internal/templating/resources/cli/cli/internal/environment/environment_test.go.copy b/cli/internal/templating/resources/cli/cli/internal/environment/environment_test.go.copy new file mode 100644 index 0000000..e310591 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/environment/environment_test.go.copy @@ -0,0 +1,309 @@ +package environment + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMain(m *testing.M) { + actualValues := make(map[string]string) + for _, variable := range ciVariables { + actualValues[variable] = os.Getenv(variable) + os.Unsetenv(variable) + } + restore := func() { + for variable, value := range actualValues { + os.Setenv(variable, value) + } + } + defer restore() + exitCode := m.Run() + os.Exit(exitCode) +} + +func TestIsRunningInCI_CommonCases(t *testing.T) { + testCases := []struct { + name string + envVars map[string]string + expected bool + description string + }{ + { + name: "no CI environment variables", + envVars: map[string]string{}, + expected: false, + description: "Should return false when no CI environment variables are set", + }, + { + name: "CI variable set to true", + envVars: map[string]string{ + "CI": "true", + }, + expected: true, + description: "Should return true when CI variable is set to true", + }, + { + name: "CI variable set to false", + envVars: map[string]string{ + "CI": "false", + }, + expected: true, + description: "Should return true when CI variable is set to any non-empty value", + }, + { + name: "CI variable set to empty string", + envVars: map[string]string{ + "CI": "", + }, + expected: false, + description: "Should return false when CI variable is set to empty string", + }, + { + name: "GITHUB_ACTIONS variable set", + envVars: map[string]string{ + "GITHUB_ACTIONS": "true", + }, + expected: true, + description: "Should return true when GITHUB_ACTIONS variable is set", + }, + { + name: "GITLAB_CI variable set", + envVars: map[string]string{ + "GITLAB_CI": "true", + }, + expected: true, + description: "Should return true when GITLAB_CI variable is set", + }, + { + name: "NODE_NAME variable set", + envVars: map[string]string{ + "NODE_NAME": "build-node-1", + }, + expected: true, + description: "Should return true when NODE_NAME variable is set", + }, + { + name: "multiple CI variables set", + envVars: map[string]string{ + "CI": "true", + "GITHUB_ACTIONS": "true", + "GITLAB_CI": "true", + "NODE_NAME": "build-node-1", + }, + expected: true, + description: "Should return true when multiple CI variables are set", + }, + { + name: "mixed CI and non-CI variables", + envVars: map[string]string{ + "CI": "true", + "USER": "testuser", + "HOME": "/home/testuser", + "PATH": "/usr/bin:/bin", + "RANDOM_VAR": "random_value", + }, + expected: true, + description: "Should return true when CI variable is set among other variables", + }, + { + name: "only non-CI variables set", + envVars: map[string]string{ + "USER": "testuser", + "HOME": "/home/testuser", + "PATH": "/usr/bin:/bin", + "RANDOM_VAR": "random_value", + }, + expected: false, + description: "Should return false when only non-CI variables are set", + }, + { + name: "CI variable with spaces", + envVars: map[string]string{ + "CI": " true ", + }, + expected: true, + description: "Should return true when CI variable contains spaces", + }, + { + name: "GITHUB_ACTIONS with different values", + envVars: map[string]string{ + "GITHUB_ACTIONS": "1", + }, + expected: true, + description: "Should return true when GITHUB_ACTIONS is set to 1", + }, + { + name: "GITLAB_CI with different values", + envVars: map[string]string{ + "GITLAB_CI": "yes", + }, + expected: true, + description: "Should return true when GITLAB_CI is set to yes", + }, + { + name: "NODE_NAME with different values", + envVars: map[string]string{ + "NODE_NAME": "jenkins-slave-1", + }, + expected: true, + description: "Should return true when NODE_NAME is set to jenkins value", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Set up test environment using t.Setenv + for key, value := range tc.envVars { + if value == "" { + t.Setenv(key, "") + } else { + t.Setenv(key, value) + } + } + + // Test the function + result := IsRunningInCI() + assert.Equal(t, tc.expected, result, tc.description) + }) + } +} + +func TestIsRunningInCI_EdgeCases(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + expected bool + description string + }{ + { + name: "unset all CI variables", + envVars: map[string]string{ + "CI": "", + "GITHUB_ACTIONS": "", + "GITLAB_CI": "", + "NODE_NAME": "", + }, + expected: false, + description: "Should return false when all CI variables are unset", + }, + { + name: "set CI variable to zero", + envVars: map[string]string{ + "CI": "0", + }, + expected: true, + description: "Should return true when CI is set to 0 (non-empty)", + }, + { + name: "set CI variable to no", + envVars: map[string]string{ + "CI": "no", + }, + expected: true, + description: "Should return true when CI is set to no (non-empty)", + }, + { + name: "set CI variable to off", + envVars: map[string]string{ + "CI": "off", + }, + expected: true, + description: "Should return true when CI is set to off (non-empty)", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set up test environment using t.Setenv + for key, value := range tt.envVars { + if value == "" { + t.Setenv(key, "") // This unsets the environment variable + } else { + t.Setenv(key, value) + } + } + + // Test the function + result := IsRunningInCI() + assert.Equal(t, tt.expected, result, tt.description) + }) + } +} + +func TestIsRunningInCI_Integration(t *testing.T) { + // Test that the function works correctly in a real environment + // This test doesn't modify environment variables, just tests the current state + + // Test with a known CI environment + t.Setenv("CI", "true") + result := IsRunningInCI() + assert.True(t, result, "Should return true when CI is set to true") + + // Test with CI unset + t.Setenv("CI", "") + result = IsRunningInCI() + // The result depends on the current environment, so we just verify it doesn't panic + assert.NotPanics(t, func() { + IsRunningInCI() + }, "Should not panic when called multiple times") +} + +func TestIsRunningInCI_Performance(t *testing.T) { + // Test that the function performs well with many environment variables + // Set up a large number of non-CI environment variables + for i := 0; i < 100; i++ { + t.Setenv(fmt.Sprintf("TEST_VAR_%d", i), "value") + } + + // Test that the function still works correctly + result := IsRunningInCI() + // The result should be false since we didn't set any CI variables + assert.False(t, result, "Should return false when no CI variables are set among many other variables") +} + +func TestIsRunningInCI_Concurrent(t *testing.T) { + // Test that the function is safe for concurrent access + // This is important since environment variables can be read concurrently + + done := make(chan bool) + + // Start multiple goroutines that call IsRunningInCI + for i := 0; i < 10; i++ { + go func() { + for j := 0; j < 100; j++ { + IsRunningInCI() + } + done <- true + }() + } + + // Wait for all goroutines to complete + for i := 0; i < 10; i++ { + <-done + } + + // If we get here without panicking, the function is safe for concurrent access + assert.True(t, true, "Function should be safe for concurrent access") +} + +func TestIsRunningInCI_AllCIVariables(t *testing.T) { + // Test each CI variable individually to ensure they all work + ciVars := []string{"CI", "GITHUB_ACTIONS", "GITLAB_CI", "NODE_NAME"} + + for _, ciVar := range ciVars { + t.Run("test_"+ciVar, func(t *testing.T) { + // Test with variable set + t.Setenv(ciVar, "test_value") + result := IsRunningInCI() + assert.True(t, result, "Should return true when %s is set", ciVar) + + // Test with variable unset + t.Setenv(ciVar, "") + result = IsRunningInCI() + assert.False(t, result, "Should return false when %s is unset", ciVar) + }) + } +} diff --git a/cli/internal/templating/resources/cli/cli/internal/errorhandling/errors.go.copy b/cli/internal/templating/resources/cli/cli/internal/errorhandling/errors.go.copy new file mode 100644 index 0000000..7fd7796 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/errorhandling/errors.go.copy @@ -0,0 +1,34 @@ +package errorhandling + +import ( + "fmt" + "strings" + + "github.com/fatih/color" +) + +type CommandError struct { + Err error + ExitCode int + HelpText string +} + +func (e *CommandError) Error() string { + return e.Err.Error() +} + +func (e *CommandError) Unwrap() error { + return e.Err +} + +func (e *CommandError) String() string { + redBoldFmt := color.New(color.FgRed).Add(color.Bold).SprintfFunc() + + var renderedMessage strings.Builder + renderedMessage.WriteString("\n") + renderedMessage.WriteString(redBoldFmt("[ERROR] Hackstack internal error: %s\n", e.Err.Error())) + if e.HelpText != "" { + renderedMessage.WriteString(fmt.Sprintf("[HELP] %s", e.HelpText)) + } + return renderedMessage.String() +} diff --git a/cli/internal/templating/resources/cli/cli/internal/fileutils/context.go.copy b/cli/internal/templating/resources/cli/cli/internal/fileutils/context.go.copy new file mode 100644 index 0000000..c4abc08 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/fileutils/context.go.copy @@ -0,0 +1,25 @@ +package fileutils + +import ( + "context" + "io/fs" +) + +type contextKey string + +const ( + rootDirKey contextKey = "rootDir" +) + +func ApplyRootDirToContext(ctx context.Context, files fs.FS) context.Context { + ctx = context.WithValue(ctx, rootDirKey, files) + return ctx +} + +func RootDirFromContext(ctx context.Context) fs.FS { + rootDir, ok := ctx.Value(rootDirKey).(fs.FS) + if !ok { + panic("No root dir found in context, bad code path") + } + return rootDir +} diff --git a/cli/internal/templating/resources/cli/cli/internal/fileutils/copy.go.copy b/cli/internal/templating/resources/cli/cli/internal/fileutils/copy.go.copy new file mode 100644 index 0000000..f894d61 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/fileutils/copy.go.copy @@ -0,0 +1,131 @@ +package fileutils + +import ( + "fmt" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strings" +) + +func CopyFile(srcFS fs.FS, srcRelPath string, dstPath string) (err error) { + // Open the source file + srcFile, err := srcFS.Open(srcRelPath) + if err != nil { + return err + } + defer srcFile.Close() + + // Create the destination file + dstDirPath := path.Dir(dstPath) + err = os.MkdirAll(dstDirPath, 0755) + if err != nil { + return err + } + + dstFile, err := os.Create(dstPath) + if err != nil { + return err + } + + // Copy the contents from the source file to the destination file + if _, err := io.Copy(dstFile, srcFile); err != nil { + return err + } + return dstFile.Close() +} + +// CopyDirectory recursively copies a directory from an fs.FS to a destination path. +// Paths matching skip prefixes will not be copied. skip prefixes should be +// cleaned according to https://pkg.go.dev/path/filepath#Clean. +func CopyDirectory(srcFS fs.FS, srcDir, dstDir string, skip []string) (err error) { + subFS, err := fs.Sub(srcFS, srcDir) + if err != nil { + return err + } + err = copyFS(dstDir, subFS, skip) + if err != nil { + return err + } + return nil +} + +// copyFS copies the file system fsys into the directory dir, +// creating dir if necessary. +// +// Files are created with mode 0o666 plus any execute permissions +// from the source, and directories are created with mode 0o777 +// (before umask). +// +// copyFS will not overwrite existing files. If a file name in fsys +// already exists in the destination, copyFS will return an error +// such that errors.Is(err, fs.ErrExist) will be true. +// +// Symbolic links in fsys are not supported and will be ignored. +// +// New files added to fsys (including if dir is a subdirectory of fsys) +// while copyFS is running are not guaranteed to be copied. +// +// Copying stops at and returns the first error encountered. +// Implementation for os.copyFS @ v1.24.5 with ignore symlinks +// https://cs.opensource.google/go/go/+/refs/tags/go1.24.5:src/os/dir.go;l=152 +func copyFS(dir string, fsys fs.FS, skip []string) (err error) { + shouldSkip := func(path string) bool { + for _, i := range skip { + // filepath.Match(i, path) cannot be used because it + // does not support globstar patterns. + if strings.HasPrefix(path, i) { + return true + } + } + return false + } + + return fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if shouldSkip(path) { + return nil + } + + fpath, err := filepath.Localize(path) + if err != nil { + return err + } + newPath := filepath.Join(dir, fpath) + if d.IsDir() { + return os.MkdirAll(newPath, 0777) + } + + // Ignore Symlinks and other irregular files + if !d.Type().IsRegular() { + // Original: &PathError{Op: "CopyFS", Path: path, Err: ErrInvalid} + return nil + } + + r, err := fsys.Open(path) + if err != nil { + return err + } + defer r.Close() + info, err := r.Stat() + if err != nil { + return err + } + w, err := os.OpenFile(newPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0666|info.Mode()&0777) + if err != nil { + return err + } + + if _, err := io.Copy(w, r); err != nil { + _ = w.Close() + // Original: &PathError{Op: "Copy", Path: newPath, Err: err} + return fmt.Errorf("unable to copy content from %s to new path %s: %w", path, newPath, err) + } + return w.Close() + }) +} diff --git a/cli/internal/templating/resources/cli/cli/internal/fileutils/copy_test.go.copy b/cli/internal/templating/resources/cli/cli/internal/fileutils/copy_test.go.copy new file mode 100644 index 0000000..0bb1e20 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/fileutils/copy_test.go.copy @@ -0,0 +1,98 @@ +package fileutils + +import ( + "io/fs" + "os" + "path/filepath" + "testing" + "testing/fstest" + + "github.com/stretchr/testify/assert" +) + +func TestCopyDirectory_Success(t *testing.T) { + contents := []byte("my file contents") + tmpDir := t.TempDir() + + fileSystem := fstest.MapFS{ + "my-folder/my-file.txt": { + Mode: fs.ModePerm, + Data: contents, + }, + "my-folder/to-be-ignored/ignore.txt": { + Mode: fs.ModePerm, + }, + } + + destFolderPath := filepath.Join(tmpDir, "copied-folder") + err := CopyDirectory(fileSystem, "my-folder", destFolderPath, []string{"to-be-ignored/"}) + + assert.NoError(t, err) + destFilePath := filepath.Join(destFolderPath, "my-file.txt") + destContent, err := os.ReadFile(destFilePath) + assert.NoError(t, err) + assert.Equal(t, contents, destContent) + + destIgnorePath := filepath.Join(destFolderPath, "to-be-ignored/ignore.txt") + _, err = os.ReadFile(destIgnorePath) + assert.ErrorIs(t, err, fs.ErrNotExist) +} + +func TestCopyDirectoryWithSymlink_Success(t *testing.T) { + contents := []byte("my file contents") + tmpDir := t.TempDir() + + fileSystem := fstest.MapFS{ + "my-folder/my-file.txt": { + Mode: fs.ModePerm, + Data: contents, + }, + "my-folder/.direnv/nix_flake": { + Mode: fs.ModeIrregular, + }, + } + + destFolderPath := filepath.Join(tmpDir, "copied-folder") + err := CopyDirectory(fileSystem, "my-folder", destFolderPath, nil) + + assert.NoError(t, err) + destFilePath := filepath.Join(destFolderPath, "my-file.txt") + destContent, err := os.ReadFile(destFilePath) + assert.NoError(t, err) + assert.Equal(t, contents, destContent) + irregularFilePath := filepath.Join(destFolderPath, ".direnv", "nix_flake") + _, err = os.Open(irregularFilePath) + assert.True(t, os.IsNotExist(err)) +} + +func TestCopyDirectory_CannotReadSrc(t *testing.T) { + fileSystem := fstest.MapFS{} + err := CopyDirectory(fileSystem, "/path/does/not/exist", "/some/path", nil) + assert.ErrorContains(t, err, "sub /path/does/not/exist: invalid argument") +} + +func TestCopyLocalFile_Success(t *testing.T) { + contents := []byte("my file contents") + tmpDir := t.TempDir() + + fileSystem := fstest.MapFS{ + "my-file.txt": { + Mode: fs.ModePerm, + Data: contents, + }, + } + + destFilePath := filepath.Join(tmpDir, "mycopiedfile.txt") + err := CopyFile(fileSystem, "my-file.txt", destFilePath) + + assert.NoError(t, err) + destContent, err := os.ReadFile(destFilePath) + assert.NoError(t, err) + assert.Equal(t, contents, destContent) +} + +func TestCopyLocalFile_CannotReadSrc(t *testing.T) { + fileSystem := fstest.MapFS{} + err := CopyFile(fileSystem, "/path/does/not/exist.txt", "destination.txt") + assert.ErrorContains(t, err, "file does not exist") +} diff --git a/cli/internal/templating/resources/cli/cli/internal/fileutils/directory.go.copy b/cli/internal/templating/resources/cli/cli/internal/fileutils/directory.go.copy new file mode 100644 index 0000000..72793f5 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/fileutils/directory.go.copy @@ -0,0 +1,11 @@ +package fileutils + +import "os" + +func IsDir(path string) bool { + fileInfo, err := os.Stat(path) + if err != nil { + return false + } + return fileInfo.IsDir() +} diff --git a/cli/internal/templating/resources/cli/cli/internal/fileutils/directory_test.go.copy b/cli/internal/templating/resources/cli/cli/internal/fileutils/directory_test.go.copy new file mode 100644 index 0000000..ca827d8 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/fileutils/directory_test.go.copy @@ -0,0 +1,50 @@ +package fileutils + +import ( + "os" + "path" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsDir(t *testing.T) { + tmpDir := t.TempDir() + file, err := os.Create(path.Join(tmpDir, "file")) + require.NoError(t, err) + defer file.Close() + + testCases := []struct { + description string + testPath string + isDir bool + }{ + { + description: "Valid Directory", + testPath: tmpDir, + isDir: true, + }, + { + description: "Unknown Directory", + testPath: path.Join(tmpDir, "unknown"), + isDir: false, + }, + { + description: "Valid File", + testPath: path.Join(tmpDir, "file"), + isDir: false, + }, + { + description: "Unknown File", + testPath: path.Join(tmpDir, "unknownFile"), + isDir: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.description, func(t *testing.T) { + assert.Equal(t, tc.isDir, IsDir(tc.testPath)) + }) + } +} diff --git a/cli/internal/templating/resources/cli/cli/internal/fileutils/tar.go.copy b/cli/internal/templating/resources/cli/cli/internal/fileutils/tar.go.copy new file mode 100644 index 0000000..f15d4c4 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/fileutils/tar.go.copy @@ -0,0 +1,26 @@ +package fileutils + +import ( + "bytes" + "os/exec" +) + +func UntarFile(src, dest string) error { + cmd := exec.Command("tar", "-xzf", src, "-C", dest) + err := cmd.Run() + if err != nil { + return err + } + return nil +} + +func CreateTarGz(src, dest string) (err error) { + var buf bytes.Buffer + cmd := exec.Command("tar", "-czf", dest, "-C", src, ".") + cmd.Stderr = &buf + err = cmd.Run() + if err != nil { + return err + } + return nil +} diff --git a/cli/internal/templating/resources/cli/cli/internal/fileutils/tar_test.go.copy b/cli/internal/templating/resources/cli/cli/internal/fileutils/tar_test.go.copy new file mode 100644 index 0000000..378fbc5 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/fileutils/tar_test.go.copy @@ -0,0 +1,95 @@ +package fileutils + +import ( + "archive/tar" + "compress/gzip" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUntarSuccess(t *testing.T) { + tmpDir := t.TempDir() + + tarFile, err := os.Create(tmpDir + "test_sample.tar.gz") + require.NoError(t, err) + defer func() { + err := tarFile.Close() + require.NoError(t, err) + }() + + files := map[string][]byte{ + "file_1.txt": []byte("Hello from file 1"), + "sub/file_2.txt": []byte("Hello from file 2"), + } + createTar(t, tarFile, files) + + err = UntarFile(tarFile.Name(), tmpDir) + assert.NoError(t, err) + + // Assert on tar content + for fileName, expectedContent := range files { + untarFilePath := filepath.Join(tmpDir, fileName) + + assert.FileExists(t, untarFilePath) + + fileContent, err := os.ReadFile(untarFilePath) + require.NoError(t, err) + + assert.Equal(t, expectedContent, fileContent) + } +} + +func TestCreateTarGz(t *testing.T) { + tempDir := t.TempDir() + srcDir := filepath.Join(tempDir, "src") + destFile := filepath.Join(tempDir, "test.tar.gz") + + err := os.MkdirAll(srcDir, 0755) + require.NoError(t, err) + testFilePath := filepath.Join(srcDir, "test.txt") + err = os.WriteFile(testFilePath, []byte("Hello, World!"), 0644) + require.NoError(t, err) + + err = CreateTarGz(srcDir, destFile) + require.NoError(t, err) + + _, err = os.Stat(destFile) + assert.NoError(t, err) +} + +func writeTar(t *testing.T, tw *tar.Writer, name string, contents []byte, mode int64) { + hdr := &tar.Header{ + Name: name, + Size: int64(len(contents)), + Mode: mode, + ModTime: time.Now(), + } + err := tw.WriteHeader(hdr) + require.NoError(t, err) + _, err = tw.Write(contents) + require.NoError(t, err) +} + +func createTar(t *testing.T, writer io.Writer, files map[string][]byte) { + gw := gzip.NewWriter(writer) + defer func() { + err := gw.Close() + require.NoError(t, err) + }() + + tw := tar.NewWriter(gw) + defer func() { + err := tw.Close() + require.NoError(t, err) + }() + + for name, contents := range files { + writeTar(t, tw, name, contents, 0644) + } +} diff --git a/cli/internal/templating/resources/cli/cli/internal/logging/logging.go.copy b/cli/internal/templating/resources/cli/cli/internal/logging/logging.go.copy new file mode 100644 index 0000000..b9b211f --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/logging/logging.go.copy @@ -0,0 +1,41 @@ +package logging + +import ( + "context" + "io" + "time" + + "github.com/sirupsen/logrus" +) + +type contextLogKey string + +const contextKey contextLogKey = "logger" + +func New(stream io.Writer, level logrus.Level) *logrus.Logger { + logger := logrus.New() + logger.SetOutput(stream) + logger.SetLevel(level) + + logger.SetFormatter(&logrus.TextFormatter{ + DisableColors: false, + PadLevelText: true, + QuoteEmptyFields: true, + FullTimestamp: true, + DisableSorting: true, + DisableLevelTruncation: true, + TimestampFormat: time.DateTime, + }) + return logger +} + +func AddToContext(ctx context.Context, logger *logrus.Logger) context.Context { + return context.WithValue(ctx, contextKey, logger) +} + +func FromContext(ctx context.Context) *logrus.Logger { + if logger, ok := ctx.Value(contextKey).(*logrus.Logger); ok { + return logger + } + panic("no logger set in context") +} diff --git a/cli/internal/templating/resources/cli/cli/internal/logging/logging_test.go.copy b/cli/internal/templating/resources/cli/cli/internal/logging/logging_test.go.copy new file mode 100644 index 0000000..fddab10 --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/logging/logging_test.go.copy @@ -0,0 +1,24 @@ +package logging + +import ( + "bytes" + "context" + "testing" + + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestApplyToContext(t *testing.T) { + var buf bytes.Buffer + logger := New(&buf, logrus.TraceLevel) + ctx := AddToContext(context.Background(), logger) + assert.Equal(t, logger, FromContext(ctx)) +} + +func TestFromContext(t *testing.T) { + var buf bytes.Buffer + logger := New(&buf, logrus.TraceLevel) + ctx := AddToContext(context.Background(), logger) + assert.Equal(t, logger, FromContext(ctx)) +} diff --git a/cli/internal/templating/resources/cli/cli/internal/testutils/testutils.go.copy b/cli/internal/templating/resources/cli/cli/internal/testutils/testutils.go.copy new file mode 100644 index 0000000..323055d --- /dev/null +++ b/cli/internal/templating/resources/cli/cli/internal/testutils/testutils.go.copy @@ -0,0 +1,36 @@ +package testutils + +import ( + "bytes" + "testing" + + "github.com/spf13/cobra" +) + +type CliCommandFunction func() *cobra.Command + +type CommandRunner func(cmd *cobra.Command, args []string) + +type ExecResult struct { + Stdout string + Stderr string + RunErr error +} + +// Helper function to simulate CLI execution +func RunCommand(t *testing.T, cmd *cobra.Command, args ...string) ExecResult { + t.Helper() + + stdout := new(bytes.Buffer) + stderr := new(bytes.Buffer) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + cmd.SetArgs(args) + + _, err := cmd.ExecuteC() + return ExecResult{ + Stdout: stdout.String(), + Stderr: stderr.String(), + RunErr: err, + } +} diff --git a/cli/internal/templating/resources/cli/go.mod.j2 b/cli/internal/templating/resources/cli/go.mod.j2 new file mode 100644 index 0000000..495a3ad --- /dev/null +++ b/cli/internal/templating/resources/cli/go.mod.j2 @@ -0,0 +1,3 @@ +module github.com/{{ .Username }}/{{ .Name }} + +go {{ .GoVersion }} diff --git a/cli/internal/templating/resources/cli/main.go.j2 b/cli/internal/templating/resources/cli/main.go.j2 new file mode 100644 index 0000000..a634374 --- /dev/null +++ b/cli/internal/templating/resources/cli/main.go.j2 @@ -0,0 +1,14 @@ +package main + +import ( + "github.com/{{ .Username }}/{{ .Name }}/cli/entrypoint" + + _ "embed" // Required for the //go:embed directive +) + +//go:embed specs.json +var embeddedConfig []byte + +func main() { + entrypoint.Run(embeddedConfig) +} diff --git a/cli/internal/templating/resources/cli/specs.json.j2 b/cli/internal/templating/resources/cli/specs.json.j2 new file mode 100644 index 0000000..eb65cef --- /dev/null +++ b/cli/internal/templating/resources/cli/specs.json.j2 @@ -0,0 +1,6 @@ +{ + "author": "{{ .Author }}", + "name": "{{ .Name }}", + "description": "CLI application for {{ .Name }}", + "version": "0.0.0" +} diff --git a/cli/internal/tui/tui.go b/cli/internal/tui/tui.go new file mode 100644 index 0000000..baff262 --- /dev/null +++ b/cli/internal/tui/tui.go @@ -0,0 +1,119 @@ +package tui + +import ( + "errors" + "fmt" + "runtime" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/jgfranco17/hackstack/cli/internal/templating" +) + +// PromptForCLI launches an interactive TUI that walks the user through each +// project field one at a time and returns the resulting CLIProject. +// GoVersion is populated from the host runtime. +func PromptForCLI() (templating.CLIProject, error) { + m, err := tea.NewProgram(newModel()).Run() + if err != nil { + return templating.CLIProject{}, fmt.Errorf("failed to load TUI: %w", err) + } + + result, ok := m.(model) + if !ok { + return templating.CLIProject{}, errors.New("unexpected TUI model type") + } + if result.cancelled { + return templating.CLIProject{}, errors.New("cancelled by user") + } + + return templating.CLIProject{ + Name: result.inputs[0].Value(), + Username: result.inputs[1].Value(), + Author: result.inputs[2].Value(), + GoVersion: strings.TrimPrefix(runtime.Version(), "go"), + }, nil +} + +// fieldDef holds the display label for a single prompt field. +type fieldDef struct { + label string +} + +var fields = []fieldDef{ + {"Project name"}, + {"GitHub username"}, + {"Author name"}, +} + +// model is the bubbletea model for the step-by-step data-entry TUI. +type model struct { + inputs []textinput.Model + step int + cancelled bool +} + +func newModel() model { + inputs := make([]textinput.Model, len(fields)) + for i, f := range fields { + ti := textinput.New() + ti.Placeholder = f.label + ti.CharLimit = 100 + if i == 0 { + ti.Focus() + } + inputs[i] = ti + } + return model{inputs: inputs} +} + +func (m model) Init() tea.Cmd { + return textinput.Blink +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.Type { + case tea.KeyCtrlC, tea.KeyEsc: + m.cancelled = true + return m, tea.Quit + + case tea.KeyEnter: + m.step++ + if m.step >= len(m.inputs) { + // All fields confirmed — submit. + return m, tea.Quit + } + m.inputs[m.step-1].Blur() + m.inputs[m.step].Focus() + return m, nil + } + } + + // Forward keystrokes to the current input. + var cmd tea.Cmd + m.inputs[m.step], cmd = m.inputs[m.step].Update(msg) + return m, cmd +} + +func (m model) View() string { + // bubbletea calls View once more after tea.Quit is returned; guard against + // the step being out of range at that point. + if m.step >= len(fields) { + return "" + } + var b strings.Builder + for i := 0; i < m.step; i++ { + fmt.Fprintf(&b, "%s: %s\n", fields[i].label, m.inputs[i].Value()) + } + if m.step > 0 { + b.WriteString("\n") + } + f := fields[m.step] + fmt.Fprintf(&b, "%s (%d/%d)\n", f.label, m.step+1, len(fields)) + b.WriteString(m.inputs[m.step].View()) + b.WriteString("\n\nEnter to confirm • Ctrl+C to cancel\n") + return b.String() +} diff --git a/cli/internal/tui/tui_test.go b/cli/internal/tui/tui_test.go new file mode 100644 index 0000000..247eefa --- /dev/null +++ b/cli/internal/tui/tui_test.go @@ -0,0 +1,158 @@ +package tui + +import ( + "fmt" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// isQuitCmd returns true when cmd produces a tea.QuitMsg, which is how +// bubbletea signals a program exit. +func isQuitCmd(t *testing.T, cmd tea.Cmd) bool { + t.Helper() + if cmd == nil { + return false + } + _, ok := cmd().(tea.QuitMsg) + return ok +} + +func TestNewModel_InitialState(t *testing.T) { + m := newModel() + + require.Len(t, m.inputs, len(fields)) + assert.Equal(t, 0, m.step) + assert.False(t, m.cancelled) + assert.True(t, m.inputs[0].Focused(), "first input should be focused by default") + for i := 1; i < len(m.inputs); i++ { + assert.False(t, m.inputs[i].Focused(), "input %d should not be focused by default", i) + } +} + +func TestModel_Init(t *testing.T) { + m := newModel() + cmd := m.Init() + assert.NotNil(t, cmd, "Init should return a non-nil Cmd (textinput.Blink)") +} + +func TestModel_View_ShowsCurrentFieldAndProgress(t *testing.T) { + m := newModel() + view := m.View() + + assert.Contains(t, view, fields[0].label) + assert.Contains(t, view, fmt.Sprintf("1/%d", len(fields))) + assert.Contains(t, view, "Enter") + assert.NotContains(t, view, fields[1].label, "only the current field should be visible") +} + +func TestModel_View_UpdatesAfterStepAdvance(t *testing.T) { + m := newModel() + m.inputs[0].SetValue("myapp") + + out, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + result := out.(model) + view := result.View() + + assert.Contains(t, view, fields[1].label, "current field label should be shown") + assert.Contains(t, view, fmt.Sprintf("2/%d", len(fields))) + assert.Contains(t, view, fields[0].label, "completed field label should appear in summary") + assert.Contains(t, view, "myapp", "completed field value should appear in summary") +} + +func TestModel_View_ShowsAllCompletedFields(t *testing.T) { + m := newModel() + m.inputs[0].SetValue("myapp") + m.inputs[1].SetValue("jgfranco17") + m.step = 2 + view := m.View() + + assert.Contains(t, view, "myapp") + assert.Contains(t, view, "jgfranco17") + assert.Contains(t, view, fields[2].label, "current field label should be shown") +} + +func TestModel_Update_CtrlC_SetsCancel(t *testing.T) { + m := newModel() + + out, cmd := m.Update(tea.KeyMsg{Type: tea.KeyCtrlC}) + + result := out.(model) + assert.True(t, result.cancelled) + assert.True(t, isQuitCmd(t, cmd)) +} + +func TestModel_Update_Esc_SetsCancel(t *testing.T) { + m := newModel() + + out, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEsc}) + + result := out.(model) + assert.True(t, result.cancelled) + assert.True(t, isQuitCmd(t, cmd)) +} + +func TestModel_Update_Enter_AdvancesStep(t *testing.T) { + m := newModel() + + out, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + result := out.(model) + assert.Equal(t, 1, result.step) + assert.False(t, result.cancelled) + assert.Nil(t, cmd) +} + +func TestModel_Update_Enter_UpdatesFocusState(t *testing.T) { + m := newModel() + require.True(t, m.inputs[0].Focused()) + + out, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + result := out.(model) + + assert.False(t, result.inputs[0].Focused(), "completed input should lose focus") + assert.True(t, result.inputs[1].Focused(), "next input should gain focus") +} + +func TestModel_Update_Enter_OnLastField_Quits(t *testing.T) { + m := newModel() + m.step = len(m.inputs) - 1 + + _, cmd := m.Update(tea.KeyMsg{Type: tea.KeyEnter}) + + assert.True(t, isQuitCmd(t, cmd), "confirming the last field should quit") +} + +func TestModel_View_AfterSubmit_DoesNotPanic(t *testing.T) { + // bubbletea calls View once more after tea.Quit is returned; step will be + // out of range at that point. + m := newModel() + m.step = len(fields) + + assert.NotPanics(t, func() { m.View() }) + assert.Empty(t, m.View()) +} + +func TestModel_Update_Rune_ForwardedToCurrentInput(t *testing.T) { + m := newModel() + // Clear the pre-filled default then type a new value. + m.inputs[0].SetValue("") + out, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + + result := out.(model) + assert.Contains(t, result.inputs[0].Value(), "x") +} + +func TestModel_Update_Rune_NotForwardedToOtherInputs(t *testing.T) { + m := newModel() + m.inputs[1].SetValue("") + m.inputs[2].SetValue("") + + out, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) + result := out.(model) + + assert.Empty(t, result.inputs[1].Value(), "non-active input should not receive keystrokes") + assert.Empty(t, result.inputs[2].Value(), "non-active input should not receive keystrokes") +} diff --git a/examples/cli.yaml b/examples/cli.yaml new file mode 100644 index 0000000..cf88031 --- /dev/null +++ b/examples/cli.yaml @@ -0,0 +1,4 @@ +--- +name: my-test-project +username: jgfranco +author: Chino diff --git a/go.mod b/go.mod index 409d399..27cbd12 100644 --- a/go.mod +++ b/go.mod @@ -1,26 +1,48 @@ module github.com/jgfranco17/hackstack -go 1.24.0 +go 1.24.2 toolchain go1.24.3 require ( + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/fatih/color v1.18.0 github.com/sirupsen/logrus v1.9.4 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/pretty v0.1.0 // indirect github.com/kr/text v0.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.3.8 // indirect gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 689aaf0..d07983b 100644 --- a/go.sum +++ b/go.sum @@ -1,28 +1,64 @@ -github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9 h1:uDmaGzcdjhF4i/plgjmEsriH11Y0o7RKapEf/LDaM3w= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pty v1.1.1 h1:VkoXIwSboBpnk99O/KFauAEILuNHv5DVFKZMBN/gUgw= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= @@ -30,16 +66,20 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= +golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/justfile b/justfile index f1e8d40..22f46bb 100644 --- a/justfile +++ b/justfile @@ -66,8 +66,3 @@ update-deps: @echo "Updating project dependencies..." go get -u ./... go mod tidy - -# Run the docs server locally -docs: - mkdocs build --strict --clean - mkdocs serve --open