Some checks failed
CI / Test (Go 1.24.x, ubuntu-latest) (push) Successful in 1m53s
CI / Code Quality (push) Failing after 26s
CI / Security Scan (push) Failing after 11s
CI / Test Coverage (push) Successful in 1m13s
CI / Benchmarks (push) Failing after 10m22s
CI / Build CLI (push) Failing after 8s
Benchmarks / Run Benchmarks (push) Failing after 10m13s
Release / Test (push) Successful in 55s
Release / Build (amd64, darwin, ) (push) Failing after 12s
Release / Build (amd64, linux, ) (push) Failing after 6s
Release / Build (amd64, windows, .exe) (push) Failing after 12s
Release / Build (arm64, darwin, ) (push) Failing after 12s
Release / Build (arm64, linux, ) (push) Failing after 12s
Release / Release (push) Has been skipped
CI / Test (Go 1.24.x, macos-latest) (push) Has been cancelled
CI / Test (Go 1.24.x, windows-latest) (push) Has been cancelled
Move hosting from GitHub to private Gitea instance.
661 lines
18 KiB
Go
661 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/viper"
|
|
"gitea.dockr.co/kev/go-jdenticon/jdenticon"
|
|
)
|
|
|
|
// TestGenerateCommand tests the generate command functionality
|
|
func TestGenerateCommand(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantErr bool
|
|
outputCheck func(t *testing.T, output []byte, outputFile string)
|
|
}{
|
|
{
|
|
name: "generate SVG to stdout",
|
|
args: []string{"generate", "test@example.com"},
|
|
wantErr: false,
|
|
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
|
if !bytes.Contains(output, []byte("<svg")) {
|
|
t.Error("Expected SVG output to contain <svg tag")
|
|
}
|
|
if !bytes.Contains(output, []byte("xmlns=\"http://www.w3.org/2000/svg\"")) {
|
|
t.Error("Expected SVG output to contain namespace declaration")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "generate with custom size",
|
|
args: []string{"generate", "--size", "128", "test@example.com"},
|
|
wantErr: false,
|
|
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
|
if !bytes.Contains(output, []byte("width=\"128\"")) {
|
|
t.Error("Expected SVG to have width=128")
|
|
}
|
|
if !bytes.Contains(output, []byte("height=\"128\"")) {
|
|
t.Error("Expected SVG to have height=128")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "generate PNG format",
|
|
args: []string{"generate", "--format", "png", "test@example.com"},
|
|
wantErr: false,
|
|
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
|
// PNG files start with specific magic bytes
|
|
if len(output) < 8 || !bytes.Equal(output[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}) {
|
|
t.Error("Expected PNG output to have PNG magic bytes")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "generate with background color",
|
|
args: []string{"generate", "--bg-color", "#ffffff", "test@example.com"},
|
|
wantErr: false,
|
|
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
|
// SVG should contain background rect
|
|
if !bytes.Contains(output, []byte("fill=\"#ffffff\"")) {
|
|
t.Error("Expected SVG to contain background color")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "generate with custom padding",
|
|
args: []string{"generate", "--padding", "0.2", "test@example.com"},
|
|
wantErr: false,
|
|
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
|
// Should generate valid SVG
|
|
if !bytes.Contains(output, []byte("<svg")) {
|
|
t.Error("Expected valid SVG output")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "no arguments",
|
|
args: []string{},
|
|
wantErr: false, // Shows help, doesn't error
|
|
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
|
// Should show help text
|
|
outputStr := string(output)
|
|
if !strings.Contains(outputStr, "Usage:") && !strings.Contains(outputStr, "generate") {
|
|
t.Error("Expected help text to be shown when no arguments provided")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "too many arguments",
|
|
args: []string{"arg1", "arg2"},
|
|
wantErr: true,
|
|
outputCheck: func(t *testing.T, output []byte, outputFile string) {
|
|
// Should not produce output on error
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
// Reset viper for clean state
|
|
viper.Reset()
|
|
|
|
// Create a buffer to capture output
|
|
var output bytes.Buffer
|
|
|
|
// Create the generate command for testing
|
|
cmd := createTestGenerateCommand()
|
|
cmd.SetOut(&output)
|
|
cmd.SetErr(&output)
|
|
|
|
// Set args and execute
|
|
cmd.SetArgs(tt.args)
|
|
err := cmd.Execute()
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("generateCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
|
|
if !tt.wantErr && tt.outputCheck != nil {
|
|
tt.outputCheck(t, output.Bytes(), "")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGenerateToFile tests file output functionality
|
|
func TestGenerateToFile(t *testing.T) {
|
|
// Create a temporary directory for test outputs
|
|
tempDir, err := os.MkdirTemp("", "jdenticon-test")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
// Change to temp directory to test file creation there
|
|
originalDir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get current directory: %v", err)
|
|
}
|
|
defer os.Chdir(originalDir)
|
|
|
|
if err := os.Chdir(tempDir); err != nil {
|
|
t.Fatalf("Failed to change to temp directory: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
filename string
|
|
wantErr bool
|
|
fileCheck func(t *testing.T, filepath string)
|
|
}{
|
|
{
|
|
name: "generate SVG to file",
|
|
args: []string{"generate", "test@example.com"},
|
|
filename: "test.svg",
|
|
wantErr: false,
|
|
fileCheck: func(t *testing.T, filepath string) {
|
|
content, err := os.ReadFile(filepath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read output file: %v", err)
|
|
}
|
|
if !bytes.Contains(content, []byte("<svg")) {
|
|
t.Error("Expected SVG file to contain <svg tag")
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "generate PNG to file",
|
|
args: []string{"generate", "--format", "png", "test@example.com"},
|
|
filename: "test.png",
|
|
wantErr: false,
|
|
fileCheck: func(t *testing.T, filepath string) {
|
|
content, err := os.ReadFile(filepath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read output file: %v", err)
|
|
}
|
|
// Check PNG magic bytes
|
|
if len(content) < 8 || !bytes.Equal(content[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}) {
|
|
t.Error("Expected PNG file to have PNG magic bytes")
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
viper.Reset()
|
|
|
|
// Use relative path since we're in temp directory
|
|
outputPath := tt.filename
|
|
args := append(tt.args, "--output", outputPath)
|
|
|
|
var output bytes.Buffer
|
|
cmd := createTestGenerateCommand()
|
|
cmd.SetOut(&output)
|
|
cmd.SetErr(&output)
|
|
cmd.SetArgs(args)
|
|
|
|
err := cmd.Execute()
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("generateCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
|
|
if !tt.wantErr {
|
|
// Check that file was created
|
|
if _, err := os.Stat(outputPath); os.IsNotExist(err) {
|
|
t.Errorf("Expected output file to be created at %s", outputPath)
|
|
return
|
|
}
|
|
|
|
if tt.fileCheck != nil {
|
|
tt.fileCheck(t, outputPath)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGenerateValidation tests input validation
|
|
func TestGenerateValidation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
args []string
|
|
wantErr bool
|
|
errorCheck func(t *testing.T, err error)
|
|
}{
|
|
{
|
|
name: "negative size",
|
|
args: []string{"generate", "--size", "-1", "test@example.com"},
|
|
wantErr: true,
|
|
errorCheck: func(t *testing.T, err error) {
|
|
if !strings.Contains(err.Error(), "size must be positive") {
|
|
t.Errorf("Expected size validation error, got: %v", err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "zero size",
|
|
args: []string{"generate", "--size", "0", "test@example.com"},
|
|
wantErr: true,
|
|
errorCheck: func(t *testing.T, err error) {
|
|
if !strings.Contains(err.Error(), "size must be positive") {
|
|
t.Errorf("Expected size validation error, got: %v", err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid padding",
|
|
args: []string{"generate", "--padding", "-0.1", "test@example.com"},
|
|
wantErr: true,
|
|
errorCheck: func(t *testing.T, err error) {
|
|
if !strings.Contains(err.Error(), "padding") {
|
|
t.Errorf("Expected padding validation error, got: %v", err)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
name: "invalid color saturation",
|
|
args: []string{"generate", "--color-saturation", "1.5", "test@example.com"},
|
|
wantErr: true,
|
|
errorCheck: func(t *testing.T, err error) {
|
|
if !strings.Contains(err.Error(), "saturation") {
|
|
t.Errorf("Expected saturation validation error, got: %v", err)
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
viper.Reset()
|
|
|
|
var output bytes.Buffer
|
|
cmd := createTestGenerateCommand()
|
|
cmd.SetOut(&output)
|
|
cmd.SetErr(&output)
|
|
cmd.SetArgs(tt.args)
|
|
|
|
err := cmd.Execute()
|
|
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("generateCmd.Execute() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
|
|
if tt.wantErr && tt.errorCheck != nil {
|
|
tt.errorCheck(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestPathTraversalSecurity tests the path traversal vulnerability fix
|
|
func TestPathTraversalSecurity(t *testing.T) {
|
|
// Create a temporary directory for test outputs
|
|
tempDir, err := os.MkdirTemp("", "jdenticon-security-test")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
// Change to temp directory to have a controlled test environment
|
|
originalDir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get current directory: %v", err)
|
|
}
|
|
defer os.Chdir(originalDir)
|
|
|
|
if err := os.Chdir(tempDir); err != nil {
|
|
t.Fatalf("Failed to change to temp directory: %v", err)
|
|
}
|
|
|
|
// Create a subdirectory to test valid subdirectory writes
|
|
subDir := filepath.Join(tempDir, "images")
|
|
if err := os.Mkdir(subDir, 0755); err != nil {
|
|
t.Fatalf("Failed to create subdirectory: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
outputPath string
|
|
expectError bool
|
|
errorMessage string
|
|
description string
|
|
}{
|
|
{
|
|
name: "valid_current_dir",
|
|
outputPath: "avatar.png",
|
|
expectError: false,
|
|
description: "Should allow files in current directory",
|
|
},
|
|
{
|
|
name: "valid_subdirectory",
|
|
outputPath: "images/avatar.png",
|
|
expectError: false,
|
|
description: "Should allow files in subdirectories",
|
|
},
|
|
{
|
|
name: "valid_relative_current",
|
|
outputPath: "./avatar.png",
|
|
expectError: false,
|
|
description: "Should allow explicit current directory notation",
|
|
},
|
|
{
|
|
name: "path_traversal_up_one",
|
|
outputPath: "../avatar.png",
|
|
expectError: true,
|
|
errorMessage: "outside allowed directory",
|
|
description: "Should block path traversal attempts with ../",
|
|
},
|
|
{
|
|
name: "path_traversal_up_multiple",
|
|
outputPath: "../../avatar.png",
|
|
expectError: true,
|
|
errorMessage: "outside allowed directory",
|
|
description: "Should block multiple directory traversal attempts",
|
|
},
|
|
{
|
|
name: "path_traversal_complex",
|
|
outputPath: "../../../etc/passwd",
|
|
expectError: true,
|
|
errorMessage: "outside allowed directory",
|
|
description: "Should block attempts to write to system directories",
|
|
},
|
|
{
|
|
name: "absolute_path_system",
|
|
outputPath: filepath.Join(os.TempDir(), "avatar.png"),
|
|
expectError: true,
|
|
errorMessage: "outside allowed directory",
|
|
description: "Should block absolute paths to system directories",
|
|
},
|
|
{
|
|
name: "absolute_path_root",
|
|
outputPath: func() string {
|
|
if runtime.GOOS == "windows" {
|
|
return `C:\Windows\test.png`
|
|
} else {
|
|
return "/etc/passwd"
|
|
}
|
|
}(),
|
|
expectError: true,
|
|
errorMessage: "outside allowed directory",
|
|
description: "Should block attempts to overwrite system files",
|
|
},
|
|
{
|
|
name: "mixed_path_traversal",
|
|
outputPath: filepath.Join(".", "images", "..", "..", "..", os.TempDir(), "avatar.png"),
|
|
expectError: true,
|
|
errorMessage: "outside allowed directory",
|
|
description: "Should block mixed path traversal attempts",
|
|
},
|
|
{
|
|
name: "windows_style_traversal",
|
|
outputPath: "..\\..\\evil.exe",
|
|
expectError: runtime.GOOS == "windows", // Only expect error on Windows
|
|
errorMessage: "outside allowed directory",
|
|
description: "Should block Windows-style path traversal on Windows (backslashes are valid filename chars on Unix)",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
viper.Reset()
|
|
|
|
// Test args for generating an identicon with the specified output path
|
|
args := []string{"generate", "--output", tt.outputPath, "test@example.com"}
|
|
|
|
var output bytes.Buffer
|
|
cmd := createTestGenerateCommand()
|
|
cmd.SetOut(&output)
|
|
cmd.SetErr(&output)
|
|
cmd.SetArgs(args)
|
|
|
|
err := cmd.Execute()
|
|
|
|
if tt.expectError {
|
|
if err == nil {
|
|
t.Errorf("Test %s: Expected error but got none. %s", tt.name, tt.description)
|
|
return
|
|
}
|
|
if tt.errorMessage != "" && !strings.Contains(err.Error(), tt.errorMessage) {
|
|
t.Errorf("Test %s: Expected error to contain %q, got: %v", tt.name, tt.errorMessage, err)
|
|
}
|
|
t.Logf("Test %s: Correctly blocked path %q with error: %v", tt.name, tt.outputPath, err)
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("Test %s: Expected no error but got: %v. %s", tt.name, err, tt.description)
|
|
return
|
|
}
|
|
|
|
// For successful cases, verify the file was created in the expected location
|
|
expectedPath := filepath.Join(tempDir, tt.outputPath)
|
|
expectedPath = filepath.Clean(expectedPath)
|
|
|
|
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
|
|
t.Errorf("Test %s: Expected file to be created at %s", tt.name, expectedPath)
|
|
} else {
|
|
t.Logf("Test %s: Successfully created file at %s", tt.name, expectedPath)
|
|
// Clean up the created file
|
|
os.Remove(expectedPath)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateAndResolveOutputPath tests the security helper function directly
|
|
func TestValidateAndResolveOutputPath(t *testing.T) {
|
|
tempDir, err := os.MkdirTemp("", "jdenticon-path-test")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp dir: %v", err)
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
// Change to temp directory to test relative path resolution correctly
|
|
originalDir, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get current directory: %v", err)
|
|
}
|
|
defer os.Chdir(originalDir)
|
|
|
|
if err := os.Chdir(tempDir); err != nil {
|
|
t.Fatalf("Failed to change to temp directory: %v", err)
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
baseDir string
|
|
outputPath string
|
|
expectError bool
|
|
description string
|
|
}{
|
|
{
|
|
name: "valid_relative_file",
|
|
baseDir: tempDir,
|
|
outputPath: "test.png",
|
|
expectError: false,
|
|
description: "Valid relative file path should be allowed",
|
|
},
|
|
{
|
|
name: "valid_subdirectory_file",
|
|
baseDir: tempDir,
|
|
outputPath: "sub/test.png",
|
|
expectError: false,
|
|
description: "Valid file in subdirectory should be allowed",
|
|
},
|
|
{
|
|
name: "traversal_attack",
|
|
baseDir: tempDir,
|
|
outputPath: "../../../etc/passwd",
|
|
expectError: true,
|
|
description: "Path traversal attack should be blocked",
|
|
},
|
|
{
|
|
name: "absolute_outside_path",
|
|
baseDir: tempDir,
|
|
outputPath: filepath.Join(os.TempDir(), "test.png"),
|
|
expectError: true,
|
|
description: "Absolute path outside base should be blocked",
|
|
},
|
|
{
|
|
name: "current_dir_notation",
|
|
baseDir: tempDir,
|
|
outputPath: "./test.png",
|
|
expectError: false,
|
|
description: "Current directory notation should be allowed",
|
|
},
|
|
{
|
|
name: "complex_traversal",
|
|
baseDir: tempDir,
|
|
outputPath: "sub/../../../secret.txt",
|
|
expectError: true,
|
|
description: "Complex path traversal should be blocked",
|
|
},
|
|
{
|
|
name: "absolute_inside_allowed",
|
|
baseDir: tempDir,
|
|
outputPath: filepath.Join(tempDir, "allowed.png"),
|
|
expectError: false,
|
|
description: "Absolute path inside base directory should be allowed",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result, err := validateAndResolveOutputPath(tt.baseDir, tt.outputPath)
|
|
|
|
if tt.expectError {
|
|
if err == nil {
|
|
t.Errorf("Expected error for %s, but got none. Result: %s", tt.description, result)
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("Unexpected error for %s: %v", tt.description, err)
|
|
} else {
|
|
// Verify the result is within the base directory
|
|
// Use EvalSymlinks to handle macOS symlink issues
|
|
absBase, _ := filepath.Abs(tt.baseDir)
|
|
resolvedBase, err := filepath.EvalSymlinks(absBase)
|
|
if err != nil {
|
|
resolvedBase = absBase // fallback to original if symlink resolution fails
|
|
}
|
|
|
|
resolvedResult, err := filepath.EvalSymlinks(filepath.Dir(result))
|
|
if err != nil {
|
|
resolvedResult = filepath.Dir(result) // fallback to original if symlink resolution fails
|
|
}
|
|
resolvedResult = filepath.Join(resolvedResult, filepath.Base(result))
|
|
|
|
if !strings.HasPrefix(resolvedResult, resolvedBase) {
|
|
t.Errorf("Result path %s (resolved: %s) is not within base directory %s (resolved: %s)", result, resolvedResult, absBase, resolvedBase)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// createTestGenerateCommand creates a generate command for testing
|
|
func createTestGenerateCommand() *cobra.Command {
|
|
// Create root command with flags
|
|
rootCmd := &cobra.Command{
|
|
Use: "jdenticon",
|
|
Short: "Generate identicons from any input string",
|
|
}
|
|
|
|
// Initialize root flags
|
|
initTestFlags(rootCmd)
|
|
|
|
// Create generate command similar to the actual one
|
|
generateCmd := &cobra.Command{
|
|
Use: "generate <value>",
|
|
Short: "Generate a single identicon",
|
|
Args: cobra.ExactArgs(1),
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
// Get the input value
|
|
value := args[0]
|
|
|
|
// Get output file flag
|
|
outputFile, _ := cmd.Flags().GetString("output")
|
|
|
|
// Get format from viper
|
|
format, err := getFormatFromViper()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Populate library config from root persistent flags
|
|
config, size, err := populateConfigFromFlags()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Generate the identicon with custom config
|
|
generator, err := jdenticon.NewGeneratorWithConfig(config, generatorCacheSize)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
icon, err := generator.Generate(context.Background(), value, size)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Generate output based on format
|
|
result, err := renderIcon(icon, format)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Output to file or stdout
|
|
if outputFile != "" {
|
|
// Determine the base directory for allowed writes. For a CLI, this is typically the CWD.
|
|
baseDir, err := os.Getwd()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate and resolve the user-provided output path.
|
|
safeOutputPath, err := validateAndResolveOutputPath(baseDir, outputFile)
|
|
if err != nil {
|
|
// This is a security-related error, explicitly state it.
|
|
return err
|
|
}
|
|
|
|
// Now use the safe and validated path for writing.
|
|
if err := os.WriteFile(safeOutputPath, result, 0644); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
// Write to stdout for piping
|
|
if _, err := cmd.OutOrStdout().Write(result); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
},
|
|
}
|
|
|
|
// Add generate-specific flags
|
|
generateCmd.Flags().StringP("output", "o", "", "Output file path. If empty, writes to stdout.")
|
|
|
|
// Add to root command
|
|
rootCmd.AddCommand(generateCmd)
|
|
|
|
return rootCmd
|
|
}
|