diff --git a/internal/app/cli/cli.go b/internal/app/cli/cli.go index 548c8f1..58f45b9 100644 --- a/internal/app/cli/cli.go +++ b/internal/app/cli/cli.go @@ -6,6 +6,7 @@ import ( "os" "strings" + "fuku/internal/app/generator" "fuku/internal/app/runner" "fuku/internal/app/ui/wire" "fuku/internal/config" @@ -14,12 +15,19 @@ import ( const ( Usage = `Usage: + fuku init [--profile=] [--service=] [--force] [--dry-run] + Generate fuku.yaml from template fuku --run= Run services with specified profile (with TUI) fuku --run= --no-ui Run services without TUI fuku help Show help fuku version Show version Examples: + fuku init Generate fuku.yaml with defaults + fuku init --profile=dev --service=backend + Generate with custom profile and service + fuku init --force Overwrite existing fuku.yaml + fuku init --dry-run Preview generated file without writing fuku --run=default Run all services with TUI fuku --run=core --no-ui Run core services without TUI fuku --run=minimal Run minimal services with TUI @@ -48,10 +56,11 @@ type CLI interface { // cli represents the command-line interface for the application type cli struct { - cfg *config.Config - runner runner.Runner - ui wire.UI - log logger.Logger + cfg *config.Config + runner runner.Runner + ui wire.UI + generator generator.Generator + log logger.Logger } // NewCLI creates a new cli instance @@ -59,20 +68,26 @@ func NewCLI( cfg *config.Config, runner runner.Runner, ui wire.UI, + generator generator.Generator, log logger.Logger, ) CLI { return &cli{ - cfg: cfg, - runner: runner, - ui: ui, - log: log, + cfg: cfg, + runner: runner, + ui: ui, + generator: generator, + log: log, } } // Run processes command-line arguments and executes commands func (c *cli) Run(args []string) (int, error) { noUI := false + force := false + dryRun := false profile := config.DefaultProfile + initProfile := "" + initService := "" var remainingArgs []string @@ -80,11 +95,19 @@ func (c *cli) Run(args []string) (int, error) { switch { case arg == "--no-ui": noUI = true + case arg == "--force": + force = true + case arg == "--dry-run": + dryRun = true case strings.HasPrefix(arg, "--run="): profile = strings.TrimPrefix(arg, "--run=") if profile == "" { profile = config.DefaultProfile } + case strings.HasPrefix(arg, "--profile="): + initProfile = strings.TrimPrefix(arg, "--profile=") + case strings.HasPrefix(arg, "--service="): + initService = strings.TrimPrefix(arg, "--service=") default: remainingArgs = append(remainingArgs, arg) } @@ -97,6 +120,8 @@ func (c *cli) Run(args []string) (int, error) { cmd := remainingArgs[0] switch cmd { + case "init", "--init", "-i": + return c.handleInit(initProfile, initService, force, dryRun) case "help", "--help", "-h": return c.handleHelp() case "version", "--version", "-v": @@ -176,6 +201,36 @@ func (c *cli) handleVersion() (int, error) { return 0, nil } +// handleInit generates a fuku.yaml file from template +func (c *cli) handleInit(profileName, serviceName string, force bool, dryRun bool) (int, error) { + opts := generator.DefaultOptions() + if profileName != "" { + opts.ProfileName = profileName + } + + if serviceName != "" { + opts.ServiceName = serviceName + } + + c.log.Debug().Msgf("Generating fuku.yaml (profile=%s, service=%s, force=%v, dryRun=%v)", opts.ProfileName, opts.ServiceName, force, dryRun) + + if err := c.generator.Generate(opts, force, dryRun); err != nil { + c.log.Error().Err(err).Msg("Failed to generate fuku.yaml") + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + + return 1, err + } + + if !dryRun { + fmt.Println("Generated fuku.yaml") + fmt.Println("\nNext steps:") + fmt.Println(" 1. Edit fuku.yaml to configure your services") + fmt.Printf(" 2. Run 'fuku --run=%s' to start services\n", opts.ProfileName) + } + + return 0, nil +} + // handleUnknown handles unknown commands func (c *cli) handleUnknown() (int, error) { c.log.Debug().Msg("Unknown command") diff --git a/internal/app/cli/cli_test.go b/internal/app/cli/cli_test.go index a368920..43afd80 100644 --- a/internal/app/cli/cli_test.go +++ b/internal/app/cli/cli_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "go.uber.org/mock/gomock" + "fuku/internal/app/generator" "fuku/internal/app/runner" "fuku/internal/app/ui/wire" "fuku/internal/config" @@ -28,9 +29,10 @@ func Test_NewCLI(t *testing.T) { mockUI := func(ctx context.Context, profile string) (*tea.Program, error) { return nil, nil } + mockGenerator := generator.NewMockGenerator(ctrl) mockLogger := logger.NewMockLogger(ctrl) - cliInstance := NewCLI(cfg, mockRunner, mockUI, mockLogger) + cliInstance := NewCLI(cfg, mockRunner, mockUI, mockGenerator, mockLogger) assert.NotNil(t, cliInstance) instance, ok := cliInstance.(*cli) @@ -39,6 +41,7 @@ func Test_NewCLI(t *testing.T) { assert.Equal(t, cfg, instance.cfg) assert.Equal(t, mockRunner, instance.runner) assert.NotNil(t, instance.ui) + assert.Equal(t, mockGenerator, instance.generator) assert.Equal(t, mockLogger, instance.log) } @@ -47,6 +50,7 @@ func Test_Run(t *testing.T) { defer ctrl.Finish() mockRunner := runner.NewMockRunner(ctrl) + mockGenerator := generator.NewMockGenerator(ctrl) cfg := config.DefaultConfig() mockUI := wire.UI(func(ctx context.Context, profile string) (*tea.Program, error) { return nil, nil @@ -54,10 +58,11 @@ func Test_Run(t *testing.T) { mockLogger := logger.NewMockLogger(ctrl) c := &cli{ - cfg: cfg, - runner: mockRunner, - ui: mockUI, - log: mockLogger, + cfg: cfg, + runner: mockRunner, + ui: mockUI, + generator: mockGenerator, + log: mockLogger, } tests := []struct { diff --git a/internal/app/generator/generator.go b/internal/app/generator/generator.go new file mode 100644 index 0000000..42ac57d --- /dev/null +++ b/internal/app/generator/generator.go @@ -0,0 +1,86 @@ +package generator + +import ( + "bytes" + "embed" + "fmt" + "os" + "text/template" + + "fuku/internal/config/logger" +) + +const ( + templatePath = "templates/fuku.yaml.tmpl" + fileName = "fuku.yaml" +) + +//go:embed templates/fuku.yaml.tmpl +var templateFS embed.FS + +// Options contains the configuration for generating fuku.yaml +type Options struct { + ProfileName string + ServiceName string +} + +// DefaultOptions returns sensible defaults for generation +func DefaultOptions() Options { + return Options{ + ProfileName: "default", + ServiceName: "api", + } +} + +// Generator defines the interface for generating fuku.yaml +type Generator interface { + Generate(opts Options, force bool, dryRun bool) error +} + +type generator struct { + log logger.Logger +} + +// NewGenerator creates a new generator instance +func NewGenerator(log logger.Logger) Generator { + return &generator{ + log: log, + } +} + +// Generate creates a fuku.yaml file from the template +func (g *generator) Generate(opts Options, force bool, dryRun bool) error { + if !dryRun && !force { + if _, err := os.Stat(fileName); err == nil { + return fmt.Errorf("file %s already exists, use --force to overwrite", fileName) + } + } + + tmplContent, err := templateFS.ReadFile(templatePath) + if err != nil { + return fmt.Errorf("failed to read template: %w", err) + } + + tmpl, err := template.New("fuku.yaml").Parse(string(tmplContent)) + if err != nil { + return fmt.Errorf("failed to parse template: %w", err) + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, opts); err != nil { + return fmt.Errorf("failed to execute template: %w", err) + } + + if dryRun { + fmt.Print(buf.String()) + return nil + } + + if err := os.WriteFile(fileName, buf.Bytes(), 0600); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + g.log.Info().Msgf("Generated %s", fileName) + + return nil +} diff --git a/internal/app/generator/generator_mock.go b/internal/app/generator/generator_mock.go new file mode 100644 index 0000000..6a378e7 --- /dev/null +++ b/internal/app/generator/generator_mock.go @@ -0,0 +1,54 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: internal/app/generator/generator.go +// +// Generated by this command: +// +// mockgen -source=internal/app/generator/generator.go -destination=internal/app/generator/generator_mock.go -package=generator +// + +// Package generator is a generated GoMock package. +package generator + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockGenerator is a mock of Generator interface. +type MockGenerator struct { + ctrl *gomock.Controller + recorder *MockGeneratorMockRecorder + isgomock struct{} +} + +// MockGeneratorMockRecorder is the mock recorder for MockGenerator. +type MockGeneratorMockRecorder struct { + mock *MockGenerator +} + +// NewMockGenerator creates a new mock instance. +func NewMockGenerator(ctrl *gomock.Controller) *MockGenerator { + mock := &MockGenerator{ctrl: ctrl} + mock.recorder = &MockGeneratorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockGenerator) EXPECT() *MockGeneratorMockRecorder { + return m.recorder +} + +// Generate mocks base method. +func (m *MockGenerator) Generate(opts Options, force, dryRun bool) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Generate", opts, force, dryRun) + ret0, _ := ret[0].(error) + return ret0 +} + +// Generate indicates an expected call of Generate. +func (mr *MockGeneratorMockRecorder) Generate(opts, force, dryRun any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockGenerator)(nil).Generate), opts, force, dryRun) +} diff --git a/internal/app/generator/generator_test.go b/internal/app/generator/generator_test.go new file mode 100644 index 0000000..4a58e28 --- /dev/null +++ b/internal/app/generator/generator_test.go @@ -0,0 +1,182 @@ +package generator + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "go.uber.org/mock/gomock" + + "fuku/internal/config/logger" +) + +func newTestLogger(ctrl *gomock.Controller) *logger.MockLogger { + mockLog := logger.NewMockLogger(ctrl) + noopLogger := zerolog.New(io.Discard) + noopEvent := noopLogger.Info() + mockLog.EXPECT().Info().Return(noopEvent).AnyTimes() + + return mockLog +} + +func Test_DefaultOptions(t *testing.T) { + opts := DefaultOptions() + assert.Equal(t, "default", opts.ProfileName) + assert.Equal(t, "api", opts.ServiceName) +} + +func Test_NewGenerator(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockLog := newTestLogger(ctrl) + gen := NewGenerator(mockLog) + assert.NotNil(t, gen) +} + +func Test_Generator_Generate(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + tmpDir := t.TempDir() + oldDir, _ := os.Getwd() + + defer func() { _ = os.Chdir(oldDir) }() + + _ = os.Chdir(tmpDir) + + mockLog := newTestLogger(ctrl) + gen := NewGenerator(mockLog) + opts := DefaultOptions() + + err := gen.Generate(opts, false, false) + assert.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(tmpDir, fileName)) + assert.NoError(t, err) + assert.Contains(t, string(content), "version: 1") + assert.Contains(t, string(content), "api:") + assert.Contains(t, string(content), "default:") + assert.Contains(t, string(content), "server started") + assert.Contains(t, string(content), "x-readiness-log: &readiness-log") + assert.Contains(t, string(content), "x-readiness-http: &readiness-http") + assert.Contains(t, string(content), "http://localhost:8080/healthz") +} + +func Test_Generator_Generate_CustomOptions(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + tmpDir := t.TempDir() + oldDir, _ := os.Getwd() + + defer func() { _ = os.Chdir(oldDir) }() + + _ = os.Chdir(tmpDir) + + mockLog := newTestLogger(ctrl) + gen := NewGenerator(mockLog) + opts := Options{ProfileName: "dev", ServiceName: "backend"} + + err := gen.Generate(opts, false, false) + assert.NoError(t, err) + + content, err := os.ReadFile(filepath.Join(tmpDir, fileName)) + assert.NoError(t, err) + assert.Contains(t, string(content), "backend:") + assert.Contains(t, string(content), "dev:") +} + +func Test_Generator_Generate_FileExists(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + tmpDir := t.TempDir() + oldDir, _ := os.Getwd() + + defer func() { _ = os.Chdir(oldDir) }() + + _ = os.Chdir(tmpDir) + _ = os.WriteFile(fileName, []byte("existing"), 0600) + + mockLog := newTestLogger(ctrl) + gen := NewGenerator(mockLog) + opts := DefaultOptions() + + err := gen.Generate(opts, false, false) + assert.Error(t, err) + assert.Contains(t, err.Error(), "already exists") +} + +func Test_Generator_Generate_ForceOverwrite(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + tmpDir := t.TempDir() + oldDir, _ := os.Getwd() + + defer func() { _ = os.Chdir(oldDir) }() + + _ = os.Chdir(tmpDir) + _ = os.WriteFile(fileName, []byte("existing"), 0600) + + mockLog := newTestLogger(ctrl) + gen := NewGenerator(mockLog) + opts := DefaultOptions() + + err := gen.Generate(opts, true, false) + assert.NoError(t, err) + + content, err := os.ReadFile(fileName) + assert.NoError(t, err) + assert.Contains(t, string(content), "version: 1") +} + +func Test_Generator_Generate_DryRun(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + tmpDir := t.TempDir() + oldDir, _ := os.Getwd() + + defer func() { _ = os.Chdir(oldDir) }() + + _ = os.Chdir(tmpDir) + + mockLog := newTestLogger(ctrl) + gen := NewGenerator(mockLog) + opts := DefaultOptions() + + err := gen.Generate(opts, false, true) + assert.NoError(t, err) + + _, err = os.Stat(fileName) + assert.True(t, os.IsNotExist(err)) +} + +func Test_Generator_Generate_DryRun_IgnoresExistingFile(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + tmpDir := t.TempDir() + oldDir, _ := os.Getwd() + + defer func() { _ = os.Chdir(oldDir) }() + + _ = os.Chdir(tmpDir) + _ = os.WriteFile(fileName, []byte("existing"), 0600) + + mockLog := newTestLogger(ctrl) + gen := NewGenerator(mockLog) + opts := DefaultOptions() + + err := gen.Generate(opts, false, true) + assert.NoError(t, err) + + content, err := os.ReadFile(fileName) + assert.NoError(t, err) + assert.Equal(t, "existing", string(content)) +} diff --git a/internal/app/generator/module.go b/internal/app/generator/module.go new file mode 100644 index 0000000..ad59c93 --- /dev/null +++ b/internal/app/generator/module.go @@ -0,0 +1,8 @@ +package generator + +import "go.uber.org/fx" + +// Module provides the generator dependencies +var Module = fx.Options( + fx.Provide(NewGenerator), +) diff --git a/internal/app/generator/templates/fuku.yaml.tmpl b/internal/app/generator/templates/fuku.yaml.tmpl new file mode 100644 index 0000000..20afba1 --- /dev/null +++ b/internal/app/generator/templates/fuku.yaml.tmpl @@ -0,0 +1,34 @@ +version: 1 + +x-readiness-log: &readiness-log + type: log + pattern: "server started" + timeout: 30s + +x-readiness-http: &readiness-http + type: http + url: http://localhost:8080/healthz + timeout: 10s + interval: 500ms + +services: + {{.ServiceName}}: + dir: {{.ServiceName}} + readiness: + <<: *readiness-log + + # Example using HTTP readiness check: + # {{.ServiceName}}-http: + # dir: {{.ServiceName}} + # readiness: + # <<: *readiness-http + # url: http://localhost:8080/healthz + +profiles: + {{.ProfileName}}: + include: + - {{.ServiceName}} + +logging: + format: console + level: info diff --git a/internal/app/module.go b/internal/app/module.go index c7d84ac..ab1d284 100644 --- a/internal/app/module.go +++ b/internal/app/module.go @@ -4,6 +4,7 @@ import ( "go.uber.org/fx" "fuku/internal/app/cli" + "fuku/internal/app/generator" "fuku/internal/app/monitor" "fuku/internal/app/runner" "fuku/internal/app/runtime" @@ -13,6 +14,7 @@ import ( // Module provides the fx dependency injection options for the app package var Module = fx.Options( cli.Module, + generator.Module, monitor.Module, runner.Module, runtime.Module,