diff --git a/docs/plans/2026-01-30-cli-commands-design.md b/docs/plans/2026-01-30-cli-commands-design.md new file mode 100644 index 00000000..73212af6 --- /dev/null +++ b/docs/plans/2026-01-30-cli-commands-design.md @@ -0,0 +1,185 @@ +# CLI Commands Registration Design + +## Overview + +Move CLI commands from `cmd/` into self-registering packages in `pkg/`. This enables build variants with reduced attack surface - only compiled code exists in the binary. + +## Pattern + +Same pattern as `i18n.RegisterLocales()`: +- Packages register themselves during `init()` +- Registration is stored until `cli.Init()` runs +- Build tags control which packages are imported + +## Registration API + +```go +// pkg/cli/commands.go + +type CommandRegistration func(root *cobra.Command) + +var ( + registeredCommands []CommandRegistration + registeredCommandsMu sync.Mutex +) + +// RegisterCommands registers a function that adds commands to the CLI. +func RegisterCommands(fn CommandRegistration) { + registeredCommandsMu.Lock() + defer registeredCommandsMu.Unlock() + registeredCommands = append(registeredCommands, fn) +} + +func attachRegisteredCommands(root *cobra.Command) { + registeredCommandsMu.Lock() + defer registeredCommandsMu.Unlock() + for _, fn := range registeredCommands { + fn(root) + } +} +``` + +## Integration with Core.App + +The CLI stores `rootCmd` in `core.App`, unifying GUI and CLI under the same pattern: + +```go +// pkg/cli/runtime.go + +func Init(opts Options) error { + once.Do(func() { + rootCmd := &cobra.Command{ + Use: opts.AppName, + Version: opts.Version, + } + + attachRegisteredCommands(rootCmd) + + c, err := framework.New( + framework.WithApp(rootCmd), + // ... services ... + ) + // ... + }) + return initErr +} + +func RootCmd() *cobra.Command { + return framework.App().(*cobra.Command) +} + +func Execute() error { + return RootCmd().Execute() +} +``` + +## Package Structure + +Commands move from `cmd/` to `pkg/` with a `cmd.go` file: + +``` +pkg/ +├── php/ +│ ├── i18n.go # registers locales +│ ├── cmd.go # registers commands +│ ├── locales/ +│ └── ... +├── dev/ +│ ├── cmd.go # registers commands +│ └── ... +├── cli/ +│ ├── commands.go # RegisterCommands API +│ ├── runtime.go # Init, Execute +│ └── ... +``` + +Each `cmd.go`: + +```go +// pkg/php/cmd.go +package php + +import "github.com/host-uk/core/pkg/cli" + +func init() { + cli.RegisterCommands(AddCommands) +} + +func AddCommands(root *cobra.Command) { + // ... existing command setup ... +} +``` + +## Build Variants + +Import files with build tags in `cmd/variants/`: + +``` +cmd/ +├── main.go +└── variants/ + ├── full.go # default: all packages + ├── ci.go # CI/release only + ├── php.go # PHP tooling only + └── minimal.go # core only +``` + +```go +// cmd/variants/full.go +//go:build !ci && !php && !minimal + +package variants + +import ( + _ "github.com/host-uk/core/pkg/ai" + _ "github.com/host-uk/core/pkg/build" + _ "github.com/host-uk/core/pkg/ci" + _ "github.com/host-uk/core/pkg/dev" + _ "github.com/host-uk/core/pkg/docs" + _ "github.com/host-uk/core/pkg/doctor" + _ "github.com/host-uk/core/pkg/go" + _ "github.com/host-uk/core/pkg/php" + _ "github.com/host-uk/core/pkg/pkg" + _ "github.com/host-uk/core/pkg/sdk" + _ "github.com/host-uk/core/pkg/setup" + _ "github.com/host-uk/core/pkg/test" + _ "github.com/host-uk/core/pkg/vm" +) +``` + +```go +// cmd/variants/ci.go +//go:build ci + +package variants + +import ( + _ "github.com/host-uk/core/pkg/build" + _ "github.com/host-uk/core/pkg/ci" + _ "github.com/host-uk/core/pkg/doctor" + _ "github.com/host-uk/core/pkg/sdk" +) +``` + +## Build Commands + +- `go build` → full variant (default) +- `go build -tags ci` → CI variant +- `go build -tags php` → PHP-only variant + +## Benefits + +1. **Smaller attack surface** - only compiled code exists in binary +2. **Self-registering packages** - same pattern as `i18n.RegisterLocales()` +3. **Uses existing `core.App`** - no new framework concepts +4. **Simple build variants** - just add `-tags` flag +5. **Defence in depth** - no code = no vulnerabilities + +## Migration Steps + +1. Add `RegisterCommands()` to `pkg/cli/commands.go` +2. Update `pkg/cli/runtime.go` to use `core.App` for rootCmd +3. Move each `cmd/*` package to `pkg/*/cmd.go` +4. Create `cmd/variants/` with build tag files +5. Simplify `cmd/main.go` to minimal entry point +6. Remove old `cmd/core_dev.go` and `cmd/core_ci.go`