From 5e2d058b26cd839afeaf4892ea0373288d219c84 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 30 Jan 2026 10:55:30 +0000 Subject: [PATCH] feat(cli): wire Core runtime with i18n and log services - Add i18n service wrapping pkg/i18n for translations via cli.T() - Add log service with levels (quiet/error/warn/info/debug) - Wire cli.Init() in cmd.Execute() with explicit service names - Fix main.go to print errors to stderr and exit with code 1 - Update runtime.go to accept additional services via Options Services use WithName() to avoid name collision since both are defined in pkg/cli (WithService would auto-name both "cli"). Co-Authored-By: Claude Opus 4.5 --- cmd/core.go | 25 +++++- main.go | 9 ++- pkg/cli/i18n.go | 109 ++++++++++++++++++++++++++ pkg/cli/log.go | 186 +++++++++++++++++++++++++++++++++++++++++++++ pkg/cli/runtime.go | 17 +++-- 5 files changed, 335 insertions(+), 11 deletions(-) create mode 100644 pkg/cli/i18n.go create mode 100644 pkg/cli/log.go diff --git a/cmd/core.go b/cmd/core.go index fc67cc78..92360c38 100644 --- a/cmd/core.go +++ b/cmd/core.go @@ -20,9 +20,15 @@ import ( "os" "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/framework" "github.com/spf13/cobra" ) +const ( + appName = "core" + appVersion = "0.1.0" +) + // Terminal styles using Tailwind colour palette (from shared package). var ( // coreStyle is used for primary headings and the CLI name. @@ -34,14 +40,29 @@ var ( // rootCmd is the base command for the CLI. var rootCmd = &cobra.Command{ - Use: "core", + Use: appName, Short: "CLI tool for development and production", - Version: "0.1.0", + Version: appVersion, } // Execute initialises and runs the CLI application. // Commands are registered based on build tags (see core_ci.go and core_dev.go). func Execute() error { + // Initialise CLI runtime with services + if err := cli.Init(cli.Options{ + AppName: appName, + Version: appVersion, + Services: []framework.Option{ + framework.WithName("i18n", cli.NewI18nService(cli.I18nOptions{})), + framework.WithName("log", cli.NewLogService(cli.LogOptions{ + Level: cli.LogLevelInfo, + })), + }, + }); err != nil { + return err + } + defer cli.Shutdown() + return rootCmd.Execute() } diff --git a/main.go b/main.go index cad1354d..b5cd903b 100644 --- a/main.go +++ b/main.go @@ -1,12 +1,15 @@ package main import ( + "fmt" + "os" + "github.com/host-uk/core/cmd" ) func main() { - err := cmd.Execute() - if err != nil { - return + if err := cmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) } } diff --git a/pkg/cli/i18n.go b/pkg/cli/i18n.go new file mode 100644 index 00000000..2e3fd7f9 --- /dev/null +++ b/pkg/cli/i18n.go @@ -0,0 +1,109 @@ +package cli + +import ( + "context" + + "github.com/host-uk/core/pkg/framework" + "github.com/host-uk/core/pkg/i18n" +) + +// I18nService wraps i18n as a Core service. +type I18nService struct { + *framework.ServiceRuntime[I18nOptions] + svc *i18n.Service +} + +// I18nOptions configures the i18n service. +type I18nOptions struct { + // Language overrides auto-detection (e.g., "en-GB", "de") + Language string +} + +// NewI18nService creates an i18n service factory. +func NewI18nService(opts I18nOptions) func(*framework.Core) (any, error) { + return func(c *framework.Core) (any, error) { + svc, err := i18n.New() + if err != nil { + return nil, err + } + + if opts.Language != "" { + svc.SetLanguage(opts.Language) + } + + return &I18nService{ + ServiceRuntime: framework.NewServiceRuntime(c, opts), + svc: svc, + }, nil + } +} + +// OnStartup initialises the i18n service. +func (s *I18nService) OnStartup(ctx context.Context) error { + s.Core().RegisterQuery(s.handleQuery) + return nil +} + +// Queries for i18n service + +// QueryTranslate requests a translation. +type QueryTranslate struct { + Key string + Args map[string]any +} + +func (s *I18nService) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) { + switch m := q.(type) { + case QueryTranslate: + return s.svc.T(m.Key, m.Args), true, nil + } + return nil, false, nil +} + +// T translates a key with optional arguments. +func (s *I18nService) T(key string, args ...map[string]any) string { + if len(args) > 0 { + return s.svc.T(key, args[0]) + } + return s.svc.T(key) +} + +// SetLanguage changes the current language. +func (s *I18nService) SetLanguage(lang string) { + s.svc.SetLanguage(lang) +} + +// Language returns the current language. +func (s *I18nService) Language() string { + return s.svc.Language() +} + +// AvailableLanguages returns all available languages. +func (s *I18nService) AvailableLanguages() []string { + return s.svc.AvailableLanguages() +} + +// --- Package-level convenience --- + +// T translates a key using the CLI's i18n service. +// Falls back to the global i18n.T if CLI not initialised. +func T(key string, args ...map[string]any) string { + if instance == nil { + // CLI not initialised, use global i18n + if len(args) > 0 { + return i18n.T(key, args[0]) + } + return i18n.T(key) + } + + svc, err := framework.ServiceFor[*I18nService](instance.core, "i18n") + if err != nil { + // i18n service not registered, use global + if len(args) > 0 { + return i18n.T(key, args[0]) + } + return i18n.T(key) + } + + return svc.T(key, args...) +} diff --git a/pkg/cli/log.go b/pkg/cli/log.go new file mode 100644 index 00000000..5b3473e8 --- /dev/null +++ b/pkg/cli/log.go @@ -0,0 +1,186 @@ +package cli + +import ( + "context" + "fmt" + "io" + "os" + "sync" + "time" + + "github.com/host-uk/core/pkg/framework" +) + +// LogLevel defines logging verbosity. +type LogLevel int + +const ( + LogLevelQuiet LogLevel = iota + LogLevelError + LogLevelWarn + LogLevelInfo + LogLevelDebug +) + +// LogService provides structured logging for the CLI. +type LogService struct { + *framework.ServiceRuntime[LogOptions] + mu sync.RWMutex + level LogLevel + output io.Writer +} + +// LogOptions configures the log service. +type LogOptions struct { + Level LogLevel + Output io.Writer // defaults to os.Stderr +} + +// NewLogService creates a log service factory. +func NewLogService(opts LogOptions) func(*framework.Core) (any, error) { + return func(c *framework.Core) (any, error) { + output := opts.Output + if output == nil { + output = os.Stderr + } + + return &LogService{ + ServiceRuntime: framework.NewServiceRuntime(c, opts), + level: opts.Level, + output: output, + }, nil + } +} + +// OnStartup registers query handlers. +func (s *LogService) OnStartup(ctx context.Context) error { + s.Core().RegisterQuery(s.handleQuery) + s.Core().RegisterTask(s.handleTask) + return nil +} + +// Queries and tasks for log service + +// QueryLogLevel returns the current log level. +type QueryLogLevel struct{} + +// TaskSetLogLevel changes the log level. +type TaskSetLogLevel struct { + Level LogLevel +} + +func (s *LogService) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) { + switch q.(type) { + case QueryLogLevel: + s.mu.RLock() + defer s.mu.RUnlock() + return s.level, true, nil + } + return nil, false, nil +} + +func (s *LogService) handleTask(c *framework.Core, t framework.Task) (any, bool, error) { + switch m := t.(type) { + case TaskSetLogLevel: + s.mu.Lock() + s.level = m.Level + s.mu.Unlock() + return nil, true, nil + } + return nil, false, nil +} + +// SetLevel changes the log level. +func (s *LogService) SetLevel(level LogLevel) { + s.mu.Lock() + s.level = level + s.mu.Unlock() +} + +// Level returns the current log level. +func (s *LogService) Level() LogLevel { + s.mu.RLock() + defer s.mu.RUnlock() + return s.level +} + +func (s *LogService) shouldLog(level LogLevel) bool { + s.mu.RLock() + defer s.mu.RUnlock() + return level <= s.level +} + +func (s *LogService) log(level, prefix, msg string) { + timestamp := time.Now().Format("15:04:05") + fmt.Fprintf(s.output, "%s %s %s\n", DimStyle.Render(timestamp), prefix, msg) +} + +// Debug logs a debug message. +func (s *LogService) Debug(msg string) { + if s.shouldLog(LogLevelDebug) { + s.log("debug", DimStyle.Render("[DBG]"), msg) + } +} + +// Infof logs an info message. +func (s *LogService) Infof(msg string) { + if s.shouldLog(LogLevelInfo) { + s.log("info", InfoStyle.Render("[INF]"), msg) + } +} + +// Warnf logs a warning message. +func (s *LogService) Warnf(msg string) { + if s.shouldLog(LogLevelWarn) { + s.log("warn", WarningStyle.Render("[WRN]"), msg) + } +} + +// Errorf logs an error message. +func (s *LogService) Errorf(msg string) { + if s.shouldLog(LogLevelError) { + s.log("error", ErrorStyle.Render("[ERR]"), msg) + } +} + +// --- Package-level convenience --- + +// Log returns the CLI's log service, or nil if not available. +func Log() *LogService { + if instance == nil { + return nil + } + svc, err := framework.ServiceFor[*LogService](instance.core, "log") + if err != nil { + return nil + } + return svc +} + +// LogDebug logs a debug message if log service is available. +func LogDebug(msg string) { + if l := Log(); l != nil { + l.Debug(msg) + } +} + +// LogInfo logs an info message if log service is available. +func LogInfo(msg string) { + if l := Log(); l != nil { + l.Infof(msg) + } +} + +// LogWarn logs a warning message if log service is available. +func LogWarn(msg string) { + if l := Log(); l != nil { + l.Warnf(msg) + } +} + +// LogError logs an error message if log service is available. +func LogError(msg string) { + if l := Log(); l != nil { + l.Errorf(msg) + } +} diff --git a/pkg/cli/runtime.go b/pkg/cli/runtime.go index 526ed658..bcbbf550 100644 --- a/pkg/cli/runtime.go +++ b/pkg/cli/runtime.go @@ -38,21 +38,26 @@ type runtime struct { // Options configures the CLI runtime. type Options struct { - AppName string - Version string + AppName string + Version string + Services []framework.Option // Additional services to register } // Init initialises the global CLI runtime. -// Call this once at startup (typically in main.go). +// Call this once at startup (typically in main.go or cmd.Execute). func Init(opts Options) error { var initErr error once.Do(func() { ctx, cancel := context.WithCancel(context.Background()) - c, err := framework.New( + // Build options: signal service + any additional services + coreOpts := []framework.Option{ framework.WithName("signal", newSignalService(cancel)), - framework.WithServiceLock(), - ) + } + coreOpts = append(coreOpts, opts.Services...) + coreOpts = append(coreOpts, framework.WithServiceLock()) + + c, err := framework.New(coreOpts...) if err != nil { initErr = err cancel()