refactor: migrate to dappco.re/go/core + Options{} API
Some checks failed
Deploy / build (push) Failing after 6s
Security Scan / security (push) Successful in 19s

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-21 20:01:25 +00:00
parent 542698c579
commit 92da6e8a73
7 changed files with 79 additions and 144 deletions

View file

@ -7,7 +7,7 @@ import (
"forge.lthn.ai/core/go-i18n"
)
// printInstallInstructions prints OS-specific installation instructions
// printInstallInstructions prints OperatingSystem-specific installation instructions
func printInstallInstructions() {
switch runtime.GOOS {
case "darwin":

View file

@ -74,9 +74,10 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oasdiff/oasdiff v1.12.1 // indirect
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c // indirect
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b // indirect
github.com/oasdiff/kin-openapi v0.136.1 // indirect
github.com/oasdiff/oasdiff v1.12.3 // indirect
github.com/oasdiff/yaml v0.0.1 // indirect
github.com/oasdiff/yaml3 v0.0.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect

View file

@ -159,12 +159,10 @@ 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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oasdiff/oasdiff v1.12.1 h1:wnvBQS/WSqGqH23u1Jo3XVaF5y5X67TC5znSiy5nIug=
github.com/oasdiff/oasdiff v1.12.1/go.mod h1:4l8lF8SkdyiBVpa7AH3xc+oyDDXS1QTegX25mBS11/E=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c h1:7ACFcSaQsrWtrH4WHHfUqE1C+f8r2uv8KGaW0jTNjus=
github.com/oasdiff/yaml v0.0.0-20260313112342-a3ea61cb4d4c/go.mod h1:JKox4Gszkxt57kj27u7rvi7IFoIULvCZHUsBTUmQM/s=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b h1:vivRhVUAa9t1q0Db4ZmezBP8pWQWnXHFokZj0AOea2g=
github.com/oasdiff/yaml3 v0.0.0-20260224194419-61cd415a242b/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/oasdiff/kin-openapi v0.136.1 h1:x1G9doDyPcagCNXDcMK5dt5yAmIgsSCiK7F5gPUiQdM=
github.com/oasdiff/oasdiff v1.12.3 h1:eUzJ/AiyyCY1KwUZPv7fosgDyETacIZbFesJrRz+QdY=
github.com/oasdiff/yaml v0.0.1 h1:dPrn0F2PJ7HdzHPndJkArvB2Fw0cwgFdVUKCEkoFuds=
github.com/oasdiff/yaml3 v0.0.1 h1:kReOSraQLTxuuGNX9aNeJ7tcsvUB2MS+iupdUrWe4Z0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=

2
go.mod
View file

@ -2,7 +2,7 @@ module forge.lthn.ai/core/cli
go 1.26.0
require forge.lthn.ai/core/go v0.3.3
require dappco.re/go/core v0.4.7
require (
forge.lthn.ai/core/go-i18n v0.1.7

View file

@ -9,7 +9,7 @@ import (
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
"dappco.re/go/core"
"github.com/spf13/cobra"
)
@ -60,16 +60,6 @@ func WithAppName(name string) {
AppName = name
}
// Main initialises and runs the CLI application.
// Pass command services via WithCommands to register CLI commands
// through the Core framework lifecycle.
//
// cli.Main(
// cli.WithCommands("config", config.AddConfigCommands),
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
// )
//
// Exits with code 1 on error or panic.
// LocaleSource pairs a filesystem with a directory for loading translations.
type LocaleSource = i18n.FSSource
@ -78,13 +68,16 @@ func WithLocales(fsys fs.FS, dir string) LocaleSource {
return LocaleSource{FS: fsys, Dir: dir}
}
// CommandSetup is a function that registers commands on the CLI after init.
type CommandSetup func(c *core.Core)
// Main initialises and runs the CLI with the framework's built-in translations.
func Main(commands ...core.Option) {
func Main(commands ...CommandSetup) {
MainWithLocales(nil, commands...)
}
// MainWithLocales initialises and runs the CLI with additional translation sources.
func MainWithLocales(locales []LocaleSource, commands ...core.Option) {
func MainWithLocales(locales []LocaleSource, commands ...CommandSetup) {
// Recovery from panics
defer func() {
if r := recover(); r != nil {
@ -103,25 +96,22 @@ func MainWithLocales(locales []LocaleSource, commands ...core.Option) {
extraFS = append(extraFS, i18n.FSSource{FS: lfs, Dir: "."})
}
// Core services load first, then command services
services := []core.Option{
core.WithName("i18n", i18n.NewCoreService(i18n.ServiceOptions{
ExtraFS: extraFS,
})),
}
services = append(services, commands...)
// Initialise CLI runtime with services
// Initialise CLI runtime
if err := Init(Options{
AppName: AppName,
Version: SemVer(),
Services: services,
AppName: AppName,
Version: SemVer(),
I18nSources: extraFS,
}); err != nil {
Error(err.Error())
os.Exit(1)
}
defer Shutdown()
// Run command setup functions
for _, setup := range commands {
setup(Core())
}
// Add completion command to the CLI's root
RootCmd().AddCommand(newCompletionCmd())

View file

@ -2,80 +2,36 @@
package cli
import (
"context"
"io/fs"
"iter"
"sync"
"forge.lthn.ai/core/go/pkg/core"
"dappco.re/go/core"
"github.com/spf13/cobra"
)
// WithCommands creates a framework Option that registers a command group.
// The register function receives the root command during service startup,
// allowing commands to participate in the Core lifecycle.
// WithCommands returns a CommandSetup that registers a command group.
// The register function receives the root cobra command during Main().
//
// cli.Main(
// cli.WithCommands("config", config.AddConfigCommands),
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
// )
// WithCommands creates a framework Option that registers a command group.
// Optionally pass a locale fs.FS as the third argument to provide translations.
//
// cli.WithCommands("dev", dev.AddDevCommands, locales.FS)
func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) core.Option {
return core.WithName("cmd."+name, func(c *core.Core) (any, error) {
svc := &commandService{core: c, name: name, register: register}
if len(localeFS) > 0 {
svc.localeFS = localeFS[0]
func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) CommandSetup {
return func(c *core.Core) {
if root, ok := c.App().Runtime.(*cobra.Command); ok {
register(root)
}
return svc, nil
})
}
type commandService struct {
core *core.Core
name string
register func(root *Command)
localeFS fs.FS
}
func (s *commandService) OnStartup(_ context.Context) error {
if root, ok := s.core.App().Runtime.(*cobra.Command); ok {
s.register(root)
// Auto-set Short/Long from i18n keys derived from command name.
// The Conclave's i18n service has already loaded all translations
// from sibling services' LocaleProvider before commands attach.
s.applyI18n(root)
}
return nil
}
// applyI18n walks commands added by this service and sets Short/Long
// from derived i18n keys if they're empty or still raw keys.
func (s *commandService) applyI18n(root *cobra.Command) {
for _, cmd := range root.Commands() {
key := "cmd." + cmd.Name()
// Only set if Short is empty or looks like a raw key (contains dots)
if cmd.Short == "" || cmd.Short == key+".short" {
if translated := T(key + ".short"); translated != key+".short" {
cmd.Short = translated
}
}
if cmd.Long == "" || cmd.Long == key+".long" {
if translated := T(key + ".long"); translated != key+".long" {
cmd.Long = translated
}
// Register locale FS if provided
if len(localeFS) > 0 && localeFS[0] != nil {
registeredCommandsMu.Lock()
registeredLocales = append(registeredLocales, localeFS[0])
registeredCommandsMu.Unlock()
}
}
}
// Locales implements core.LocaleProvider.
func (s *commandService) Locales() fs.FS {
return s.localeFS
}
// CommandRegistration is a function that adds commands to the root.
// CommandRegistration is a function that adds commands to the CLI root.
type CommandRegistration func(root *cobra.Command)
var (
@ -138,4 +94,3 @@ func attachRegisteredCommands(root *cobra.Command) {
}
commandsAttached = true
}

View file

@ -20,7 +20,7 @@ import (
"sync"
"syscall"
"forge.lthn.ai/core/go/pkg/core"
"dappco.re/go/core"
"github.com/spf13/cobra"
)
@ -39,9 +39,10 @@ type runtime struct {
// Options configures the CLI runtime.
type Options struct {
AppName string
Version string
Services []core.Option // Additional services to register
AppName string
Version string
Services []core.Service // Additional services to register
I18nSources []LocaleSource // Additional i18n translation sources
// OnReload is called when SIGHUP is received (daemon mode).
// Use for configuration reloading. Leave nil to ignore SIGHUP.
@ -63,25 +64,35 @@ func Init(opts Options) error {
SilenceUsage: true,
}
// Build signal service options
var signalOpts []SignalOption
// Create Core with app identity
c := core.New(core.Options{
{Key: "name", Value: opts.AppName},
})
c.App().Version = opts.Version
c.App().Runtime = rootCmd
// Register signal service
signalSvc := &signalService{
cancel: cancel,
sigChan: make(chan os.Signal, 1),
}
if opts.OnReload != nil {
signalOpts = append(signalOpts, WithReloadHandler(opts.OnReload))
signalSvc.onReload = opts.OnReload
}
c.Service("signal", core.Service{
OnStart: func() core.Result {
return signalSvc.start(ctx)
},
OnStop: func() core.Result {
return signalSvc.stop()
},
})
// Build options: app, signal service + any additional services
coreOpts := []core.Option{
core.WithApp(rootCmd),
core.WithName("signal", newSignalService(cancel, signalOpts...)),
}
coreOpts = append(coreOpts, opts.Services...)
coreOpts = append(coreOpts, core.WithServiceLock())
c, err := core.New(coreOpts...)
if err != nil {
initErr = err
cancel()
return
// Register additional services
for _, svc := range opts.Services {
if svc.Name != "" {
c.Service(svc.Name, svc)
}
}
instance = &runtime{
@ -91,8 +102,11 @@ func Init(opts Options) error {
cancel: cancel,
}
if err := c.ServiceStartup(ctx, nil); err != nil {
initErr = err
r := c.ServiceStartup(ctx, nil)
if !r.OK {
if err, ok := r.Value.(error); ok {
initErr = err
}
return
}
@ -145,7 +159,7 @@ func Shutdown() {
_ = instance.core.ServiceShutdown(instance.ctx)
}
// --- Signal Service (internal) ---
// --- Signal Srv (internal) ---
type signalService struct {
cancel context.CancelFunc
@ -154,30 +168,7 @@ type signalService struct {
shutdownOnce sync.Once
}
// SignalOption configures signal handling.
type SignalOption func(*signalService)
// WithReloadHandler sets a callback for SIGHUP.
func WithReloadHandler(fn func() error) SignalOption {
return func(s *signalService) {
s.onReload = fn
}
}
func newSignalService(cancel context.CancelFunc, opts ...SignalOption) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
svc := &signalService{
cancel: cancel,
sigChan: make(chan os.Signal, 1),
}
for _, opt := range opts {
opt(svc)
}
return svc, nil
}
}
func (s *signalService) OnStartup(ctx context.Context) error {
func (s *signalService) start(ctx context.Context) core.Result {
signals := []os.Signal{syscall.SIGINT, syscall.SIGTERM}
if s.onReload != nil {
signals = append(signals, syscall.SIGHUP)
@ -207,13 +198,13 @@ func (s *signalService) OnStartup(ctx context.Context) error {
}
}()
return nil
return core.Result{OK: true}
}
func (s *signalService) OnShutdown(ctx context.Context) error {
func (s *signalService) stop() core.Result {
s.shutdownOnce.Do(func() {
signal.Stop(s.sigChan)
close(s.sigChan)
})
return nil
return core.Result{OK: true}
}