Compare commits

..

No commits in common. "dev" and "v0.3.7" have entirely different histories.
dev ... v0.3.7

88 changed files with 1089 additions and 4366 deletions

44
.gitignore vendored
View file

@ -1,28 +1,28 @@
.idea/
.vscode/
wails3
.task
vendor/
.idea
node_modules/
.DS_Store
*.log
.core/
# Build artefacts
dist/
bin/
/core
/cli
# Go
vendor/
go.work.sum
.env
.env.*.local
coverage/
coverage.out
coverage.html
coverage.txt
# Environment / secrets
.env
.env.*.local
# OS / tooling
.task
*.cache
node_modules/
/coverage.txt
bin/
dist/
tasks
/cli
/core
local.test
/i18n-validate
.angular/
patch_cov.*
go.work.sum
.kb
.core/
.idea/

View file

@ -6,8 +6,6 @@ import (
)
// AddConfigCommands registers the 'config' command group and all subcommands.
//
// config.AddConfigCommands(rootCmd)
func AddConfigCommands(root *cli.Command) {
configCmd := cli.NewGroup("config", "Manage configuration", "")
root.AddCommand(configCmd)
@ -19,9 +17,9 @@ func AddConfigCommands(root *cli.Command) {
}
func loadConfig() (*config.Config, error) {
configuration, err := config.New()
cfg, err := config.New()
if err != nil {
return nil, cli.Wrap(err, "failed to load config")
}
return configuration, nil
return cfg, nil
}

View file

@ -1,6 +1,8 @@
package config
import (
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
)
@ -8,17 +10,17 @@ func addGetCommand(parent *cli.Command) {
cmd := cli.NewCommand("get", "Get a configuration value", "", func(cmd *cli.Command, args []string) error {
key := args[0]
configuration, err := loadConfig()
cfg, err := loadConfig()
if err != nil {
return err
}
var value any
if err := configuration.Get(key, &value); err != nil {
if err := cfg.Get(key, &value); err != nil {
return cli.Err("key not found: %s", key)
}
cli.Println("%v", value)
fmt.Println(value)
return nil
})

View file

@ -1,6 +1,7 @@
package config
import (
"fmt"
"maps"
"forge.lthn.ai/core/cli/pkg/cli"
@ -9,23 +10,23 @@ import (
func addListCommand(parent *cli.Command) {
cmd := cli.NewCommand("list", "List all configuration values", "", func(cmd *cli.Command, args []string) error {
configuration, err := loadConfig()
cfg, err := loadConfig()
if err != nil {
return err
}
all := maps.Collect(configuration.All())
all := maps.Collect(cfg.All())
if len(all) == 0 {
cli.Dim("No configuration values set")
return nil
}
output, err := yaml.Marshal(all)
out, err := yaml.Marshal(all)
if err != nil {
return cli.Wrap(err, "failed to format config")
}
cli.Print("%s", string(output))
fmt.Print(string(out))
return nil
})

View file

@ -1,17 +1,19 @@
package config
import (
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
)
func addPathCommand(parent *cli.Command) {
cmd := cli.NewCommand("path", "Show the configuration file path", "", func(cmd *cli.Command, args []string) error {
configuration, err := loadConfig()
cfg, err := loadConfig()
if err != nil {
return err
}
cli.Println("%s", configuration.Path())
fmt.Println(cfg.Path())
return nil
})

View file

@ -9,12 +9,12 @@ func addSetCommand(parent *cli.Command) {
key := args[0]
value := args[1]
configuration, err := loadConfig()
cfg, err := loadConfig()
if err != nil {
return err
}
if err := configuration.Set(key, value); err != nil {
if err := cfg.Set(key, value); err != nil {
return cli.Wrap(err, "failed to set config value")
}

View file

@ -2,8 +2,8 @@ package doctor
import (
"os/exec"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n"
)
@ -26,13 +26,6 @@ func requiredChecks() []check {
args: []string{"--version"},
versionFlag: "--version",
},
{
name: i18n.T("cmd.doctor.check.go.name"),
description: i18n.T("cmd.doctor.check.go.description"),
command: "go",
args: []string{"version"},
versionFlag: "version",
},
{
name: i18n.T("cmd.doctor.check.gh.name"),
description: i18n.T("cmd.doctor.check.gh.description"),
@ -91,20 +84,18 @@ func optionalChecks() []check {
}
}
// runCheck executes a tool check and returns success status and version info.
//
// ok, version := runCheck(check{command: "git", args: []string{"--version"}})
func runCheck(toolCheck check) (bool, string) {
proc := exec.Command(toolCheck.command, toolCheck.args...)
output, err := proc.CombinedOutput()
// runCheck executes a tool check and returns success status and version info
func runCheck(c check) (bool, string) {
cmd := exec.Command(c.command, c.args...)
output, err := cmd.CombinedOutput()
if err != nil {
return false, ""
}
// Extract first line as version info.
lines := core.Split(core.Trim(string(output)), "\n")
// Extract first line as version
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
if len(lines) > 0 {
return true, core.Trim(lines[0])
return true, strings.TrimSpace(lines[0])
}
return true, ""
}

View file

@ -1,22 +0,0 @@
package doctor
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRequiredChecksIncludesGo(t *testing.T) {
checks := requiredChecks()
var found bool
for _, c := range checks {
if c.command == "go" {
found = true
assert.Equal(t, "version", c.versionFlag)
break
}
}
assert.True(t, found, "required checks should include the Go compiler")
}

View file

@ -16,8 +16,6 @@ import (
)
// AddDoctorCommands registers the 'doctor' command and all subcommands.
//
// doctor.AddDoctorCommands(rootCmd)
func AddDoctorCommands(root *cobra.Command) {
doctorCmd.Short = i18n.T("cmd.doctor.short")
doctorCmd.Long = i18n.T("cmd.doctor.long")

View file

@ -2,6 +2,9 @@
package doctor
import (
"errors"
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
"github.com/spf13/cobra"
@ -29,72 +32,72 @@ func init() {
}
func runDoctor(verbose bool) error {
cli.Println("%s", i18n.T("common.progress.checking", map[string]any{"Item": "development environment"}))
cli.Blank()
fmt.Println(i18n.T("common.progress.checking", map[string]any{"Item": "development environment"}))
fmt.Println()
var passed, failed, optional int
// Check required tools
cli.Println("%s", i18n.T("cmd.doctor.required"))
for _, toolCheck := range requiredChecks() {
ok, version := runCheck(toolCheck)
fmt.Println(i18n.T("cmd.doctor.required"))
for _, c := range requiredChecks() {
ok, version := runCheck(c)
if ok {
if verbose {
cli.Println("%s", formatCheckResult(true, toolCheck.name, version))
fmt.Println(formatCheckResult(true, c.name, version))
} else {
cli.Println("%s", formatCheckResult(true, toolCheck.name, ""))
fmt.Println(formatCheckResult(true, c.name, ""))
}
passed++
} else {
cli.Println(" %s %s - %s", errorStyle.Render(cli.Glyph(":cross:")), toolCheck.name, toolCheck.description)
fmt.Printf(" %s %s - %s\n", errorStyle.Render(cli.Glyph(":cross:")), c.name, c.description)
failed++
}
}
// Check optional tools
cli.Println("\n%s", i18n.T("cmd.doctor.optional"))
for _, toolCheck := range optionalChecks() {
ok, version := runCheck(toolCheck)
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.optional"))
for _, c := range optionalChecks() {
ok, version := runCheck(c)
if ok {
if verbose {
cli.Println("%s", formatCheckResult(true, toolCheck.name, version))
fmt.Println(formatCheckResult(true, c.name, version))
} else {
cli.Println("%s", formatCheckResult(true, toolCheck.name, ""))
fmt.Println(formatCheckResult(true, c.name, ""))
}
passed++
} else {
cli.Println(" %s %s - %s", dimStyle.Render(cli.Glyph(":skip:")), toolCheck.name, dimStyle.Render(toolCheck.description))
fmt.Printf(" %s %s - %s\n", dimStyle.Render(cli.Glyph(":skip:")), c.name, dimStyle.Render(c.description))
optional++
}
}
// Check GitHub access
cli.Println("\n%s", i18n.T("cmd.doctor.github"))
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.github"))
if checkGitHubSSH() {
cli.Println("%s", formatCheckResult(true, i18n.T("cmd.doctor.ssh_found"), ""))
fmt.Println(formatCheckResult(true, i18n.T("cmd.doctor.ssh_found"), ""))
} else {
cli.Println(" %s %s", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.ssh_missing"))
fmt.Printf(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.ssh_missing"))
failed++
}
if checkGitHubCLI() {
cli.Println("%s", formatCheckResult(true, i18n.T("cmd.doctor.cli_auth"), ""))
fmt.Println(formatCheckResult(true, i18n.T("cmd.doctor.cli_auth"), ""))
} else {
cli.Println(" %s %s", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.cli_auth_missing"))
fmt.Printf(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), i18n.T("cmd.doctor.cli_auth_missing"))
failed++
}
// Check workspace
cli.Println("\n%s", i18n.T("cmd.doctor.workspace"))
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.workspace"))
checkWorkspace()
// Summary
cli.Blank()
fmt.Println()
if failed > 0 {
cli.Error(i18n.T("cmd.doctor.issues", map[string]any{"Count": failed}))
cli.Println("\n%s", i18n.T("cmd.doctor.install_missing"))
fmt.Printf("\n%s\n", i18n.T("cmd.doctor.install_missing"))
printInstallInstructions()
return cli.Err("%s", i18n.T("cmd.doctor.issues_error", map[string]any{"Count": failed}))
return errors.New(i18n.T("cmd.doctor.issues_error", map[string]any{"Count": failed}))
}
cli.Success(i18n.T("cmd.doctor.ready"))
@ -102,16 +105,16 @@ func runDoctor(verbose bool) error {
}
func formatCheckResult(ok bool, name, detail string) string {
checkBuilder := cli.Check(name)
check := cli.Check(name)
if ok {
checkBuilder.Pass()
check.Pass()
} else {
checkBuilder.Fail()
check.Fail()
}
if detail != "" {
checkBuilder.Message(detail)
check.Message(detail)
} else {
checkBuilder.Message("")
check.Message("")
}
return checkBuilder.String()
return check.String()
}

View file

@ -1,29 +1,31 @@
package doctor
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
io "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
)
// checkGitHubSSH checks if SSH keys exist for GitHub access.
// Returns true if any standard SSH key file exists in ~/.ssh/.
// checkGitHubSSH checks if SSH keys exist for GitHub access
func checkGitHubSSH() bool {
// Just check if SSH keys exist - don't try to authenticate
// (key might be locked/passphrase protected)
home, err := os.UserHomeDir()
if err != nil {
return false
}
sshDirectory := core.Path(home, ".ssh")
sshDir := filepath.Join(home, ".ssh")
keyPatterns := []string{"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"}
for _, keyName := range keyPatterns {
keyPath := core.Path(sshDirectory, keyName)
for _, key := range keyPatterns {
keyPath := filepath.Join(sshDir, key)
if _, err := os.Stat(keyPath); err == nil {
return true
}
@ -32,46 +34,46 @@ func checkGitHubSSH() bool {
return false
}
// checkGitHubCLI checks if the GitHub CLI is authenticated.
// Returns true when 'gh auth status' output contains "Logged in to".
// checkGitHubCLI checks if the GitHub CLI is authenticated
func checkGitHubCLI() bool {
proc := exec.Command("gh", "auth", "status")
output, _ := proc.CombinedOutput()
return core.Contains(string(output), "Logged in to")
cmd := exec.Command("gh", "auth", "status")
output, _ := cmd.CombinedOutput()
// Check for any successful login (even if there's also a failing token)
return strings.Contains(string(output), "Logged in to")
}
// checkWorkspace checks for repos.yaml and counts cloned repos.
// checkWorkspace checks for repos.yaml and counts cloned repos
func checkWorkspace() {
registryPath, err := repos.FindRegistry(io.Local)
if err == nil {
cli.Println(" %s %s", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_yaml_found", map[string]any{"Path": registryPath}))
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_yaml_found", map[string]any{"Path": registryPath}))
registry, err := repos.LoadRegistry(io.Local, registryPath)
reg, err := repos.LoadRegistry(io.Local, registryPath)
if err == nil {
basePath := registry.BasePath
basePath := reg.BasePath
if basePath == "" {
basePath = "./packages"
}
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(registryPath), basePath)
}
if core.HasPrefix(basePath, "~/") {
if strings.HasPrefix(basePath, "~/") {
home, _ := os.UserHomeDir()
basePath = core.Path(home, basePath[2:])
basePath = filepath.Join(home, basePath[2:])
}
// Count existing repos.
allRepos := registry.List()
// Count existing repos
allRepos := reg.List()
var cloned int
for _, repo := range allRepos {
repoPath := core.Path(basePath, repo.Name)
if _, err := os.Stat(core.Path(repoPath, ".git")); err == nil {
repoPath := filepath.Join(basePath, repo.Name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
cloned++
}
}
cli.Println(" %s %s", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_cloned", map[string]any{"Cloned": cloned, "Total": len(allRepos)}))
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_cloned", map[string]any{"Cloned": cloned, "Total": len(allRepos)}))
}
} else {
cli.Println(" %s %s", dimStyle.Render("○"), i18n.T("cmd.doctor.no_repos_yaml"))
fmt.Printf(" %s %s\n", dimStyle.Render("○"), i18n.T("cmd.doctor.no_repos_yaml"))
}
}

View file

@ -1,26 +1,26 @@
package doctor
import (
"fmt"
"runtime"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
)
// printInstallInstructions prints operating-system-specific installation instructions.
// printInstallInstructions prints OS-specific installation instructions
func printInstallInstructions() {
switch runtime.GOOS {
case "darwin":
cli.Println(" %s", i18n.T("cmd.doctor.install_macos"))
cli.Println(" %s", i18n.T("cmd.doctor.install_macos_cask"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_macos"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_macos_cask"))
case "linux":
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_header"))
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_git"))
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_gh"))
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_php"))
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_node"))
cli.Println(" %s", i18n.T("cmd.doctor.install_linux_pnpm"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_header"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_git"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_gh"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_php"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_node"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_pnpm"))
default:
cli.Println(" %s", i18n.T("cmd.doctor.install_other"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_other"))
}
}

View file

@ -74,10 +74,9 @@ 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/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/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/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,14 +159,12 @@ 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/kin-openapi v0.136.1 h1:x1G9doDyPcagCNXDcMK5dt5yAmIgsSCiK7F5gPUiQdM=
github.com/oasdiff/kin-openapi v0.136.1/go.mod h1:BMeaLn+GmFJKtHJ31JrgXFt91eZi/q+Og4tr7sq0BzI=
github.com/oasdiff/oasdiff v1.12.3 h1:eUzJ/AiyyCY1KwUZPv7fosgDyETacIZbFesJrRz+QdY=
github.com/oasdiff/oasdiff v1.12.3/go.mod h1:ApEJGlkuRdrcBgTE4ioicwIM7nzkxPqLPPvcB5AytQ0=
github.com/oasdiff/yaml v0.0.1 h1:dPrn0F2PJ7HdzHPndJkArvB2Fw0cwgFdVUKCEkoFuds=
github.com/oasdiff/yaml v0.0.1/go.mod h1:r8bgVgpWT5iIN/AgP0GljFvB6CicK+yL1nIAbm+8/QQ=
github.com/oasdiff/yaml3 v0.0.1 h1:kReOSraQLTxuuGNX9aNeJ7tcsvUB2MS+iupdUrWe4Z0=
github.com/oasdiff/yaml3 v0.0.1/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
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/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=

View file

@ -1,13 +1,12 @@
package help
import (
"fmt"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-help"
)
// AddHelpCommands registers the help command and subcommands.
//
// help.AddHelpCommands(rootCmd)
func AddHelpCommands(root *cli.Command) {
var searchFlag string
@ -20,28 +19,28 @@ func AddHelpCommands(root *cli.Command) {
if searchFlag != "" {
results := catalog.Search(searchFlag)
if len(results) == 0 {
cli.Println("No topics found.")
fmt.Println("No topics found.")
return
}
cli.Println("Search Results:")
for _, result := range results {
cli.Println(" %s - %s", result.Topic.ID, result.Topic.Title)
fmt.Println("Search Results:")
for _, res := range results {
fmt.Printf(" %s - %s\n", res.Topic.ID, res.Topic.Title)
}
return
}
if len(args) == 0 {
topics := catalog.List()
cli.Println("Available Help Topics:")
for _, topic := range topics {
cli.Println(" %s - %s", topic.ID, topic.Title)
fmt.Println("Available Help Topics:")
for _, t := range topics {
fmt.Printf(" %s - %s\n", t.ID, t.Title)
}
return
}
topic, err := catalog.Get(args[0])
if err != nil {
cli.Errorf("Error: %v", err)
fmt.Printf("Error: %v\n", err)
return
}
@ -53,9 +52,11 @@ func AddHelpCommands(root *cli.Command) {
root.AddCommand(helpCmd)
}
func renderTopic(topic *help.Topic) {
cli.Println("\n%s", cli.TitleStyle.Render(topic.Title))
cli.Println("----------------------------------------")
cli.Println("%s", topic.Content)
cli.Blank()
func renderTopic(t *help.Topic) {
// Simple ANSI rendering for now
// Use explicit ANSI codes or just print
fmt.Printf("\n\033[1;34m%s\033[0m\n", t.Title) // Blue bold title
fmt.Println("----------------------------------------")
fmt.Println(t.Content)
fmt.Println()
}

View file

@ -1,241 +0,0 @@
package help
import (
"bytes"
"io"
"os"
"testing"
"forge.lthn.ai/core/cli/pkg/cli"
gohelp "forge.lthn.ai/core/go-help"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func captureOutput(t *testing.T, fn func()) string {
t.Helper()
oldOut := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
defer func() {
os.Stdout = oldOut
}()
fn()
require.NoError(t, w.Close())
var buf bytes.Buffer
_, err = io.Copy(&buf, r)
require.NoError(t, err)
return buf.String()
}
func newHelpCommand(t *testing.T) *cli.Command {
t.Helper()
root := &cli.Command{Use: "core"}
AddHelpCommands(root)
cmd, _, err := root.Find([]string{"help"})
require.NoError(t, err)
return cmd
}
func searchableHelpQuery(t *testing.T) string {
t.Helper()
catalog := gohelp.DefaultCatalog()
for _, candidate := range []string{"configuration", "docs", "search", "topic", "help"} {
if _, err := catalog.Get(candidate); err == nil {
continue
}
if len(catalog.Search(candidate)) > 0 {
return candidate
}
}
t.Skip("no suitable query found with suggestions")
return ""
}
func TestAddHelpCommands_Good(t *testing.T) {
cmd := newHelpCommand(t)
topics := gohelp.DefaultCatalog().List()
require.NotEmpty(t, topics)
out := captureOutput(t, func() {
err := cmd.RunE(cmd, nil)
require.NoError(t, err)
})
assert.Contains(t, out, "AVAILABLE HELP TOPICS")
assert.Contains(t, out, topics[0].ID)
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help search <topic>")
}
func TestAddHelpCommands_Good_Serve(t *testing.T) {
root := &cli.Command{Use: "core"}
AddHelpCommands(root)
cmd, _, err := root.Find([]string{"help", "serve"})
require.NoError(t, err)
require.NotNil(t, cmd)
oldStart := startHelpServer
defer func() { startHelpServer = oldStart }()
var gotAddr string
startHelpServer = func(catalog *gohelp.Catalog, addr string) error {
require.NotNil(t, catalog)
gotAddr = addr
return nil
}
require.NoError(t, cmd.Flags().Set("addr", "127.0.0.1:9090"))
err = cmd.RunE(cmd, nil)
require.NoError(t, err)
assert.Equal(t, "127.0.0.1:9090", gotAddr)
}
func TestAddHelpCommands_Good_Search(t *testing.T) {
root := &cli.Command{Use: "core"}
AddHelpCommands(root)
cmd, _, err := root.Find([]string{"help", "search"})
require.NoError(t, err)
require.NotNil(t, cmd)
query := searchableHelpQuery(t)
require.NoError(t, cmd.Flags().Set("query", query))
out := captureOutput(t, func() {
err := cmd.RunE(cmd, nil)
require.NoError(t, err)
})
assert.Contains(t, out, "SEARCH RESULTS")
assert.Contains(t, out, query)
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help search")
}
func TestRenderSearchResults_Good(t *testing.T) {
out := captureOutput(t, func() {
err := renderSearchResults([]*gohelp.SearchResult{
{
Topic: &gohelp.Topic{
ID: "config",
Title: "Configuration",
},
Snippet: "Core is configured via environment variables.",
},
}, "config")
require.NoError(t, err)
})
assert.Contains(t, out, "SEARCH RESULTS")
assert.Contains(t, out, "config - Configuration")
assert.Contains(t, out, "Core is configured via environment variables.")
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help search \"config\"")
}
func TestRenderTopicList_Good(t *testing.T) {
out := captureOutput(t, func() {
err := renderTopicList([]*gohelp.Topic{
{
ID: "config",
Title: "Configuration",
Content: "# Configuration\n\nCore is configured via environment variables.\n\nMore details follow.",
},
})
require.NoError(t, err)
})
assert.Contains(t, out, "AVAILABLE HELP TOPICS")
assert.Contains(t, out, "config - Configuration")
assert.Contains(t, out, "Core is configured via environment variables.")
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help search <topic>")
}
func TestRenderTopic_Good(t *testing.T) {
out := captureOutput(t, func() {
renderTopic(&gohelp.Topic{
ID: "config",
Title: "Configuration",
Content: "Core is configured via environment variables.",
})
})
assert.Contains(t, out, "Configuration")
assert.Contains(t, out, "Core is configured via environment variables.")
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help search \"config\"")
}
func TestAddHelpCommands_Bad(t *testing.T) {
t.Run("missing search results", func(t *testing.T) {
cmd := newHelpCommand(t)
require.NoError(t, cmd.Flags().Set("search", "zzzyyyxxx"))
out := captureOutput(t, func() {
err := cmd.RunE(cmd, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "no help topics matched")
})
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help")
assert.Contains(t, out, "core help search")
})
t.Run("missing topic without suggestions shows hints", func(t *testing.T) {
cmd := newHelpCommand(t)
out := captureOutput(t, func() {
err := cmd.RunE(cmd, []string{"definitely-not-a-real-topic"})
require.Error(t, err)
assert.Contains(t, err.Error(), "help topic")
})
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help")
})
t.Run("missing search query", func(t *testing.T) {
root := &cli.Command{Use: "core"}
AddHelpCommands(root)
cmd, _, findErr := root.Find([]string{"help", "search"})
require.NoError(t, findErr)
require.NotNil(t, cmd)
var runErr error
out := captureOutput(t, func() {
runErr = cmd.RunE(cmd, nil)
})
require.Error(t, runErr)
assert.Contains(t, runErr.Error(), "help search query is required")
assert.Contains(t, out, "browse")
assert.Contains(t, out, "core help")
})
t.Run("missing topic shows suggestions when available", func(t *testing.T) {
query := searchableHelpQuery(t)
cmd := newHelpCommand(t)
out := captureOutput(t, func() {
err := cmd.RunE(cmd, []string{query})
require.Error(t, err)
assert.Contains(t, err.Error(), "help topic")
})
assert.Contains(t, out, "SEARCH RESULTS")
})
}

View file

@ -2,16 +2,21 @@ package pkgcmd
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
"github.com/spf13/cobra"
)
import (
"errors"
)
var (
installTargetDir string
installAddToReg bool
@ -25,7 +30,7 @@ func addPkgInstallCommand(parent *cobra.Command) {
Long: i18n.T("cmd.pkg.install.long"),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cli.Err(i18n.T("cmd.pkg.error.repo_required"))
return errors.New(i18n.T("cmd.pkg.error.repo_required"))
}
return runPkgInstall(args[0], installTargetDir, installAddToReg)
},
@ -37,119 +42,119 @@ func addPkgInstallCommand(parent *cobra.Command) {
parent.AddCommand(installCmd)
}
func runPkgInstall(repoArg, targetDirectory string, addToRegistry bool) error {
func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error {
ctx := context.Background()
// Parse org/repo argument.
parts := core.Split(repoArg, "/")
// Parse org/repo
parts := strings.Split(repoArg, "/")
if len(parts) != 2 {
return cli.Err(i18n.T("cmd.pkg.error.invalid_repo_format"))
return errors.New(i18n.T("cmd.pkg.error.invalid_repo_format"))
}
org, repoName := parts[0], parts[1]
// Determine target directory from registry or default.
if targetDirectory == "" {
if registryPath, err := repos.FindRegistry(coreio.Local); err == nil {
if registry, err := repos.LoadRegistry(coreio.Local, registryPath); err == nil {
targetDirectory = registry.BasePath
if targetDirectory == "" {
targetDirectory = "./packages"
// Determine target directory
if targetDir == "" {
if regPath, err := repos.FindRegistry(coreio.Local); err == nil {
if reg, err := repos.LoadRegistry(coreio.Local, regPath); err == nil {
targetDir = reg.BasePath
if targetDir == "" {
targetDir = "./packages"
}
if !core.PathIsAbs(targetDirectory) {
targetDirectory = core.Path(core.PathDir(registryPath), targetDirectory)
if !filepath.IsAbs(targetDir) {
targetDir = filepath.Join(filepath.Dir(regPath), targetDir)
}
}
}
if targetDirectory == "" {
targetDirectory = "."
if targetDir == "" {
targetDir = "."
}
}
if core.HasPrefix(targetDirectory, "~/") {
if strings.HasPrefix(targetDir, "~/") {
home, _ := os.UserHomeDir()
targetDirectory = core.Path(home, targetDirectory[2:])
targetDir = filepath.Join(home, targetDir[2:])
}
repoPath := core.Path(targetDirectory, repoName)
repoPath := filepath.Join(targetDir, repoName)
if coreio.Local.Exists(core.Path(repoPath, ".git")) {
cli.Println("%s %s", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.pkg.install.already_exists", map[string]string{"Name": repoName, "Path": repoPath}))
if coreio.Local.Exists(filepath.Join(repoPath, ".git")) {
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.pkg.install.already_exists", map[string]string{"Name": repoName, "Path": repoPath}))
return nil
}
if err := coreio.Local.EnsureDir(targetDirectory); err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.create", "directory"))
if err := coreio.Local.EnsureDir(targetDir); err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.create", "directory"), err)
}
cli.Println("%s %s/%s", dimStyle.Render(i18n.T("cmd.pkg.install.installing_label")), org, repoName)
cli.Println("%s %s", dimStyle.Render(i18n.Label("target")), repoPath)
cli.Blank()
fmt.Printf("%s %s/%s\n", dimStyle.Render(i18n.T("cmd.pkg.install.installing_label")), org, repoName)
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("target")), repoPath)
fmt.Println()
cli.Print(" %s... ", dimStyle.Render(i18n.T("common.status.cloning")))
fmt.Printf(" %s... ", dimStyle.Render(i18n.T("common.status.cloning")))
err := gitClone(ctx, org, repoName, repoPath)
if err != nil {
cli.Println("%s", errorStyle.Render("✗ "+err.Error()))
fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error()))
return err
}
cli.Println("%s", successStyle.Render("✓"))
fmt.Printf("%s\n", successStyle.Render("✓"))
if addToRegistry {
if err := addToRegistryFile(org, repoName); err != nil {
cli.Println(" %s %s: %s", errorStyle.Render("✗"), i18n.T("cmd.pkg.install.add_to_registry"), err)
fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), i18n.T("cmd.pkg.install.add_to_registry"), err)
} else {
cli.Println(" %s %s", successStyle.Render("✓"), i18n.T("cmd.pkg.install.added_to_registry"))
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.pkg.install.added_to_registry"))
}
}
cli.Blank()
cli.Println("%s %s", successStyle.Render(i18n.T("i18n.done.install")), i18n.T("cmd.pkg.install.installed", map[string]string{"Name": repoName}))
fmt.Println()
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("i18n.done.install")), i18n.T("cmd.pkg.install.installed", map[string]string{"Name": repoName}))
return nil
}
func addToRegistryFile(org, repoName string) error {
registryPath, err := repos.FindRegistry(coreio.Local)
regPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil {
return err
}
if _, exists := registry.Get(repoName); exists {
if _, exists := reg.Get(repoName); exists {
return nil
}
content, err := coreio.Local.Read(registryPath)
content, err := coreio.Local.Read(regPath)
if err != nil {
return err
}
repoType := detectRepoType(repoName)
entry := cli.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
repoName, repoType)
content += entry
return coreio.Local.Write(registryPath, content)
return coreio.Local.Write(regPath, content)
}
func detectRepoType(name string) string {
lowerName := core.Lower(name)
if core.Contains(lowerName, "-mod-") || core.HasSuffix(lowerName, "-mod") {
lower := strings.ToLower(name)
if strings.Contains(lower, "-mod-") || strings.HasSuffix(lower, "-mod") {
return "module"
}
if core.Contains(lowerName, "-plug-") || core.HasSuffix(lowerName, "-plug") {
if strings.Contains(lower, "-plug-") || strings.HasSuffix(lower, "-plug") {
return "plugin"
}
if core.Contains(lowerName, "-services-") || core.HasSuffix(lowerName, "-services") {
if strings.Contains(lower, "-services-") || strings.HasSuffix(lower, "-services") {
return "service"
}
if core.Contains(lowerName, "-website-") || core.HasSuffix(lowerName, "-website") {
if strings.Contains(lower, "-website-") || strings.HasSuffix(lower, "-website") {
return "website"
}
if core.HasPrefix(lowerName, "core-") {
if strings.HasPrefix(lower, "core-") {
return "package"
}
return "package"

View file

@ -1,114 +0,0 @@
package pkgcmd
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRunPkgInstall_AllowsRepoShorthand_Good(t *testing.T) {
tmp := t.TempDir()
targetDir := filepath.Join(tmp, "packages")
originalGitClone := gitClone
t.Cleanup(func() {
gitClone = originalGitClone
})
var gotOrg, gotRepo, gotPath string
gitClone = func(_ context.Context, org, repoName, repoPath string) error {
gotOrg = org
gotRepo = repoName
gotPath = repoPath
return nil
}
err := runPkgInstall("core-api", targetDir, false)
require.NoError(t, err)
assert.Equal(t, "host-uk", gotOrg)
assert.Equal(t, "core-api", gotRepo)
assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath)
_, err = os.Stat(targetDir)
require.NoError(t, err)
}
func TestRunPkgInstall_AllowsExplicitOrgRepo_Good(t *testing.T) {
tmp := t.TempDir()
targetDir := filepath.Join(tmp, "packages")
originalGitClone := gitClone
t.Cleanup(func() {
gitClone = originalGitClone
})
var gotOrg, gotRepo, gotPath string
gitClone = func(_ context.Context, org, repoName, repoPath string) error {
gotOrg = org
gotRepo = repoName
gotPath = repoPath
return nil
}
err := runPkgInstall("myorg/core-api", targetDir, false)
require.NoError(t, err)
assert.Equal(t, "myorg", gotOrg)
assert.Equal(t, "core-api", gotRepo)
assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath)
}
func TestRunPkgInstall_InvalidRepoFormat_Bad(t *testing.T) {
err := runPkgInstall("a/b/c", t.TempDir(), false)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid repo format")
}
func TestParsePkgInstallSource_Good(t *testing.T) {
t.Run("default org and repo", func(t *testing.T) {
org, repo, ref, err := parsePkgInstallSource("core-api")
require.NoError(t, err)
assert.Equal(t, "host-uk", org)
assert.Equal(t, "core-api", repo)
assert.Empty(t, ref)
})
t.Run("explicit org and ref", func(t *testing.T) {
org, repo, ref, err := parsePkgInstallSource("myorg/core-api@v1.2.3")
require.NoError(t, err)
assert.Equal(t, "myorg", org)
assert.Equal(t, "core-api", repo)
assert.Equal(t, "v1.2.3", ref)
})
}
func TestRunPkgInstall_WithRef_UsesRefClone_Good(t *testing.T) {
tmp := t.TempDir()
targetDir := filepath.Join(tmp, "packages")
originalGitCloneRef := gitCloneRef
t.Cleanup(func() {
gitCloneRef = originalGitCloneRef
})
var gotOrg, gotRepo, gotPath, gotRef string
gitCloneRef = func(_ context.Context, org, repoName, repoPath, ref string) error {
gotOrg = org
gotRepo = repoName
gotPath = repoPath
gotRef = ref
return nil
}
err := runPkgInstall("myorg/core-api@v1.2.3", targetDir, false)
require.NoError(t, err)
assert.Equal(t, "myorg", gotOrg)
assert.Equal(t, "core-api", gotRepo)
assert.Equal(t, filepath.Join(targetDir, "core-api"), gotPath)
assert.Equal(t, "v1.2.3", gotRef)
}

View file

@ -1,10 +1,12 @@
package pkgcmd
import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
@ -26,36 +28,36 @@ func addPkgListCommand(parent *cobra.Command) {
}
func runPkgList() error {
registryPath, err := repos.FindRegistry(coreio.Local)
regPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml_workspace"))
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml_workspace"))
}
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
}
basePath := registry.BasePath
basePath := reg.BasePath
if basePath == "" {
basePath = "."
}
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
}
allRepos := registry.List()
allRepos := reg.List()
if len(allRepos) == 0 {
cli.Println("%s", i18n.T("cmd.pkg.list.no_packages"))
fmt.Println(i18n.T("cmd.pkg.list.no_packages"))
return nil
}
cli.Println("%s\n", repoNameStyle.Render(i18n.T("cmd.pkg.list.title")))
fmt.Printf("%s\n\n", repoNameStyle.Render(i18n.T("cmd.pkg.list.title")))
var installed, missing int
for _, repo := range allRepos {
repoPath := core.Path(basePath, repo.Name)
exists := coreio.Local.Exists(core.Path(repoPath, ".git"))
for _, r := range allRepos {
repoPath := filepath.Join(basePath, r.Name)
exists := coreio.Local.Exists(filepath.Join(repoPath, ".git"))
if exists {
installed++
} else {
@ -67,23 +69,23 @@ func runPkgList() error {
status = dimStyle.Render("○")
}
description := repo.Description
if len(description) > 40 {
description = description[:37] + "..."
desc := r.Description
if len(desc) > 40 {
desc = desc[:37] + "..."
}
if description == "" {
description = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
if desc == "" {
desc = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
}
cli.Println(" %s %s", status, repoNameStyle.Render(repo.Name))
cli.Println(" %s", description)
fmt.Printf(" %s %s\n", status, repoNameStyle.Render(r.Name))
fmt.Printf(" %s\n", desc)
}
cli.Blank()
cli.Println("%s %s", dimStyle.Render(i18n.Label("total")), i18n.T("cmd.pkg.list.summary", map[string]int{"Installed": installed, "Missing": missing}))
fmt.Println()
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("total")), i18n.T("cmd.pkg.list.summary", map[string]int{"Installed": installed, "Missing": missing}))
if missing > 0 {
cli.Println("\n%s %s", i18n.T("cmd.pkg.list.install_missing"), dimStyle.Render("core setup"))
fmt.Printf("\n%s %s\n", i18n.T("cmd.pkg.list.install_missing"), dimStyle.Render("core setup"))
}
return nil
@ -99,7 +101,7 @@ func addPkgUpdateCommand(parent *cobra.Command) {
Long: i18n.T("cmd.pkg.update.long"),
RunE: func(cmd *cobra.Command, args []string) error {
if !updateAll && len(args) == 0 {
return cli.Err(i18n.T("cmd.pkg.error.specify_package"))
return errors.New(i18n.T("cmd.pkg.error.specify_package"))
}
return runPkgUpdate(args, updateAll)
},
@ -111,66 +113,66 @@ func addPkgUpdateCommand(parent *cobra.Command) {
}
func runPkgUpdate(packages []string, all bool) error {
registryPath, err := repos.FindRegistry(coreio.Local)
regPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
}
basePath := registry.BasePath
basePath := reg.BasePath
if basePath == "" {
basePath = "."
}
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
}
var toUpdate []string
if all {
for _, repo := range registry.List() {
toUpdate = append(toUpdate, repo.Name)
for _, r := range reg.List() {
toUpdate = append(toUpdate, r.Name)
}
} else {
toUpdate = packages
}
cli.Println("%s %s\n", dimStyle.Render(i18n.T("cmd.pkg.update.update_label")), i18n.T("cmd.pkg.update.updating", map[string]int{"Count": len(toUpdate)}))
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.update.update_label")), i18n.T("cmd.pkg.update.updating", map[string]int{"Count": len(toUpdate)}))
var updated, skipped, failed int
for _, name := range toUpdate {
repoPath := core.Path(basePath, name)
repoPath := filepath.Join(basePath, name)
if _, err := coreio.Local.List(core.Path(repoPath, ".git")); err != nil {
cli.Println(" %s %s (%s)", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed"))
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err != nil {
fmt.Printf(" %s %s (%s)\n", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed"))
skipped++
continue
}
cli.Print(" %s %s... ", dimStyle.Render("↓"), name)
fmt.Printf(" %s %s... ", dimStyle.Render("↓"), name)
proc := exec.Command("git", "-C", repoPath, "pull", "--ff-only")
output, err := proc.CombinedOutput()
cmd := exec.Command("git", "-C", repoPath, "pull", "--ff-only")
output, err := cmd.CombinedOutput()
if err != nil {
cli.Println("%s", errorStyle.Render("✗"))
cli.Println(" %s", core.Trim(string(output)))
fmt.Printf("%s\n", errorStyle.Render("✗"))
fmt.Printf(" %s\n", strings.TrimSpace(string(output)))
failed++
continue
}
if core.Contains(string(output), "Already up to date") {
cli.Println("%s", dimStyle.Render(i18n.T("common.status.up_to_date")))
if strings.Contains(string(output), "Already up to date") {
fmt.Printf("%s\n", dimStyle.Render(i18n.T("common.status.up_to_date")))
} else {
cli.Println("%s", successStyle.Render("✓"))
fmt.Printf("%s\n", successStyle.Render("✓"))
}
updated++
}
cli.Blank()
cli.Println("%s %s",
fmt.Println()
fmt.Printf("%s %s\n",
dimStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.update.summary", map[string]int{"Updated": updated, "Skipped": skipped, "Failed": failed}))
return nil
@ -191,63 +193,63 @@ func addPkgOutdatedCommand(parent *cobra.Command) {
}
func runPkgOutdated() error {
registryPath, err := repos.FindRegistry(coreio.Local)
regPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
}
basePath := registry.BasePath
basePath := reg.BasePath
if basePath == "" {
basePath = "."
}
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
}
cli.Println("%s %s\n", dimStyle.Render(i18n.T("cmd.pkg.outdated.outdated_label")), i18n.T("common.progress.checking_updates"))
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.outdated.outdated_label")), i18n.T("common.progress.checking_updates"))
var outdated, upToDate, notInstalled int
for _, repo := range registry.List() {
repoPath := core.Path(basePath, repo.Name)
for _, r := range reg.List() {
repoPath := filepath.Join(basePath, r.Name)
if !coreio.Local.Exists(core.Path(repoPath, ".git")) {
if !coreio.Local.Exists(filepath.Join(repoPath, ".git")) {
notInstalled++
continue
}
// Fetch updates silently.
// Fetch updates
_ = exec.Command("git", "-C", repoPath, "fetch", "--quiet").Run()
// Check commit count behind upstream.
proc := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD..@{u}")
output, err := proc.Output()
// Check if behind
cmd := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD..@{u}")
output, err := cmd.Output()
if err != nil {
continue
}
commitCount := core.Trim(string(output))
if commitCount != "0" {
cli.Println(" %s %s (%s)",
errorStyle.Render("↓"), repoNameStyle.Render(repo.Name), i18n.T("cmd.pkg.outdated.commits_behind", map[string]string{"Count": commitCount}))
count := strings.TrimSpace(string(output))
if count != "0" {
fmt.Printf(" %s %s (%s)\n",
errorStyle.Render("↓"), repoNameStyle.Render(r.Name), i18n.T("cmd.pkg.outdated.commits_behind", map[string]string{"Count": count}))
outdated++
} else {
upToDate++
}
}
cli.Blank()
fmt.Println()
if outdated == 0 {
cli.Println("%s %s", successStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.outdated.all_up_to_date"))
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.outdated.all_up_to_date"))
} else {
cli.Println("%s %s",
fmt.Printf("%s %s\n",
dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.pkg.outdated.summary", map[string]int{"Outdated": outdated, "UpToDate": upToDate}))
cli.Println("\n%s %s", i18n.T("cmd.pkg.outdated.update_with"), dimStyle.Render("core pkg update --all"))
fmt.Printf("\n%s %s\n", i18n.T("cmd.pkg.outdated.update_with"), dimStyle.Render("core pkg update --all"))
}
return nil

View file

@ -1,350 +0,0 @@
package pkgcmd
import (
"bytes"
"encoding/json"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"forge.lthn.ai/core/go-cache"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func capturePkgOutput(t *testing.T, fn func()) string {
t.Helper()
oldStdout := os.Stdout
r, w, err := os.Pipe()
require.NoError(t, err)
os.Stdout = w
defer func() {
os.Stdout = oldStdout
}()
fn()
require.NoError(t, w.Close())
var buf bytes.Buffer
_, err = io.Copy(&buf, r)
require.NoError(t, err)
return buf.String()
}
func withWorkingDir(t *testing.T, dir string) {
t.Helper()
oldwd, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(dir))
t.Cleanup(func() {
require.NoError(t, os.Chdir(oldwd))
})
}
func writeTestRegistry(t *testing.T, dir string) {
t.Helper()
registry := strings.TrimSpace(`
org: host-uk
base_path: .
repos:
core-alpha:
type: foundation
description: Alpha package
core-beta:
type: module
description: Beta package
`) + "\n"
require.NoError(t, os.WriteFile(filepath.Join(dir, "repos.yaml"), []byte(registry), 0644))
require.NoError(t, os.MkdirAll(filepath.Join(dir, "core-alpha", ".git"), 0755))
}
func gitCommand(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = dir
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git %v failed: %s", args, string(out))
return string(out)
}
func commitGitRepo(t *testing.T, dir, filename, content, message string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(dir, filename), []byte(content), 0644))
gitCommand(t, dir, "add", filename)
gitCommand(t, dir, "commit", "-m", message)
}
func setupOutdatedRegistry(t *testing.T) string {
t.Helper()
tmp := t.TempDir()
remoteDir := filepath.Join(tmp, "remote.git")
gitCommand(t, tmp, "init", "--bare", remoteDir)
seedDir := filepath.Join(tmp, "seed")
require.NoError(t, os.MkdirAll(seedDir, 0755))
gitCommand(t, seedDir, "init")
gitCommand(t, seedDir, "config", "user.email", "test@test.com")
gitCommand(t, seedDir, "config", "user.name", "Test")
commitGitRepo(t, seedDir, "repo.txt", "v1\n", "initial")
gitCommand(t, seedDir, "remote", "add", "origin", remoteDir)
gitCommand(t, seedDir, "push", "-u", "origin", "master")
freshDir := filepath.Join(tmp, "core-fresh")
gitCommand(t, tmp, "clone", remoteDir, freshDir)
staleDir := filepath.Join(tmp, "core-stale")
gitCommand(t, tmp, "clone", remoteDir, staleDir)
commitGitRepo(t, seedDir, "repo.txt", "v2\n", "second")
gitCommand(t, seedDir, "push")
gitCommand(t, freshDir, "pull", "--ff-only")
registry := strings.TrimSpace(`
org: host-uk
base_path: .
repos:
core-fresh:
type: foundation
description: Fresh package
core-stale:
type: module
description: Stale package
core-missing:
type: module
description: Missing package
`) + "\n"
require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644))
return tmp
}
func TestRunPkgList_Good(t *testing.T) {
tmp := t.TempDir()
writeTestRegistry(t, tmp)
withWorkingDir(t, tmp)
out := capturePkgOutput(t, func() {
err := runPkgList("table")
require.NoError(t, err)
})
assert.Contains(t, out, "core-alpha")
assert.Contains(t, out, "core-beta")
assert.Contains(t, out, "core setup")
}
func TestRunPkgList_JSON(t *testing.T) {
tmp := t.TempDir()
writeTestRegistry(t, tmp)
withWorkingDir(t, tmp)
out := capturePkgOutput(t, func() {
err := runPkgList("json")
require.NoError(t, err)
})
var report pkgListReport
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report))
assert.Equal(t, "json", report.Format)
assert.Equal(t, 2, report.Total)
assert.Equal(t, 1, report.Installed)
assert.Equal(t, 1, report.Missing)
require.Len(t, report.Packages, 2)
assert.Equal(t, "core-alpha", report.Packages[0].Name)
assert.True(t, report.Packages[0].Installed)
assert.Equal(t, filepath.Join(tmp, "core-alpha"), report.Packages[0].Path)
assert.Equal(t, "core-beta", report.Packages[1].Name)
assert.False(t, report.Packages[1].Installed)
}
func TestRunPkgList_UnsupportedFormat(t *testing.T) {
tmp := t.TempDir()
writeTestRegistry(t, tmp)
withWorkingDir(t, tmp)
err := runPkgList("yaml")
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported format")
}
func TestRunPkgOutdated_JSON(t *testing.T) {
tmp := setupOutdatedRegistry(t)
withWorkingDir(t, tmp)
out := capturePkgOutput(t, func() {
err := runPkgOutdated("json")
require.NoError(t, err)
})
var report pkgOutdatedReport
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report))
assert.Equal(t, "json", report.Format)
assert.Equal(t, 3, report.Total)
assert.Equal(t, 2, report.Installed)
assert.Equal(t, 1, report.Missing)
assert.Equal(t, 1, report.Outdated)
assert.Equal(t, 1, report.UpToDate)
require.Len(t, report.Packages, 3)
var staleFound, freshFound, missingFound bool
for _, pkg := range report.Packages {
switch pkg.Name {
case "core-stale":
staleFound = true
assert.True(t, pkg.Installed)
assert.False(t, pkg.UpToDate)
assert.Equal(t, 1, pkg.Behind)
case "core-fresh":
freshFound = true
assert.True(t, pkg.Installed)
assert.True(t, pkg.UpToDate)
assert.Equal(t, 0, pkg.Behind)
case "core-missing":
missingFound = true
assert.False(t, pkg.Installed)
assert.False(t, pkg.UpToDate)
assert.Equal(t, 0, pkg.Behind)
}
}
assert.True(t, staleFound)
assert.True(t, freshFound)
assert.True(t, missingFound)
}
func TestRenderPkgSearchResults_ShowsMetadata(t *testing.T) {
out := capturePkgOutput(t, func() {
renderPkgSearchResults([]ghRepo{
{
FullName: "host-uk/core-alpha",
Name: "core-alpha",
Description: "Alpha package",
Visibility: "private",
StargazerCount: 42,
PrimaryLanguage: ghLanguage{
Name: "Go",
},
UpdatedAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339),
},
})
})
assert.Contains(t, out, "host-uk/core-alpha")
assert.Contains(t, out, "Alpha package")
assert.Contains(t, out, "42 stars")
assert.Contains(t, out, "Go")
assert.Contains(t, out, "updated 2h ago")
}
func TestRunPkgSearch_RespectsLimitWithCachedResults(t *testing.T) {
tmp := t.TempDir()
writeTestRegistry(t, tmp)
withWorkingDir(t, tmp)
c, err := cache.New(nil, filepath.Join(tmp, ".core", "cache"), 0)
require.NoError(t, err)
require.NoError(t, c.Set(cache.GitHubReposKey("host-uk"), []ghRepo{
{
FullName: "host-uk/core-alpha",
Name: "core-alpha",
Description: "Alpha package",
Visibility: "public",
UpdatedAt: time.Now().Add(-time.Hour).Format(time.RFC3339),
StargazerCount: 1,
PrimaryLanguage: ghLanguage{
Name: "Go",
},
},
{
FullName: "host-uk/core-beta",
Name: "core-beta",
Description: "Beta package",
Visibility: "public",
UpdatedAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339),
StargazerCount: 2,
PrimaryLanguage: ghLanguage{
Name: "Go",
},
},
}))
out := capturePkgOutput(t, func() {
err := runPkgSearch("host-uk", "*", "", 1, false, "table")
require.NoError(t, err)
})
assert.Contains(t, out, "core-alpha")
assert.NotContains(t, out, "core-beta")
}
func TestRunPkgUpdate_NoArgs_UpdatesAll(t *testing.T) {
tmp := setupOutdatedRegistry(t)
withWorkingDir(t, tmp)
out := capturePkgOutput(t, func() {
err := runPkgUpdate(nil, false, "table")
require.NoError(t, err)
})
assert.Contains(t, out, "updating")
assert.Contains(t, out, "core-fresh")
assert.Contains(t, out, "core-stale")
}
func TestRunPkgUpdate_JSON(t *testing.T) {
tmp := setupOutdatedRegistry(t)
withWorkingDir(t, tmp)
out := capturePkgOutput(t, func() {
err := runPkgUpdate(nil, false, "json")
require.NoError(t, err)
})
var report pkgUpdateReport
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(out)), &report))
assert.Equal(t, "json", report.Format)
assert.Equal(t, 3, report.Total)
assert.Equal(t, 2, report.Installed)
assert.Equal(t, 1, report.Missing)
assert.Equal(t, 1, report.Updated)
assert.Equal(t, 1, report.UpToDate)
assert.Equal(t, 0, report.Failed)
require.Len(t, report.Packages, 3)
var updatedFound, upToDateFound, missingFound bool
for _, pkg := range report.Packages {
switch pkg.Name {
case "core-stale":
updatedFound = true
assert.True(t, pkg.Installed)
assert.Equal(t, "updated", pkg.Status)
case "core-fresh":
upToDateFound = true
assert.True(t, pkg.Installed)
assert.Equal(t, "up_to_date", pkg.Status)
case "core-missing":
missingFound = true
assert.False(t, pkg.Installed)
assert.Equal(t, "missing", pkg.Status)
}
}
assert.True(t, updatedFound)
assert.True(t, upToDateFound)
assert.True(t, missingFound)
}

View file

@ -15,7 +15,6 @@ var (
dimStyle = cli.DimStyle
ghAuthenticated = cli.GhAuthenticated
gitClone = cli.GitClone
gitCloneRef = clonePackageAtRef
)
// AddPkgCommands adds the 'pkg' command and subcommands for package management.

View file

@ -8,10 +8,12 @@
package pkgcmd
import (
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos"
@ -28,7 +30,7 @@ func addPkgRemoveCommand(parent *cobra.Command) {
changes or unpushed branches. Use --force to skip safety checks.`,
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return cli.Err(i18n.T("cmd.pkg.error.repo_required"))
return errors.New(i18n.T("cmd.pkg.error.repo_required"))
}
return runPkgRemove(args[0], removeForce)
},
@ -40,105 +42,102 @@ changes or unpushed branches. Use --force to skip safety checks.`,
}
func runPkgRemove(name string, force bool) error {
// Find package path via registry.
registryPath, err := repos.FindRegistry(coreio.Local)
// Find package path via registry
regPath, err := repos.FindRegistry(coreio.Local)
if err != nil {
return cli.Err(i18n.T("cmd.pkg.error.no_repos_yaml"))
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml"))
}
registry, err := repos.LoadRegistry(coreio.Local, registryPath)
reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil {
return cli.Wrap(err, i18n.T("i18n.fail.load", "registry"))
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err)
}
basePath := registry.BasePath
basePath := reg.BasePath
if basePath == "" {
basePath = "."
}
if !core.PathIsAbs(basePath) {
basePath = core.Path(core.PathDir(registryPath), basePath)
if !filepath.IsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath)
}
repoPath := core.Path(basePath, name)
repoPath := filepath.Join(basePath, name)
if !coreio.Local.IsDir(core.Path(repoPath, ".git")) {
return cli.Err("package %s is not installed at %s", name, repoPath)
if !coreio.Local.IsDir(filepath.Join(repoPath, ".git")) {
return fmt.Errorf("package %s is not installed at %s", name, repoPath)
}
if !force {
blocked, reasons := checkRepoSafety(repoPath)
if blocked {
cli.Println("%s Cannot remove %s:", errorStyle.Render("Blocked:"), repoNameStyle.Render(name))
for _, reason := range reasons {
cli.Println(" %s %s", errorStyle.Render("·"), reason)
fmt.Printf("%s Cannot remove %s:\n", errorStyle.Render("Blocked:"), repoNameStyle.Render(name))
for _, r := range reasons {
fmt.Printf(" %s %s\n", errorStyle.Render("·"), r)
}
cli.Println("\nResolve the issues above or use --force to override.")
return cli.Err("package has unresolved changes")
fmt.Printf("\nResolve the issues above or use --force to override.\n")
return errors.New("package has unresolved changes")
}
}
// Remove the directory.
cli.Print("%s %s... ", dimStyle.Render("Removing"), repoNameStyle.Render(name))
// Remove the directory
fmt.Printf("%s %s... ", dimStyle.Render("Removing"), repoNameStyle.Render(name))
if err := coreio.Local.DeleteAll(repoPath); err != nil {
cli.Println("%s", errorStyle.Render("x "+err.Error()))
fmt.Printf("%s\n", errorStyle.Render("x "+err.Error()))
return err
}
cli.Println("%s", successStyle.Render("ok"))
fmt.Printf("%s\n", successStyle.Render("ok"))
return nil
}
// checkRepoSafety checks a git repo for uncommitted changes and unpushed branches.
//
// blocked, reasons := checkRepoSafety("/path/to/repo")
// if blocked { fmt.Println(reasons) }
func checkRepoSafety(repoPath string) (blocked bool, reasons []string) {
// Check for uncommitted changes (staged, unstaged, untracked).
proc := exec.Command("git", "-C", repoPath, "status", "--porcelain")
output, err := proc.Output()
if err == nil && core.Trim(string(output)) != "" {
lines := core.Split(core.Trim(string(output)), "\n")
// Check for uncommitted changes (staged, unstaged, untracked)
cmd := exec.Command("git", "-C", repoPath, "status", "--porcelain")
output, err := cmd.Output()
if err == nil && strings.TrimSpace(string(output)) != "" {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
blocked = true
reasons = append(reasons, cli.Sprintf("has %d uncommitted changes", len(lines)))
reasons = append(reasons, fmt.Sprintf("has %d uncommitted changes", len(lines)))
}
// Check for unpushed commits on current branch.
proc = exec.Command("git", "-C", repoPath, "log", "--oneline", "@{u}..HEAD")
output, err = proc.Output()
if err == nil && core.Trim(string(output)) != "" {
lines := core.Split(core.Trim(string(output)), "\n")
// Check for unpushed commits on current branch
cmd = exec.Command("git", "-C", repoPath, "log", "--oneline", "@{u}..HEAD")
output, err = cmd.Output()
if err == nil && strings.TrimSpace(string(output)) != "" {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
blocked = true
reasons = append(reasons, cli.Sprintf("has %d unpushed commits on current branch", len(lines)))
reasons = append(reasons, fmt.Sprintf("has %d unpushed commits on current branch", len(lines)))
}
// Check all local branches for unpushed work.
proc = exec.Command("git", "-C", repoPath, "branch", "--no-merged", "origin/HEAD")
output, _ = proc.Output()
if trimmedOutput := core.Trim(string(output)); trimmedOutput != "" {
branches := core.Split(trimmedOutput, "\n")
// Check all local branches for unpushed work
cmd = exec.Command("git", "-C", repoPath, "branch", "--no-merged", "origin/HEAD")
output, _ = cmd.Output()
if trimmed := strings.TrimSpace(string(output)); trimmed != "" {
branches := strings.Split(trimmed, "\n")
var unmerged []string
for _, branchName := range branches {
branchName = core.Trim(branchName)
branchName = core.TrimPrefix(branchName, "* ")
if branchName != "" {
unmerged = append(unmerged, branchName)
for _, b := range branches {
b = strings.TrimSpace(b)
b = strings.TrimPrefix(b, "* ")
if b != "" {
unmerged = append(unmerged, b)
}
}
if len(unmerged) > 0 {
blocked = true
reasons = append(reasons, cli.Sprintf("has %d unmerged branches: %s",
len(unmerged), core.Join(", ", unmerged...)))
reasons = append(reasons, fmt.Sprintf("has %d unmerged branches: %s",
len(unmerged), strings.Join(unmerged, ", ")))
}
}
// Check for stashed changes.
proc = exec.Command("git", "-C", repoPath, "stash", "list")
output, err = proc.Output()
if err == nil && core.Trim(string(output)) != "" {
lines := core.Split(core.Trim(string(output)), "\n")
// Check for stashed changes
cmd = exec.Command("git", "-C", repoPath, "stash", "list")
output, err = cmd.Output()
if err == nil && strings.TrimSpace(string(output)) != "" {
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
blocked = true
reasons = append(reasons, cli.Sprintf("has %d stashed entries", len(lines)))
reasons = append(reasons, fmt.Sprintf("has %d stashed entries", len(lines)))
}
return blocked, reasons

View file

@ -1,11 +1,9 @@
package pkgcmd
import (
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@ -14,52 +12,24 @@ import (
func setupTestRepo(t *testing.T, dir, name string) string {
t.Helper()
repoPath := filepath.Join(dir, name)
require.NoError(t, os.MkdirAll(repoPath, 0755))
gitCommand(t, repoPath, "init")
gitCommand(t, repoPath, "config", "user.email", "test@test.com")
gitCommand(t, repoPath, "config", "user.name", "Test")
gitCommand(t, repoPath, "commit", "--allow-empty", "-m", "initial")
cmds := [][]string{
{"git", "init"},
{"git", "config", "user.email", "test@test.com"},
{"git", "config", "user.name", "Test"},
{"git", "commit", "--allow-empty", "-m", "initial"},
}
for _, c := range cmds {
cmd := exec.Command(c[0], c[1:]...)
cmd.Dir = repoPath
out, err := cmd.CombinedOutput()
require.NoError(t, err, "cmd %v failed: %s", c, string(out))
}
return repoPath
}
func capturePkgStreams(t *testing.T, fn func()) (string, string) {
t.Helper()
oldStdout := os.Stdout
oldStderr := os.Stderr
rOut, wOut, err := os.Pipe()
require.NoError(t, err)
rErr, wErr, err := os.Pipe()
require.NoError(t, err)
os.Stdout = wOut
os.Stderr = wErr
defer func() {
os.Stdout = oldStdout
os.Stderr = oldStderr
}()
fn()
require.NoError(t, wOut.Close())
require.NoError(t, wErr.Close())
var stdout bytes.Buffer
var stderr bytes.Buffer
_, err = io.Copy(&stdout, rOut)
require.NoError(t, err)
_, err = io.Copy(&stderr, rErr)
require.NoError(t, err)
return stdout.String(), stderr.String()
}
func TestCheckRepoSafety_Clean(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "clean-repo")
@ -85,90 +55,38 @@ func TestCheckRepoSafety_Stash(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "stash-repo")
// Create a file, add, stash
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "stash.txt"), []byte("data"), 0644))
gitCommand(t, repoPath, "add", ".")
gitCommand(t, repoPath, "stash")
cmd := exec.Command("git", "add", ".")
cmd.Dir = repoPath
require.NoError(t, cmd.Run())
cmd = exec.Command("git", "stash")
cmd.Dir = repoPath
require.NoError(t, cmd.Run())
blocked, reasons := checkRepoSafety(repoPath)
assert.True(t, blocked)
found := false
for _, r := range reasons {
if strings.Contains(r, "stash") {
found = true
if assert.ObjectsAreEqual("stashed", "") || len(r) > 0 {
if contains(r, "stash") {
found = true
}
}
}
assert.True(t, found, "expected stash warning in reasons: %v", reasons)
}
func TestRunPkgRemove_RemovesRegistryEntry_Good(t *testing.T) {
tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "core-alpha")
registry := strings.TrimSpace(`
version: 1
org: host-uk
base_path: .
repos:
core-alpha:
type: foundation
description: Alpha package
core-beta:
type: module
description: Beta package
`) + "\n"
require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644))
oldwd, err := os.Getwd()
require.NoError(t, err)
require.NoError(t, os.Chdir(tmp))
t.Cleanup(func() {
require.NoError(t, os.Chdir(oldwd))
})
require.NoError(t, runPkgRemove("core-alpha", false))
_, err = os.Stat(repoPath)
assert.True(t, os.IsNotExist(err))
updated, err := os.ReadFile(filepath.Join(tmp, "repos.yaml"))
require.NoError(t, err)
assert.NotContains(t, string(updated), "core-alpha")
assert.Contains(t, string(updated), "core-beta")
func contains(s, substr string) bool {
return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsStr(s, substr))
}
func TestRunPkgRemove_Bad_BlockedWarningsGoToStderr(t *testing.T) {
tmp := t.TempDir()
registry := strings.TrimSpace(`
org: host-uk
base_path: .
repos:
core-alpha:
type: foundation
description: Alpha package
`) + "\n"
require.NoError(t, os.WriteFile(filepath.Join(tmp, "repos.yaml"), []byte(registry), 0644))
repoPath := filepath.Join(tmp, "core-alpha")
require.NoError(t, os.MkdirAll(repoPath, 0755))
gitCommand(t, repoPath, "init")
gitCommand(t, repoPath, "config", "user.email", "test@test.com")
gitCommand(t, repoPath, "config", "user.name", "Test")
commitGitRepo(t, repoPath, "file.txt", "v1\n", "initial")
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "file.txt"), []byte("v2\n"), 0644))
withWorkingDir(t, tmp)
stdout, stderr := capturePkgStreams(t, func() {
err := runPkgRemove("core-alpha", false)
require.Error(t, err)
assert.Contains(t, err.Error(), "unresolved changes")
})
assert.Empty(t, stdout)
assert.Contains(t, stderr, "Cannot remove core-alpha")
assert.Contains(t, stderr, "uncommitted changes")
assert.Contains(t, stderr, "Resolve the issues above or use --force to override.")
func containsStr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View file

@ -2,12 +2,16 @@ package pkgcmd
import (
"cmp"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-cache"
"forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io"
@ -65,83 +69,82 @@ type ghRepo struct {
}
func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error {
// Initialise cache in workspace .core/ directory.
var cacheDirectory string
if registryPath, err := repos.FindRegistry(coreio.Local); err == nil {
cacheDirectory = core.Path(core.PathDir(registryPath), ".core", "cache")
// Initialize cache in workspace .core/ directory
var cacheDir string
if regPath, err := repos.FindRegistry(coreio.Local); err == nil {
cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache")
}
cacheInstance, err := cache.New(coreio.Local, cacheDirectory, 0)
c, err := cache.New(coreio.Local, cacheDir, 0)
if err != nil {
cacheInstance = nil
c = nil
}
cacheKey := cache.GitHubReposKey(org)
var ghRepos []ghRepo
var fromCache bool
// Try cache first (unless refresh requested).
if cacheInstance != nil && !refresh {
if found, err := cacheInstance.Get(cacheKey, &ghRepos); found && err == nil {
// Try cache first (unless refresh requested)
if c != nil && !refresh {
if found, err := c.Get(cacheKey, &ghRepos); found && err == nil {
fromCache = true
age := cacheInstance.Age(cacheKey)
cli.Println("%s %s %s", dimStyle.Render(i18n.T("cmd.pkg.search.cache_label")), org, dimStyle.Render(cli.Sprintf("(%s ago)", age.Round(time.Second))))
age := c.Age(cacheKey)
fmt.Printf("%s %s %s\n", dimStyle.Render(i18n.T("cmd.pkg.search.cache_label")), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second))))
}
}
// Fetch from GitHub if not cached.
// Fetch from GitHub if not cached
if !fromCache {
if !ghAuthenticated() {
return cli.Err(i18n.T("cmd.pkg.error.gh_not_authenticated"))
return errors.New(i18n.T("cmd.pkg.error.gh_not_authenticated"))
}
if core.Env("GH_TOKEN") != "" {
cli.Println("%s %s", dimStyle.Render(i18n.Label("note")), i18n.T("cmd.pkg.search.gh_token_warning"))
cli.Println("%s %s\n", dimStyle.Render(""), i18n.T("cmd.pkg.search.gh_token_unset"))
if os.Getenv("GH_TOKEN") != "" {
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("note")), i18n.T("cmd.pkg.search.gh_token_warning"))
fmt.Printf("%s %s\n\n", dimStyle.Render(""), i18n.T("cmd.pkg.search.gh_token_unset"))
}
cli.Print("%s %s... ", dimStyle.Render(i18n.T("cmd.pkg.search.fetching_label")), org)
fmt.Printf("%s %s... ", dimStyle.Render(i18n.T("cmd.pkg.search.fetching_label")), org)
proc := exec.Command("gh", "repo", "list", org,
cmd := exec.Command("gh", "repo", "list", org,
"--json", "name,description,visibility,updatedAt,primaryLanguage",
"--limit", cli.Sprintf("%d", limit))
output, err := proc.CombinedOutput()
"--limit", fmt.Sprintf("%d", limit))
output, err := cmd.CombinedOutput()
if err != nil {
cli.Blank()
errorOutput := core.Trim(string(output))
if core.Contains(errorOutput, "401") || core.Contains(errorOutput, "Bad credentials") {
return cli.Err(i18n.T("cmd.pkg.error.auth_failed"))
fmt.Println()
errStr := strings.TrimSpace(string(output))
if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") {
return errors.New(i18n.T("cmd.pkg.error.auth_failed"))
}
return cli.Err("%s: %s", i18n.T("cmd.pkg.error.search_failed"), errorOutput)
return fmt.Errorf("%s: %s", i18n.T("cmd.pkg.error.search_failed"), errStr)
}
result := core.JSONUnmarshal(output, &ghRepos)
if !result.OK {
return cli.Wrap(result.Value.(error), i18n.T("i18n.fail.parse", "results"))
if err := json.Unmarshal(output, &ghRepos); err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.parse", "results"), err)
}
if cacheInstance != nil {
_ = cacheInstance.Set(cacheKey, ghRepos)
if c != nil {
_ = c.Set(cacheKey, ghRepos)
}
cli.Println("%s", successStyle.Render("✓"))
fmt.Printf("%s\n", successStyle.Render("✓"))
}
// Filter by glob pattern and type.
// Filter by glob pattern and type
var filtered []ghRepo
for _, repo := range ghRepos {
if !matchGlob(pattern, repo.Name) {
for _, r := range ghRepos {
if !matchGlob(pattern, r.Name) {
continue
}
if repoType != "" && !core.Contains(repo.Name, repoType) {
if repoType != "" && !strings.Contains(r.Name, repoType) {
continue
}
filtered = append(filtered, repo)
filtered = append(filtered, r)
}
if len(filtered) == 0 {
cli.Println("%s", i18n.T("cmd.pkg.search.no_repos_found"))
fmt.Println(i18n.T("cmd.pkg.search.no_repos_found"))
return nil
}
@ -149,65 +152,54 @@ func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error
return cmp.Compare(a.Name, b.Name)
})
cli.Print(i18n.T("cmd.pkg.search.found_repos", map[string]int{"Count": len(filtered)}) + "\n\n")
fmt.Print(i18n.T("cmd.pkg.search.found_repos", map[string]int{"Count": len(filtered)}) + "\n\n")
for _, repo := range filtered {
for _, r := range filtered {
visibility := ""
if repo.Visibility == "private" {
if r.Visibility == "private" {
visibility = dimStyle.Render(" " + i18n.T("cmd.pkg.search.private_label"))
}
description := repo.Description
if len(description) > 50 {
description = description[:47] + "..."
desc := r.Description
if len(desc) > 50 {
desc = desc[:47] + "..."
}
if description == "" {
description = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
if desc == "" {
desc = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
}
cli.Println(" %s%s", repoNameStyle.Render(repo.Name), visibility)
cli.Println(" %s", description)
fmt.Printf(" %s%s\n", repoNameStyle.Render(r.Name), visibility)
fmt.Printf(" %s\n", desc)
}
cli.Blank()
cli.Println("%s %s", i18n.T("common.hint.install_with"), dimStyle.Render(cli.Sprintf("core pkg install %s/<repo-name>", org)))
fmt.Println()
fmt.Printf("%s %s\n", i18n.T("common.hint.install_with"), dimStyle.Render(fmt.Sprintf("core pkg install %s/<repo-name>", org)))
return nil
}
// matchGlob does simple glob matching with * wildcards.
//
// matchGlob("core-*", "core-php") // true
// matchGlob("*-mod", "core-php") // false
// matchGlob does simple glob matching with * wildcards
func matchGlob(pattern, name string) bool {
if pattern == "*" || pattern == "" {
return true
}
parts := core.Split(pattern, "*")
parts := strings.Split(pattern, "*")
pos := 0
for i, part := range parts {
if part == "" {
continue
}
// Find part in name starting from pos.
remaining := name[pos:]
idx := -1
for j := 0; j <= len(remaining)-len(part); j++ {
if remaining[j:j+len(part)] == part {
idx = j
break
}
}
idx := strings.Index(name[pos:], part)
if idx == -1 {
return false
}
if i == 0 && !core.HasPrefix(pattern, "*") && idx != 0 {
if i == 0 && !strings.HasPrefix(pattern, "*") && idx != 0 {
return false
}
pos += idx + len(part)
}
if !core.HasSuffix(pattern, "*") && pos != len(name) {
if !strings.HasSuffix(pattern, "*") && pos != len(name) {
return false
}
return true

View file

@ -1,66 +0,0 @@
package pkgcmd
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestResolvePkgSearchPattern_Good(t *testing.T) {
t.Run("uses flag pattern when set", func(t *testing.T) {
got := resolvePkgSearchPattern("core-*", []string{"api"})
assert.Equal(t, "core-*", got)
})
t.Run("uses positional pattern when flag is empty", func(t *testing.T) {
got := resolvePkgSearchPattern("", []string{"api"})
assert.Equal(t, "api", got)
})
t.Run("defaults to wildcard when nothing is provided", func(t *testing.T) {
got := resolvePkgSearchPattern("", nil)
assert.Equal(t, "*", got)
})
}
func TestBuildPkgSearchReport_Good(t *testing.T) {
repos := []ghRepo{
{
FullName: "host-uk/core-api",
Name: "core-api",
Description: "REST API framework",
Visibility: "public",
UpdatedAt: "2026-03-30T12:00:00Z",
StargazerCount: 42,
PrimaryLanguage: ghLanguage{
Name: "Go",
},
},
}
report := buildPkgSearchReport("host-uk", "core-*", "api", 50, true, repos)
assert.Equal(t, "json", report.Format)
assert.Equal(t, "host-uk", report.Org)
assert.Equal(t, "core-*", report.Pattern)
assert.Equal(t, "api", report.Type)
assert.Equal(t, 50, report.Limit)
assert.True(t, report.Cached)
assert.Equal(t, 1, report.Count)
requireRepo := report.Repos
if assert.Len(t, requireRepo, 1) {
assert.Equal(t, "core-api", requireRepo[0].Name)
assert.Equal(t, "host-uk/core-api", requireRepo[0].FullName)
assert.Equal(t, "REST API framework", requireRepo[0].Description)
assert.Equal(t, "public", requireRepo[0].Visibility)
assert.Equal(t, 42, requireRepo[0].StargazerCount)
assert.Equal(t, "Go", requireRepo[0].PrimaryLanguage)
assert.Equal(t, "2026-03-30T12:00:00Z", requireRepo[0].UpdatedAt)
assert.NotEmpty(t, requireRepo[0].Updated)
}
out, err := json.Marshal(report)
assert.NoError(t, err)
assert.Contains(t, string(out), `"format":"json"`)
}

View file

@ -33,5 +33,4 @@ core pkg update core-api
```bash
core pkg outdated
core pkg outdated --format json
```

View file

@ -60,10 +60,10 @@ core pkg search --refresh
## pkg install
Clone a package from GitHub. If you pass only a repo name, `core` assumes the `host-uk` org.
Clone a package from GitHub.
```bash
core pkg install [org/]repo [flags]
core pkg install <org/repo> [flags]
```
### Flags
@ -76,9 +76,6 @@ core pkg install [org/]repo [flags]
### Examples
```bash
# Clone from the default host-uk org
core pkg install core-api
# Clone to packages/
core pkg install host-uk/core-php
@ -101,16 +98,6 @@ core pkg list
Shows installed status (✓) and description for each package.
### Flags
| Flag | Description |
|------|-------------|
| `--format` | Output format (`table` or `json`) |
### JSON Output
When `--format json` is set, `core pkg list` emits a structured report with package entries, installed state, and summary counts.
---
## pkg update
@ -126,7 +113,6 @@ core pkg update [<name>...] [flags]
| Flag | Description |
|------|-------------|
| `--all` | Update all packages |
| `--format` | Output format (`table` or `json`) |
### Examples
@ -136,15 +122,8 @@ core pkg update core-php
# Update all packages
core pkg update --all
# JSON output for automation
core pkg update --format json
```
### JSON Output
When `--format json` is set, `core pkg update` emits a structured report with per-package update status and summary totals.
---
## pkg outdated
@ -157,16 +136,6 @@ core pkg outdated
Fetches from remote and shows packages that are behind.
### Flags
| Flag | Description |
|------|-------------|
| `--format` | Output format (`table` or `json`) |
### JSON Output
When `--format json` is set, `core pkg outdated` emits a structured report with package status, behind counts, and summary totals.
---
## See Also

View file

@ -19,7 +19,6 @@ core pkg search [flags]
| `--type` | Filter by type in name (mod, services, plug, website) |
| `--limit` | Max results (default: 50) |
| `--refresh` | Bypass cache and fetch fresh data |
| `--format` | Output format (`table` or `json`) |
## Examples
@ -41,9 +40,6 @@ core pkg search --refresh
# Combine filters
core pkg search --pattern "core-*" --type mod --limit 20
# JSON output for automation
core pkg search --format json
```
## Output

View file

@ -85,11 +85,6 @@ Persistent flags are inherited by all subcommands:
```go
cli.PersistentStringFlag(parentCmd, &dbPath, "db", "d", "", "Database path")
cli.PersistentBoolFlag(parentCmd, &debug, "debug", "", false, "Debug mode")
cli.PersistentIntFlag(parentCmd, &retries, "retries", "r", 3, "Retry count")
cli.PersistentInt64Flag(parentCmd, &seed, "seed", "", 0, "Seed value")
cli.PersistentFloat64Flag(parentCmd, &ratio, "ratio", "", 1.0, "Scaling ratio")
cli.PersistentDurationFlag(parentCmd, &timeout, "timeout", "t", 30*time.Second, "Timeout")
cli.PersistentStringSliceFlag(parentCmd, &tags, "tag", "", nil, "Tags")
```
## Args Validation

View file

@ -5,7 +5,7 @@ description: Daemon process management, PID files, health checks, and execution
# Daemon Mode
The framework provides execution mode detection and signal handling for daemon processes.
The framework provides both low-level daemon primitives and a high-level command group that adds `start`, `stop`, `status`, and `run` subcommands to your CLI.
## Execution Modes
@ -29,9 +29,63 @@ cli.IsStdinTTY() // stdin is a terminal?
cli.IsStderrTTY() // stderr is a terminal?
```
## Simple Daemon
## Adding Daemon Commands
Use `cli.Context()` for cancellation-aware daemon loops:
`AddDaemonCommand` registers a command group with four subcommands:
```go
func AddMyCommands(root *cli.Command) {
cli.AddDaemonCommand(root, cli.DaemonCommandConfig{
Name: "daemon", // Command group name (default: "daemon")
Description: "Manage the worker", // Short description
PIDFile: "/var/run/myapp.pid",
HealthAddr: ":9090",
RunForeground: func(ctx context.Context, daemon *process.Daemon) error {
// Your long-running service logic here.
// ctx is cancelled on SIGINT/SIGTERM.
return runWorker(ctx)
},
})
}
```
This creates:
- `myapp daemon start` -- Re-executes the binary as a background process with `CORE_DAEMON=1`
- `myapp daemon stop` -- Sends SIGTERM to the daemon, waits for shutdown (30s timeout, then SIGKILL)
- `myapp daemon status` -- Reports whether the daemon is running and queries health endpoints
- `myapp daemon run` -- Runs in the foreground (for development or process managers like systemd)
### Custom Persistent Flags
Add flags that apply to all daemon subcommands:
```go
cli.AddDaemonCommand(root, cli.DaemonCommandConfig{
// ...
Flags: func(cmd *cli.Command) {
cli.PersistentStringFlag(cmd, &configPath, "config", "c", "", "Config file")
},
ExtraStartArgs: func() []string {
return []string{"--config", configPath}
},
})
```
`ExtraStartArgs` passes additional flags when re-executing the binary as a daemon.
### Health Endpoints
When `HealthAddr` is set, the daemon serves:
- `GET /health` -- Liveness check (200 if server is up, 503 if health checks fail)
- `GET /ready` -- Readiness check (200 if `daemon.SetReady(true)` has been called)
The `start` command waits up to 5 seconds for the health endpoint to become available before reporting success.
## Simple Daemon (Manual)
For cases where you do not need the full command group:
```go
func runDaemon(cmd *cli.Command, args []string) error {
@ -42,39 +96,6 @@ func runDaemon(cmd *cli.Command, args []string) error {
}
```
## Daemon Helper
Use `cli.NewDaemon()` when you want a helper that writes a PID file and serves
basic `/health` and `/ready` probes:
```go
daemon := cli.NewDaemon(cli.DaemonOptions{
PIDFile: "/tmp/core.pid",
HealthAddr: "127.0.0.1:8080",
HealthCheck: func() bool {
return true
},
ReadyCheck: func() bool {
return true
},
})
if err := daemon.Start(context.Background()); err != nil {
return err
}
defer func() {
_ = daemon.Stop(context.Background())
}()
```
`Start()` writes the current process ID to the configured file, and `Stop()`
removes it after shutting the probe server down.
If you need to stop a daemon process from outside its own process tree, use
`cli.StopPIDFile(pidFile, timeout)`. It sends `SIGTERM`, waits up to the
timeout for exit, escalates to `SIGKILL` if needed, and removes the PID file
after the process stops.
## Shutdown with Timeout
The daemon stop logic sends SIGTERM and waits up to 30 seconds. If the process has not exited by then, it sends SIGKILL and removes the PID file.
@ -96,3 +117,15 @@ cli.Init(cli.Options{
```
No manual signal handling is needed in commands. Use `cli.Context()` for cancellation-aware operations.
## DaemonCommandConfig Reference
| Field | Type | Description |
|-------|------|-------------|
| `Name` | `string` | Command group name (default: `"daemon"`) |
| `Description` | `string` | Short description for help text |
| `PIDFile` | `string` | PID file path (default flag value) |
| `HealthAddr` | `string` | Health check listen address (default flag value) |
| `RunForeground` | `func(ctx, daemon) error` | Service logic for foreground/daemon mode |
| `Flags` | `func(cmd)` | Registers custom persistent flags |
| `ExtraStartArgs` | `func() []string` | Additional args for background re-exec |

View file

@ -57,10 +57,10 @@ If a command returns an `*ExitError`, the process exits with that code. All othe
This is the preferred way to register commands. It wraps your registration function in a Core service that participates in the lifecycle:
```go
func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) CommandSetup
func WithCommands(name string, register func(root *Command)) core.Option
```
During `Main()`, the CLI calls your function with the Core instance. Internally it retrieves the root cobra command and passes it to your register function:
During startup, the Core framework calls your function with the root cobra command. Your function adds subcommands to it:
```go
func AddScoreCommands(root *cli.Command) {
@ -98,17 +98,18 @@ func main() {
}
```
Where `Commands()` returns a slice of `CommandSetup` functions:
Where `Commands()` returns a slice of framework options:
```go
package lemcmd
import (
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/cli/pkg/cli"
)
func Commands() []cli.CommandSetup {
return []cli.CommandSetup{
func Commands() []core.Option {
return []core.Option{
cli.WithCommands("score", addScoreCommands),
cli.WithCommands("gen", addGenCommands),
cli.WithCommands("data", addDataCommands),
@ -140,7 +141,7 @@ If you need more control over the lifecycle:
cli.Init(cli.Options{
AppName: "myapp",
Version: "1.0.0",
Services: []core.Service{...},
Services: []core.Option{...},
OnReload: func() error { return reloadConfig() },
})
defer cli.Shutdown()

View file

@ -52,7 +52,6 @@ The framework has three layers:
| `TreeNode` | Tree structure with box-drawing connectors |
| `TaskTracker` | Concurrent task display with live spinners |
| `CheckBuilder` | Fluent API for pass/fail/skip result lines |
| `Daemon` | PID file and probe helper for background processes |
| `AnsiStyle` | Terminal text styling (bold, dim, colour) |
## Built-in Services

View file

@ -280,5 +280,4 @@ cli.LogInfo("server started", "port", 8080)
cli.LogWarn("slow query", "duration", "3.2s")
cli.LogError("connection failed", "err", err)
cli.LogSecurity("login attempt", "user", "admin")
cli.LogSecurityf("login attempt from %s", username)
```

View file

@ -135,12 +135,6 @@ choice := cli.Choose("Select a file:", files,
)
```
Enable `cli.Filter()` to let users type a substring and narrow the visible choices before selecting a number:
```go
choice := cli.Choose("Select:", items, cli.Filter[Item]())
```
With a default selection:
```go

View file

@ -34,19 +34,17 @@ When word-wrap is enabled, the stream tracks the current column position and ins
## Custom Output Writer
By default, streams write to the CLI stdout writer (`stdoutWriter()`), so tests can
redirect output via `cli.SetStdout` and other callers can provide any `io.Writer`:
By default, streams write to `os.Stdout`. Redirect to any `io.Writer`:
```go
var buf strings.Builder
stream := cli.NewStream(cli.WithStreamOutput(&buf))
// ... write tokens ...
stream.Done()
result, ok := stream.CapturedOK() // or buf.String()
result := stream.Captured() // or buf.String()
```
`Captured()` returns the output as a string when using a `*strings.Builder` or any `fmt.Stringer`.
`CapturedOK()` reports whether capture is supported by the configured writer.
## Reading from `io.Reader`
@ -70,15 +68,14 @@ stream.Done()
| `Done()` | Signal completion (adds trailing newline if needed) |
| `Wait()` | Block until `Done` is called |
| `Column()` | Current column position |
| `Captured()` | Get output as string (returns `""` if capture is unsupported) |
| `CapturedOK()` | Get output and support status |
| `Captured()` | Get output as string (requires `*strings.Builder` or `fmt.Stringer` writer) |
## Options
| Option | Description |
|--------|-------------|
| `WithWordWrap(cols)` | Set the word-wrap column width |
| `WithStreamOutput(w)` | Set the output writer (default: `stdoutWriter()`) |
| `WithStreamOutput(w)` | Set the output writer (default: `os.Stdout`) |
## Example: LLM Token Streaming

15
go.mod
View file

@ -1,26 +1,24 @@
module dappco.re/go/core/cli
module forge.lthn.ai/core/cli
go 1.26.0
require dappco.re/go/core v0.4.7
require forge.lthn.ai/core/go v0.3.2
require (
dappco.re/go/core/i18n v0.1.7
dappco.re/go/core/log v0.0.4
forge.lthn.ai/core/go-i18n v0.1.7
forge.lthn.ai/core/go-log v0.0.4
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/x/ansi v0.11.6
github.com/mattn/go-runewidth v0.0.21
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
golang.org/x/term v0.41.0
)
require (
dappco.re/go/core v0.3.3 // indirect
dappco.re/go/core/inference v0.1.7 // indirect
forge.lthn.ai/core/go-inference v0.1.6 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/x/ansi v0.11.6 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
@ -31,6 +29,7 @@ require (
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect

10
go.sum
View file

@ -1,11 +1,9 @@
dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA=
dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ=
forge.lthn.ai/core/go v0.3.2/go.mod h1:f7/zb3Labn4ARfwTq5Bi2AFHY+uxyPHozO+hLb54eFo=
forge.lthn.ai/core/go-i18n v0.1.7 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=
forge.lthn.ai/core/go-i18n v0.1.7/go.mod h1:0VDjwtY99NSj2iqwrI09h5GUsJeM9s48MLkr+/Dn4G8=
forge.lthn.ai/core/go-inference v0.1.7 h1:9Dy6v03jX5ZRH3n5iTzlYyGtucuBIgSe+S7GWvBzx9Q=
forge.lthn.ai/core/go-inference v0.1.7/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-inference v0.1.6 h1:ce42zC0zO8PuISUyAukAN1NACEdWp5wF1mRgnh5+58E=
forge.lthn.ai/core/go-inference v0.1.6/go.mod h1:jfWz+IJX55wAH98+ic6FEqqGB6/P31CHlg7VY7pxREw=
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=

View file

@ -18,9 +18,8 @@ const (
)
var (
colorEnabled = true
colorEnabledMu sync.RWMutex
asciiDisabledColors bool
colorEnabled = true
colorEnabledMu sync.RWMutex
)
func init() {
@ -49,18 +48,6 @@ func ColorEnabled() bool {
func SetColorEnabled(enabled bool) {
colorEnabledMu.Lock()
colorEnabled = enabled
if enabled {
asciiDisabledColors = false
}
colorEnabledMu.Unlock()
}
func restoreColorIfASCII() {
colorEnabledMu.Lock()
if asciiDisabledColors {
colorEnabled = true
asciiDisabledColors = false
}
colorEnabledMu.Unlock()
}

View file

@ -76,7 +76,9 @@ func TestRender_ColorEnabled_Good(t *testing.T) {
}
func TestUseASCII_Good(t *testing.T) {
restoreThemeAndColors(t)
// Save original state
original := ColorEnabled()
defer SetColorEnabled(original)
// Enable first, then UseASCII should disable colors
SetColorEnabled(true)
@ -86,76 +88,10 @@ func TestUseASCII_Good(t *testing.T) {
}
}
func TestUseUnicodeAndEmojiRestoreColorsAfterASCII(t *testing.T) {
restoreThemeAndColors(t)
SetColorEnabled(true)
UseASCII()
if ColorEnabled() {
t.Fatal("UseASCII should disable colors")
}
UseUnicode()
if !ColorEnabled() {
t.Fatal("UseUnicode should restore colors after ASCII mode")
}
UseASCII()
if ColorEnabled() {
t.Fatal("UseASCII should disable colors again")
}
UseEmoji()
if !ColorEnabled() {
t.Fatal("UseEmoji should restore colors after ASCII mode")
}
}
func TestRender_NilStyle_Good(t *testing.T) {
restoreThemeAndColors(t)
var s *AnsiStyle
got := s.Render("test")
if got != "test" {
t.Errorf("Nil style should return plain text, got %q", got)
}
}
func TestAnsiStyle_Bad(t *testing.T) {
restoreThemeAndColors(t)
original := ColorEnabled()
defer SetColorEnabled(original)
// Invalid hex colour falls back to white (255,255,255).
SetColorEnabled(true)
style := NewStyle().Foreground("notahex")
got := style.Render("text")
if !strings.Contains(got, "text") {
t.Errorf("Invalid hex: expected 'text' in output, got %q", got)
}
// Short hex (less than 6 chars) also falls back.
style = NewStyle().Foreground("#abc")
got = style.Render("x")
if !strings.Contains(got, "x") {
t.Errorf("Short hex: expected 'x' in output, got %q", got)
}
}
func TestAnsiStyle_Ugly(t *testing.T) {
restoreThemeAndColors(t)
original := ColorEnabled()
defer SetColorEnabled(original)
// All style modifiers stack without panicking.
SetColorEnabled(true)
style := NewStyle().Bold().Dim().Italic().Underline().
Foreground("#3b82f6").Background("#1f2937")
got := style.Render("styled")
if !strings.Contains(got, "styled") {
t.Errorf("All modifiers: expected 'styled' in output, got %q", got)
}
// Empty string renders without panicking.
got = style.Render("")
_ = got
}

View file

@ -7,9 +7,9 @@ import (
"os"
"runtime/debug"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
"github.com/spf13/cobra"
)
@ -34,16 +34,9 @@ var (
)
// SemVer returns the full SemVer 2.0.0 version string.
//
// Examples:
// // Release only:
// // AppVersion=1.2.0 -> 1.2.0
// cli.AppVersion = "1.2.0"
// fmt.Println(cli.SemVer())
//
// // Pre-release + commit + date:
// // AppVersion=1.2.0, BuildPreRelease=dev.8, BuildCommit=df94c24, BuildDate=20260206
// // -> 1.2.0-dev.8+df94c24.20260206
// - Release: 1.2.0
// - Pre-release: 1.2.0-dev.8
// - Full: 1.2.0-dev.8+df94c24.20260206
func SemVer() string {
v := AppVersion
if BuildPreRelease != "" {
@ -67,42 +60,31 @@ 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
// WithLocales returns a locale source for use with MainWithLocales.
//
// Example:
// fs := embed.FS{}
// locales := cli.WithLocales(fs, "locales")
// cli.MainWithLocales([]cli.LocaleSource{locales})
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.
//
// Example:
// cli.Main(
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
// )
type CommandSetup func(c *core.Core)
// Main initialises and runs the CLI with the framework's built-in translations.
//
// Example:
// cli.WithAppName("core")
// cli.Main(config.AddConfigCommands)
func Main(commands ...CommandSetup) {
func Main(commands ...core.Option) {
MainWithLocales(nil, commands...)
}
// MainWithLocales initialises and runs the CLI with additional translation sources.
//
// Example:
// locales := []cli.LocaleSource{cli.WithLocales(embeddedLocales, "locales")}
// cli.MainWithLocales(locales, doctor.AddDoctorCommands)
func MainWithLocales(locales []LocaleSource, commands ...CommandSetup) {
func MainWithLocales(locales []LocaleSource, commands ...core.Option) {
// Recovery from panics
defer func() {
if r := recover(); r != nil {
@ -121,22 +103,25 @@ func MainWithLocales(locales []LocaleSource, commands ...CommandSetup) {
extraFS = append(extraFS, i18n.FSSource{FS: lfs, Dir: "."})
}
// Initialise CLI runtime
// 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
if err := Init(Options{
AppName: AppName,
Version: SemVer(),
I18nSources: extraFS,
AppName: AppName,
Version: SemVer(),
Services: services,
}); 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())
@ -200,13 +185,13 @@ PowerShell:
Run: func(cmd *cobra.Command, args []string) {
switch args[0] {
case "bash":
_ = cmd.Root().GenBashCompletion(stdoutWriter())
_ = cmd.Root().GenBashCompletion(os.Stdout)
case "zsh":
_ = cmd.Root().GenZshCompletion(stdoutWriter())
_ = cmd.Root().GenZshCompletion(os.Stdout)
case "fish":
_ = cmd.Root().GenFishCompletion(stdoutWriter(), true)
_ = cmd.Root().GenFishCompletion(os.Stdout, true)
case "powershell":
_ = cmd.Root().GenPowerShellCompletionWithDesc(stdoutWriter())
_ = cmd.Root().GenPowerShellCompletionWithDesc(os.Stdout)
}
},
}

View file

@ -1,5 +1,7 @@
package cli
import "fmt"
// CheckBuilder provides fluent API for check results.
type CheckBuilder struct {
name string
@ -38,7 +40,7 @@ func (c *CheckBuilder) Fail() *CheckBuilder {
func (c *CheckBuilder) Skip() *CheckBuilder {
c.status = "skipped"
c.style = DimStyle
c.icon = Glyph(":skip:")
c.icon = "-"
return c
}
@ -64,27 +66,26 @@ func (c *CheckBuilder) Message(msg string) *CheckBuilder {
// String returns the formatted check line.
func (c *CheckBuilder) String() string {
icon := compileGlyphs(c.icon)
icon := c.icon
if c.style != nil {
icon = c.style.Render(icon)
icon = c.style.Render(c.icon)
}
name := Pad(compileGlyphs(c.name), 20)
status := Pad(compileGlyphs(c.status), 10)
status := c.status
if c.style != nil && c.status != "" {
status = c.style.Render(status)
status = c.style.Render(c.status)
}
if c.duration != "" {
return Sprintf(" %s %s %s %s", icon, name, status, DimStyle.Render(compileGlyphs(c.duration)))
return fmt.Sprintf(" %s %-20s %-10s %s", icon, c.name, status, DimStyle.Render(c.duration))
}
if status != "" {
return Sprintf(" %s %s %s", icon, name, status)
return fmt.Sprintf(" %s %s %s", icon, c.name, status)
}
return Sprintf(" %s %s", icon, name)
return fmt.Sprintf(" %s %s", icon, c.name)
}
// Print outputs the check result.
func (c *CheckBuilder) Print() {
Println("%s", c.String())
fmt.Println(c.String())
}

View file

@ -1,62 +1,49 @@
package cli
import (
"strings"
"testing"
)
import "testing"
func TestCheckBuilder_Good(t *testing.T) {
restoreThemeAndColors(t)
func TestCheckBuilder(t *testing.T) {
UseASCII() // Deterministic output
checkResult := Check("database").Pass()
got := checkResult.String()
// Pass
c := Check("foo").Pass()
got := c.String()
if got == "" {
t.Error("Pass: expected non-empty output")
t.Error("Empty output for Pass")
}
if !strings.Contains(got, "database") {
t.Errorf("Pass: expected name in output, got %q", got)
}
}
func TestCheckBuilder_Bad(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
checkResult := Check("lint").Fail()
got := checkResult.String()
if got == "" {
t.Error("Fail: expected non-empty output")
}
checkResult = Check("build").Skip()
got = checkResult.String()
if got == "" {
t.Error("Skip: expected non-empty output")
}
checkResult = Check("tests").Warn()
got = checkResult.String()
if got == "" {
t.Error("Warn: expected non-empty output")
}
}
func TestCheckBuilder_Ugly(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
// Zero-value builder should not panic.
checkResult := &CheckBuilder{}
got := checkResult.String()
if got == "" {
t.Error("Ugly: empty builder should still produce output")
}
// Duration and Message chaining.
checkResult = Check("audit").Pass().Duration("2.3s").Message("all clear")
got = checkResult.String()
if !strings.Contains(got, "2.3s") {
t.Errorf("Ugly: expected duration in output, got %q", got)
// Fail
c = Check("foo").Fail()
got = c.String()
if got == "" {
t.Error("Empty output for Fail")
}
// Skip
c = Check("foo").Skip()
got = c.String()
if got == "" {
t.Error("Empty output for Skip")
}
// Warn
c = Check("foo").Warn()
got = c.String()
if got == "" {
t.Error("Empty output for Warn")
}
// Duration
c = Check("foo").Pass().Duration("1s")
got = c.String()
if got == "" {
t.Error("Empty output for Duration")
}
// Message
c = Check("foo").Message("status")
got = c.String()
if got == "" {
t.Error("Empty output for Message")
}
}

View file

@ -173,32 +173,6 @@ func StringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []stri
}
}
// StringArrayFlag adds a string array flag to a command.
// The value will be stored in the provided pointer.
//
// var tags []string
// cli.StringArrayFlag(cmd, &tags, "tag", "t", nil, "Tags to apply")
func StringArrayFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
if short != "" {
cmd.Flags().StringArrayVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().StringArrayVar(ptr, name, def, usage)
}
}
// StringToStringFlag adds a string-to-string map flag to a command.
// The value will be stored in the provided pointer.
//
// var labels map[string]string
// cli.StringToStringFlag(cmd, &labels, "label", "l", nil, "Labels to apply")
func StringToStringFlag(cmd *Command, ptr *map[string]string, name, short string, def map[string]string, usage string) {
if short != "" {
cmd.Flags().StringToStringVarP(ptr, name, short, def, usage)
} else {
cmd.Flags().StringToStringVar(ptr, name, def, usage)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Persistent Flag Helpers
// ─────────────────────────────────────────────────────────────────────────────
@ -221,69 +195,6 @@ func PersistentBoolFlag(cmd *Command, ptr *bool, name, short string, def bool, u
}
}
// PersistentIntFlag adds a persistent integer flag (inherited by subcommands).
func PersistentIntFlag(cmd *Command, ptr *int, name, short string, def int, usage string) {
if short != "" {
cmd.PersistentFlags().IntVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().IntVar(ptr, name, def, usage)
}
}
// PersistentInt64Flag adds a persistent int64 flag (inherited by subcommands).
func PersistentInt64Flag(cmd *Command, ptr *int64, name, short string, def int64, usage string) {
if short != "" {
cmd.PersistentFlags().Int64VarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().Int64Var(ptr, name, def, usage)
}
}
// PersistentFloat64Flag adds a persistent float64 flag (inherited by subcommands).
func PersistentFloat64Flag(cmd *Command, ptr *float64, name, short string, def float64, usage string) {
if short != "" {
cmd.PersistentFlags().Float64VarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().Float64Var(ptr, name, def, usage)
}
}
// PersistentDurationFlag adds a persistent time.Duration flag (inherited by subcommands).
func PersistentDurationFlag(cmd *Command, ptr *time.Duration, name, short string, def time.Duration, usage string) {
if short != "" {
cmd.PersistentFlags().DurationVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().DurationVar(ptr, name, def, usage)
}
}
// PersistentStringSliceFlag adds a persistent string slice flag (inherited by subcommands).
func PersistentStringSliceFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
if short != "" {
cmd.PersistentFlags().StringSliceVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().StringSliceVar(ptr, name, def, usage)
}
}
// PersistentStringArrayFlag adds a persistent string array flag (inherited by subcommands).
func PersistentStringArrayFlag(cmd *Command, ptr *[]string, name, short string, def []string, usage string) {
if short != "" {
cmd.PersistentFlags().StringArrayVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().StringArrayVar(ptr, name, def, usage)
}
}
// PersistentStringToStringFlag adds a persistent string-to-string map flag (inherited by subcommands).
func PersistentStringToStringFlag(cmd *Command, ptr *map[string]string, name, short string, def map[string]string, usage string) {
if short != "" {
cmd.PersistentFlags().StringToStringVarP(ptr, name, short, def, usage)
} else {
cmd.PersistentFlags().StringToStringVar(ptr, name, def, usage)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Command Configuration
// ─────────────────────────────────────────────────────────────────────────────

View file

@ -1,73 +0,0 @@
package cli
import "testing"
func TestCommand_Good(t *testing.T) {
// NewCommand creates a command with RunE.
called := false
cmd := NewCommand("build", "Build the project", "", func(cmd *Command, args []string) error {
called = true
return nil
})
if cmd == nil {
t.Fatal("NewCommand: returned nil")
}
if cmd.Use != "build" {
t.Errorf("NewCommand: Use=%q, expected 'build'", cmd.Use)
}
if cmd.RunE == nil {
t.Fatal("NewCommand: RunE is nil")
}
_ = called
// NewGroup creates a command with no RunE.
groupCmd := NewGroup("dev", "Development commands", "")
if groupCmd.RunE != nil {
t.Error("NewGroup: RunE should be nil")
}
// NewRun creates a command with Run.
runCmd := NewRun("version", "Show version", "", func(cmd *Command, args []string) {})
if runCmd.Run == nil {
t.Fatal("NewRun: Run is nil")
}
}
func TestCommand_Bad(t *testing.T) {
// NewCommand with empty long string should not set Long.
cmd := NewCommand("test", "Short desc", "", func(cmd *Command, args []string) error {
return nil
})
if cmd.Long != "" {
t.Errorf("NewCommand: Long should be empty, got %q", cmd.Long)
}
// Flag helpers with empty short should not add short flag.
var value string
StringFlag(cmd, &value, "output", "", "default", "Output path")
if cmd.Flags().Lookup("output") == nil {
t.Error("StringFlag: flag 'output' not registered")
}
}
func TestCommand_Ugly(t *testing.T) {
// WithArgs and WithExample are chainable.
cmd := NewCommand("deploy", "Deploy", "Long desc", func(cmd *Command, args []string) error {
return nil
})
result := WithExample(cmd, "core deploy production")
if result != cmd {
t.Error("WithExample: should return the same command")
}
if cmd.Example != "core deploy production" {
t.Errorf("WithExample: Example=%q", cmd.Example)
}
// ExactArgs, NoArgs, MinimumNArgs, MaximumNArgs, ArbitraryArgs should not panic.
_ = ExactArgs(1)
_ = NoArgs()
_ = MinimumNArgs(1)
_ = MaximumNArgs(5)
_ = ArbitraryArgs()
_ = RangeArgs(1, 3)
}

View file

@ -2,40 +2,80 @@
package cli
import (
"context"
"io/fs"
"iter"
"sync"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go/pkg/core"
"github.com/spf13/cobra"
)
// WithCommands returns a CommandSetup that registers a command group.
// The register function receives the root cobra command during Main().
// 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.
//
// cli.Main(
// cli.WithCommands("config", config.AddConfigCommands),
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
// )
func WithCommands(name string, register func(root *Command), localeFS ...fs.FS) CommandSetup {
return func(c *core.Core) {
loadLocaleSources(localeSourcesFromFS(localeFS...)...)
if root, ok := c.App().Runtime.(*cobra.Command); ok {
register(root)
// 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]
}
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.(*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
}
}
appendLocales(localeFS...)
}
}
// CommandRegistration is a function that adds commands to the CLI root.
//
// Example:
// func addCommands(root *cobra.Command) {
// root.AddCommand(cli.NewRun("ping", "Ping API", "", func(cmd *cli.Command, args []string) {
// cli.Println("pong")
// }))
// }
// Locales implements core.LocaleProvider.
func (s *commandService) Locales() fs.FS {
return s.localeFS
}
// CommandRegistration is a function that adds commands to the root.
type CommandRegistration func(root *cobra.Command)
var (
@ -51,101 +91,35 @@ var (
// func init() {
// cli.RegisterCommands(AddCommands, locales.FS)
// }
//
// Example:
// cli.RegisterCommands(func(root *cobra.Command) {
// root.AddCommand(cli.NewRun("version", "Show version", "", func(cmd *cli.Command, args []string) {
// cli.Println(cli.SemVer())
// }))
// })
func RegisterCommands(fn CommandRegistration, localeFS ...fs.FS) {
registeredCommandsMu.Lock()
defer registeredCommandsMu.Unlock()
registeredCommands = append(registeredCommands, fn)
attached := commandsAttached && instance != nil && instance.root != nil
root := instance
registeredCommandsMu.Unlock()
loadLocaleSources(localeSourcesFromFS(localeFS...)...)
appendLocales(localeFS...)
for _, lfs := range localeFS {
if lfs != nil {
registeredLocales = append(registeredLocales, lfs)
}
}
// If commands already attached (CLI already running), attach immediately
if attached {
fn(root.root)
}
}
// appendLocales appends non-nil locale filesystems to the registry.
func appendLocales(localeFS ...fs.FS) {
var nonempty []fs.FS
for _, lfs := range localeFS {
if lfs != nil {
nonempty = append(nonempty, lfs)
}
}
if len(nonempty) == 0 {
return
}
registeredCommandsMu.Lock()
registeredLocales = append(registeredLocales, nonempty...)
registeredCommandsMu.Unlock()
}
func localeSourcesFromFS(localeFS ...fs.FS) []LocaleSource {
sources := make([]LocaleSource, 0, len(localeFS))
for _, lfs := range localeFS {
if lfs != nil {
sources = append(sources, LocaleSource{FS: lfs, Dir: "."})
}
}
return sources
}
func loadLocaleSources(sources ...LocaleSource) {
svc := i18n.Default()
if svc == nil {
return
}
for _, src := range sources {
if src.FS == nil {
continue
}
if err := svc.AddLoader(i18n.NewFSLoader(src.FS, src.Dir)); err != nil {
LogDebug("failed to load locale source", "dir", src.Dir, "err", err)
}
if commandsAttached && instance != nil && instance.root != nil {
fn(instance.root)
}
}
// RegisteredLocales returns all locale filesystems registered by command packages.
//
// Example:
// for _, fs := range cli.RegisteredLocales() {
// _ = fs
// }
func RegisteredLocales() []fs.FS {
registeredCommandsMu.Lock()
defer registeredCommandsMu.Unlock()
if len(registeredLocales) == 0 {
return nil
}
out := make([]fs.FS, len(registeredLocales))
copy(out, registeredLocales)
return out
return registeredLocales
}
// RegisteredCommands returns an iterator over the registered command functions.
//
// Example:
// for attach := range cli.RegisteredCommands() {
// _ = attach
// }
func RegisteredCommands() iter.Seq[CommandRegistration] {
return func(yield func(CommandRegistration) bool) {
registeredCommandsMu.Lock()
snapshot := make([]CommandRegistration, len(registeredCommands))
copy(snapshot, registeredCommands)
registeredCommandsMu.Unlock()
for _, fn := range snapshot {
defer registeredCommandsMu.Unlock()
for _, fn := range registeredCommands {
if !yield(fn) {
return
}
@ -157,12 +131,11 @@ func RegisteredCommands() iter.Seq[CommandRegistration] {
// Called by Init() after creating the root command.
func attachRegisteredCommands(root *cobra.Command) {
registeredCommandsMu.Lock()
snapshot := make([]CommandRegistration, len(registeredCommands))
copy(snapshot, registeredCommands)
commandsAttached = true
registeredCommandsMu.Unlock()
defer registeredCommandsMu.Unlock()
for _, fn := range snapshot {
for _, fn := range registeredCommands {
fn(root)
}
commandsAttached = true
}

View file

@ -12,16 +12,21 @@ import (
// resetGlobals clears the CLI singleton and command registry for test isolation.
func resetGlobals(t *testing.T) {
t.Helper()
doReset()
t.Cleanup(doReset)
}
t.Cleanup(func() {
// Restore clean state after each test.
registeredCommandsMu.Lock()
registeredCommands = nil
commandsAttached = false
registeredCommandsMu.Unlock()
if instance != nil {
Shutdown()
}
instance = nil
once = sync.Once{}
})
// doReset clears all package-level state. Only safe from a single goroutine
// with no concurrent RegisterCommands calls in flight (i.e. test setup/teardown).
func doReset() {
registeredCommandsMu.Lock()
registeredCommands = nil
registeredLocales = nil
commandsAttached = false
registeredCommandsMu.Unlock()
if instance != nil {
@ -159,28 +164,3 @@ func TestWithAppName_Good(t *testing.T) {
})
}
// TestRegisterCommands_Ugly tests edge cases and concurrent registration.
func TestRegisterCommands_Ugly(t *testing.T) {
t.Run("register nil function does not panic", func(t *testing.T) {
resetGlobals(t)
// Registering a nil function should not panic at registration time.
assert.NotPanics(t, func() {
RegisterCommands(nil)
})
})
t.Run("re-init after shutdown is idempotent", func(t *testing.T) {
resetGlobals(t)
err := Init(Options{AppName: "test"})
require.NoError(t, err)
Shutdown()
resetGlobals(t)
err = Init(Options{AppName: "test"})
require.NoError(t, err)
assert.NotNil(t, RootCmd())
})
}

View file

@ -8,11 +8,6 @@ import (
)
// Mode represents the CLI execution mode.
//
// mode := cli.DetectMode()
// if mode == cli.ModeDaemon {
// cli.LogInfo("running headless")
// }
type Mode int
const (
@ -39,11 +34,7 @@ func (m Mode) String() string {
}
// DetectMode determines the execution mode based on environment.
//
// mode := cli.DetectMode()
// // cli.ModeDaemon when CORE_DAEMON=1
// // cli.ModePipe when stdout is not a terminal
// // cli.ModeInteractive otherwise
// Checks CORE_DAEMON env var first, then TTY status.
func DetectMode() Mode {
if os.Getenv("CORE_DAEMON") == "1" {
return ModeDaemon
@ -55,37 +46,17 @@ func DetectMode() Mode {
}
// IsTTY returns true if stdout is a terminal.
//
// if cli.IsTTY() {
// cli.Success("interactive output enabled")
// }
func IsTTY() bool {
if f, ok := stdoutWriter().(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
return term.IsTerminal(int(os.Stdout.Fd()))
}
// IsStdinTTY returns true if stdin is a terminal.
//
// if !cli.IsStdinTTY() {
// cli.Warn("input is piped")
// }
func IsStdinTTY() bool {
if f, ok := stdinReader().(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
return term.IsTerminal(int(os.Stdin.Fd()))
}
// IsStderrTTY returns true if stderr is a terminal.
//
// if cli.IsStderrTTY() {
// cli.Progress("load", 1, 3, "config")
// }
func IsStderrTTY() bool {
if f, ok := stderrWriter().(*os.File); ok {
return term.IsTerminal(int(f.Fd()))
}
return false
return term.IsTerminal(int(os.Stderr.Fd()))
}

View file

@ -0,0 +1,44 @@
package cli
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAddDaemonCommand_RegistersSubcommands(t *testing.T) {
root := &Command{Use: "test"}
AddDaemonCommand(root, DaemonCommandConfig{
Name: "daemon",
PIDFile: "/tmp/test-daemon.pid",
HealthAddr: "127.0.0.1:0",
})
// Should have the daemon command
daemonCmd, _, err := root.Find([]string{"daemon"})
require.NoError(t, err)
require.NotNil(t, daemonCmd)
// Should have subcommands
var subNames []string
for _, sub := range daemonCmd.Commands() {
subNames = append(subNames, sub.Name())
}
assert.Contains(t, subNames, "start")
assert.Contains(t, subNames, "stop")
assert.Contains(t, subNames, "status")
assert.Contains(t, subNames, "run")
}
func TestDaemonCommandConfig_DefaultName(t *testing.T) {
root := &Command{Use: "test"}
AddDaemonCommand(root, DaemonCommandConfig{})
// Should default to "daemon"
daemonCmd, _, err := root.Find([]string{"daemon"})
require.NoError(t, err)
require.NotNil(t, daemonCmd)
}

View file

@ -1,322 +0,0 @@
package cli
import (
"context"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"time"
)
// DaemonOptions configures a background process helper.
//
// daemon := cli.NewDaemon(cli.DaemonOptions{
// PIDFile: "/tmp/core.pid",
// HealthAddr: "127.0.0.1:8080",
// })
type DaemonOptions struct {
// PIDFile stores the current process ID on Start and removes it on Stop.
PIDFile string
// HealthAddr binds the HTTP health server.
// Pass an empty string to disable the server.
HealthAddr string
// HealthPath serves the liveness probe endpoint.
HealthPath string
// ReadyPath serves the readiness probe endpoint.
ReadyPath string
// HealthCheck reports whether the process is healthy.
// Defaults to true when nil.
HealthCheck func() bool
// ReadyCheck reports whether the process is ready to serve traffic.
// Defaults to HealthCheck when nil, or true when both are nil.
ReadyCheck func() bool
}
// Daemon manages a PID file and optional HTTP health endpoints.
//
// daemon := cli.NewDaemon(cli.DaemonOptions{PIDFile: "/tmp/core.pid"})
// _ = daemon.Start(context.Background())
type Daemon struct {
opts DaemonOptions
mu sync.Mutex
listener net.Listener
server *http.Server
addr string
started bool
}
var (
processNow = time.Now
processSleep = time.Sleep
processAlive = func(pid int) bool {
proc, err := os.FindProcess(pid)
if err != nil {
return false
}
err = proc.Signal(syscall.Signal(0))
return err == nil || errors.Is(err, syscall.EPERM)
}
processSignal = func(pid int, sig syscall.Signal) error {
proc, err := os.FindProcess(pid)
if err != nil {
return err
}
return proc.Signal(sig)
}
processPollInterval = 100 * time.Millisecond
processShutdownWait = 30 * time.Second
)
// NewDaemon creates a daemon helper with sensible defaults.
func NewDaemon(opts DaemonOptions) *Daemon {
if opts.HealthPath == "" {
opts.HealthPath = "/health"
}
if opts.ReadyPath == "" {
opts.ReadyPath = "/ready"
}
return &Daemon{opts: opts}
}
// Start writes the PID file and starts the health server, if configured.
func (d *Daemon) Start(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
d.mu.Lock()
defer d.mu.Unlock()
if d.started {
return nil
}
if err := d.writePIDFile(); err != nil {
return err
}
if d.opts.HealthAddr != "" {
if err := d.startHealthServer(ctx); err != nil {
_ = d.removePIDFile()
return err
}
}
d.started = true
return nil
}
// Stop shuts down the health server and removes the PID file.
func (d *Daemon) Stop(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
d.mu.Lock()
server := d.server
listener := d.listener
d.server = nil
d.listener = nil
d.addr = ""
d.started = false
d.mu.Unlock()
var firstErr error
if server != nil {
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil && !isClosedServerError(err) {
firstErr = err
}
}
if listener != nil {
if err := listener.Close(); err != nil && !isListenerClosedError(err) && firstErr == nil {
firstErr = err
}
}
if err := d.removePIDFile(); err != nil && firstErr == nil {
firstErr = err
}
return firstErr
}
// HealthAddr returns the bound health server address, if running.
func (d *Daemon) HealthAddr() string {
d.mu.Lock()
defer d.mu.Unlock()
if d.addr != "" {
return d.addr
}
return d.opts.HealthAddr
}
// StopPIDFile sends SIGTERM to the process identified by pidFile, waits for it
// to exit, escalates to SIGKILL after the timeout, and then removes the file.
//
// If the PID file does not exist, StopPIDFile returns nil.
func StopPIDFile(pidFile string, timeout time.Duration) error {
if pidFile == "" {
return nil
}
if timeout <= 0 {
timeout = processShutdownWait
}
rawPID, err := os.ReadFile(pidFile)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
pid, err := parsePID(strings.TrimSpace(string(rawPID)))
if err != nil {
return fmt.Errorf("parse pid file %q: %w", pidFile, err)
}
if err := processSignal(pid, syscall.SIGTERM); err != nil && !isProcessGone(err) {
return err
}
deadline := processNow().Add(timeout)
for processAlive(pid) && processNow().Before(deadline) {
processSleep(processPollInterval)
}
if processAlive(pid) {
if err := processSignal(pid, syscall.SIGKILL); err != nil && !isProcessGone(err) {
return err
}
deadline = processNow().Add(processShutdownWait)
for processAlive(pid) && processNow().Before(deadline) {
processSleep(processPollInterval)
}
if processAlive(pid) {
return fmt.Errorf("process %d did not exit after SIGKILL", pid)
}
}
return os.Remove(pidFile)
}
func parsePID(raw string) (int, error) {
if raw == "" {
return 0, fmt.Errorf("empty pid")
}
pid, err := strconv.Atoi(raw)
if err != nil {
return 0, err
}
if pid <= 0 {
return 0, fmt.Errorf("invalid pid %d", pid)
}
return pid, nil
}
func isProcessGone(err error) bool {
return errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH)
}
func (d *Daemon) writePIDFile() error {
if d.opts.PIDFile == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(d.opts.PIDFile), 0o755); err != nil {
return err
}
return os.WriteFile(d.opts.PIDFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644)
}
func (d *Daemon) removePIDFile() error {
if d.opts.PIDFile == "" {
return nil
}
if err := os.Remove(d.opts.PIDFile); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
func (d *Daemon) startHealthServer(ctx context.Context) error {
mux := http.NewServeMux()
healthCheck := d.opts.HealthCheck
if healthCheck == nil {
healthCheck = func() bool { return true }
}
readyCheck := d.opts.ReadyCheck
if readyCheck == nil {
readyCheck = healthCheck
}
mux.HandleFunc(d.opts.HealthPath, func(w http.ResponseWriter, r *http.Request) {
writeProbe(w, healthCheck())
})
mux.HandleFunc(d.opts.ReadyPath, func(w http.ResponseWriter, r *http.Request) {
writeProbe(w, readyCheck())
})
listener, err := net.Listen("tcp", d.opts.HealthAddr)
if err != nil {
return err
}
server := &http.Server{
Handler: mux,
BaseContext: func(net.Listener) context.Context {
return ctx
},
}
d.listener = listener
d.server = server
d.addr = listener.Addr().String()
go func() {
err := server.Serve(listener)
if err != nil && !isClosedServerError(err) {
_ = err
}
}()
return nil
}
func writeProbe(w http.ResponseWriter, ok bool) {
if ok {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok\n")
return
}
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = io.WriteString(w, "unhealthy\n")
}
func isClosedServerError(err error) bool {
return err == nil || err == http.ErrServerClosed
}
func isListenerClosedError(err error) bool {
return err == nil || errors.Is(err, net.ErrClosed)
}

View file

@ -1,199 +0,0 @@
package cli
import (
"context"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDaemon_StartStop(t *testing.T) {
tmp := t.TempDir()
pidFile := filepath.Join(tmp, "daemon.pid")
ready := false
daemon := NewDaemon(DaemonOptions{
PIDFile: pidFile,
HealthAddr: "127.0.0.1:0",
HealthCheck: func() bool {
return true
},
ReadyCheck: func() bool {
return ready
},
})
require.NoError(t, daemon.Start(context.Background()))
defer func() {
require.NoError(t, daemon.Stop(context.Background()))
}()
rawPID, err := os.ReadFile(pidFile)
require.NoError(t, err)
assert.Equal(t, strconv.Itoa(os.Getpid()), strings.TrimSpace(string(rawPID)))
addr := daemon.HealthAddr()
require.NotEmpty(t, addr)
client := &http.Client{Timeout: 2 * time.Second}
resp, err := client.Get("http://" + addr + "/health")
require.NoError(t, err)
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "ok\n", string(body))
resp, err = client.Get("http://" + addr + "/ready")
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
assert.Equal(t, "unhealthy\n", string(body))
ready = true
resp, err = client.Get("http://" + addr + "/ready")
require.NoError(t, err)
body, err = io.ReadAll(resp.Body)
resp.Body.Close()
require.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "ok\n", string(body))
}
func TestDaemon_StopRemovesPIDFile(t *testing.T) {
tmp := t.TempDir()
pidFile := filepath.Join(tmp, "daemon.pid")
daemon := NewDaemon(DaemonOptions{PIDFile: pidFile})
require.NoError(t, daemon.Start(context.Background()))
_, err := os.Stat(pidFile)
require.NoError(t, err)
require.NoError(t, daemon.Stop(context.Background()))
_, err = os.Stat(pidFile)
require.Error(t, err)
assert.True(t, os.IsNotExist(err))
}
func TestStopPIDFile_Good(t *testing.T) {
tmp := t.TempDir()
pidFile := filepath.Join(tmp, "daemon.pid")
require.NoError(t, os.WriteFile(pidFile, []byte("1234\n"), 0o644))
originalSignal := processSignal
originalAlive := processAlive
originalNow := processNow
originalSleep := processSleep
originalPoll := processPollInterval
originalShutdownWait := processShutdownWait
t.Cleanup(func() {
processSignal = originalSignal
processAlive = originalAlive
processNow = originalNow
processSleep = originalSleep
processPollInterval = originalPoll
processShutdownWait = originalShutdownWait
})
var mu sync.Mutex
var signals []syscall.Signal
processSignal = func(pid int, sig syscall.Signal) error {
mu.Lock()
signals = append(signals, sig)
mu.Unlock()
return nil
}
processAlive = func(pid int) bool {
mu.Lock()
defer mu.Unlock()
if len(signals) == 0 {
return true
}
return signals[len(signals)-1] != syscall.SIGTERM
}
processPollInterval = 0
processShutdownWait = 0
require.NoError(t, StopPIDFile(pidFile, time.Second))
mu.Lock()
defer mu.Unlock()
require.Equal(t, []syscall.Signal{syscall.SIGTERM}, signals)
_, err := os.Stat(pidFile)
require.Error(t, err)
assert.True(t, os.IsNotExist(err))
}
func TestStopPIDFile_Bad_Escalates(t *testing.T) {
tmp := t.TempDir()
pidFile := filepath.Join(tmp, "daemon.pid")
require.NoError(t, os.WriteFile(pidFile, []byte("4321\n"), 0o644))
originalSignal := processSignal
originalAlive := processAlive
originalNow := processNow
originalSleep := processSleep
originalPoll := processPollInterval
originalShutdownWait := processShutdownWait
t.Cleanup(func() {
processSignal = originalSignal
processAlive = originalAlive
processNow = originalNow
processSleep = originalSleep
processPollInterval = originalPoll
processShutdownWait = originalShutdownWait
})
var mu sync.Mutex
var signals []syscall.Signal
current := time.Unix(0, 0)
processNow = func() time.Time {
mu.Lock()
defer mu.Unlock()
return current
}
processSleep = func(d time.Duration) {
mu.Lock()
current = current.Add(d)
mu.Unlock()
}
processSignal = func(pid int, sig syscall.Signal) error {
mu.Lock()
signals = append(signals, sig)
mu.Unlock()
return nil
}
processAlive = func(pid int) bool {
mu.Lock()
defer mu.Unlock()
if len(signals) == 0 {
return true
}
return signals[len(signals)-1] != syscall.SIGKILL
}
processPollInterval = 10 * time.Millisecond
processShutdownWait = 0
require.NoError(t, StopPIDFile(pidFile, 15*time.Millisecond))
mu.Lock()
defer mu.Unlock()
require.Equal(t, []syscall.Signal{syscall.SIGTERM, syscall.SIGKILL}, signals)
}

View file

@ -6,21 +6,16 @@ import (
"github.com/stretchr/testify/assert"
)
func TestDetectMode_Good(t *testing.T) {
t.Setenv("CORE_DAEMON", "1")
assert.Equal(t, ModeDaemon, DetectMode())
}
func TestDetectMode(t *testing.T) {
t.Run("daemon mode from env", func(t *testing.T) {
t.Setenv("CORE_DAEMON", "1")
assert.Equal(t, ModeDaemon, DetectMode())
})
func TestDetectMode_Bad(t *testing.T) {
t.Setenv("CORE_DAEMON", "0")
mode := DetectMode()
assert.NotEqual(t, ModeDaemon, mode)
}
func TestDetectMode_Ugly(t *testing.T) {
// Mode.String() covers all branches including the default unknown case.
assert.Equal(t, "interactive", ModeInteractive.String())
assert.Equal(t, "pipe", ModePipe.String())
assert.Equal(t, "daemon", ModeDaemon.String())
assert.Equal(t, "unknown", Mode(99).String())
t.Run("mode string", func(t *testing.T) {
assert.Equal(t, "interactive", ModeInteractive.String())
assert.Equal(t, "pipe", ModePipe.String())
assert.Equal(t, "daemon", ModeDaemon.String())
assert.Equal(t, "unknown", Mode(99).String())
})
}

View file

@ -78,12 +78,6 @@ func Join(errs ...error) error {
}
// ExitError represents an error that should cause the CLI to exit with a specific code.
//
// err := cli.Exit(2, cli.Err("validation failed"))
// var exitErr *cli.ExitError
// if cli.As(err, &exitErr) {
// cli.Println("exit code:", exitErr.Code)
// }
type ExitError struct {
Code int
Err error
@ -101,8 +95,7 @@ func (e *ExitError) Unwrap() error {
}
// Exit creates a new ExitError with the given code and error.
//
// return cli.Exit(2, cli.Err("validation failed"))
// Use this to return an error from a command with a specific exit code.
func Exit(code int, err error) error {
if err == nil {
return nil
@ -120,7 +113,7 @@ func Exit(code int, err error) error {
func Fatal(err error) {
if err != nil {
LogError("Fatal error", "err", err)
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
os.Exit(1)
}
}
@ -131,7 +124,7 @@ func Fatal(err error) {
func Fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...)
LogError("Fatal error", "msg", msg)
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+msg))
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
os.Exit(1)
}
@ -147,7 +140,7 @@ func FatalWrap(err error, msg string) {
}
LogError("Fatal error", "msg", msg, "err", err)
fullMsg := fmt.Sprintf("%s: %v", msg, err)
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
os.Exit(1)
}
@ -164,6 +157,6 @@ func FatalWrapVerb(err error, verb, subject string) {
msg := i18n.ActionFailed(verb, subject)
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
fullMsg := fmt.Sprintf("%s: %v", msg, err)
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
os.Exit(1)
}

View file

@ -1,76 +0,0 @@
package cli
import (
"errors"
"strings"
"testing"
)
func TestErrors_Good(t *testing.T) {
// Err creates a formatted error.
err := Err("key not found: %s", "theme")
if err == nil {
t.Fatal("Err: expected non-nil error")
}
if !strings.Contains(err.Error(), "theme") {
t.Errorf("Err: expected 'theme' in message, got %q", err.Error())
}
// Wrap prepends a message.
base := errors.New("connection refused")
wrapped := Wrap(base, "connect to database")
if !strings.Contains(wrapped.Error(), "connect to database") {
t.Errorf("Wrap: expected prefix in message, got %q", wrapped.Error())
}
if !Is(wrapped, base) {
t.Error("Wrap: errors.Is should unwrap to original")
}
}
func TestErrors_Bad(t *testing.T) {
// Wrap with nil error returns nil.
if Wrap(nil, "should be nil") != nil {
t.Error("Wrap(nil): expected nil return")
}
// WrapVerb with nil error returns nil.
if WrapVerb(nil, "load", "config") != nil {
t.Error("WrapVerb(nil): expected nil return")
}
// WrapAction with nil error returns nil.
if WrapAction(nil, "connect") != nil {
t.Error("WrapAction(nil): expected nil return")
}
}
func TestErrors_Ugly(t *testing.T) {
// Join with multiple errors.
err1 := Err("first error")
err2 := Err("second error")
joined := Join(err1, err2)
if joined == nil {
t.Fatal("Join: expected non-nil error")
}
if !Is(joined, err1) {
t.Error("Join: errors.Is should find first error")
}
// Exit creates ExitError with correct code.
exitErr := Exit(2, Err("exit with code 2"))
if exitErr == nil {
t.Fatal("Exit: expected non-nil error")
}
var exitErrorValue *ExitError
if !As(exitErr, &exitErrorValue) {
t.Fatal("Exit: expected *ExitError type")
}
if exitErrorValue.Code != 2 {
t.Errorf("Exit: expected code 2, got %d", exitErrorValue.Code)
}
// Exit with nil returns nil.
if Exit(1, nil) != nil {
t.Error("Exit(nil): expected nil return")
}
}

View file

@ -10,7 +10,6 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"golang.org/x/term"
)
@ -61,7 +60,7 @@ func NewFrame(variant string) *Frame {
variant: variant,
layout: Layout(variant),
models: make(map[Region]Model),
out: stderrWriter(),
out: os.Stdout,
done: make(chan struct{}),
focused: RegionContent,
keyMap: DefaultKeyMap(),
@ -70,15 +69,6 @@ func NewFrame(variant string) *Frame {
}
}
// WithOutput sets the destination writer for rendered output.
// Pass nil to keep the current writer unchanged.
func (f *Frame) WithOutput(out io.Writer) *Frame {
if out != nil {
f.out = out
}
return f
}
// Header sets the Header region model.
func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f }
@ -438,7 +428,6 @@ func (f *Frame) String() string {
if view == "" {
return ""
}
view = ansi.Strip(view)
// Ensure trailing newline for non-TTY consistency
if !strings.HasSuffix(view, "\n") {
view += "\n"
@ -463,11 +452,12 @@ func (f *Frame) termSize() (int, int) {
return 80, 24 // sensible default
}
func (f *Frame) runLive() {
opts := []tea.ProgramOption{
tea.WithAltScreen(),
}
if f.out != stdoutWriter() {
if f.out != os.Stdout {
opts = append(opts, tea.WithOutput(f.out))
}

View file

@ -20,9 +20,9 @@ func StatusLine(title string, pairs ...string) Model {
}
func (s *statusLineModel) View(width, _ int) string {
parts := []string{BoldStyle.Render(compileGlyphs(s.title))}
parts := []string{BoldStyle.Render(s.title)}
for _, p := range s.pairs {
parts = append(parts, DimStyle.Render(compileGlyphs(p)))
parts = append(parts, DimStyle.Render(p))
}
line := strings.Join(parts, " ")
if width > 0 {
@ -46,7 +46,7 @@ func KeyHints(hints ...string) Model {
func (k *keyHintsModel) View(width, _ int) string {
parts := make([]string, len(k.hints))
for i, h := range k.hints {
parts[i] = DimStyle.Render(compileGlyphs(h))
parts[i] = DimStyle.Render(h)
}
line := strings.Join(parts, " ")
if width > 0 {
@ -70,11 +70,10 @@ func Breadcrumb(parts ...string) Model {
func (b *breadcrumbModel) View(width, _ int) string {
styled := make([]string, len(b.parts))
for i, p := range b.parts {
part := compileGlyphs(p)
if i == len(b.parts)-1 {
styled[i] = BoldStyle.Render(part)
styled[i] = BoldStyle.Render(p)
} else {
styled[i] = DimStyle.Render(part)
styled[i] = DimStyle.Render(p)
}
}
line := strings.Join(styled, DimStyle.Render(" > "))
@ -95,5 +94,5 @@ func StaticModel(text string) Model {
}
func (s *staticModel) View(_, _ int) string {
return compileGlyphs(s.text)
return s.text
}

View file

@ -1,65 +0,0 @@
package cli
import (
"strings"
"testing"
)
func TestFrameComponents_Good(t *testing.T) {
// StatusLine renders title and pairs.
model := StatusLine("core dev", "18 repos", "main")
output := model.View(80, 1)
if !strings.Contains(output, "core dev") {
t.Errorf("StatusLine: expected 'core dev' in output, got %q", output)
}
// KeyHints renders hints.
hints := KeyHints("↑/↓ navigate", "enter select", "q quit")
output = hints.View(80, 1)
if !strings.Contains(output, "navigate") {
t.Errorf("KeyHints: expected 'navigate' in output, got %q", output)
}
// Breadcrumb renders navigation path.
breadcrumb := Breadcrumb("core", "dev", "health")
output = breadcrumb.View(80, 1)
if !strings.Contains(output, "health") {
t.Errorf("Breadcrumb: expected 'health' in output, got %q", output)
}
// StaticModel returns static text.
static := StaticModel("static content")
output = static.View(80, 1)
if output != "static content" {
t.Errorf("StaticModel: expected 'static content', got %q", output)
}
}
func TestFrameComponents_Bad(t *testing.T) {
// StatusLine with zero width should truncate to empty or short string.
model := StatusLine("long title that should be truncated")
output := model.View(0, 1)
// Zero width means no truncation guard in current impl — just verify no panic.
_ = output
// KeyHints with no hints should not panic.
hints := KeyHints()
output = hints.View(80, 1)
_ = output
}
func TestFrameComponents_Ugly(t *testing.T) {
// Breadcrumb with single item has no separator.
breadcrumb := Breadcrumb("root")
output := breadcrumb.View(80, 1)
if !strings.Contains(output, "root") {
t.Errorf("Breadcrumb single: expected 'root', got %q", output)
}
// StatusLine with very narrow width truncates output.
model := StatusLine("core dev", "18 repos")
output = model.View(5, 1)
if len(output) > 10 {
t.Errorf("StatusLine truncated: output too long for width 5, got %q", output)
}
}

View file

@ -551,40 +551,3 @@ func TestFrameMessageRouting_Good(t *testing.T) {
})
}
func TestFrame_Ugly(t *testing.T) {
t.Run("navigate with nil model does not panic", func(t *testing.T) {
f := NewFrame("HCF")
f.out = &bytes.Buffer{}
f.Content(StaticModel("base"))
assert.NotPanics(t, func() {
f.Navigate(nil)
})
})
t.Run("deeply nested back stack does not panic", func(t *testing.T) {
f := NewFrame("C")
f.out = &bytes.Buffer{}
f.Content(StaticModel("p0"))
for i := 1; i <= 20; i++ {
f.Navigate(StaticModel("p" + string(rune('0'+i%10))))
}
for f.Back() {
// drain the full history stack
}
assert.False(t, f.Back(), "no more history after full drain")
})
t.Run("zero-size window renders without panic", func(t *testing.T) {
f := NewFrame("HCF")
f.out = &bytes.Buffer{}
f.Content(StaticModel("x"))
f.width = 0
f.height = 0
assert.NotPanics(t, func() {
_ = f.View()
})
})
}

View file

@ -20,24 +20,15 @@ const (
var currentTheme = ThemeUnicode
// UseUnicode switches the glyph theme to Unicode.
func UseUnicode() {
currentTheme = ThemeUnicode
restoreColorIfASCII()
}
func UseUnicode() { currentTheme = ThemeUnicode }
// UseEmoji switches the glyph theme to Emoji.
func UseEmoji() {
currentTheme = ThemeEmoji
restoreColorIfASCII()
}
func UseEmoji() { currentTheme = ThemeEmoji }
// UseASCII switches the glyph theme to ASCII and disables colors.
func UseASCII() {
currentTheme = ThemeASCII
SetColorEnabled(false)
colorEnabledMu.Lock()
asciiDisabledColors = true
colorEnabledMu.Unlock()
}
func glyphMap() map[string]string {

View file

@ -2,8 +2,7 @@ package cli
import "testing"
func TestGlyph_Good(t *testing.T) {
restoreThemeAndColors(t)
func TestGlyph(t *testing.T) {
UseUnicode()
if Glyph(":check:") != "✓" {
t.Errorf("Expected ✓, got %s", Glyph(":check:"))
@ -15,49 +14,10 @@ func TestGlyph_Good(t *testing.T) {
}
}
func TestGlyph_Bad(t *testing.T) {
restoreThemeAndColors(t)
// Unknown shortcode returns the shortcode unchanged.
UseUnicode()
got := Glyph(":unknown:")
if got != ":unknown:" {
t.Errorf("Unknown shortcode should return unchanged, got %q", got)
}
}
func TestGlyph_Ugly(t *testing.T) {
restoreThemeAndColors(t)
// Empty shortcode should not panic.
got := Glyph("")
if got != "" {
t.Errorf("Empty shortcode should return empty string, got %q", got)
}
}
func TestCompileGlyphs_Good(t *testing.T) {
restoreThemeAndColors(t)
func TestCompileGlyphs(t *testing.T) {
UseUnicode()
got := compileGlyphs("Status: :check:")
if got != "Status: ✓" {
t.Errorf("Expected 'Status: ✓', got %q", got)
}
}
func TestCompileGlyphs_Bad(t *testing.T) {
restoreThemeAndColors(t)
UseUnicode()
// Text with no shortcodes should be returned as-is.
got := compileGlyphs("no glyphs here")
if got != "no glyphs here" {
t.Errorf("Expected unchanged text, got %q", got)
}
}
func TestCompileGlyphs_Ugly(t *testing.T) {
restoreThemeAndColors(t)
// Empty string should not panic.
got := compileGlyphs("")
if got != "" {
t.Errorf("Empty string should return empty, got %q", got)
t.Errorf("Expected Status: ✓, got %s", got)
}
}

View file

@ -6,9 +6,6 @@ import (
// T translates a key using the CLI's i18n service.
// Falls back to the global i18n.T if CLI not initialised.
//
// label := cli.T("cmd.doctor.required")
// msg := cli.T("cmd.doctor.issues", map[string]any{"Count": 3})
func T(key string, args ...map[string]any) string {
if len(args) > 0 {
return i18n.T(key, args[0])

View file

@ -1,30 +0,0 @@
package cli
import "testing"
func TestT_Good(t *testing.T) {
// T should return a non-empty string for any key
// (falls back to the key itself when no translation is found).
result := T("some.key")
if result == "" {
t.Error("T: returned empty string for unknown key")
}
}
func TestT_Bad(t *testing.T) {
// T with args map should not panic.
result := T("cmd.doctor.issues", map[string]any{"Count": 0})
if result == "" {
t.Error("T with args: returned empty string")
}
}
func TestT_Ugly(t *testing.T) {
// T with empty key should not panic.
defer func() {
if r := recover(); r != nil {
t.Errorf("T(\"\") panicked: %v", r)
}
}()
_ = T("")
}

View file

@ -1,68 +0,0 @@
package cli
import (
"io"
"os"
"sync"
)
var (
stdin io.Reader = os.Stdin
stdoutOverride io.Writer
stderrOverride io.Writer
ioMu sync.RWMutex
)
// SetStdin overrides the default stdin reader for testing.
// Pass nil to restore the real os.Stdin reader.
func SetStdin(r io.Reader) {
ioMu.Lock()
defer ioMu.Unlock()
if r == nil {
stdin = os.Stdin
return
}
stdin = r
}
// SetStdout overrides the default stdout writer.
// Pass nil to restore writes to os.Stdout.
func SetStdout(w io.Writer) {
ioMu.Lock()
defer ioMu.Unlock()
stdoutOverride = w
}
// SetStderr overrides the default stderr writer.
// Pass nil to restore writes to os.Stderr.
func SetStderr(w io.Writer) {
ioMu.Lock()
defer ioMu.Unlock()
stderrOverride = w
}
func stdinReader() io.Reader {
ioMu.RLock()
defer ioMu.RUnlock()
return stdin
}
func stdoutWriter() io.Writer {
ioMu.RLock()
defer ioMu.RUnlock()
if stdoutOverride != nil {
return stdoutOverride
}
return os.Stdout
}
func stderrWriter() io.Writer {
ioMu.RLock()
defer ioMu.RUnlock()
if stderrOverride != nil {
return stderrOverride
}
return os.Stderr
}

View file

@ -68,7 +68,7 @@ type Renderable interface {
type StringBlock string
// Render returns the string content.
func (s StringBlock) Render() string { return compileGlyphs(string(s)) }
func (s StringBlock) Render() string { return string(s) }
// Layout creates a new layout from a variant string.
func Layout(variant string) *Composite {

View file

@ -2,49 +2,24 @@ package cli
import "testing"
func TestParseVariant_Good(t *testing.T) {
composite, err := ParseVariant("H[LC]F")
func TestParseVariant(t *testing.T) {
c, err := ParseVariant("H[LC]F")
if err != nil {
t.Fatalf("Parse failed: %v", err)
}
if _, ok := composite.regions[RegionHeader]; !ok {
if _, ok := c.regions[RegionHeader]; !ok {
t.Error("Expected Header region")
}
if _, ok := composite.regions[RegionFooter]; !ok {
if _, ok := c.regions[RegionFooter]; !ok {
t.Error("Expected Footer region")
}
headerSlot := composite.regions[RegionHeader]
if headerSlot.child == nil {
t.Error("Header should have child layout for H[LC]")
hSlot := c.regions[RegionHeader]
if hSlot.child == nil {
t.Error("Header should have child layout")
} else {
if _, ok := headerSlot.child.regions[RegionLeft]; !ok {
if _, ok := hSlot.child.regions[RegionLeft]; !ok {
t.Error("Child should have Left region")
}
}
}
func TestParseVariant_Bad(t *testing.T) {
// Invalid region character.
_, err := ParseVariant("X")
if err == nil {
t.Error("Expected error for invalid region character 'X'")
}
// Unmatched bracket.
_, err = ParseVariant("H[C")
if err == nil {
t.Error("Expected error for unmatched bracket")
}
}
func TestParseVariant_Ugly(t *testing.T) {
// Empty variant should produce empty composite without panic.
composite, err := ParseVariant("")
if err != nil {
t.Fatalf("Empty variant should not error: %v", err)
}
if len(composite.regions) != 0 {
t.Errorf("Empty variant should have no regions, got %d", len(composite.regions))
}
}

View file

@ -12,9 +12,7 @@
"install_missing": "Install missing tools:",
"install_macos": "brew install",
"install_macos_cask": "brew install --cask",
"install_macos_go": "brew install go",
"install_linux_header": "Install on Linux:",
"install_linux_go": "sudo apt install golang-go",
"install_linux_git": "sudo apt install git",
"install_linux_node": "curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt install -y nodejs",
"install_linux_php": "sudo apt install php php-cli php-mbstring php-xml php-curl",
@ -32,7 +30,6 @@
"no_repos_yaml": "No repos.yaml found (run from workspace root)",
"check": {
"git": { "name": "Git", "description": "Version control" },
"go": { "name": "Go", "description": "Go compiler" },
"docker": { "name": "Docker", "description": "Container runtime" },
"node": { "name": "Node.js", "description": "JavaScript runtime" },
"php": { "name": "PHP", "description": "PHP interpreter" },
@ -111,10 +108,7 @@
"all_up_to_date": "All packages are up to date",
"commits_behind": "{{.Count}} commits behind",
"update_with": "Update with: core pkg update {{.Name}}",
"summary": "{{.Outdated}}/{{.Total}} outdated",
"flag": {
"format": "Output format: table or json"
}
"summary": "{{.Outdated}}/{{.Total}} outdated"
}
}
},

View file

@ -1,8 +1,6 @@
package cli
import (
"fmt"
"forge.lthn.ai/core/go-log"
)
@ -18,33 +16,13 @@ const (
)
// LogDebug logs a debug message if the default logger is available.
//
// cli.LogDebug("cache miss", "key", cacheKey)
func LogDebug(msg string, keyvals ...any) { log.Debug(msg, keyvals...) }
// LogInfo logs an info message.
//
// cli.LogInfo("configuration reloaded", "path", configPath)
func LogInfo(msg string, keyvals ...any) { log.Info(msg, keyvals...) }
// LogWarn logs a warning message.
//
// cli.LogWarn("GitHub CLI not authenticated", "user", username)
func LogWarn(msg string, keyvals ...any) { log.Warn(msg, keyvals...) }
// LogError logs an error message.
//
// cli.LogError("Fatal error", "err", err)
func LogError(msg string, keyvals ...any) { log.Error(msg, keyvals...) }
// LogSecurity logs a security-sensitive message.
//
// cli.LogSecurity("login attempt", "user", "admin")
func LogSecurity(msg string, keyvals ...any) { log.Security(msg, keyvals...) }
// LogSecurityf logs a formatted security-sensitive message.
//
// cli.LogSecurityf("login attempt from %s", username)
func LogSecurityf(format string, args ...any) {
log.Security(fmt.Sprintf(format, args...))
}

View file

@ -1,43 +0,0 @@
package cli
import "testing"
func TestLog_Good(t *testing.T) {
// All log functions should not panic when called without a configured logger.
defer func() {
if r := recover(); r != nil {
t.Errorf("LogInfo panicked: %v", r)
}
}()
LogInfo("test info message", "key", "value")
}
func TestLog_Bad(t *testing.T) {
// LogError should not panic with an empty message.
defer func() {
if r := recover(); r != nil {
t.Errorf("LogError panicked: %v", r)
}
}()
LogError("")
}
func TestLog_Ugly(t *testing.T) {
// All log levels should not panic.
defer func() {
if r := recover(); r != nil {
t.Errorf("log function panicked: %v", r)
}
}()
LogDebug("debug", "k", "v")
LogInfo("info", "k", "v")
LogWarn("warn", "k", "v")
LogError("error", "k", "v")
// Level constants should be accessible.
_ = LogLevelQuiet
_ = LogLevelError
_ = LogLevelWarn
_ = LogLevelInfo
_ = LogLevelDebug
}

View file

@ -2,6 +2,7 @@ package cli
import (
"fmt"
"os"
"strings"
"forge.lthn.ai/core/go-i18n"
@ -9,35 +10,35 @@ import (
// Blank prints an empty line.
func Blank() {
fmt.Fprintln(stdoutWriter())
fmt.Println()
}
// Echo translates a key via i18n.T and prints with newline.
// No automatic styling - use Success/Error/Warn/Info for styled output.
func Echo(key string, args ...any) {
fmt.Fprintln(stdoutWriter(), compileGlyphs(i18n.T(key, args...)))
fmt.Println(i18n.T(key, args...))
}
// Print outputs formatted text (no newline).
// Glyph shortcodes like :check: are converted.
func Print(format string, args ...any) {
fmt.Fprint(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...)))
fmt.Print(compileGlyphs(fmt.Sprintf(format, args...)))
}
// Println outputs formatted text with newline.
// Glyph shortcodes like :check: are converted.
func Println(format string, args ...any) {
fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...)))
fmt.Println(compileGlyphs(fmt.Sprintf(format, args...)))
}
// Text prints arguments like fmt.Println, but handling glyphs.
func Text(args ...any) {
fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprint(args...)))
fmt.Println(compileGlyphs(fmt.Sprint(args...)))
}
// Success prints a success message with checkmark (green).
func Success(msg string) {
fmt.Fprintln(stdoutWriter(), SuccessStyle.Render(Glyph(":check:")+" "+compileGlyphs(msg)))
fmt.Println(SuccessStyle.Render(Glyph(":check:") + " " + msg))
}
// Successf prints a formatted success message.
@ -48,7 +49,7 @@ func Successf(format string, args ...any) {
// Error prints an error message with cross (red) to stderr and logs it.
func Error(msg string) {
LogError(msg)
fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg)))
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg))
}
// Errorf prints a formatted error message to stderr and logs it.
@ -85,7 +86,7 @@ func ErrorWrapAction(err error, verb string) {
// Warn prints a warning message with warning symbol (amber) to stderr and logs it.
func Warn(msg string) {
LogWarn(msg)
fmt.Fprintln(stderrWriter(), WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg)))
fmt.Fprintln(os.Stderr, WarningStyle.Render(Glyph(":warn:")+" "+msg))
}
// Warnf prints a formatted warning message to stderr and logs it.
@ -95,7 +96,7 @@ func Warnf(format string, args ...any) {
// Info prints an info message with info symbol (blue).
func Info(msg string) {
fmt.Fprintln(stdoutWriter(), InfoStyle.Render(Glyph(":info:")+" "+compileGlyphs(msg)))
fmt.Println(InfoStyle.Render(Glyph(":info:") + " " + msg))
}
// Infof prints a formatted info message.
@ -105,33 +106,33 @@ func Infof(format string, args ...any) {
// Dim prints dimmed text.
func Dim(msg string) {
fmt.Fprintln(stdoutWriter(), DimStyle.Render(compileGlyphs(msg)))
fmt.Println(DimStyle.Render(msg))
}
// Progress prints a progress indicator that overwrites the current line.
// Uses i18n.Progress for gerund form ("Checking...").
func Progress(verb string, current, total int, item ...string) {
msg := compileGlyphs(i18n.Progress(verb))
msg := i18n.Progress(verb)
if len(item) > 0 && item[0] != "" {
fmt.Fprintf(stderrWriter(), "\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, compileGlyphs(item[0]))
fmt.Printf("\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, item[0])
} else {
fmt.Fprintf(stderrWriter(), "\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
fmt.Printf("\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
}
}
// ProgressDone clears the progress line.
func ProgressDone() {
fmt.Fprint(stderrWriter(), "\033[2K\r")
fmt.Print("\033[2K\r")
}
// Label prints a "Label: value" line.
func Label(word, value string) {
fmt.Fprintf(stdoutWriter(), "%s %s\n", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value))
fmt.Printf("%s %s\n", KeyStyle.Render(i18n.Label(word)), value)
}
// Scanln reads from stdin.
func Scanln(a ...any) (int, error) {
return fmt.Fscanln(newReader(), a...)
return fmt.Scanln(a...)
}
// Task prints a task header: "[label] message"
@ -139,16 +140,15 @@ func Scanln(a ...any) (int, error) {
// cli.Task("php", "Running tests...") // [php] Running tests...
// cli.Task("go", i18n.Progress("build")) // [go] Building...
func Task(label, message string) {
fmt.Fprintf(stdoutWriter(), "%s %s\n\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message))
fmt.Printf("%s %s\n\n", DimStyle.Render("["+label+"]"), message)
}
// Section prints a section header: "── SECTION ──"
//
// cli.Section("audit") // ── AUDIT ──
func Section(name string) {
dash := Glyph(":dash:")
header := dash + dash + " " + strings.ToUpper(compileGlyphs(name)) + " " + dash + dash
fmt.Fprintln(stdoutWriter(), AccentStyle.Render(header))
header := "── " + strings.ToUpper(name) + " ──"
fmt.Println(AccentStyle.Render(header))
}
// Hint prints a labelled hint: "label: message"
@ -156,7 +156,7 @@ func Section(name string) {
// cli.Hint("install", "composer require vimeo/psalm")
// cli.Hint("fix", "core php fmt --fix")
func Hint(label, message string) {
fmt.Fprintf(stdoutWriter(), " %s %s\n", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message))
fmt.Printf(" %s %s\n", DimStyle.Render(label+":"), message)
}
// Severity prints a severity-styled message.
@ -179,7 +179,7 @@ func Severity(level, message string) {
default:
style = DimStyle
}
fmt.Fprintf(stdoutWriter(), " %s %s\n", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message))
fmt.Printf(" %s %s\n", style.Render("["+level+"]"), message)
}
// Result prints a result line: "✓ message" or "✗ message"

View file

@ -4,93 +4,98 @@ import (
"bytes"
"io"
"os"
"strings"
"testing"
)
func captureOutput(f func()) string {
oldOut := os.Stdout
oldErr := os.Stderr
reader, writer, _ := os.Pipe()
os.Stdout = writer
os.Stderr = writer
r, w, _ := os.Pipe()
os.Stdout = w
os.Stderr = w
f()
_ = writer.Close()
_ = w.Close()
os.Stdout = oldOut
os.Stderr = oldErr
var buf bytes.Buffer
_, _ = io.Copy(&buf, reader)
_, _ = io.Copy(&buf, r)
return buf.String()
}
func TestSemanticOutput_Good(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
SetColorEnabled(false)
defer SetColorEnabled(true)
cases := []struct {
name string
fn func()
}{
{"Success", func() { Success("done") }},
{"Info", func() { Info("info") }},
{"Task", func() { Task("task", "msg") }},
{"Section", func() { Section("section") }},
{"Hint", func() { Hint("hint", "msg") }},
{"Result_pass", func() { Result(true, "pass") }},
}
for _, testCase := range cases {
output := captureOutput(testCase.fn)
if output == "" {
t.Errorf("%s: output was empty", testCase.name)
}
}
}
func TestSemanticOutput_Bad(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
SetColorEnabled(false)
defer SetColorEnabled(true)
// Error and Warn go to stderr — both captured here.
errorOutput := captureOutput(func() { Error("fail") })
if errorOutput == "" {
t.Error("Error: output was empty")
}
warnOutput := captureOutput(func() { Warn("warn") })
if warnOutput == "" {
t.Error("Warn: output was empty")
}
failureOutput := captureOutput(func() { Result(false, "fail") })
if failureOutput == "" {
t.Error("Result(false): output was empty")
}
}
func TestSemanticOutput_Ugly(t *testing.T) {
restoreThemeAndColors(t)
func TestSemanticOutput(t *testing.T) {
UseASCII()
// Severity with various levels should not panic.
levels := []string{"critical", "high", "medium", "low", "unknown", ""}
for _, level := range levels {
output := captureOutput(func() { Severity(level, "test message") })
if output == "" {
t.Errorf("Severity(%q): output was empty", level)
}
// Test Success
out := captureOutput(func() {
Success("done")
})
if out == "" {
t.Error("Success output empty")
}
// Section uppercases the name.
output := captureOutput(func() { Section("audit") })
if !strings.Contains(output, "AUDIT") {
t.Errorf("Section: expected AUDIT in output, got %q", output)
// Test Error
out = captureOutput(func() {
Error("fail")
})
if out == "" {
t.Error("Error output empty")
}
// Test Warn
out = captureOutput(func() {
Warn("warn")
})
if out == "" {
t.Error("Warn output empty")
}
// Test Info
out = captureOutput(func() {
Info("info")
})
if out == "" {
t.Error("Info output empty")
}
// Test Task
out = captureOutput(func() {
Task("task", "msg")
})
if out == "" {
t.Error("Task output empty")
}
// Test Section
out = captureOutput(func() {
Section("section")
})
if out == "" {
t.Error("Section output empty")
}
// Test Hint
out = captureOutput(func() {
Hint("hint", "msg")
})
if out == "" {
t.Error("Hint output empty")
}
// Test Result
out = captureOutput(func() {
Result(true, "pass")
})
if out == "" {
t.Error("Result(true) output empty")
}
out = captureOutput(func() {
Result(false, "fail")
})
if out == "" {
t.Error("Result(false) output empty")
}
}

View file

@ -5,42 +5,39 @@ import (
"errors"
"fmt"
"io"
"os"
"strconv"
"strings"
)
var stdin io.Reader = os.Stdin
// SetStdin overrides the default stdin reader for testing.
func SetStdin(r io.Reader) { stdin = r }
// newReader wraps stdin in a bufio.Reader if it isn't one already.
func newReader() *bufio.Reader {
if br, ok := stdinReader().(*bufio.Reader); ok {
if br, ok := stdin.(*bufio.Reader); ok {
return br
}
return bufio.NewReader(stdinReader())
return bufio.NewReader(stdin)
}
// Prompt asks for text input with a default value.
func Prompt(label, defaultVal string) (string, error) {
label = compileGlyphs(label)
defaultVal = compileGlyphs(defaultVal)
if defaultVal != "" {
fmt.Fprintf(stderrWriter(), "%s [%s]: ", label, defaultVal)
fmt.Printf("%s [%s]: ", label, defaultVal)
} else {
fmt.Fprintf(stderrWriter(), "%s: ", label)
fmt.Printf("%s: ", label)
}
r := newReader()
input, err := r.ReadString('\n')
input = strings.TrimSpace(input)
if err != nil {
if !errors.Is(err, io.EOF) {
return "", err
}
if input == "" {
if defaultVal != "" {
return defaultVal, nil
}
return "", err
}
return "", err
}
input = strings.TrimSpace(input)
if input == "" {
return defaultVal, nil
}
@ -49,62 +46,46 @@ func Prompt(label, defaultVal string) (string, error) {
// Select presents numbered options and returns the selected value.
func Select(label string, options []string) (string, error) {
if len(options) == 0 {
return "", nil
}
fmt.Fprintln(stderrWriter(), compileGlyphs(label))
fmt.Println(label)
for i, opt := range options {
fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt))
fmt.Printf(" %d. %s\n", i+1, opt)
}
fmt.Fprintf(stderrWriter(), "Choose [1-%d]: ", len(options))
fmt.Printf("Choose [1-%d]: ", len(options))
r := newReader()
input, err := r.ReadString('\n')
if err != nil && strings.TrimSpace(input) == "" {
promptHint("No input received. Selection cancelled.")
return "", Wrap(err, "selection cancelled")
if err != nil {
return "", err
}
trimmed := strings.TrimSpace(input)
n, err := strconv.Atoi(trimmed)
n, err := strconv.Atoi(strings.TrimSpace(input))
if err != nil || n < 1 || n > len(options) {
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(options)))
return "", Err("invalid selection %q: choose a number between 1 and %d", trimmed, len(options))
return "", errors.New("invalid selection")
}
return options[n-1], nil
}
// MultiSelect presents checkboxes (space-separated numbers).
func MultiSelect(label string, options []string) ([]string, error) {
if len(options) == 0 {
return []string{}, nil
}
fmt.Fprintln(stderrWriter(), compileGlyphs(label))
fmt.Println(label)
for i, opt := range options {
fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt))
fmt.Printf(" %d. %s\n", i+1, opt)
}
fmt.Fprintf(stderrWriter(), "Choose (space-separated) [1-%d]: ", len(options))
fmt.Printf("Choose (space-separated) [1-%d]: ", len(options))
r := newReader()
input, err := r.ReadString('\n')
trimmed := strings.TrimSpace(input)
if err != nil && trimmed == "" {
return []string{}, nil
}
if err != nil && !errors.Is(err, io.EOF) {
if err != nil {
return nil, err
}
selected, parseErr := parseMultiSelection(trimmed, len(options))
if parseErr != nil {
return nil, Wrap(parseErr, fmt.Sprintf("invalid selection %q", trimmed))
var selected []string
for _, s := range strings.Fields(input) {
n, err := strconv.Atoi(s)
if err != nil || n < 1 || n > len(options) {
continue
}
selected = append(selected, options[n-1])
}
selectedOptions := make([]string, 0, len(selected))
for _, idx := range selected {
selectedOptions = append(selectedOptions, options[idx])
}
return selectedOptions, nil
return selected, nil
}

View file

@ -50,44 +50,3 @@ func TestMultiSelect_Good(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, []string{"a", "c"}, vals)
}
func TestPrompt_Ugly(t *testing.T) {
t.Run("empty prompt label does not panic", func(t *testing.T) {
SetStdin(strings.NewReader("value\n"))
defer SetStdin(nil)
assert.NotPanics(t, func() {
_, _ = Prompt("", "")
})
})
t.Run("prompt with only whitespace input returns default", func(t *testing.T) {
SetStdin(strings.NewReader(" \n"))
defer SetStdin(nil)
val, err := Prompt("Name", "fallback")
assert.NoError(t, err)
// Either whitespace-trimmed empty returns default, or returns whitespace — no panic.
_ = val
})
}
func TestSelect_Ugly(t *testing.T) {
t.Run("empty choices does not panic", func(t *testing.T) {
SetStdin(strings.NewReader("1\n"))
defer SetStdin(nil)
assert.NotPanics(t, func() {
_, _ = Select("Pick", []string{})
})
})
t.Run("non-numeric input returns error without panic", func(t *testing.T) {
SetStdin(strings.NewReader("abc\n"))
defer SetStdin(nil)
assert.NotPanics(t, func() {
_, _ = Select("Pick", []string{"a", "b"})
})
})
}

View file

@ -6,10 +6,6 @@ import (
)
// RenderStyle controls how layouts are rendered.
//
// cli.UseRenderBoxed()
// frame := cli.NewFrame("HCF")
// fmt.Print(frame.String())
type RenderStyle int
// Render style constants for layout output.
@ -25,23 +21,17 @@ const (
var currentRenderStyle = RenderFlat
// UseRenderFlat sets the render style to flat (no borders).
//
// cli.UseRenderFlat()
func UseRenderFlat() { currentRenderStyle = RenderFlat }
// UseRenderSimple sets the render style to simple (--- separators).
//
// cli.UseRenderSimple()
func UseRenderSimple() { currentRenderStyle = RenderSimple }
// UseRenderBoxed sets the render style to boxed (Unicode box drawing).
//
// cli.UseRenderBoxed()
func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
// Render outputs the layout to terminal.
func (c *Composite) Render() {
fmt.Fprint(stdoutWriter(), c.String())
fmt.Print(c.String())
}
// String returns the rendered layout.
@ -76,9 +66,9 @@ func (c *Composite) renderSeparator(sb *strings.Builder, depth int) {
indent := strings.Repeat(" ", depth)
switch currentRenderStyle {
case RenderBoxed:
sb.WriteString(indent + Glyph(":tee:") + strings.Repeat(Glyph(":dash:"), 40) + Glyph(":tee:") + "\n")
sb.WriteString(indent + "├" + strings.Repeat("─", 40) + "┤\n")
case RenderSimple:
sb.WriteString(indent + strings.Repeat(Glyph(":dash:"), 40) + "\n")
sb.WriteString(indent + strings.Repeat("─", 40) + "\n")
}
}

View file

@ -1,48 +0,0 @@
package cli
import (
"strings"
"testing"
)
func TestCompositeRender_Good(t *testing.T) {
UseRenderFlat()
composite := Layout("HCF")
composite.H("Header content").C("Body content").F("Footer content")
output := composite.String()
if !strings.Contains(output, "Header content") {
t.Errorf("Render flat: expected 'Header content' in output, got %q", output)
}
if !strings.Contains(output, "Body content") {
t.Errorf("Render flat: expected 'Body content' in output, got %q", output)
}
}
func TestCompositeRender_Bad(t *testing.T) {
// Rendering an empty composite should not panic and return empty string.
composite := Layout("HCF")
output := composite.String()
if output != "" {
t.Errorf("Empty composite render: expected empty string, got %q", output)
}
}
func TestCompositeRender_Ugly(t *testing.T) {
// RenderSimple and RenderBoxed styles add separators between sections.
UseRenderSimple()
defer UseRenderFlat()
composite := Layout("HCF")
composite.H("top").C("middle").F("bottom")
output := composite.String()
if output == "" {
t.Error("RenderSimple: expected non-empty output")
}
UseRenderBoxed()
output = composite.String()
if output == "" {
t.Error("RenderBoxed: expected non-empty output")
}
}

View file

@ -19,9 +19,8 @@ import (
"os/signal"
"sync"
"syscall"
"time"
"dappco.re/go/core"
"forge.lthn.ai/core/go/pkg/core"
"github.com/spf13/cobra"
)
@ -39,17 +38,10 @@ type runtime struct {
}
// Options configures the CLI runtime.
//
// Example:
// opts := cli.Options{
// AppName: "core",
// Version: "1.0.0",
// }
type Options struct {
AppName string
Version string
Services []core.Service // Additional services to register
I18nSources []LocaleSource // Additional i18n translation sources
AppName string
Version string
Services []core.Option // Additional services to register
// OnReload is called when SIGHUP is received (daemon mode).
// Use for configuration reloading. Leave nil to ignore SIGHUP.
@ -58,11 +50,6 @@ type Options struct {
// Init initialises the global CLI runtime.
// Call this once at startup (typically in main.go or cmd.Execute).
//
// Example:
// err := cli.Init(cli.Options{AppName: "core"})
// if err != nil { panic(err) }
// defer cli.Shutdown()
func Init(opts Options) error {
var initErr error
once.Do(func() {
@ -76,35 +63,25 @@ func Init(opts Options) error {
SilenceUsage: true,
}
// 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),
}
// Build signal service options
var signalOpts []SignalOption
if opts.OnReload != nil {
signalSvc.onReload = opts.OnReload
signalOpts = append(signalOpts, WithReloadHandler(opts.OnReload))
}
c.Service("signal", core.Service{
OnStart: func() core.Result {
return signalSvc.start(ctx)
},
OnStop: func() core.Result {
return signalSvc.stop()
},
})
// Register additional services
for _, svc := range opts.Services {
if svc.Name != "" {
c.Service(svc.Name, svc)
}
// 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
}
instance = &runtime{
@ -114,16 +91,11 @@ func Init(opts Options) error {
cancel: cancel,
}
r := c.ServiceStartup(ctx, nil)
if !r.OK {
if err, ok := r.Value.(error); ok {
initErr = err
}
if err := c.ServiceStartup(ctx, nil); err != nil {
initErr = err
return
}
loadLocaleSources(opts.I18nSources...)
// Attach registered commands AFTER Core startup so i18n is available
attachRegisteredCommands(rootCmd)
})
@ -152,101 +124,28 @@ func RootCmd() *cobra.Command {
// Execute runs the CLI root command.
// Returns an error if the command fails.
//
// Example:
// if err := cli.Execute(); err != nil {
// cli.Warn("command failed:", "err", err)
// }
func Execute() error {
mustInit()
return instance.root.Execute()
}
// Run executes the CLI and watches an external context for cancellation.
// If the context is cancelled first, the runtime is shut down and the
// command error is returned if execution failed during shutdown.
//
// Example:
// ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
// defer cancel()
// if err := cli.Run(ctx); err != nil {
// cli.Error(err.Error())
// }
func Run(ctx context.Context) error {
mustInit()
if ctx == nil {
ctx = context.Background()
}
errCh := make(chan error, 1)
go func() {
errCh <- Execute()
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
Shutdown()
if err := <-errCh; err != nil {
return err
}
return ctx.Err()
}
}
// RunWithTimeout returns a shutdown helper that waits for the runtime to stop
// for up to timeout before giving up. It is intended for deferred cleanup.
//
// Example:
// stop := cli.RunWithTimeout(5 * time.Second)
// defer stop()
func RunWithTimeout(timeout time.Duration) func() {
return func() {
if timeout <= 0 {
Shutdown()
return
}
done := make(chan struct{})
go func() {
Shutdown()
close(done)
}()
select {
case <-done:
case <-time.After(timeout):
// Give up waiting, but let the shutdown goroutine finish in the background.
}
}
}
// Context returns the CLI's root context.
// Cancelled on SIGINT/SIGTERM.
//
// Example:
// if ctx := cli.Context(); ctx != nil {
// _ = ctx
// }
func Context() context.Context {
mustInit()
return instance.ctx
}
// Shutdown gracefully shuts down the CLI.
//
// Example:
// cli.Shutdown()
func Shutdown() {
if instance == nil {
return
}
instance.cancel()
_ = instance.core.ServiceShutdown(context.WithoutCancel(instance.ctx))
_ = instance.core.ServiceShutdown(instance.ctx)
}
// --- Signal Srv (internal) ---
// --- Signal Service (internal) ---
type signalService struct {
cancel context.CancelFunc
@ -255,7 +154,30 @@ type signalService struct {
shutdownOnce sync.Once
}
func (s *signalService) start(ctx context.Context) core.Result {
// 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 {
signals := []os.Signal{syscall.SIGINT, syscall.SIGTERM}
if s.onReload != nil {
signals = append(signals, syscall.SIGHUP)
@ -285,13 +207,13 @@ func (s *signalService) start(ctx context.Context) core.Result {
}
}()
return core.Result{OK: true}
return nil
}
func (s *signalService) stop() core.Result {
func (s *signalService) OnShutdown(ctx context.Context) error {
s.shutdownOnce.Do(func() {
signal.Stop(s.sigChan)
close(s.sigChan)
})
return core.Result{OK: true}
return nil
}

View file

@ -1,79 +0,0 @@
package cli
import (
"context"
"errors"
"sync"
"testing"
"time"
"dappco.re/go/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRun_Good_ReturnsCommandError(t *testing.T) {
resetGlobals(t)
require.NoError(t, Init(Options{AppName: "test"}))
RootCmd().AddCommand(NewCommand("boom", "Boom", "", func(_ *Command, _ []string) error {
return errors.New("boom")
}))
RootCmd().SetArgs([]string{"boom"})
err := Run(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "boom")
}
func TestRun_Good_CancelledContext(t *testing.T) {
resetGlobals(t)
require.NoError(t, Init(Options{AppName: "test"}))
RootCmd().AddCommand(NewCommand("wait", "Wait", "", func(_ *Command, _ []string) error {
<-Context().Done()
return nil
}))
RootCmd().SetArgs([]string{"wait"})
ctx, cancel := context.WithCancel(context.Background())
time.AfterFunc(25*time.Millisecond, cancel)
err := Run(ctx)
require.Error(t, err)
assert.ErrorIs(t, err, context.Canceled)
}
func TestRunWithTimeout_Good_ReturnsHelper(t *testing.T) {
resetGlobals(t)
finished := make(chan struct{})
var finishedOnce sync.Once
require.NoError(t, Init(Options{
AppName: "test",
Services: []core.Service{
{
Name: "slow-stop",
OnStop: func() core.Result {
time.Sleep(100 * time.Millisecond)
finishedOnce.Do(func() {
close(finished)
})
return core.Result{OK: true}
},
},
},
}))
start := time.Now()
RunWithTimeout(20 * time.Millisecond)()
require.Less(t, time.Since(start), 80*time.Millisecond)
select {
case <-finished:
case <-time.After(time.Second):
t.Fatal("shutdown did not complete")
}
}

View file

@ -1,54 +0,0 @@
package cli
import "testing"
func TestRuntime_Good(t *testing.T) {
// Init with valid options should succeed.
err := Init(Options{
AppName: "test-cli",
Version: "0.0.1",
})
if err != nil {
t.Fatalf("Init: unexpected error: %v", err)
}
defer Shutdown()
// Core() returns non-nil after Init.
coreInstance := Core()
if coreInstance == nil {
t.Error("Core(): returned nil after Init")
}
// RootCmd() returns non-nil after Init.
rootCommand := RootCmd()
if rootCommand == nil {
t.Error("RootCmd(): returned nil after Init")
}
// Context() returns non-nil after Init.
ctx := Context()
if ctx == nil {
t.Error("Context(): returned nil after Init")
}
}
func TestRuntime_Bad(t *testing.T) {
// Shutdown when not initialised should not panic.
defer func() {
if r := recover(); r != nil {
t.Errorf("Shutdown() panicked when not initialised: %v", r)
}
}()
// Reset singleton so this test can run standalone.
// We use a fresh Shutdown here — it should be a no-op.
Shutdown()
}
func TestRuntime_Ugly(t *testing.T) {
// Once is idempotent: calling Init twice should succeed.
err := Init(Options{AppName: "test-ugly"})
if err != nil {
t.Fatalf("Init (second call): unexpected error: %v", err)
}
defer Shutdown()
}

View file

@ -3,16 +3,13 @@ package cli
import (
"fmt"
"io"
"os"
"strings"
"sync"
"github.com/mattn/go-runewidth"
"unicode/utf8"
)
// StreamOption configures a Stream.
//
// stream := cli.NewStream(cli.WithWordWrap(80))
// stream.Wait()
type StreamOption func(*Stream)
// WithWordWrap sets the word-wrap column width.
@ -20,7 +17,7 @@ func WithWordWrap(cols int) StreamOption {
return func(s *Stream) { s.wrap = cols }
}
// WithStreamOutput sets the output writer (default: stdoutWriter()).
// WithStreamOutput sets the output writer (default: os.Stdout).
func WithStreamOutput(w io.Writer) StreamOption {
return func(s *Stream) { s.out = w }
}
@ -41,14 +38,13 @@ type Stream struct {
wrap int
col int // current column position (visible characters)
done chan struct{}
once sync.Once
mu sync.Mutex
}
// NewStream creates a streaming text renderer.
func NewStream(opts ...StreamOption) *Stream {
s := &Stream{
out: stdoutWriter(),
out: os.Stdout,
done: make(chan struct{}),
}
for _, opt := range opts {
@ -64,11 +60,11 @@ func (s *Stream) Write(text string) {
if s.wrap <= 0 {
fmt.Fprint(s.out, text)
// Track visible width across newlines for Done() trailing-newline logic.
// Track column across newlines for Done() trailing-newline logic.
if idx := strings.LastIndex(text, "\n"); idx >= 0 {
s.col = runewidth.StringWidth(text[idx+1:])
s.col = utf8.RuneCountInString(text[idx+1:])
} else {
s.col += runewidth.StringWidth(text)
s.col += utf8.RuneCountInString(text)
}
return
}
@ -80,14 +76,13 @@ func (s *Stream) Write(text string) {
continue
}
rw := runewidth.RuneWidth(r)
if rw > 0 && s.col > 0 && s.col+rw > s.wrap {
if s.col >= s.wrap {
fmt.Fprintln(s.out)
s.col = 0
}
fmt.Fprint(s.out, string(r))
s.col += rw
s.col++
}
}
@ -110,14 +105,12 @@ func (s *Stream) WriteFrom(r io.Reader) error {
// Done signals that no more text will arrive.
func (s *Stream) Done() {
s.once.Do(func() {
s.mu.Lock()
if s.col > 0 {
fmt.Fprintln(s.out) // ensure trailing newline
}
s.mu.Unlock()
close(s.done)
})
s.mu.Lock()
if s.col > 0 {
fmt.Fprintln(s.out) // ensure trailing newline
}
s.mu.Unlock()
close(s.done)
}
// Wait blocks until Done is called.
@ -132,24 +125,16 @@ func (s *Stream) Column() int {
return s.col
}
// Captured returns the stream output as a string when the output writer is
// capture-capable. If the writer cannot be captured, it returns an empty string.
// Use CapturedOK when you need to distinguish that case.
// Captured returns the stream output as a string when using a bytes.Buffer.
// Panics if the output writer is not a *strings.Builder or fmt.Stringer.
func (s *Stream) Captured() string {
out, _ := s.CapturedOK()
return out
}
// CapturedOK returns the stream output and whether the configured writer
// supports capture.
func (s *Stream) CapturedOK() (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if sb, ok := s.out.(*strings.Builder); ok {
return sb.String(), true
return sb.String()
}
if st, ok := s.out.(fmt.Stringer); ok {
return st.String(), true
return st.String()
}
return "", false
return ""
}

View file

@ -157,41 +157,3 @@ func TestStream_Bad(t *testing.T) {
assert.Equal(t, "", buf.String())
})
}
func TestStream_Ugly(t *testing.T) {
t.Run("Write after Done does not panic", func(t *testing.T) {
var buf bytes.Buffer
s := NewStream(WithStreamOutput(&buf))
s.Done()
s.Wait()
assert.NotPanics(t, func() {
s.Write("late write")
})
})
t.Run("word wrap width of 1 does not panic", func(t *testing.T) {
var buf bytes.Buffer
s := NewStream(WithWordWrap(1), WithStreamOutput(&buf))
assert.NotPanics(t, func() {
s.Write("hello")
s.Done()
s.Wait()
})
})
t.Run("very large write does not panic", func(t *testing.T) {
var buf bytes.Buffer
s := NewStream(WithStreamOutput(&buf))
large := strings.Repeat("x", 100_000)
assert.NotPanics(t, func() {
s.Write(large)
s.Done()
s.Wait()
})
assert.Equal(t, 100_000, len(strings.TrimRight(buf.String(), "\n")))
})
}

View file

@ -2,71 +2,47 @@ package cli
import "fmt"
// Sprintf formats a string using a format template.
//
// msg := cli.Sprintf("Hello, %s! You have %d messages.", name, count)
// Sprintf formats a string (fmt.Sprintf wrapper).
func Sprintf(format string, args ...any) string {
return fmt.Sprintf(format, args...)
}
// Sprint formats using default formats without a format string.
//
// label := cli.Sprint("count:", count)
// Sprint formats using default formats (fmt.Sprint wrapper).
func Sprint(args ...any) string {
return fmt.Sprint(args...)
}
// Styled returns text with a style applied.
//
// label := cli.Styled(cli.AccentStyle, "core dev")
func Styled(style *AnsiStyle, text string) string {
if style == nil {
return compileGlyphs(text)
}
return style.Render(compileGlyphs(text))
return style.Render(text)
}
// Styledf returns formatted text with a style applied.
//
// header := cli.Styledf(cli.HeaderStyle, "%s v%s", name, version)
func Styledf(style *AnsiStyle, format string, args ...any) string {
if style == nil {
return compileGlyphs(fmt.Sprintf(format, args...))
}
return style.Render(compileGlyphs(fmt.Sprintf(format, args...)))
return style.Render(fmt.Sprintf(format, args...))
}
// SuccessStr returns a success-styled string without printing it.
//
// line := cli.SuccessStr("all tests passed")
// SuccessStr returns success-styled string.
func SuccessStr(msg string) string {
return SuccessStyle.Render(Glyph(":check:") + " " + compileGlyphs(msg))
return SuccessStyle.Render(Glyph(":check:") + " " + msg)
}
// ErrorStr returns an error-styled string without printing it.
//
// line := cli.ErrorStr("connection refused")
// ErrorStr returns error-styled string.
func ErrorStr(msg string) string {
return ErrorStyle.Render(Glyph(":cross:") + " " + compileGlyphs(msg))
return ErrorStyle.Render(Glyph(":cross:") + " " + msg)
}
// WarnStr returns a warning-styled string without printing it.
//
// line := cli.WarnStr("deprecated flag")
// WarnStr returns warning-styled string.
func WarnStr(msg string) string {
return WarningStyle.Render(Glyph(":warn:") + " " + compileGlyphs(msg))
return WarningStyle.Render(Glyph(":warn:") + " " + msg)
}
// InfoStr returns an info-styled string without printing it.
//
// line := cli.InfoStr("listening on :8080")
// InfoStr returns info-styled string.
func InfoStr(msg string) string {
return InfoStyle.Render(Glyph(":info:") + " " + compileGlyphs(msg))
return InfoStyle.Render(Glyph(":info:") + " " + msg)
}
// DimStr returns a dim-styled string without printing it.
//
// line := cli.DimStr("optional: use --verbose for details")
// DimStr returns dim-styled string.
func DimStr(msg string) string {
return DimStyle.Render(compileGlyphs(msg))
return DimStyle.Render(msg)
}

View file

@ -1,68 +0,0 @@
package cli
import (
"strings"
"testing"
)
func TestStrings_Good(t *testing.T) {
// Sprintf formats correctly.
result := Sprintf("Hello, %s! Count: %d", "world", 42)
if result != "Hello, world! Count: 42" {
t.Errorf("Sprintf: got %q", result)
}
// Sprint joins with spaces.
result = Sprint("foo", "bar")
if result == "" {
t.Error("Sprint: got empty string")
}
// SuccessStr, ErrorStr, WarnStr, InfoStr, DimStr return non-empty strings.
if SuccessStr("done") == "" {
t.Error("SuccessStr: got empty string")
}
if ErrorStr("fail") == "" {
t.Error("ErrorStr: got empty string")
}
if WarnStr("warn") == "" {
t.Error("WarnStr: got empty string")
}
if InfoStr("info") == "" {
t.Error("InfoStr: got empty string")
}
if DimStr("dim") == "" {
t.Error("DimStr: got empty string")
}
}
func TestStrings_Bad(t *testing.T) {
// Sprintf with no args returns the format string unchanged.
result := Sprintf("no args here")
if result != "no args here" {
t.Errorf("Sprintf no-args: got %q", result)
}
// Styled with nil style should not panic.
defer func() {
if r := recover(); r != nil {
t.Errorf("Styled with nil style panicked: %v", r)
}
}()
Styled(nil, "text")
}
func TestStrings_Ugly(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
// Without colour, styled strings contain the raw text.
result := Styled(NewStyle().Bold(), "core")
if !strings.Contains(result, "core") {
t.Errorf("Styled: expected 'core' in result, got %q", result)
}
// Styledf with empty format.
result = Styledf(DimStyle, "")
_ = result // should not panic
}

View file

@ -5,9 +5,6 @@ import (
"fmt"
"strings"
"time"
"github.com/charmbracelet/x/ansi"
"github.com/mattn/go-runewidth"
)
// Tailwind colour palette (hex strings)
@ -72,53 +69,21 @@ var (
// Truncate shortens a string to max length with ellipsis.
func Truncate(s string, max int) string {
if max <= 0 || s == "" {
return ""
}
if displayWidth(s) <= max {
if len(s) <= max {
return s
}
if max <= 3 {
return truncateByWidth(s, max)
return s[:max]
}
return truncateByWidth(s, max-3) + "..."
return s[:max-3] + "..."
}
// Pad right-pads a string to width.
func Pad(s string, width int) string {
if displayWidth(s) >= width {
if len(s) >= width {
return s
}
return s + strings.Repeat(" ", width-displayWidth(s))
}
func displayWidth(s string) int {
return runewidth.StringWidth(ansi.Strip(s))
}
func truncateByWidth(s string, max int) string {
if max <= 0 || s == "" {
return ""
}
plain := ansi.Strip(s)
if displayWidth(plain) <= max {
return plain
}
var (
width int
out strings.Builder
)
for _, r := range plain {
rw := runewidth.RuneWidth(r)
if width+rw > max {
break
}
out.WriteRune(r)
width += rw
}
return out.String()
return s + strings.Repeat(" ", width-len(s))
}
// FormatAge formats a time as human-readable age (e.g., "2h ago", "3d ago").
@ -174,13 +139,6 @@ var borderSets = map[BorderStyle]borderSet{
BorderDouble: {"╔", "╗", "╚", "╝", "═", "║", "╦", "╩", "╠", "╣", "╬"},
}
var borderSetsASCII = map[BorderStyle]borderSet{
BorderNormal: {"+", "+", "+", "+", "-", "|", "+", "+", "+", "+", "+"},
BorderRounded: {"+", "+", "+", "+", "-", "|", "+", "+", "+", "+", "+"},
BorderHeavy: {"+", "+", "+", "+", "=", "|", "+", "+", "+", "+", "+"},
BorderDouble: {"+", "+", "+", "+", "=", "|", "+", "+", "+", "+", "+"},
}
// CellStyleFn returns a style based on the cell's raw value.
// Return nil to use the table's default CellStyle.
type CellStyleFn func(value string) *AnsiStyle
@ -275,7 +233,7 @@ func (t *Table) String() string {
// Render prints the table to stdout.
func (t *Table) Render() {
fmt.Fprint(stdoutWriter(), t.String())
fmt.Print(t.String())
}
func (t *Table) colCount() int {
@ -291,16 +249,14 @@ func (t *Table) columnWidths() []int {
widths := make([]int, cols)
for i, h := range t.Headers {
if w := displayWidth(compileGlyphs(h)); w > widths[i] {
widths[i] = w
if len(h) > widths[i] {
widths[i] = len(h)
}
}
for _, row := range t.Rows {
for i, cell := range row {
if i < cols {
if w := displayWidth(compileGlyphs(cell)); w > widths[i] {
widths[i] = w
}
if i < cols && len(cell) > widths[i] {
widths[i] = len(cell)
}
}
}
@ -367,7 +323,7 @@ func (t *Table) renderPlain() string {
if i > 0 {
sb.WriteString(sep)
}
cell := Pad(Truncate(compileGlyphs(h), widths[i]), widths[i])
cell := Pad(Truncate(h, widths[i]), widths[i])
if t.Style.HeaderStyle != nil {
cell = t.Style.HeaderStyle.Render(cell)
}
@ -385,7 +341,7 @@ func (t *Table) renderPlain() string {
if i < len(row) {
val = row[i]
}
cell := Pad(Truncate(compileGlyphs(val), widths[i]), widths[i])
cell := Pad(Truncate(val, widths[i]), widths[i])
if style := t.resolveStyle(i, val); style != nil {
cell = style.Render(cell)
}
@ -398,7 +354,7 @@ func (t *Table) renderPlain() string {
}
func (t *Table) renderBordered() string {
b := tableBorderSet(t.borders)
b := borderSets[t.borders]
widths := t.columnWidths()
cols := t.colCount()
@ -423,7 +379,7 @@ func (t *Table) renderBordered() string {
if i < len(t.Headers) {
h = t.Headers[i]
}
cell := Pad(Truncate(compileGlyphs(h), widths[i]), widths[i])
cell := Pad(Truncate(h, widths[i]), widths[i])
if t.Style.HeaderStyle != nil {
cell = t.Style.HeaderStyle.Render(cell)
}
@ -454,7 +410,7 @@ func (t *Table) renderBordered() string {
if i < len(row) {
val = row[i]
}
cell := Pad(Truncate(compileGlyphs(val), widths[i]), widths[i])
cell := Pad(Truncate(val, widths[i]), widths[i])
if style := t.resolveStyle(i, val); style != nil {
cell = style.Render(cell)
}
@ -479,15 +435,3 @@ func (t *Table) renderBordered() string {
return sb.String()
}
func tableBorderSet(style BorderStyle) borderSet {
if currentTheme == ThemeASCII {
if b, ok := borderSetsASCII[style]; ok {
return b
}
}
if b, ok := borderSets[style]; ok {
return b
}
return borderSet{}
}

View file

@ -81,22 +81,6 @@ func TestTable_Good(t *testing.T) {
assert.Contains(t, out, "║")
})
t.Run("ASCII theme uses ASCII borders", func(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
tbl := NewTable("REPO", "STATUS").WithBorders(BorderRounded)
tbl.AddRow("core", "clean")
out := tbl.String()
assert.Contains(t, out, "+")
assert.Contains(t, out, "-")
assert.Contains(t, out, "|")
assert.NotContains(t, out, "╭")
assert.NotContains(t, out, "╮")
assert.NotContains(t, out, "│")
})
t.Run("bordered structure", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
@ -146,19 +130,6 @@ func TestTable_Good(t *testing.T) {
assert.Contains(t, out, "ok")
})
t.Run("glyph shortcodes render in headers and cells", func(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
tbl := NewTable(":check: NAME", "STATUS").
WithBorders(BorderRounded)
tbl.AddRow("core", ":warn:")
out := tbl.String()
assert.Contains(t, out, "[OK] NAME")
assert.Contains(t, out, "[WARN]")
})
t.Run("max width truncates", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
@ -223,81 +194,13 @@ func TestTable_Bad(t *testing.T) {
})
}
func TestTable_Ugly(t *testing.T) {
t.Run("no columns no panic", func(t *testing.T) {
assert.NotPanics(t, func() {
tbl := NewTable()
tbl.AddRow()
_ = tbl.String()
})
})
t.Run("cell style function returning nil does not panic", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
tbl := NewTable("A").WithCellStyle(0, func(_ string) *AnsiStyle {
return nil
})
tbl.AddRow("value")
assert.NotPanics(t, func() {
_ = tbl.String()
})
})
t.Run("max width of 1 does not panic", func(t *testing.T) {
SetColorEnabled(false)
defer SetColorEnabled(true)
tbl := NewTable("HEADER").WithMaxWidth(1)
tbl.AddRow("data")
assert.NotPanics(t, func() {
_ = tbl.String()
})
})
}
func TestTruncate_Good(t *testing.T) {
assert.Equal(t, "hel...", Truncate("hello world", 6))
assert.Equal(t, "hi", Truncate("hi", 6))
assert.Equal(t, "he", Truncate("hello", 2))
assert.Equal(t, "東", Truncate("東京", 3))
}
func TestTruncate_Ugly(t *testing.T) {
t.Run("zero max does not panic", func(t *testing.T) {
assert.NotPanics(t, func() {
_ = Truncate("hello", 0)
})
})
}
func TestPad_Good(t *testing.T) {
assert.Equal(t, "hi ", Pad("hi", 5))
assert.Equal(t, "hello", Pad("hello", 3))
assert.Equal(t, "東京 ", Pad("東京", 6))
}
func TestStyled_Good_NilStyle(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
assert.Equal(t, "hello [OK]", Styled(nil, "hello :check:"))
}
func TestStyledf_Good_NilStyle(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
assert.Equal(t, "value: [WARN]", Styledf(nil, "value: %s", ":warn:"))
}
func TestPad_Ugly(t *testing.T) {
t.Run("zero width does not panic", func(t *testing.T) {
assert.NotPanics(t, func() {
_ = Pad("hello", 0)
})
})
}

View file

@ -12,9 +12,8 @@ import (
"golang.org/x/term"
)
// Spinner frames for the live tracker.
var spinnerFramesUnicode = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
var spinnerFramesASCII = []string{"-", "\\", "|", "/"}
// Spinner frames (braille pattern).
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
// taskState tracks the lifecycle of a tracked task.
type taskState int
@ -89,11 +88,8 @@ type TaskTracker struct {
func (tr *TaskTracker) Tasks() iter.Seq[*TrackedTask] {
return func(yield func(*TrackedTask) bool) {
tr.mu.Lock()
tasks := make([]*TrackedTask, len(tr.tasks))
copy(tasks, tr.tasks)
tr.mu.Unlock()
for _, t := range tasks {
defer tr.mu.Unlock()
for _, t := range tr.tasks {
if !yield(t) {
return
}
@ -105,11 +101,8 @@ func (tr *TaskTracker) Tasks() iter.Seq[*TrackedTask] {
func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] {
return func(yield func(string, string) bool) {
tr.mu.Lock()
tasks := make([]*TrackedTask, len(tr.tasks))
copy(tasks, tr.tasks)
tr.mu.Unlock()
for _, t := range tasks {
defer tr.mu.Unlock()
for _, t := range tr.tasks {
name, status, _ := t.snapshot()
if !yield(name, status) {
return
@ -120,16 +113,7 @@ func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] {
// NewTaskTracker creates a new parallel task tracker.
func NewTaskTracker() *TaskTracker {
return &TaskTracker{out: stderrWriter()}
}
// WithOutput sets the destination writer for tracker output.
// Pass nil to keep the current writer unchanged.
func (tr *TaskTracker) WithOutput(out io.Writer) *TaskTracker {
if out != nil {
tr.out = out
}
return tr
return &TaskTracker{out: os.Stdout}
}
// Add registers a task and returns it for goroutine use.
@ -175,8 +159,6 @@ func (tr *TaskTracker) waitStatic() {
allDone := true
for i, t := range tasks {
name, status, state := t.snapshot()
name = compileGlyphs(name)
status = compileGlyphs(status)
if state != taskDone && state != taskFailed {
allDone = false
continue
@ -208,9 +190,6 @@ func (tr *TaskTracker) waitLive() {
for i := range n {
tr.renderLine(i, frame)
}
if n == 0 || tr.allDone() {
return
}
ticker := time.NewTicker(80 * time.Millisecond)
defer ticker.Stop()
@ -241,8 +220,6 @@ func (tr *TaskTracker) renderLine(idx, frame int) {
tr.mu.Unlock()
name, status, state := t.snapshot()
name = compileGlyphs(name)
status = compileGlyphs(status)
nameW := tr.nameWidth()
var icon string
@ -250,7 +227,7 @@ func (tr *TaskTracker) renderLine(idx, frame int) {
case taskPending:
icon = DimStyle.Render(Glyph(":pending:"))
case taskRunning:
icon = InfoStyle.Render(trackerSpinnerFrame(frame))
icon = InfoStyle.Render(spinnerFrames[frame%len(spinnerFrames)])
case taskDone:
icon = SuccessStyle.Render(Glyph(":check:"))
case taskFailed:
@ -267,7 +244,7 @@ func (tr *TaskTracker) renderLine(idx, frame int) {
styledStatus = DimStyle.Render(status)
}
fmt.Fprintf(tr.out, "\033[2K%s %s %s\n", icon, Pad(name, nameW), styledStatus)
fmt.Fprintf(tr.out, "\033[2K%s %-*s %s\n", icon, nameW, name, styledStatus)
}
func (tr *TaskTracker) nameWidth() int {
@ -275,8 +252,8 @@ func (tr *TaskTracker) nameWidth() int {
defer tr.mu.Unlock()
w := 0
for _, t := range tr.tasks {
if nameW := displayWidth(compileGlyphs(t.name)); nameW > w {
w = nameW
if len(t.name) > w {
w = len(t.name)
}
}
return w
@ -327,26 +304,16 @@ func (tr *TaskTracker) String() string {
var sb strings.Builder
for _, t := range tasks {
name, status, state := t.snapshot()
name = compileGlyphs(name)
status = compileGlyphs(status)
icon := Glyph(":pending:")
icon := "…"
switch state {
case taskDone:
icon = Glyph(":check:")
icon = "✓"
case taskFailed:
icon = Glyph(":cross:")
icon = "✗"
case taskRunning:
icon = Glyph(":spinner:")
icon = "⠋"
}
fmt.Fprintf(&sb, "%s %s %s\n", icon, Pad(name, nameW), status)
fmt.Fprintf(&sb, "%s %-*s %s\n", icon, nameW, name, status)
}
return sb.String()
}
func trackerSpinnerFrame(frame int) string {
frames := spinnerFramesUnicode
if currentTheme == ThemeASCII {
frames = spinnerFramesASCII
}
return frames[frame%len(frames)]
}

View file

@ -10,17 +10,6 @@ import (
"github.com/stretchr/testify/require"
)
func restoreThemeAndColors(t *testing.T) {
t.Helper()
prevTheme := currentTheme
prevColor := ColorEnabled()
t.Cleanup(func() {
currentTheme = prevTheme
SetColorEnabled(prevColor)
})
}
func TestTaskTracker_Good(t *testing.T) {
t.Run("add and complete tasks", func(t *testing.T) {
tr := NewTaskTracker()
@ -121,7 +110,8 @@ func TestTaskTracker_Good(t *testing.T) {
t.Run("wait completes for non-TTY", func(t *testing.T) {
var buf bytes.Buffer
tr := NewTaskTracker().WithOutput(&buf)
tr := NewTaskTracker()
tr.out = &buf
task := tr.Add("quick")
go func() {
@ -134,17 +124,6 @@ func TestTaskTracker_Good(t *testing.T) {
assert.Contains(t, buf.String(), "done")
})
t.Run("WithOutput sets output writer", func(t *testing.T) {
var buf bytes.Buffer
tr := NewTaskTracker().WithOutput(&buf)
tr.Add("quick").Done("done")
tr.Wait()
assert.Contains(t, buf.String(), "quick")
assert.Contains(t, buf.String(), "done")
})
t.Run("name width alignment", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
@ -156,17 +135,6 @@ func TestTaskTracker_Good(t *testing.T) {
assert.Equal(t, 19, w)
})
t.Run("name width counts visible width", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
tr.Add("東京")
tr.Add("repo")
w := tr.nameWidth()
assert.Equal(t, 4, w)
})
t.Run("String output format", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
@ -180,68 +148,6 @@ func TestTaskTracker_Good(t *testing.T) {
assert.Contains(t, out, "✗")
assert.Contains(t, out, "⠋")
})
t.Run("glyph shortcodes render in names and statuses", func(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
tr.Add(":check: repo").Done("done :warn:")
out := tr.String()
assert.Contains(t, out, "[OK] repo")
assert.Contains(t, out, "[WARN]")
})
t.Run("ASCII theme uses ASCII symbols", func(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
tr.Add("repo-a").Done("clean")
tr.Add("repo-b").Fail("dirty")
tr.Add("repo-c").Update("pulling")
out := tr.String()
assert.Contains(t, out, "[OK]")
assert.Contains(t, out, "[FAIL]")
assert.Contains(t, out, "-")
assert.NotContains(t, out, "✓")
assert.NotContains(t, out, "✗")
})
t.Run("iterators tolerate mutation during iteration", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
tr.Add("first")
tr.Add("second")
done := make(chan struct{})
go func() {
defer close(done)
for task := range tr.Tasks() {
task.Update("visited")
}
}()
require.Eventually(t, func() bool {
select {
case <-done:
return true
default:
return false
}
}, time.Second, 10*time.Millisecond)
for name, status := range tr.Snapshots() {
assert.Equal(t, "visited", status, name)
}
})
}
func TestTaskTracker_Bad(t *testing.T) {
@ -280,46 +186,3 @@ func TestTrackedTask_Good(t *testing.T) {
require.Equal(t, "running", status)
})
}
func TestTaskTracker_Ugly(t *testing.T) {
t.Run("empty task name does not panic", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
assert.NotPanics(t, func() {
task := tr.Add("")
task.Done("ok")
})
})
t.Run("Done called twice does not panic", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
task := tr.Add("double-done")
assert.NotPanics(t, func() {
task.Done("first")
task.Done("second")
})
})
t.Run("Fail after Done does not panic", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
task := tr.Add("already-done")
assert.NotPanics(t, func() {
task.Done("completed")
task.Fail("too late")
})
})
t.Run("String on empty tracker does not panic", func(t *testing.T) {
tr := NewTaskTracker()
tr.out = &bytes.Buffer{}
assert.NotPanics(t, func() {
_ = tr.String()
})
})
}

View file

@ -79,29 +79,24 @@ func (n *TreeNode) String() string {
// Render prints the tree to stdout.
func (n *TreeNode) Render() {
fmt.Fprint(stdoutWriter(), n.String())
fmt.Print(n.String())
}
func (n *TreeNode) renderLabel() string {
label := compileGlyphs(n.label)
if n.style != nil {
return n.style.Render(label)
return n.style.Render(n.label)
}
return label
return n.label
}
func (n *TreeNode) writeChildren(sb *strings.Builder, prefix string) {
tee := Glyph(":tee:") + Glyph(":dash:") + Glyph(":dash:") + " "
corner := Glyph(":corner:") + Glyph(":dash:") + Glyph(":dash:") + " "
pipe := Glyph(":pipe:") + " "
for i, child := range n.children {
last := i == len(n.children)-1
connector := tee
next := pipe
connector := "├── "
next := "│ "
if last {
connector = corner
connector = "└── "
next = " "
}

View file

@ -103,40 +103,6 @@ func TestTree_Good(t *testing.T) {
"└── child\n"
assert.Equal(t, expected, tree.String())
})
t.Run("ASCII theme uses ASCII connectors", func(t *testing.T) {
prevTheme := currentTheme
prevColor := ColorEnabled()
UseASCII()
t.Cleanup(func() {
currentTheme = prevTheme
SetColorEnabled(prevColor)
})
tree := NewTree("core-php")
tree.Add("core-tenant").Add("core-bio")
tree.Add("core-admin")
tree.Add("core-api")
expected := "core-php\n" +
"+-- core-tenant\n" +
"| `-- core-bio\n" +
"+-- core-admin\n" +
"`-- core-api\n"
assert.Equal(t, expected, tree.String())
})
t.Run("glyph shortcodes render in labels", func(t *testing.T) {
restoreThemeAndColors(t)
UseASCII()
tree := NewTree(":check: root")
tree.Add(":warn: child")
out := tree.String()
assert.Contains(t, out, "[OK] root")
assert.Contains(t, out, "[WARN] child")
})
}
func TestTree_Bad(t *testing.T) {
@ -145,31 +111,3 @@ func TestTree_Bad(t *testing.T) {
assert.Equal(t, "\n", tree.String())
})
}
func TestTree_Ugly(t *testing.T) {
t.Run("nil style does not panic", func(t *testing.T) {
assert.NotPanics(t, func() {
tree := NewTree("root").WithStyle(nil)
tree.Add("child")
_ = tree.String()
})
})
t.Run("AddStyled with nil style does not panic", func(t *testing.T) {
assert.NotPanics(t, func() {
tree := NewTree("root")
tree.AddStyled("item", nil)
_ = tree.String()
})
})
t.Run("very deep nesting does not panic", func(t *testing.T) {
assert.NotPanics(t, func() {
node := NewTree("root")
for range 100 {
node = node.Add("child")
}
_ = NewTree("root").String()
})
})
}

View file

@ -1,13 +1,14 @@
package cli
import (
"bufio"
"context"
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
"unicode"
"forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-log"
@ -30,10 +31,6 @@ func GhAuthenticated() bool {
}
// ConfirmOption configures Confirm behaviour.
//
// if cli.Confirm("Proceed?", cli.DefaultYes()) {
// cli.Success("continuing")
// }
type ConfirmOption func(*confirmConfig)
type confirmConfig struct {
@ -42,14 +39,6 @@ type confirmConfig struct {
timeout time.Duration
}
func promptHint(msg string) {
fmt.Fprintln(stderrWriter(), DimStyle.Render(compileGlyphs(msg)))
}
func promptWarning(msg string) {
fmt.Fprintln(stderrWriter(), WarningStyle.Render(compileGlyphs(msg)))
}
// DefaultYes sets the default response to "yes" (pressing Enter confirms).
func DefaultYes() ConfirmOption {
return func(c *confirmConfig) {
@ -93,8 +82,6 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
opt(cfg)
}
prompt = compileGlyphs(prompt)
// Build the prompt suffix
var suffix string
if cfg.required {
@ -110,50 +97,37 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
suffix = fmt.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second))
}
reader := newReader()
reader := bufio.NewReader(os.Stdin)
for {
fmt.Fprintf(stderrWriter(), "%s %s", prompt, suffix)
fmt.Printf("%s %s", prompt, suffix)
var response string
var readErr error
if cfg.timeout > 0 {
// Use timeout-based reading
resultChan := make(chan string, 1)
errChan := make(chan error, 1)
go func() {
line, err := reader.ReadString('\n')
line, _ := reader.ReadString('\n')
resultChan <- line
errChan <- err
}()
select {
case response = <-resultChan:
readErr = <-errChan
response = strings.ToLower(strings.TrimSpace(response))
case <-time.After(cfg.timeout):
fmt.Fprintln(stderrWriter()) // New line after timeout
fmt.Println() // New line after timeout
return cfg.defaultYes
}
} else {
line, err := reader.ReadString('\n')
readErr = err
if err != nil && line == "" {
return cfg.defaultYes
}
response = line
response, _ = reader.ReadString('\n')
response = strings.ToLower(strings.TrimSpace(response))
}
// Handle empty response
if response == "" {
if readErr == nil && cfg.required {
promptHint("Please enter y or n, then press Enter.")
continue
}
if cfg.required {
return cfg.defaultYes
continue // Ask again
}
return cfg.defaultYes
}
@ -168,7 +142,7 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
// Invalid response
if cfg.required {
promptHint("Please enter y or n, then press Enter.")
fmt.Println("Please enter 'y' or 'n'")
continue
}
@ -201,8 +175,6 @@ func ConfirmDangerousAction(verb, subject string) bool {
}
// QuestionOption configures Question behaviour.
//
// name := cli.Question("Project name:", cli.WithDefault("my-app"))
type QuestionOption func(*questionConfig)
type questionConfig struct {
@ -243,28 +215,23 @@ func Question(prompt string, opts ...QuestionOption) string {
opt(cfg)
}
prompt = compileGlyphs(prompt)
reader := newReader()
reader := bufio.NewReader(os.Stdin)
for {
// Build prompt with default
if cfg.defaultValue != "" {
fmt.Fprintf(stderrWriter(), "%s [%s] ", prompt, compileGlyphs(cfg.defaultValue))
fmt.Printf("%s [%s] ", prompt, cfg.defaultValue)
} else {
fmt.Fprintf(stderrWriter(), "%s ", prompt)
fmt.Printf("%s ", prompt)
}
response, err := reader.ReadString('\n')
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(response)
if err != nil && response == "" {
return cfg.defaultValue
}
// Handle empty response
if response == "" {
if cfg.required {
promptHint("Please enter a value, then press Enter.")
fmt.Println("Response required")
continue
}
response = cfg.defaultValue
@ -273,7 +240,7 @@ func Question(prompt string, opts ...QuestionOption) string {
// Validate if validator provided
if cfg.validator != nil {
if err := cfg.validator(response); err != nil {
promptWarning(fmt.Sprintf("Invalid: %v", err))
fmt.Printf("Invalid: %v\n", err)
continue
}
}
@ -291,16 +258,12 @@ func QuestionAction(verb, subject string, opts ...QuestionOption) string {
}
// ChooseOption configures Choose behaviour.
//
// choice := cli.Choose("Pick one:", items, cli.Display(func(v Item) string {
// return v.Name
// }))
type ChooseOption[T any] func(*chooseConfig[T])
type chooseConfig[T any] struct {
displayFn func(T) string
defaultN int // 0-based index of default selection
filter bool // Enable type-to-filter selection
filter bool // Enable fuzzy filtering
multi bool // Allow multiple selection
}
@ -319,7 +282,9 @@ func WithDefaultIndex[T any](idx int) ChooseOption[T] {
}
// Filter enables type-to-filter functionality.
// When enabled, typed text narrows the visible options before selection.
// Users can type to narrow down the list of options.
// Note: This is a hint for interactive UIs; the basic CLI Choose
// implementation uses numbered selection which doesn't support filtering.
func Filter[T any]() ChooseOption[T] {
return func(c *chooseConfig[T]) {
c.filter = true
@ -355,77 +320,42 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
cfg := &chooseConfig[T]{
displayFn: func(item T) string { return fmt.Sprint(item) },
defaultN: -1,
}
for _, opt := range opts {
opt(cfg)
}
prompt = compileGlyphs(prompt)
reader := newReader()
visible := make([]int, len(items))
for i := range items {
visible[i] = i
// Display options
fmt.Println(prompt)
for i, item := range items {
marker := " "
if i == cfg.defaultN {
marker = "*"
}
fmt.Printf(" %s%d. %s\n", marker, i+1, cfg.displayFn(item))
}
allVisible := append([]int(nil), visible...)
reader := bufio.NewReader(os.Stdin)
for {
renderChoices(prompt, items, visible, cfg.displayFn, cfg.defaultN, cfg.filter)
if cfg.filter {
fmt.Fprintf(stderrWriter(), "Enter number [1-%d] or filter: ", len(visible))
} else {
fmt.Fprintf(stderrWriter(), "Enter number [1-%d]: ", len(visible))
}
response, err := reader.ReadString('\n')
fmt.Printf("Enter number [1-%d]: ", len(items))
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(response)
if err != nil && response == "" {
if idx, ok := defaultVisibleIndex(visible, cfg.defaultN); ok {
return items[idx]
}
var zero T
return zero
}
// Empty response uses default
if response == "" {
if cfg.filter && len(visible) != len(allVisible) {
visible = append([]int(nil), allVisible...)
promptHint("Filter cleared.")
continue
}
if idx, ok := defaultVisibleIndex(visible, cfg.defaultN); ok {
return items[idx]
}
if cfg.defaultN >= 0 {
promptHint("Default selection is not available in the current list. Narrow the list or choose another number.")
continue
}
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
continue
return items[cfg.defaultN]
}
// Parse number
var n int
if _, err := fmt.Sscanf(response, "%d", &n); err == nil {
if n >= 1 && n <= len(visible) {
return items[visible[n-1]]
if n >= 1 && n <= len(items) {
return items[n-1]
}
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
continue
}
if cfg.filter {
nextVisible := filterVisible(items, visible, response, cfg.displayFn)
if len(nextVisible) == 0 {
promptHint(fmt.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
continue
}
visible = nextVisible
continue
}
promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible)))
fmt.Printf("Please enter a number between 1 and %d\n", len(items))
}
}
@ -455,126 +385,51 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
cfg := &chooseConfig[T]{
displayFn: func(item T) string { return fmt.Sprint(item) },
defaultN: -1,
}
for _, opt := range opts {
opt(cfg)
}
prompt = compileGlyphs(prompt)
reader := newReader()
visible := make([]int, len(items))
for i := range items {
visible[i] = i
// Display options
fmt.Println(prompt)
for i, item := range items {
fmt.Printf(" %d. %s\n", i+1, cfg.displayFn(item))
}
for {
renderChoices(prompt, items, visible, cfg.displayFn, -1, cfg.filter)
reader := bufio.NewReader(os.Stdin)
if cfg.filter {
fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ")
} else {
fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
}
for {
fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
response, _ := reader.ReadString('\n')
response = strings.TrimSpace(response)
// Empty response returns no selections.
// Empty response returns no selections
if response == "" {
return nil
}
// Parse the selection.
selected, err := parseMultiSelection(response, len(visible))
// Parse the selection
selected, err := parseMultiSelection(response, len(items))
if err != nil {
if cfg.filter && !looksLikeMultiSelectionInput(response) {
nextVisible := filterVisible(items, visible, response, cfg.displayFn)
if len(nextVisible) == 0 {
promptHint(fmt.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response))
continue
}
visible = nextVisible
continue
}
promptWarning(fmt.Sprintf("Invalid selection %q: enter numbers like 1 3 or 1-3.", response))
fmt.Printf("Invalid selection: %v\n", err)
continue
}
// Build result
result := make([]T, 0, len(selected))
for _, idx := range selected {
result = append(result, items[visible[idx]])
result = append(result, items[idx])
}
return result
}
}
func renderChoices[T any](prompt string, items []T, visible []int, displayFn func(T) string, defaultN int, filter bool) {
fmt.Fprintln(stderrWriter(), prompt)
for i, idx := range visible {
marker := " "
if defaultN >= 0 && idx == defaultN {
marker = "*"
}
fmt.Fprintf(stderrWriter(), " %s%d. %s\n", marker, i+1, compileGlyphs(displayFn(items[idx])))
}
if filter {
fmt.Fprintln(stderrWriter(), " (type to filter the list)")
}
}
func defaultVisibleIndex(visible []int, defaultN int) (int, bool) {
if defaultN < 0 {
return 0, false
}
for _, idx := range visible {
if idx == defaultN {
return idx, true
}
}
return 0, false
}
func filterVisible[T any](items []T, visible []int, query string, displayFn func(T) string) []int {
q := strings.ToLower(strings.TrimSpace(query))
if q == "" {
return visible
}
filtered := make([]int, 0, len(visible))
for _, idx := range visible {
if strings.Contains(strings.ToLower(displayFn(items[idx])), q) {
filtered = append(filtered, idx)
}
}
return filtered
}
func looksLikeMultiSelectionInput(input string) bool {
hasDigit := false
for _, r := range input {
switch {
case unicode.IsSpace(r), r == '-' || r == ',':
continue
case unicode.IsDigit(r):
hasDigit = true
default:
return false
}
}
return hasDigit
}
// parseMultiSelection parses a multi-selection string like "1 3 5", "1,3,5",
// or "1-3 5".
// parseMultiSelection parses a multi-selection string like "1 3 5" or "1-3 5".
// Returns 0-based indices.
func parseMultiSelection(input string, maxItems int) ([]int, error) {
selected := make(map[int]bool)
normalized := strings.NewReplacer(",", " ").Replace(input)
for part := range strings.FieldsSeq(normalized) {
for part := range strings.FieldsSeq(input) {
// Check for range (e.g., "1-3")
if strings.Contains(part, "-") {
var rangeParts []string
@ -582,17 +437,17 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
rangeParts = append(rangeParts, p)
}
if len(rangeParts) != 2 {
return nil, Err("invalid range: %s", part)
return nil, fmt.Errorf("invalid range: %s", part)
}
var start, end int
if _, err := fmt.Sscanf(rangeParts[0], "%d", &start); err != nil {
return nil, Err("invalid range start: %s", rangeParts[0])
return nil, fmt.Errorf("invalid range start: %s", rangeParts[0])
}
if _, err := fmt.Sscanf(rangeParts[1], "%d", &end); err != nil {
return nil, Err("invalid range end: %s", rangeParts[1])
return nil, fmt.Errorf("invalid range end: %s", rangeParts[1])
}
if start < 1 || start > maxItems || end < 1 || end > maxItems || start > end {
return nil, Err("range out of bounds: %s", part)
return nil, fmt.Errorf("range out of bounds: %s", part)
}
for i := start; i <= end; i++ {
selected[i-1] = true // Convert to 0-based
@ -601,10 +456,10 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
// Single number
var n int
if _, err := fmt.Sscanf(part, "%d", &n); err != nil {
return nil, Err("invalid number: %s", part)
return nil, fmt.Errorf("invalid number: %s", part)
}
if n < 1 || n > maxItems {
return nil, Err("number out of range: %d", n)
return nil, fmt.Errorf("number out of range: %d", n)
}
selected[n-1] = true // Convert to 0-based
}
@ -631,19 +486,9 @@ func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOpt
// GitClone clones a GitHub repository to the specified path.
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
func GitClone(ctx context.Context, org, repo, path string) error {
return GitCloneRef(ctx, org, repo, path, "")
}
// GitCloneRef clones a GitHub repository at a specific ref to the specified path.
// Prefers 'gh repo clone' if authenticated, falls back to SSH.
func GitCloneRef(ctx context.Context, org, repo, path, ref string) error {
if GhAuthenticated() {
httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo)
args := []string{"repo", "clone", httpsURL, path}
if ref != "" {
args = append(args, "--", "--branch", ref, "--single-branch")
}
cmd := exec.CommandContext(ctx, "gh", args...)
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path)
output, err := cmd.CombinedOutput()
if err == nil {
return nil
@ -654,12 +499,7 @@ func GitCloneRef(ctx context.Context, org, repo, path, ref string) error {
}
}
// Fall back to SSH clone
args := []string{"clone"}
if ref != "" {
args = append(args, "--branch", ref, "--single-branch")
}
args = append(args, fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path)
cmd := exec.CommandContext(ctx, "git", args...)
cmd := exec.CommandContext(ctx, "git", "clone", fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path)
output, err := cmd.CombinedOutput()
if err != nil {
return errors.New(strings.TrimSpace(string(output)))

View file

@ -1,88 +0,0 @@
package cli
import (
"strings"
"testing"
)
func TestParseMultiSelection_Good(t *testing.T) {
// Single numbers.
result, err := parseMultiSelection("1 3 5", 5)
if err != nil {
t.Fatalf("parseMultiSelection: unexpected error: %v", err)
}
if len(result) != 3 {
t.Errorf("parseMultiSelection: expected 3 results, got %d: %v", len(result), result)
}
// Range notation.
result, err = parseMultiSelection("1-3", 5)
if err != nil {
t.Fatalf("parseMultiSelection range: unexpected error: %v", err)
}
if len(result) != 3 {
t.Errorf("parseMultiSelection range: expected 3 results, got %d: %v", len(result), result)
}
}
func TestParseMultiSelection_Bad(t *testing.T) {
// Out of range number.
_, err := parseMultiSelection("10", 5)
if err == nil {
t.Error("parseMultiSelection: expected error for out-of-range number")
}
// Invalid range format.
_, err = parseMultiSelection("1-2-3", 5)
if err == nil {
t.Error("parseMultiSelection: expected error for invalid range '1-2-3'")
}
// Non-numeric input.
_, err = parseMultiSelection("abc", 5)
if err == nil {
t.Error("parseMultiSelection: expected error for non-numeric input")
}
}
func TestParseMultiSelection_Ugly(t *testing.T) {
// Empty input returns empty slice.
result, err := parseMultiSelection("", 5)
if err != nil {
t.Fatalf("parseMultiSelection empty: unexpected error: %v", err)
}
if len(result) != 0 {
t.Errorf("parseMultiSelection empty: expected 0 results, got %d", len(result))
}
// Choose with empty items returns zero value.
choice := Choose("Select:", []string{})
if choice != "" {
t.Errorf("Choose empty: expected empty string, got %q", choice)
}
}
func TestMatchGlobInSearch_Good(t *testing.T) {
// matchGlob is in cmd_search.go — test parseMultiSelection indirectly here.
// Verify ChooseMulti with empty items returns nil without panicking.
result := ChooseMulti("Select:", []string{})
if result != nil {
t.Errorf("ChooseMulti empty: expected nil, got %v", result)
}
}
func TestGhAuthenticated_Bad(t *testing.T) {
// GhAuthenticated requires gh CLI — should not panic even if gh is unavailable.
defer func() {
if r := recover(); r != nil {
t.Errorf("GhAuthenticated panicked: %v", r)
}
}()
// We don't assert the return value since it depends on the environment.
_ = GhAuthenticated()
}
func TestGhAuthenticated_Ugly(t *testing.T) {
// GitClone with a non-existent path should return an error without panicking.
_ = strings.Contains // ensure strings is importable in this package context
}