feat: upgrade to core v0.8.0-alpha.1, replace banned stdlib imports
All checks were successful
Security Scan / security (push) Successful in 13s

Replace fmt, errors, strings, path/filepath, encoding/json across 31
files. Migrate core.New() to functional-options API. Keep fmt.Sscanf,
strings.Index/Repeat/FieldsSeq, os, io (infra types).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-26 14:31:34 +00:00
parent e177418c90
commit e9e0eccd60
No known key found for this signature in database
GPG key ID: AF404715446AEB41
31 changed files with 393 additions and 386 deletions

View file

@ -1,8 +1,7 @@
package config package config
import ( import (
"fmt" "dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
) )
@ -20,7 +19,7 @@ func addGetCommand(parent *cli.Command) {
return cli.Err("key not found: %s", key) return cli.Err("key not found: %s", key)
} }
fmt.Println(value) core.Println(value)
return nil return nil
}) })

View file

@ -1,9 +1,10 @@
package config package config
import ( import (
"fmt"
"maps" "maps"
"os"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
@ -26,7 +27,7 @@ func addListCommand(parent *cli.Command) {
return cli.Wrap(err, "failed to format config") return cli.Wrap(err, "failed to format config")
} }
fmt.Print(string(out)) core.Print(os.Stdout, "%s", string(out))
return nil return nil
}) })

View file

@ -1,8 +1,7 @@
package config package config
import ( import (
"fmt" "dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
) )
@ -13,7 +12,7 @@ func addPathCommand(parent *cli.Command) {
return err return err
} }
fmt.Println(cfg.Path()) core.Println(cfg.Path())
return nil return nil
}) })

View file

@ -2,8 +2,8 @@ package doctor
import ( import (
"os/exec" "os/exec"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-i18n"
) )
@ -93,9 +93,9 @@ func runCheck(c check) (bool, string) {
} }
// Extract first line as version // Extract first line as version
lines := strings.Split(strings.TrimSpace(string(output)), "\n") lines := core.Split(core.Trim(string(output)), "\n")
if len(lines) > 0 { if len(lines) > 0 {
return true, strings.TrimSpace(lines[0]) return true, core.Trim(lines[0])
} }
return true, "" return true, ""
} }

View file

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

View file

@ -1,12 +1,10 @@
package doctor package doctor
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-io" "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos" "forge.lthn.ai/core/go-scm/repos"
@ -21,11 +19,11 @@ func checkGitHubSSH() bool {
return false return false
} }
sshDir := filepath.Join(home, ".ssh") sshDir := core.JoinPath(home, ".ssh")
keyPatterns := []string{"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"} keyPatterns := []string{"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"}
for _, key := range keyPatterns { for _, key := range keyPatterns {
keyPath := filepath.Join(sshDir, key) keyPath := core.JoinPath(sshDir, key)
if _, err := os.Stat(keyPath); err == nil { if _, err := os.Stat(keyPath); err == nil {
return true return true
} }
@ -39,14 +37,14 @@ func checkGitHubCLI() bool {
cmd := exec.Command("gh", "auth", "status") cmd := exec.Command("gh", "auth", "status")
output, _ := cmd.CombinedOutput() output, _ := cmd.CombinedOutput()
// Check for any successful login (even if there's also a failing token) // Check for any successful login (even if there's also a failing token)
return strings.Contains(string(output), "Logged in to") return core.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() { func checkWorkspace() {
registryPath, err := repos.FindRegistry(io.Local) registryPath, err := repos.FindRegistry(io.Local)
if err == nil { if err == nil {
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_yaml_found", map[string]any{"Path": registryPath})) core.Print(os.Stdout, " %s %s\n", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_yaml_found", map[string]any{"Path": registryPath}))
reg, err := repos.LoadRegistry(io.Local, registryPath) reg, err := repos.LoadRegistry(io.Local, registryPath)
if err == nil { if err == nil {
@ -54,26 +52,26 @@ func checkWorkspace() {
if basePath == "" { if basePath == "" {
basePath = "./packages" basePath = "./packages"
} }
if !filepath.IsAbs(basePath) { if !core.PathIsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(registryPath), basePath) basePath = core.JoinPath(core.PathDir(registryPath), basePath)
} }
if strings.HasPrefix(basePath, "~/") { if core.HasPrefix(basePath, "~/") {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
basePath = filepath.Join(home, basePath[2:]) basePath = core.JoinPath(home, basePath[2:])
} }
// Count existing repos // Count existing repos
allRepos := reg.List() allRepos := reg.List()
var cloned int var cloned int
for _, repo := range allRepos { for _, repo := range allRepos {
repoPath := filepath.Join(basePath, repo.Name) repoPath := core.JoinPath(basePath, repo.Name)
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { if _, err := os.Stat(core.JoinPath(repoPath, ".git")); err == nil {
cloned++ cloned++
} }
} }
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_cloned", map[string]any{"Cloned": cloned, "Total": len(allRepos)})) core.Print(os.Stdout, " %s %s\n", successStyle.Render("✓"), i18n.T("cmd.doctor.repos_cloned", map[string]any{"Cloned": cloned, "Total": len(allRepos)}))
} }
} else { } else {
fmt.Printf(" %s %s\n", dimStyle.Render("○"), i18n.T("cmd.doctor.no_repos_yaml")) core.Print(os.Stdout, " %s %s\n", dimStyle.Render("○"), i18n.T("cmd.doctor.no_repos_yaml"))
} }
} }

View file

@ -1,9 +1,10 @@
package doctor package doctor
import ( import (
"fmt" "os"
"runtime" "runtime"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-i18n"
) )
@ -11,16 +12,16 @@ import (
func printInstallInstructions() { func printInstallInstructions() {
switch runtime.GOOS { switch runtime.GOOS {
case "darwin": case "darwin":
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_macos")) core.Print(os.Stdout, " %s\n", i18n.T("cmd.doctor.install_macos"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_macos_cask")) core.Print(os.Stdout, " %s\n", i18n.T("cmd.doctor.install_macos_cask"))
case "linux": case "linux":
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_header")) core.Print(os.Stdout, " %s\n", i18n.T("cmd.doctor.install_linux_header"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_git")) core.Print(os.Stdout, " %s\n", i18n.T("cmd.doctor.install_linux_git"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_gh")) core.Print(os.Stdout, " %s\n", i18n.T("cmd.doctor.install_linux_gh"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_php")) core.Print(os.Stdout, " %s\n", i18n.T("cmd.doctor.install_linux_php"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_node")) core.Print(os.Stdout, " %s\n", i18n.T("cmd.doctor.install_linux_node"))
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_linux_pnpm")) core.Print(os.Stdout, " %s\n", i18n.T("cmd.doctor.install_linux_pnpm"))
default: default:
fmt.Printf(" %s\n", i18n.T("cmd.doctor.install_other")) core.Print(os.Stdout, " %s\n", i18n.T("cmd.doctor.install_other"))
} }
} }

View file

@ -1,8 +1,9 @@
package help package help
import ( import (
"fmt" "os"
"dappco.re/go/core"
"forge.lthn.ai/core/cli/pkg/cli" "forge.lthn.ai/core/cli/pkg/cli"
"forge.lthn.ai/core/go-help" "forge.lthn.ai/core/go-help"
) )
@ -19,28 +20,28 @@ func AddHelpCommands(root *cli.Command) {
if searchFlag != "" { if searchFlag != "" {
results := catalog.Search(searchFlag) results := catalog.Search(searchFlag)
if len(results) == 0 { if len(results) == 0 {
fmt.Println("No topics found.") core.Println("No topics found.")
return return
} }
fmt.Println("Search Results:") core.Println("Search Results:")
for _, res := range results { for _, res := range results {
fmt.Printf(" %s - %s\n", res.Topic.ID, res.Topic.Title) core.Print(os.Stdout, " %s - %s\n", res.Topic.ID, res.Topic.Title)
} }
return return
} }
if len(args) == 0 { if len(args) == 0 {
topics := catalog.List() topics := catalog.List()
fmt.Println("Available Help Topics:") core.Println("Available Help Topics:")
for _, t := range topics { for _, t := range topics {
fmt.Printf(" %s - %s\n", t.ID, t.Title) core.Print(os.Stdout, " %s - %s\n", t.ID, t.Title)
} }
return return
} }
topic, err := catalog.Get(args[0]) topic, err := catalog.Get(args[0])
if err != nil { if err != nil {
fmt.Printf("Error: %v\n", err) core.Print(os.Stdout, "Error: %v\n", err)
return return
} }
@ -55,8 +56,8 @@ func AddHelpCommands(root *cli.Command) {
func renderTopic(t *help.Topic) { func renderTopic(t *help.Topic) {
// Simple ANSI rendering for now // Simple ANSI rendering for now
// Use explicit ANSI codes or just print // Use explicit ANSI codes or just print
fmt.Printf("\n\033[1;34m%s\033[0m\n", t.Title) // Blue bold title core.Print(os.Stdout, "\n\033[1;34m%s\033[0m\n", t.Title) // Blue bold title
fmt.Println("----------------------------------------") core.Println("----------------------------------------")
fmt.Println(t.Content) core.Println(t.Content)
fmt.Println() core.Println()
} }

View file

@ -2,21 +2,15 @@ package pkgcmd
import ( import (
"context" "context"
"fmt"
"os" "os"
"path/filepath"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io" coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos" "forge.lthn.ai/core/go-scm/repos"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
import (
"errors"
)
var ( var (
installTargetDir string installTargetDir string
installAddToReg bool installAddToReg bool
@ -30,7 +24,7 @@ func addPkgInstallCommand(parent *cobra.Command) {
Long: i18n.T("cmd.pkg.install.long"), Long: i18n.T("cmd.pkg.install.long"),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 { if len(args) == 0 {
return errors.New(i18n.T("cmd.pkg.error.repo_required")) return core.NewError(i18n.T("cmd.pkg.error.repo_required"))
} }
return runPkgInstall(args[0], installTargetDir, installAddToReg) return runPkgInstall(args[0], installTargetDir, installAddToReg)
}, },
@ -46,9 +40,9 @@ func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error {
ctx := context.Background() ctx := context.Background()
// Parse org/repo // Parse org/repo
parts := strings.Split(repoArg, "/") parts := core.Split(repoArg, "/")
if len(parts) != 2 { if len(parts) != 2 {
return errors.New(i18n.T("cmd.pkg.error.invalid_repo_format")) return core.NewError(i18n.T("cmd.pkg.error.invalid_repo_format"))
} }
org, repoName := parts[0], parts[1] org, repoName := parts[0], parts[1]
@ -60,8 +54,8 @@ func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error {
if targetDir == "" { if targetDir == "" {
targetDir = "./packages" targetDir = "./packages"
} }
if !filepath.IsAbs(targetDir) { if !core.PathIsAbs(targetDir) {
targetDir = filepath.Join(filepath.Dir(regPath), targetDir) targetDir = core.JoinPath(core.PathDir(regPath), targetDir)
} }
} }
} }
@ -70,44 +64,44 @@ func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error {
} }
} }
if strings.HasPrefix(targetDir, "~/") { if core.HasPrefix(targetDir, "~/") {
home, _ := os.UserHomeDir() home, _ := os.UserHomeDir()
targetDir = filepath.Join(home, targetDir[2:]) targetDir = core.JoinPath(home, targetDir[2:])
} }
repoPath := filepath.Join(targetDir, repoName) repoPath := core.JoinPath(targetDir, repoName)
if coreio.Local.Exists(filepath.Join(repoPath, ".git")) { if coreio.Local.Exists(core.JoinPath(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})) core.Print(os.Stdout, "%s %s\n", dimStyle.Render(i18n.Label("skip")), i18n.T("cmd.pkg.install.already_exists", map[string]string{"Name": repoName, "Path": repoPath}))
return nil return nil
} }
if err := coreio.Local.EnsureDir(targetDir); err != nil { if err := coreio.Local.EnsureDir(targetDir); err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.create", "directory"), err) return core.Wrap(err, "", i18n.T("i18n.fail.create", "directory"))
} }
fmt.Printf("%s %s/%s\n", dimStyle.Render(i18n.T("cmd.pkg.install.installing_label")), org, repoName) core.Print(os.Stdout, "%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) core.Print(os.Stdout, "%s %s\n", dimStyle.Render(i18n.Label("target")), repoPath)
fmt.Println() core.Println()
fmt.Printf(" %s... ", dimStyle.Render(i18n.T("common.status.cloning"))) core.Print(os.Stdout, " %s... ", dimStyle.Render(i18n.T("common.status.cloning")))
err := gitClone(ctx, org, repoName, repoPath) err := gitClone(ctx, org, repoName, repoPath)
if err != nil { if err != nil {
fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error())) core.Print(os.Stdout, "%s\n", errorStyle.Render("✗ "+err.Error()))
return err return err
} }
fmt.Printf("%s\n", successStyle.Render("✓")) core.Print(os.Stdout, "%s\n", successStyle.Render("✓"))
if addToRegistry { if addToRegistry {
if err := addToRegistryFile(org, repoName); err != nil { if err := addToRegistryFile(org, repoName); err != nil {
fmt.Printf(" %s %s: %s\n", errorStyle.Render("✗"), i18n.T("cmd.pkg.install.add_to_registry"), err) core.Print(os.Stdout, " %s %s: %s\n", errorStyle.Render("✗"), i18n.T("cmd.pkg.install.add_to_registry"), err)
} else { } else {
fmt.Printf(" %s %s\n", successStyle.Render("✓"), i18n.T("cmd.pkg.install.added_to_registry")) core.Print(os.Stdout, " %s %s\n", successStyle.Render("✓"), i18n.T("cmd.pkg.install.added_to_registry"))
} }
} }
fmt.Println() core.Println()
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("i18n.done.install")), i18n.T("cmd.pkg.install.installed", map[string]string{"Name": repoName})) core.Print(os.Stdout, "%s %s\n", successStyle.Render(i18n.T("i18n.done.install")), i18n.T("cmd.pkg.install.installed", map[string]string{"Name": repoName}))
return nil return nil
} }
@ -115,7 +109,7 @@ func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error {
func addToRegistryFile(org, repoName string) error { func addToRegistryFile(org, repoName string) error {
regPath, err := repos.FindRegistry(coreio.Local) regPath, err := repos.FindRegistry(coreio.Local)
if err != nil { if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml")) return core.NewError(i18n.T("cmd.pkg.error.no_repos_yaml"))
} }
reg, err := repos.LoadRegistry(coreio.Local, regPath) reg, err := repos.LoadRegistry(coreio.Local, regPath)
@ -133,7 +127,7 @@ func addToRegistryFile(org, repoName string) error {
} }
repoType := detectRepoType(repoName) repoType := detectRepoType(repoName)
entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n", entry := core.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n",
repoName, repoType) repoName, repoType)
content += entry content += entry
@ -141,20 +135,20 @@ func addToRegistryFile(org, repoName string) error {
} }
func detectRepoType(name string) string { func detectRepoType(name string) string {
lower := strings.ToLower(name) lower := core.Lower(name)
if strings.Contains(lower, "-mod-") || strings.HasSuffix(lower, "-mod") { if core.Contains(lower, "-mod-") || core.HasSuffix(lower, "-mod") {
return "module" return "module"
} }
if strings.Contains(lower, "-plug-") || strings.HasSuffix(lower, "-plug") { if core.Contains(lower, "-plug-") || core.HasSuffix(lower, "-plug") {
return "plugin" return "plugin"
} }
if strings.Contains(lower, "-services-") || strings.HasSuffix(lower, "-services") { if core.Contains(lower, "-services-") || core.HasSuffix(lower, "-services") {
return "service" return "service"
} }
if strings.Contains(lower, "-website-") || strings.HasSuffix(lower, "-website") { if core.Contains(lower, "-website-") || core.HasSuffix(lower, "-website") {
return "website" return "website"
} }
if strings.HasPrefix(lower, "core-") { if core.HasPrefix(lower, "core-") {
return "package" return "package"
} }
return "package" return "package"

View file

@ -1,12 +1,10 @@
package pkgcmd package pkgcmd
import ( import (
"errors" "os"
"fmt"
"os/exec" "os/exec"
"path/filepath"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io" coreio "forge.lthn.ai/core/go-io"
"forge.lthn.ai/core/go-scm/repos" "forge.lthn.ai/core/go-scm/repos"
@ -30,34 +28,34 @@ func addPkgListCommand(parent *cobra.Command) {
func runPkgList() error { func runPkgList() error {
regPath, err := repos.FindRegistry(coreio.Local) regPath, err := repos.FindRegistry(coreio.Local)
if err != nil { if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml_workspace")) return core.NewError(i18n.T("cmd.pkg.error.no_repos_yaml_workspace"))
} }
reg, err := repos.LoadRegistry(coreio.Local, regPath) reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err) return core.Wrap(err, "", i18n.T("i18n.fail.load", "registry"))
} }
basePath := reg.BasePath basePath := reg.BasePath
if basePath == "" { if basePath == "" {
basePath = "." basePath = "."
} }
if !filepath.IsAbs(basePath) { if !core.PathIsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath) basePath = core.JoinPath(core.PathDir(regPath), basePath)
} }
allRepos := reg.List() allRepos := reg.List()
if len(allRepos) == 0 { if len(allRepos) == 0 {
fmt.Println(i18n.T("cmd.pkg.list.no_packages")) core.Println(i18n.T("cmd.pkg.list.no_packages"))
return nil return nil
} }
fmt.Printf("%s\n\n", repoNameStyle.Render(i18n.T("cmd.pkg.list.title"))) core.Print(os.Stdout, "%s\n\n", repoNameStyle.Render(i18n.T("cmd.pkg.list.title")))
var installed, missing int var installed, missing int
for _, r := range allRepos { for _, r := range allRepos {
repoPath := filepath.Join(basePath, r.Name) repoPath := core.JoinPath(basePath, r.Name)
exists := coreio.Local.Exists(filepath.Join(repoPath, ".git")) exists := coreio.Local.Exists(core.JoinPath(repoPath, ".git"))
if exists { if exists {
installed++ installed++
} else { } else {
@ -77,15 +75,15 @@ func runPkgList() error {
desc = dimStyle.Render(i18n.T("cmd.pkg.no_description")) desc = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
} }
fmt.Printf(" %s %s\n", status, repoNameStyle.Render(r.Name)) core.Print(os.Stdout, " %s %s\n", status, repoNameStyle.Render(r.Name))
fmt.Printf(" %s\n", desc) core.Print(os.Stdout, " %s\n", desc)
} }
fmt.Println() core.Println()
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("total")), i18n.T("cmd.pkg.list.summary", map[string]int{"Installed": installed, "Missing": missing})) core.Print(os.Stdout, "%s %s\n", dimStyle.Render(i18n.Label("total")), i18n.T("cmd.pkg.list.summary", map[string]int{"Installed": installed, "Missing": missing}))
if missing > 0 { if missing > 0 {
fmt.Printf("\n%s %s\n", i18n.T("cmd.pkg.list.install_missing"), dimStyle.Render("core setup")) core.Print(os.Stdout, "\n%s %s\n", i18n.T("cmd.pkg.list.install_missing"), dimStyle.Render("core setup"))
} }
return nil return nil
@ -101,7 +99,7 @@ func addPkgUpdateCommand(parent *cobra.Command) {
Long: i18n.T("cmd.pkg.update.long"), Long: i18n.T("cmd.pkg.update.long"),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if !updateAll && len(args) == 0 { if !updateAll && len(args) == 0 {
return errors.New(i18n.T("cmd.pkg.error.specify_package")) return core.NewError(i18n.T("cmd.pkg.error.specify_package"))
} }
return runPkgUpdate(args, updateAll) return runPkgUpdate(args, updateAll)
}, },
@ -115,20 +113,20 @@ func addPkgUpdateCommand(parent *cobra.Command) {
func runPkgUpdate(packages []string, all bool) error { func runPkgUpdate(packages []string, all bool) error {
regPath, err := repos.FindRegistry(coreio.Local) regPath, err := repos.FindRegistry(coreio.Local)
if err != nil { if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml")) return core.NewError(i18n.T("cmd.pkg.error.no_repos_yaml"))
} }
reg, err := repos.LoadRegistry(coreio.Local, regPath) reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err) return core.Wrap(err, "", i18n.T("i18n.fail.load", "registry"))
} }
basePath := reg.BasePath basePath := reg.BasePath
if basePath == "" { if basePath == "" {
basePath = "." basePath = "."
} }
if !filepath.IsAbs(basePath) { if !core.PathIsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath) basePath = core.JoinPath(core.PathDir(regPath), basePath)
} }
var toUpdate []string var toUpdate []string
@ -140,39 +138,39 @@ func runPkgUpdate(packages []string, all bool) error {
toUpdate = packages toUpdate = packages
} }
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)})) core.Print(os.Stdout, "%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 var updated, skipped, failed int
for _, name := range toUpdate { for _, name := range toUpdate {
repoPath := filepath.Join(basePath, name) repoPath := core.JoinPath(basePath, name)
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err != nil { if _, err := coreio.Local.List(core.JoinPath(repoPath, ".git")); err != nil {
fmt.Printf(" %s %s (%s)\n", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed")) core.Print(os.Stdout, " %s %s (%s)\n", dimStyle.Render("○"), name, i18n.T("cmd.pkg.update.not_installed"))
skipped++ skipped++
continue continue
} }
fmt.Printf(" %s %s... ", dimStyle.Render("↓"), name) core.Print(os.Stdout, " %s %s... ", dimStyle.Render("↓"), name)
cmd := exec.Command("git", "-C", repoPath, "pull", "--ff-only") cmd := exec.Command("git", "-C", repoPath, "pull", "--ff-only")
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
fmt.Printf("%s\n", errorStyle.Render("✗")) core.Print(os.Stdout, "%s\n", errorStyle.Render("✗"))
fmt.Printf(" %s\n", strings.TrimSpace(string(output))) core.Print(os.Stdout, " %s\n", core.Trim(string(output)))
failed++ failed++
continue continue
} }
if strings.Contains(string(output), "Already up to date") { if core.Contains(string(output), "Already up to date") {
fmt.Printf("%s\n", dimStyle.Render(i18n.T("common.status.up_to_date"))) core.Print(os.Stdout, "%s\n", dimStyle.Render(i18n.T("common.status.up_to_date")))
} else { } else {
fmt.Printf("%s\n", successStyle.Render("✓")) core.Print(os.Stdout, "%s\n", successStyle.Render("✓"))
} }
updated++ updated++
} }
fmt.Println() core.Println()
fmt.Printf("%s %s\n", core.Print(os.Stdout, "%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})) dimStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.update.summary", map[string]int{"Updated": updated, "Skipped": skipped, "Failed": failed}))
return nil return nil
@ -195,30 +193,30 @@ func addPkgOutdatedCommand(parent *cobra.Command) {
func runPkgOutdated() error { func runPkgOutdated() error {
regPath, err := repos.FindRegistry(coreio.Local) regPath, err := repos.FindRegistry(coreio.Local)
if err != nil { if err != nil {
return errors.New(i18n.T("cmd.pkg.error.no_repos_yaml")) return core.NewError(i18n.T("cmd.pkg.error.no_repos_yaml"))
} }
reg, err := repos.LoadRegistry(coreio.Local, regPath) reg, err := repos.LoadRegistry(coreio.Local, regPath)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.load", "registry"), err) return core.Wrap(err, "", i18n.T("i18n.fail.load", "registry"))
} }
basePath := reg.BasePath basePath := reg.BasePath
if basePath == "" { if basePath == "" {
basePath = "." basePath = "."
} }
if !filepath.IsAbs(basePath) { if !core.PathIsAbs(basePath) {
basePath = filepath.Join(filepath.Dir(regPath), basePath) basePath = core.JoinPath(core.PathDir(regPath), basePath)
} }
fmt.Printf("%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.outdated.outdated_label")), i18n.T("common.progress.checking_updates")) core.Print(os.Stdout, "%s %s\n\n", dimStyle.Render(i18n.T("cmd.pkg.outdated.outdated_label")), i18n.T("common.progress.checking_updates"))
var outdated, upToDate, notInstalled int var outdated, upToDate, notInstalled int
for _, r := range reg.List() { for _, r := range reg.List() {
repoPath := filepath.Join(basePath, r.Name) repoPath := core.JoinPath(basePath, r.Name)
if !coreio.Local.Exists(filepath.Join(repoPath, ".git")) { if !coreio.Local.Exists(core.JoinPath(repoPath, ".git")) {
notInstalled++ notInstalled++
continue continue
} }
@ -233,9 +231,9 @@ func runPkgOutdated() error {
continue continue
} }
count := strings.TrimSpace(string(output)) count := core.Trim(string(output))
if count != "0" { if count != "0" {
fmt.Printf(" %s %s (%s)\n", core.Print(os.Stdout, " %s %s (%s)\n",
errorStyle.Render("↓"), repoNameStyle.Render(r.Name), i18n.T("cmd.pkg.outdated.commits_behind", map[string]string{"Count": count})) errorStyle.Render("↓"), repoNameStyle.Render(r.Name), i18n.T("cmd.pkg.outdated.commits_behind", map[string]string{"Count": count}))
outdated++ outdated++
} else { } else {
@ -243,13 +241,13 @@ func runPkgOutdated() error {
} }
} }
fmt.Println() core.Println()
if outdated == 0 { if outdated == 0 {
fmt.Printf("%s %s\n", successStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.outdated.all_up_to_date")) core.Print(os.Stdout, "%s %s\n", successStyle.Render(i18n.T("i18n.done.update")), i18n.T("cmd.pkg.outdated.all_up_to_date"))
} else { } else {
fmt.Printf("%s %s\n", core.Print(os.Stdout, "%s %s\n",
dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.pkg.outdated.summary", map[string]int{"Outdated": outdated, "UpToDate": upToDate})) dimStyle.Render(i18n.Label("summary")), i18n.T("cmd.pkg.outdated.summary", map[string]int{"Outdated": outdated, "UpToDate": upToDate}))
fmt.Printf("\n%s %s\n", i18n.T("cmd.pkg.outdated.update_with"), dimStyle.Render("core pkg update --all")) core.Print(os.Stdout, "\n%s %s\n", i18n.T("cmd.pkg.outdated.update_with"), dimStyle.Render("core pkg update --all"))
} }
return nil return nil

View file

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

View file

@ -3,16 +3,16 @@ package pkgcmd
import ( import (
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"testing" "testing"
"dappco.re/go/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func setupTestRepo(t *testing.T, dir, name string) string { func setupTestRepo(t *testing.T, dir, name string) string {
t.Helper() t.Helper()
repoPath := filepath.Join(dir, name) repoPath := core.JoinPath(dir, name)
require.NoError(t, os.MkdirAll(repoPath, 0755)) require.NoError(t, os.MkdirAll(repoPath, 0755))
cmds := [][]string{ cmds := [][]string{
@ -43,7 +43,7 @@ func TestCheckRepoSafety_UncommittedChanges(t *testing.T) {
tmp := t.TempDir() tmp := t.TempDir()
repoPath := setupTestRepo(t, tmp, "dirty-repo") repoPath := setupTestRepo(t, tmp, "dirty-repo")
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "new.txt"), []byte("data"), 0644)) require.NoError(t, os.WriteFile(core.JoinPath(repoPath, "new.txt"), []byte("data"), 0644))
blocked, reasons := checkRepoSafety(repoPath) blocked, reasons := checkRepoSafety(repoPath)
assert.True(t, blocked) assert.True(t, blocked)
@ -56,7 +56,7 @@ func TestCheckRepoSafety_Stash(t *testing.T) {
repoPath := setupTestRepo(t, tmp, "stash-repo") repoPath := setupTestRepo(t, tmp, "stash-repo")
// Create a file, add, stash // Create a file, add, stash
require.NoError(t, os.WriteFile(filepath.Join(repoPath, "stash.txt"), []byte("data"), 0644)) require.NoError(t, os.WriteFile(core.JoinPath(repoPath, "stash.txt"), []byte("data"), 0644))
cmd := exec.Command("git", "add", ".") cmd := exec.Command("git", "add", ".")
cmd.Dir = repoPath cmd.Dir = repoPath
require.NoError(t, cmd.Run()) require.NoError(t, cmd.Run())

View file

@ -2,16 +2,13 @@ package pkgcmd
import ( import (
"cmp" "cmp"
"encoding/json"
"errors"
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"slices" "slices"
"strings" "strings"
"time" "time"
"dappco.re/go/core"
"forge.lthn.ai/core/go-cache" "forge.lthn.ai/core/go-cache"
"forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-i18n"
coreio "forge.lthn.ai/core/go-io" coreio "forge.lthn.ai/core/go-io"
@ -72,7 +69,7 @@ func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error
// Initialize cache in workspace .core/ directory // Initialize cache in workspace .core/ directory
var cacheDir string var cacheDir string
if regPath, err := repos.FindRegistry(coreio.Local); err == nil { if regPath, err := repos.FindRegistry(coreio.Local); err == nil {
cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache") cacheDir = core.JoinPath(core.PathDir(regPath), ".core", "cache")
} }
c, err := cache.New(coreio.Local, cacheDir, 0) c, err := cache.New(coreio.Local, cacheDir, 0)
@ -89,46 +86,47 @@ func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error
if found, err := c.Get(cacheKey, &ghRepos); found && err == nil { if found, err := c.Get(cacheKey, &ghRepos); found && err == nil {
fromCache = true fromCache = true
age := c.Age(cacheKey) 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)))) core.Print(os.Stdout, "%s %s %s\n", dimStyle.Render(i18n.T("cmd.pkg.search.cache_label")), org, dimStyle.Render(core.Sprintf("(%s ago)", age.Round(time.Second))))
} }
} }
// Fetch from GitHub if not cached // Fetch from GitHub if not cached
if !fromCache { if !fromCache {
if !ghAuthenticated() { if !ghAuthenticated() {
return errors.New(i18n.T("cmd.pkg.error.gh_not_authenticated")) return core.NewError(i18n.T("cmd.pkg.error.gh_not_authenticated"))
} }
if os.Getenv("GH_TOKEN") != "" { if os.Getenv("GH_TOKEN") != "" {
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("note")), i18n.T("cmd.pkg.search.gh_token_warning")) core.Print(os.Stdout, "%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")) core.Print(os.Stdout, "%s %s\n\n", dimStyle.Render(""), i18n.T("cmd.pkg.search.gh_token_unset"))
} }
fmt.Printf("%s %s... ", dimStyle.Render(i18n.T("cmd.pkg.search.fetching_label")), org) core.Print(os.Stdout, "%s %s... ", dimStyle.Render(i18n.T("cmd.pkg.search.fetching_label")), org)
cmd := exec.Command("gh", "repo", "list", org, cmd := exec.Command("gh", "repo", "list", org,
"--json", "name,description,visibility,updatedAt,primaryLanguage", "--json", "name,description,visibility,updatedAt,primaryLanguage",
"--limit", fmt.Sprintf("%d", limit)) "--limit", core.Sprintf("%d", limit))
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
fmt.Println() core.Println()
errStr := strings.TrimSpace(string(output)) errStr := core.Trim(string(output))
if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") { if core.Contains(errStr, "401") || core.Contains(errStr, "Bad credentials") {
return errors.New(i18n.T("cmd.pkg.error.auth_failed")) return core.NewError(i18n.T("cmd.pkg.error.auth_failed"))
} }
return fmt.Errorf("%s: %s", i18n.T("cmd.pkg.error.search_failed"), errStr) return core.NewError(core.Sprintf("%s: %s", i18n.T("cmd.pkg.error.search_failed"), errStr))
} }
if err := json.Unmarshal(output, &ghRepos); err != nil { r := core.JSONUnmarshal(output, &ghRepos)
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.parse", "results"), err) if !r.OK {
return core.NewError(core.Sprintf("%s: %v", i18n.T("i18n.fail.parse", "results"), r.Value))
} }
if c != nil { if c != nil {
_ = c.Set(cacheKey, ghRepos) _ = c.Set(cacheKey, ghRepos)
} }
fmt.Printf("%s\n", successStyle.Render("✓")) core.Print(os.Stdout, "%s\n", successStyle.Render("✓"))
} }
// Filter by glob pattern and type // Filter by glob pattern and type
@ -137,14 +135,14 @@ func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error
if !matchGlob(pattern, r.Name) { if !matchGlob(pattern, r.Name) {
continue continue
} }
if repoType != "" && !strings.Contains(r.Name, repoType) { if repoType != "" && !core.Contains(r.Name, repoType) {
continue continue
} }
filtered = append(filtered, r) filtered = append(filtered, r)
} }
if len(filtered) == 0 { if len(filtered) == 0 {
fmt.Println(i18n.T("cmd.pkg.search.no_repos_found")) core.Println(i18n.T("cmd.pkg.search.no_repos_found"))
return nil return nil
} }
@ -152,7 +150,7 @@ func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error
return cmp.Compare(a.Name, b.Name) return cmp.Compare(a.Name, b.Name)
}) })
fmt.Print(i18n.T("cmd.pkg.search.found_repos", map[string]int{"Count": len(filtered)}) + "\n\n") core.Print(os.Stdout, "%s", i18n.T("cmd.pkg.search.found_repos", map[string]int{"Count": len(filtered)})+"\n\n")
for _, r := range filtered { for _, r := range filtered {
visibility := "" visibility := ""
@ -168,12 +166,12 @@ func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error
desc = dimStyle.Render(i18n.T("cmd.pkg.no_description")) desc = dimStyle.Render(i18n.T("cmd.pkg.no_description"))
} }
fmt.Printf(" %s%s\n", repoNameStyle.Render(r.Name), visibility) core.Print(os.Stdout, " %s%s\n", repoNameStyle.Render(r.Name), visibility)
fmt.Printf(" %s\n", desc) core.Print(os.Stdout, " %s\n", desc)
} }
fmt.Println() core.Println()
fmt.Printf("%s %s\n", i18n.T("common.hint.install_with"), dimStyle.Render(fmt.Sprintf("core pkg install %s/<repo-name>", org))) core.Print(os.Stdout, "%s %s\n", i18n.T("common.hint.install_with"), dimStyle.Render(core.Sprintf("core pkg install %s/<repo-name>", org)))
return nil return nil
} }
@ -184,7 +182,7 @@ func matchGlob(pattern, name string) bool {
return true return true
} }
parts := strings.Split(pattern, "*") parts := core.Split(pattern, "*")
pos := 0 pos := 0
for i, part := range parts { for i, part := range parts {
if part == "" { if part == "" {
@ -194,12 +192,12 @@ func matchGlob(pattern, name string) bool {
if idx == -1 { if idx == -1 {
return false return false
} }
if i == 0 && !strings.HasPrefix(pattern, "*") && idx != 0 { if i == 0 && !core.HasPrefix(pattern, "*") && idx != 0 {
return false return false
} }
pos += idx + len(part) pos += idx + len(part)
} }
if !strings.HasSuffix(pattern, "*") && pos != len(name) { if !core.HasSuffix(pattern, "*") && pos != len(name) {
return false return false
} }
return true return true

2
go.mod
View file

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

4
go.sum
View file

@ -1,5 +1,5 @@
dappco.re/go/core v0.4.7 h1:KmIA/2lo6rl1NMtLrKqCWfMlUqpDZYH3q0/d10dTtGA= dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk=
dappco.re/go/core v0.4.7/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A=
forge.lthn.ai/core/go v0.3.2 h1:VB9pW6ggqBhe438cjfE2iSI5Lg+62MmRbaOFglZM+nQ= 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 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 h1:aHkAoc3W8fw3RPNvw/UszQbjyFWXHszzbZgty3SwyAA=

View file

@ -1,11 +1,11 @@
package cli package cli
import ( import (
"fmt"
"os" "os"
"strconv" "strconv"
"strings"
"sync" "sync"
"dappco.re/go/core"
) )
// ANSI escape codes // ANSI escape codes
@ -134,24 +134,24 @@ func (s *AnsiStyle) Render(text string) string {
return text return text
} }
return strings.Join(codes, "") + text + ansiReset return core.Join("", codes...) + text + ansiReset
} }
// fgColorHex converts a hex string to an ANSI foreground color code. // fgColorHex converts a hex string to an ANSI foreground color code.
func fgColorHex(hex string) string { func fgColorHex(hex string) string {
r, g, b := hexToRGB(hex) r, g, b := hexToRGB(hex)
return fmt.Sprintf("\033[38;2;%d;%d;%dm", r, g, b) return core.Sprintf("\033[38;2;%d;%d;%dm", r, g, b)
} }
// bgColorHex converts a hex string to an ANSI background color code. // bgColorHex converts a hex string to an ANSI background color code.
func bgColorHex(hex string) string { func bgColorHex(hex string) string {
r, g, b := hexToRGB(hex) r, g, b := hexToRGB(hex)
return fmt.Sprintf("\033[48;2;%d;%d;%dm", r, g, b) return core.Sprintf("\033[48;2;%d;%d;%dm", r, g, b)
} }
// hexToRGB converts a hex string to RGB values. // hexToRGB converts a hex string to RGB values.
func hexToRGB(hex string) (int, int, int) { func hexToRGB(hex string) (int, int, int) {
hex = strings.TrimPrefix(hex, "#") hex = core.TrimPrefix(hex, "#")
if len(hex) != 6 { if len(hex) != 6 {
return 255, 255, 255 return 255, 255, 255
} }

View file

@ -2,14 +2,13 @@ package cli
import ( import (
"embed" "embed"
"fmt"
"io/fs" "io/fs"
"os" "os"
"runtime/debug" "runtime/debug"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-log"
"dappco.re/go/core"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -83,7 +82,7 @@ func MainWithLocales(locales []LocaleSource, commands ...CommandSetup) {
if r := recover(); r != nil { if r := recover(); r != nil {
log.Error("recovered from panic", "error", r, "stack", string(debug.Stack())) log.Error("recovered from panic", "error", r, "stack", string(debug.Stack()))
Shutdown() Shutdown()
Fatal(fmt.Errorf("panic: %v", r)) Fatal(core.NewError(core.Sprintf("panic: %v", r)))
} }
}() }()
@ -131,7 +130,7 @@ func newCompletionCmd() *cobra.Command {
return &cobra.Command{ return &cobra.Command{
Use: "completion [bash|zsh|fish|powershell]", Use: "completion [bash|zsh|fish|powershell]",
Short: "Generate shell completion script", Short: "Generate shell completion script",
Long: fmt.Sprintf(`Generate shell completion script for the specified shell. Long: core.Sprintf(`Generate shell completion script for the specified shell.
To load completions: To load completions:

View file

@ -2,11 +2,11 @@ package cli
import ( import (
"bytes" "bytes"
"fmt"
"runtime/debug" "runtime/debug"
"sync" "sync"
"testing" "testing"
"dappco.re/go/core"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
@ -47,7 +47,7 @@ func TestPanicRecovery_Good(t *testing.T) {
} }
}() }()
panic(fmt.Errorf("error panic")) panic(core.NewError(core.Sprintf("error panic")))
}() }()
err, ok := recovered.(error) err, ok := recovered.(error)
@ -91,7 +91,7 @@ func TestPanicRecovery_Bad(t *testing.T) {
} }
}() }()
panic(fmt.Sprintf("panic from goroutine %d", id)) panic(core.Sprintf("panic from goroutine %d", id))
}(i) }(i)
} }
@ -134,7 +134,7 @@ func TestMainPanicRecoveryPattern(t *testing.T) {
// Mock implementations // Mock implementations
mockLogError := func(msg string, args ...any) { mockLogError := func(msg string, args ...any) {
fmt.Fprintf(&logBuffer, msg, args...) core.Print(&logBuffer, msg, args...)
} }
mockShutdown := func() { mockShutdown := func() {
shutdownCalled = true shutdownCalled = true
@ -149,7 +149,7 @@ func TestMainPanicRecoveryPattern(t *testing.T) {
if r := recover(); r != nil { if r := recover(); r != nil {
mockLogError("recovered from panic: %v", r) mockLogError("recovered from panic: %v", r)
mockShutdown() mockShutdown()
mockFatal(fmt.Errorf("panic: %v", r)) mockFatal(core.NewError(core.Sprintf("panic: %v", r)))
} }
}() }()

View file

@ -1,10 +1,9 @@
package cli package cli
import ( import (
"errors"
"fmt"
"os" "os"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-i18n"
) )
@ -15,7 +14,7 @@ import (
// Err creates a new error from a format string. // Err creates a new error from a format string.
// This is a direct replacement for fmt.Errorf. // This is a direct replacement for fmt.Errorf.
func Err(format string, args ...any) error { func Err(format string, args ...any) error {
return fmt.Errorf(format, args...) return core.NewError(core.Sprintf(format, args...))
} }
// Wrap wraps an error with a message. // Wrap wraps an error with a message.
@ -26,7 +25,7 @@ func Wrap(err error, msg string) error {
if err == nil { if err == nil {
return nil return nil
} }
return fmt.Errorf("%s: %w", msg, err) return core.Wrap(err, "", msg)
} }
// WrapVerb wraps an error using i18n grammar for "Failed to verb subject". // WrapVerb wraps an error using i18n grammar for "Failed to verb subject".
@ -39,7 +38,7 @@ func WrapVerb(err error, verb, subject string) error {
return nil return nil
} }
msg := i18n.ActionFailed(verb, subject) msg := i18n.ActionFailed(verb, subject)
return fmt.Errorf("%s: %w", msg, err) return core.Wrap(err, "", msg)
} }
// WrapAction wraps an error using i18n grammar for "Failed to verb". // WrapAction wraps an error using i18n grammar for "Failed to verb".
@ -52,7 +51,7 @@ func WrapAction(err error, verb string) error {
return nil return nil
} }
msg := i18n.ActionFailed(verb, "") msg := i18n.ActionFailed(verb, "")
return fmt.Errorf("%s: %w", msg, err) return core.Wrap(err, "", msg)
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
@ -62,19 +61,19 @@ func WrapAction(err error, verb string) error {
// Is reports whether any error in err's tree matches target. // Is reports whether any error in err's tree matches target.
// This is a re-export of errors.Is for convenience. // This is a re-export of errors.Is for convenience.
func Is(err, target error) bool { func Is(err, target error) bool {
return errors.Is(err, target) return core.Is(err, target)
} }
// As finds the first error in err's tree that matches target. // As finds the first error in err's tree that matches target.
// This is a re-export of errors.As for convenience. // This is a re-export of errors.As for convenience.
func As(err error, target any) bool { func As(err error, target any) bool {
return errors.As(err, target) return core.As(err, target)
} }
// Join returns an error that wraps the given errors. // Join returns an error that wraps the given errors.
// This is a re-export of errors.Join for convenience. // This is a re-export of errors.Join for convenience.
func Join(errs ...error) error { func Join(errs ...error) error {
return errors.Join(errs...) return core.ErrorJoin(errs...)
} }
// ExitError represents an error that should cause the CLI to exit with a specific code. // ExitError represents an error that should cause the CLI to exit with a specific code.
@ -113,7 +112,7 @@ func Exit(code int, err error) error {
func Fatal(err error) { func Fatal(err error) {
if err != nil { if err != nil {
LogError("Fatal error", "err", err) LogError("Fatal error", "err", err)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+err.Error())) core.Print(os.Stderr, "%s\n", ErrorStyle.Render(Glyph(":cross:")+" "+err.Error()))
os.Exit(1) os.Exit(1)
} }
} }
@ -122,9 +121,9 @@ func Fatal(err error) {
// //
// Deprecated: return an error from the command instead. // Deprecated: return an error from the command instead.
func Fatalf(format string, args ...any) { func Fatalf(format string, args ...any) {
msg := fmt.Sprintf(format, args...) msg := core.Sprintf(format, args...)
LogError("Fatal error", "msg", msg) LogError("Fatal error", "msg", msg)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg)) core.Print(os.Stderr, "%s\n", ErrorStyle.Render(Glyph(":cross:")+" "+msg))
os.Exit(1) os.Exit(1)
} }
@ -139,8 +138,8 @@ func FatalWrap(err error, msg string) {
return return
} }
LogError("Fatal error", "msg", msg, "err", err) LogError("Fatal error", "msg", msg, "err", err)
fullMsg := fmt.Sprintf("%s: %v", msg, err) fullMsg := core.Sprintf("%s: %v", msg, err)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) core.Print(os.Stderr, "%s\n", ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
os.Exit(1) os.Exit(1)
} }
@ -156,7 +155,7 @@ func FatalWrapVerb(err error, verb, subject string) {
} }
msg := i18n.ActionFailed(verb, subject) msg := i18n.ActionFailed(verb, subject)
LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject) LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject)
fullMsg := fmt.Sprintf("%s: %v", msg, err) fullMsg := core.Sprintf("%s: %v", msg, err)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) core.Print(os.Stderr, "%s\n", ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg))
os.Exit(1) os.Exit(1)
} }

View file

@ -1,13 +1,12 @@
package cli package cli
import ( import (
"fmt"
"io" "io"
"os" "os"
"strings"
"sync" "sync"
"time" "time"
"dappco.re/go/core"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
"golang.org/x/term" "golang.org/x/term"
@ -397,7 +396,7 @@ func (f *Frame) updateFocusedLocked(msg tea.Msg) tea.Cmd {
// In non-TTY mode, it renders once and returns immediately. // In non-TTY mode, it renders once and returns immediately.
func (f *Frame) Run() { func (f *Frame) Run() {
if !f.isTTY() { if !f.isTTY() {
fmt.Fprint(f.out, f.String()) _, _ = io.WriteString(f.out, f.String())
return return
} }
f.runLive() f.runLive()
@ -429,7 +428,7 @@ func (f *Frame) String() string {
return "" return ""
} }
// Ensure trailing newline for non-TTY consistency // Ensure trailing newline for non-TTY consistency
if !strings.HasSuffix(view, "\n") { if !core.HasSuffix(view, "\n") {
view += "\n" view += "\n"
} }
return view return view

View file

@ -1,8 +1,9 @@
package cli package cli
import ( import (
"fmt"
"iter" "iter"
"dappco.re/go/core"
) )
// Region represents one of the 5 HLCRF regions. // Region represents one of the 5 HLCRF regions.
@ -91,7 +92,7 @@ func ParseVariant(variant string) (*Composite, error) {
for i < len(variant) { for i < len(variant) {
r := Region(variant[i]) r := Region(variant[i])
if !isValidRegion(r) { if !isValidRegion(r) {
return nil, fmt.Errorf("invalid region: %c", r) return nil, core.NewError(core.Sprintf("invalid region: %c", r))
} }
slot := &Slot{region: r, path: string(r)} slot := &Slot{region: r, path: string(r)}
@ -101,7 +102,7 @@ func ParseVariant(variant string) (*Composite, error) {
if i < len(variant) && variant[i] == '[' { if i < len(variant) && variant[i] == '[' {
end := findMatchingBracket(variant, i) end := findMatchingBracket(variant, i)
if end == -1 { if end == -1 {
return nil, fmt.Errorf("unmatched bracket at %d", i) return nil, core.NewError(core.Sprintf("unmatched bracket at %d", i))
} }
nested, err := ParseVariant(variant[i+1 : end]) nested, err := ParseVariant(variant[i+1 : end])
if err != nil { if err != nil {
@ -168,6 +169,6 @@ func toRenderable(item any) Renderable {
case string: case string:
return StringBlock(v) return StringBlock(v)
default: default:
return StringBlock(fmt.Sprint(v)) return StringBlock(core.Sprint(v))
} }
} }

View file

@ -1,60 +1,60 @@
package cli package cli
import ( import (
"fmt" "io"
"os" "os"
"strings"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-i18n"
) )
// Blank prints an empty line. // Blank prints an empty line.
func Blank() { func Blank() {
fmt.Println() core.Println()
} }
// Echo translates a key via i18n.T and prints with newline. // Echo translates a key via i18n.T and prints with newline.
// No automatic styling - use Success/Error/Warn/Info for styled output. // No automatic styling - use Success/Error/Warn/Info for styled output.
func Echo(key string, args ...any) { func Echo(key string, args ...any) {
fmt.Println(i18n.T(key, args...)) core.Println(i18n.T(key, args...))
} }
// Print outputs formatted text (no newline). // Print outputs formatted text (no newline).
// Glyph shortcodes like :check: are converted. // Glyph shortcodes like :check: are converted.
func Print(format string, args ...any) { func Print(format string, args ...any) {
fmt.Print(compileGlyphs(fmt.Sprintf(format, args...))) _, _ = io.WriteString(os.Stdout, compileGlyphs(core.Sprintf(format, args...)))
} }
// Println outputs formatted text with newline. // Println outputs formatted text with newline.
// Glyph shortcodes like :check: are converted. // Glyph shortcodes like :check: are converted.
func Println(format string, args ...any) { func Println(format string, args ...any) {
fmt.Println(compileGlyphs(fmt.Sprintf(format, args...))) core.Println(compileGlyphs(core.Sprintf(format, args...)))
} }
// Text prints arguments like fmt.Println, but handling glyphs. // Text prints arguments like fmt.Println, but handling glyphs.
func Text(args ...any) { func Text(args ...any) {
fmt.Println(compileGlyphs(fmt.Sprint(args...))) core.Println(compileGlyphs(core.Sprint(args...)))
} }
// Success prints a success message with checkmark (green). // Success prints a success message with checkmark (green).
func Success(msg string) { func Success(msg string) {
fmt.Println(SuccessStyle.Render(Glyph(":check:") + " " + msg)) core.Println(SuccessStyle.Render(Glyph(":check:") + " " + msg))
} }
// Successf prints a formatted success message. // Successf prints a formatted success message.
func Successf(format string, args ...any) { func Successf(format string, args ...any) {
Success(fmt.Sprintf(format, args...)) Success(core.Sprintf(format, args...))
} }
// Error prints an error message with cross (red) to stderr and logs it. // Error prints an error message with cross (red) to stderr and logs it.
func Error(msg string) { func Error(msg string) {
LogError(msg) LogError(msg)
fmt.Fprintln(os.Stderr, ErrorStyle.Render(Glyph(":cross:")+" "+msg)) core.Print(os.Stderr, "%s\n", ErrorStyle.Render(Glyph(":cross:")+" "+msg))
} }
// Errorf prints a formatted error message to stderr and logs it. // Errorf prints a formatted error message to stderr and logs it.
func Errorf(format string, args ...any) { func Errorf(format string, args ...any) {
Error(fmt.Sprintf(format, args...)) Error(core.Sprintf(format, args...))
} }
// ErrorWrap prints a wrapped error message to stderr and logs it. // ErrorWrap prints a wrapped error message to stderr and logs it.
@ -62,7 +62,7 @@ func ErrorWrap(err error, msg string) {
if err == nil { if err == nil {
return return
} }
Error(fmt.Sprintf("%s: %v", msg, err)) Error(core.Sprintf("%s: %v", msg, err))
} }
// ErrorWrapVerb prints a wrapped error using i18n grammar to stderr and logs it. // ErrorWrapVerb prints a wrapped error using i18n grammar to stderr and logs it.
@ -71,7 +71,7 @@ func ErrorWrapVerb(err error, verb, subject string) {
return return
} }
msg := i18n.ActionFailed(verb, subject) msg := i18n.ActionFailed(verb, subject)
Error(fmt.Sprintf("%s: %v", msg, err)) Error(core.Sprintf("%s: %v", msg, err))
} }
// ErrorWrapAction prints a wrapped error using i18n grammar to stderr and logs it. // ErrorWrapAction prints a wrapped error using i18n grammar to stderr and logs it.
@ -80,33 +80,33 @@ func ErrorWrapAction(err error, verb string) {
return return
} }
msg := i18n.ActionFailed(verb, "") msg := i18n.ActionFailed(verb, "")
Error(fmt.Sprintf("%s: %v", msg, err)) Error(core.Sprintf("%s: %v", msg, err))
} }
// Warn prints a warning message with warning symbol (amber) to stderr and logs it. // Warn prints a warning message with warning symbol (amber) to stderr and logs it.
func Warn(msg string) { func Warn(msg string) {
LogWarn(msg) LogWarn(msg)
fmt.Fprintln(os.Stderr, WarningStyle.Render(Glyph(":warn:")+" "+msg)) core.Print(os.Stderr, "%s\n", WarningStyle.Render(Glyph(":warn:")+" "+msg))
} }
// Warnf prints a formatted warning message to stderr and logs it. // Warnf prints a formatted warning message to stderr and logs it.
func Warnf(format string, args ...any) { func Warnf(format string, args ...any) {
Warn(fmt.Sprintf(format, args...)) Warn(core.Sprintf(format, args...))
} }
// Info prints an info message with info symbol (blue). // Info prints an info message with info symbol (blue).
func Info(msg string) { func Info(msg string) {
fmt.Println(InfoStyle.Render(Glyph(":info:") + " " + msg)) core.Println(InfoStyle.Render(Glyph(":info:") + " " + msg))
} }
// Infof prints a formatted info message. // Infof prints a formatted info message.
func Infof(format string, args ...any) { func Infof(format string, args ...any) {
Info(fmt.Sprintf(format, args...)) Info(core.Sprintf(format, args...))
} }
// Dim prints dimmed text. // Dim prints dimmed text.
func Dim(msg string) { func Dim(msg string) {
fmt.Println(DimStyle.Render(msg)) core.Println(DimStyle.Render(msg))
} }
// Progress prints a progress indicator that overwrites the current line. // Progress prints a progress indicator that overwrites the current line.
@ -114,25 +114,26 @@ func Dim(msg string) {
func Progress(verb string, current, total int, item ...string) { func Progress(verb string, current, total int, item ...string) {
msg := i18n.Progress(verb) msg := i18n.Progress(verb)
if len(item) > 0 && item[0] != "" { if len(item) > 0 && item[0] != "" {
fmt.Printf("\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, item[0]) core.Print(os.Stdout, "\033[2K\r%s %d/%d %s", DimStyle.Render(msg), current, total, item[0])
} else { } else {
fmt.Printf("\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total) core.Print(os.Stdout, "\033[2K\r%s %d/%d", DimStyle.Render(msg), current, total)
} }
} }
// ProgressDone clears the progress line. // ProgressDone clears the progress line.
func ProgressDone() { func ProgressDone() {
fmt.Print("\033[2K\r") _, _ = io.WriteString(os.Stdout, "\033[2K\r")
} }
// Label prints a "Label: value" line. // Label prints a "Label: value" line.
func Label(word, value string) { func Label(word, value string) {
fmt.Printf("%s %s\n", KeyStyle.Render(i18n.Label(word)), value) core.Print(os.Stdout, "%s %s\n", KeyStyle.Render(i18n.Label(word)), value)
} }
// Scanln reads from stdin. // Scanln reads from stdin.
func Scanln(a ...any) (int, error) { func Scanln(a ...any) (int, error) {
return fmt.Scanln(a...) // fmt.Scanln is the only way to read from stdin interactively
return scanln(a...)
} }
// Task prints a task header: "[label] message" // Task prints a task header: "[label] message"
@ -140,15 +141,15 @@ func Scanln(a ...any) (int, error) {
// cli.Task("php", "Running tests...") // [php] Running tests... // cli.Task("php", "Running tests...") // [php] Running tests...
// cli.Task("go", i18n.Progress("build")) // [go] Building... // cli.Task("go", i18n.Progress("build")) // [go] Building...
func Task(label, message string) { func Task(label, message string) {
fmt.Printf("%s %s\n\n", DimStyle.Render("["+label+"]"), message) core.Print(os.Stdout, "%s %s\n\n", DimStyle.Render("["+label+"]"), message)
} }
// Section prints a section header: "── SECTION ──" // Section prints a section header: "── SECTION ──"
// //
// cli.Section("audit") // ── AUDIT ── // cli.Section("audit") // ── AUDIT ──
func Section(name string) { func Section(name string) {
header := "── " + strings.ToUpper(name) + " ──" header := "── " + core.Upper(name) + " ──"
fmt.Println(AccentStyle.Render(header)) core.Println(AccentStyle.Render(header))
} }
// Hint prints a labelled hint: "label: message" // Hint prints a labelled hint: "label: message"
@ -156,7 +157,7 @@ func Section(name string) {
// cli.Hint("install", "composer require vimeo/psalm") // cli.Hint("install", "composer require vimeo/psalm")
// cli.Hint("fix", "core php fmt --fix") // cli.Hint("fix", "core php fmt --fix")
func Hint(label, message string) { func Hint(label, message string) {
fmt.Printf(" %s %s\n", DimStyle.Render(label+":"), message) core.Print(os.Stdout, " %s %s\n", DimStyle.Render(label+":"), message)
} }
// Severity prints a severity-styled message. // Severity prints a severity-styled message.
@ -167,7 +168,7 @@ func Hint(label, message string) {
// cli.Severity("low", "Debug enabled") // gray // cli.Severity("low", "Debug enabled") // gray
func Severity(level, message string) { func Severity(level, message string) {
var style *AnsiStyle var style *AnsiStyle
switch strings.ToLower(level) { switch core.Lower(level) {
case "critical": case "critical":
style = NewStyle().Bold().Foreground(ColourRed500) style = NewStyle().Bold().Foreground(ColourRed500)
case "high": case "high":
@ -179,7 +180,7 @@ func Severity(level, message string) {
default: default:
style = DimStyle style = DimStyle
} }
fmt.Printf(" %s %s\n", style.Render("["+level+"]"), message) core.Print(os.Stdout, " %s %s\n", style.Render("["+level+"]"), message)
} }
// Result prints a result line: "✓ message" or "✗ message" // Result prints a result line: "✓ message" or "✗ message"

View file

@ -2,12 +2,12 @@ package cli
import ( import (
"bufio" "bufio"
"errors"
"fmt"
"io" "io"
"os" "os"
"strconv" "strconv"
"strings" "strings"
"dappco.re/go/core"
) )
var stdin io.Reader = os.Stdin var stdin io.Reader = os.Stdin
@ -26,9 +26,9 @@ func newReader() *bufio.Reader {
// Prompt asks for text input with a default value. // Prompt asks for text input with a default value.
func Prompt(label, defaultVal string) (string, error) { func Prompt(label, defaultVal string) (string, error) {
if defaultVal != "" { if defaultVal != "" {
fmt.Printf("%s [%s]: ", label, defaultVal) core.Print(os.Stdout, "%s [%s]: ", label, defaultVal)
} else { } else {
fmt.Printf("%s: ", label) core.Print(os.Stdout, "%s: ", label)
} }
r := newReader() r := newReader()
@ -37,7 +37,7 @@ func Prompt(label, defaultVal string) (string, error) {
return "", err return "", err
} }
input = strings.TrimSpace(input) input = core.Trim(input)
if input == "" { if input == "" {
return defaultVal, nil return defaultVal, nil
} }
@ -46,11 +46,11 @@ func Prompt(label, defaultVal string) (string, error) {
// Select presents numbered options and returns the selected value. // Select presents numbered options and returns the selected value.
func Select(label string, options []string) (string, error) { func Select(label string, options []string) (string, error) {
fmt.Println(label) core.Println(label)
for i, opt := range options { for i, opt := range options {
fmt.Printf(" %d. %s\n", i+1, opt) core.Print(os.Stdout, " %d. %s\n", i+1, opt)
} }
fmt.Printf("Choose [1-%d]: ", len(options)) core.Print(os.Stdout, "Choose [1-%d]: ", len(options))
r := newReader() r := newReader()
input, err := r.ReadString('\n') input, err := r.ReadString('\n')
@ -58,20 +58,20 @@ func Select(label string, options []string) (string, error) {
return "", err return "", err
} }
n, err := strconv.Atoi(strings.TrimSpace(input)) n, err := strconv.Atoi(core.Trim(input))
if err != nil || n < 1 || n > len(options) { if err != nil || n < 1 || n > len(options) {
return "", errors.New("invalid selection") return "", core.NewError("invalid selection")
} }
return options[n-1], nil return options[n-1], nil
} }
// MultiSelect presents checkboxes (space-separated numbers). // MultiSelect presents checkboxes (space-separated numbers).
func MultiSelect(label string, options []string) ([]string, error) { func MultiSelect(label string, options []string) ([]string, error) {
fmt.Println(label) core.Println(label)
for i, opt := range options { for i, opt := range options {
fmt.Printf(" %d. %s\n", i+1, opt) core.Print(os.Stdout, " %d. %s\n", i+1, opt)
} }
fmt.Printf("Choose (space-separated) [1-%d]: ", len(options)) core.Print(os.Stdout, "Choose (space-separated) [1-%d]: ", len(options))
r := newReader() r := newReader()
input, err := r.ReadString('\n') input, err := r.ReadString('\n')
@ -80,7 +80,7 @@ func MultiSelect(label string, options []string) ([]string, error) {
} }
var selected []string var selected []string
for _, s := range strings.Fields(input) { for s := range strings.FieldsSeq(input) {
n, err := strconv.Atoi(s) n, err := strconv.Atoi(s)
if err != nil || n < 1 || n > len(options) { if err != nil || n < 1 || n > len(options) {
continue continue

View file

@ -1,8 +1,11 @@
package cli package cli
import ( import (
"fmt" "io"
"os"
"strings" "strings"
"dappco.re/go/core"
) )
// RenderStyle controls how layouts are rendered. // RenderStyle controls how layouts are rendered.
@ -31,13 +34,13 @@ func UseRenderBoxed() { currentRenderStyle = RenderBoxed }
// Render outputs the layout to terminal. // Render outputs the layout to terminal.
func (c *Composite) Render() { func (c *Composite) Render() {
fmt.Print(c.String()) _, _ = io.WriteString(os.Stdout, c.String())
} }
// String returns the rendered layout. // String returns the rendered layout.
func (c *Composite) String() string { func (c *Composite) String() string {
var sb strings.Builder sb := core.NewBuilder()
c.renderTo(&sb, 0) c.renderTo(sb, 0)
return sb.String() return sb.String()
} }
@ -75,7 +78,7 @@ func (c *Composite) renderSeparator(sb *strings.Builder, depth int) {
func (c *Composite) renderSlot(sb *strings.Builder, slot *Slot, depth int) { func (c *Composite) renderSlot(sb *strings.Builder, slot *Slot, depth int) {
indent := strings.Repeat(" ", depth) indent := strings.Repeat(" ", depth)
for _, block := range slot.blocks { for _, block := range slot.blocks {
for _, line := range strings.Split(block.Render(), "\n") { for _, line := range core.Split(block.Render(), "\n") {
if line != "" { if line != "" {
sb.WriteString(indent + line + "\n") sb.WriteString(indent + line + "\n")
} }

View file

@ -65,9 +65,7 @@ func Init(opts Options) error {
} }
// Create Core with app identity // Create Core with app identity
c := core.New(core.Options{ c := core.New(core.WithOption("name", opts.AppName))
{Key: "name", Value: opts.AppName},
})
c.App().Version = opts.Version c.App().Version = opts.Version
c.App().Runtime = rootCmd c.App().Runtime = rootCmd

View file

@ -1,7 +1,6 @@
package cli package cli
import ( import (
"fmt"
"io" "io"
"os" "os"
"strings" "strings"
@ -59,7 +58,7 @@ func (s *Stream) Write(text string) {
defer s.mu.Unlock() defer s.mu.Unlock()
if s.wrap <= 0 { if s.wrap <= 0 {
fmt.Fprint(s.out, text) _, _ = io.WriteString(s.out, text)
// Track column across newlines for Done() trailing-newline logic. // Track column across newlines for Done() trailing-newline logic.
if idx := strings.LastIndex(text, "\n"); idx >= 0 { if idx := strings.LastIndex(text, "\n"); idx >= 0 {
s.col = utf8.RuneCountInString(text[idx+1:]) s.col = utf8.RuneCountInString(text[idx+1:])
@ -71,17 +70,17 @@ func (s *Stream) Write(text string) {
for _, r := range text { for _, r := range text {
if r == '\n' { if r == '\n' {
fmt.Fprintln(s.out) _, _ = io.WriteString(s.out, "\n")
s.col = 0 s.col = 0
continue continue
} }
if s.col >= s.wrap { if s.col >= s.wrap {
fmt.Fprintln(s.out) _, _ = io.WriteString(s.out, "\n")
s.col = 0 s.col = 0
} }
fmt.Fprint(s.out, string(r)) _, _ = io.WriteString(s.out, string(r))
s.col++ s.col++
} }
} }
@ -107,7 +106,7 @@ func (s *Stream) WriteFrom(r io.Reader) error {
func (s *Stream) Done() { func (s *Stream) Done() {
s.mu.Lock() s.mu.Lock()
if s.col > 0 { if s.col > 0 {
fmt.Fprintln(s.out) // ensure trailing newline _, _ = io.WriteString(s.out, "\n") // ensure trailing newline
} }
s.mu.Unlock() s.mu.Unlock()
close(s.done) close(s.done)
@ -125,15 +124,18 @@ func (s *Stream) Column() int {
return s.col return s.col
} }
// stringer matches any type with a String() method.
type stringer interface{ String() string }
// Captured returns the stream output as a string when using a bytes.Buffer. // 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. // Panics if the output writer is not a *strings.Builder or Stringer.
func (s *Stream) Captured() string { func (s *Stream) Captured() string {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if sb, ok := s.out.(*strings.Builder); ok { if sb, ok := s.out.(*strings.Builder); ok {
return sb.String() return sb.String()
} }
if st, ok := s.out.(fmt.Stringer); ok { if st, ok := s.out.(stringer); ok {
return st.String() return st.String()
} }
return "" return ""

View file

@ -1,15 +1,19 @@
package cli package cli
import "fmt" import (
"fmt"
// Sprintf formats a string (fmt.Sprintf wrapper). "dappco.re/go/core"
)
// Sprintf formats a string (core.Sprintf wrapper).
func Sprintf(format string, args ...any) string { func Sprintf(format string, args ...any) string {
return fmt.Sprintf(format, args...) return core.Sprintf(format, args...)
} }
// Sprint formats using default formats (fmt.Sprint wrapper). // Sprint formats using default formats (core.Sprint wrapper).
func Sprint(args ...any) string { func Sprint(args ...any) string {
return fmt.Sprint(args...) return core.Sprint(args...)
} }
// Styled returns text with a style applied. // Styled returns text with a style applied.
@ -19,7 +23,13 @@ func Styled(style *AnsiStyle, text string) string {
// Styledf returns formatted text with a style applied. // Styledf returns formatted text with a style applied.
func Styledf(style *AnsiStyle, format string, args ...any) string { func Styledf(style *AnsiStyle, format string, args ...any) string {
return style.Render(fmt.Sprintf(format, args...)) return style.Render(core.Sprintf(format, args...))
}
// scanln reads space-delimited tokens from stdin (fmt.Scanln wrapper).
// Kept as internal; fmt.Scanln has no core equivalent.
func scanln(a ...any) (int, error) {
return fmt.Scanln(a...)
} }
// SuccessStr returns success-styled string. // SuccessStr returns success-styled string.

View file

@ -2,9 +2,12 @@
package cli package cli
import ( import (
"fmt" "io"
"os"
"strings" "strings"
"time" "time"
"dappco.re/go/core"
) )
// Tailwind colour palette (hex strings) // Tailwind colour palette (hex strings)
@ -93,15 +96,15 @@ func FormatAge(t time.Time) string {
case d < time.Minute: case d < time.Minute:
return "just now" return "just now"
case d < time.Hour: case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes())) return core.Sprintf("%dm ago", int(d.Minutes()))
case d < 24*time.Hour: case d < 24*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours())) return core.Sprintf("%dh ago", int(d.Hours()))
case d < 7*24*time.Hour: case d < 7*24*time.Hour:
return fmt.Sprintf("%dd ago", int(d.Hours()/24)) return core.Sprintf("%dd ago", int(d.Hours()/24))
case d < 30*24*time.Hour: case d < 30*24*time.Hour:
return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7))) return core.Sprintf("%dw ago", int(d.Hours()/(24*7)))
default: default:
return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30))) return core.Sprintf("%dmo ago", int(d.Hours()/(24*30)))
} }
} }
@ -233,7 +236,7 @@ func (t *Table) String() string {
// Render prints the table to stdout. // Render prints the table to stdout.
func (t *Table) Render() { func (t *Table) Render() {
fmt.Print(t.String()) _, _ = io.WriteString(os.Stdout, t.String())
} }
func (t *Table) colCount() int { func (t *Table) colCount() int {
@ -315,7 +318,7 @@ func (t *Table) resolveStyle(col int, value string) *AnsiStyle {
func (t *Table) renderPlain() string { func (t *Table) renderPlain() string {
widths := t.columnWidths() widths := t.columnWidths()
var sb strings.Builder sb := core.NewBuilder()
sep := t.Style.Separator sep := t.Style.Separator
if len(t.Headers) > 0 { if len(t.Headers) > 0 {
@ -358,7 +361,7 @@ func (t *Table) renderBordered() string {
widths := t.columnWidths() widths := t.columnWidths()
cols := t.colCount() cols := t.colCount()
var sb strings.Builder sb := core.NewBuilder()
// Top border: ╭──────┬──────╮ // Top border: ╭──────┬──────╮
sb.WriteString(b.tl) sb.WriteString(b.tl)

View file

@ -1,14 +1,13 @@
package cli package cli
import ( import (
"fmt"
"io" "io"
"iter" "iter"
"os" "os"
"strings"
"sync" "sync"
"time" "time"
"dappco.re/go/core"
"golang.org/x/term" "golang.org/x/term"
) )
@ -171,7 +170,7 @@ func (tr *TaskTracker) waitStatic() {
if state == taskFailed { if state == taskFailed {
icon = Glyph(":cross:") icon = Glyph(":cross:")
} }
fmt.Fprintf(tr.out, "%s %-20s %s\n", icon, name, status) core.Print(tr.out, "%s %-20s %s\n", icon, name, status)
} }
if allDone { if allDone {
return return
@ -203,7 +202,7 @@ func (tr *TaskTracker) waitLive() {
tr.mu.Unlock() tr.mu.Unlock()
// Move cursor up to redraw all lines. // Move cursor up to redraw all lines.
fmt.Fprintf(tr.out, "\033[%dA", count) core.Print(tr.out, "\033[%dA", count)
for i := range count { for i := range count {
tr.renderLine(i, frame) tr.renderLine(i, frame)
} }
@ -244,7 +243,7 @@ func (tr *TaskTracker) renderLine(idx, frame int) {
styledStatus = DimStyle.Render(status) styledStatus = DimStyle.Render(status)
} }
fmt.Fprintf(tr.out, "\033[2K%s %-*s %s\n", icon, nameW, name, styledStatus) core.Print(tr.out, "\033[2K%s %-*s %s\n", icon, nameW, name, styledStatus)
} }
func (tr *TaskTracker) nameWidth() int { func (tr *TaskTracker) nameWidth() int {
@ -289,9 +288,9 @@ func (tr *TaskTracker) Summary() string {
total := len(tr.tasks) total := len(tr.tasks)
if failed > 0 { if failed > 0 {
return fmt.Sprintf("%d/%d passed, %d failed", passed, total, failed) return core.Sprintf("%d/%d passed, %d failed", passed, total, failed)
} }
return fmt.Sprintf("%d/%d passed", passed, total) return core.Sprintf("%d/%d passed", passed, total)
} }
// String returns the current state of all tasks as plain text (no ANSI). // String returns the current state of all tasks as plain text (no ANSI).
@ -301,7 +300,7 @@ func (tr *TaskTracker) String() string {
tr.mu.Unlock() tr.mu.Unlock()
nameW := tr.nameWidth() nameW := tr.nameWidth()
var sb strings.Builder sb := core.NewBuilder()
for _, t := range tasks { for _, t := range tasks {
name, status, state := t.snapshot() name, status, state := t.snapshot()
icon := "…" icon := "…"
@ -313,7 +312,7 @@ func (tr *TaskTracker) String() string {
case taskRunning: case taskRunning:
icon = "⠋" icon = "⠋"
} }
fmt.Fprintf(&sb, "%s %-*s %s\n", icon, nameW, name, status) core.Print(sb, "%s %-*s %s\n", icon, nameW, name, status)
} }
return sb.String() return sb.String()
} }

View file

@ -1,9 +1,12 @@
package cli package cli
import ( import (
"fmt" "io"
"iter" "iter"
"os"
"strings" "strings"
"dappco.re/go/core"
) )
// TreeNode represents a node in a displayable tree structure. // TreeNode represents a node in a displayable tree structure.
@ -70,16 +73,16 @@ func (n *TreeNode) Children() iter.Seq[*TreeNode] {
// String renders the tree with box-drawing characters. // String renders the tree with box-drawing characters.
// Implements fmt.Stringer. // Implements fmt.Stringer.
func (n *TreeNode) String() string { func (n *TreeNode) String() string {
var sb strings.Builder sb := core.NewBuilder()
sb.WriteString(n.renderLabel()) sb.WriteString(n.renderLabel())
sb.WriteByte('\n') sb.WriteByte('\n')
n.writeChildren(&sb, "") n.writeChildren(sb, "")
return sb.String() return sb.String()
} }
// Render prints the tree to stdout. // Render prints the tree to stdout.
func (n *TreeNode) Render() { func (n *TreeNode) Render() {
fmt.Print(n.String()) _, _ = io.WriteString(os.Stdout, n.String())
} }
func (n *TreeNode) renderLabel() string { func (n *TreeNode) renderLabel() string {

View file

@ -3,23 +3,26 @@ package cli
import ( import (
"bufio" "bufio"
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
"time" "time"
"dappco.re/go/core"
"forge.lthn.ai/core/go-i18n" "forge.lthn.ai/core/go-i18n"
"forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go-log"
) )
// fmt is retained solely for fmt.Sscanf (no core equivalent).
// strings is retained for strings.FieldsSeq/SplitSeq (allowed per RFC-025).
// GhAuthenticated checks if the GitHub CLI is authenticated. // GhAuthenticated checks if the GitHub CLI is authenticated.
// Returns true if 'gh auth status' indicates a logged-in user. // Returns true if 'gh auth status' indicates a logged-in user.
func GhAuthenticated() bool { func GhAuthenticated() bool {
cmd := exec.Command("gh", "auth", "status") cmd := exec.Command("gh", "auth", "status")
output, _ := cmd.CombinedOutput() output, _ := cmd.CombinedOutput()
authenticated := strings.Contains(string(output), "Logged in") authenticated := core.Contains(string(output), "Logged in")
if authenticated { if authenticated {
LogWarn("GitHub CLI authenticated", "user", log.Username()) LogWarn("GitHub CLI authenticated", "user", log.Username())
@ -94,13 +97,13 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
// Add timeout indicator if set // Add timeout indicator if set
if cfg.timeout > 0 { if cfg.timeout > 0 {
suffix = fmt.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second)) suffix = core.Sprintf("%s(auto in %s) ", suffix, cfg.timeout.Round(time.Second))
} }
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
for { for {
fmt.Printf("%s %s", prompt, suffix) core.Print(os.Stdout, "%s %s", prompt, suffix)
var response string var response string
@ -114,14 +117,14 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
select { select {
case response = <-resultChan: case response = <-resultChan:
response = strings.ToLower(strings.TrimSpace(response)) response = core.Lower(core.Trim(response))
case <-time.After(cfg.timeout): case <-time.After(cfg.timeout):
fmt.Println() // New line after timeout core.Println() // New line after timeout
return cfg.defaultYes return cfg.defaultYes
} }
} else { } else {
response, _ = reader.ReadString('\n') response, _ = reader.ReadString('\n')
response = strings.ToLower(strings.TrimSpace(response)) response = core.Lower(core.Trim(response))
} }
// Handle empty response // Handle empty response
@ -142,7 +145,7 @@ func Confirm(prompt string, opts ...ConfirmOption) bool {
// Invalid response // Invalid response
if cfg.required { if cfg.required {
fmt.Println("Please enter 'y' or 'n'") core.Println("Please enter 'y' or 'n'")
continue continue
} }
@ -220,18 +223,18 @@ func Question(prompt string, opts ...QuestionOption) string {
for { for {
// Build prompt with default // Build prompt with default
if cfg.defaultValue != "" { if cfg.defaultValue != "" {
fmt.Printf("%s [%s] ", prompt, cfg.defaultValue) core.Print(os.Stdout, "%s [%s] ", prompt, cfg.defaultValue)
} else { } else {
fmt.Printf("%s ", prompt) core.Print(os.Stdout, "%s ", prompt)
} }
response, _ := reader.ReadString('\n') response, _ := reader.ReadString('\n')
response = strings.TrimSpace(response) response = core.Trim(response)
// Handle empty response // Handle empty response
if response == "" { if response == "" {
if cfg.required { if cfg.required {
fmt.Println("Response required") core.Println("Response required")
continue continue
} }
response = cfg.defaultValue response = cfg.defaultValue
@ -240,7 +243,7 @@ func Question(prompt string, opts ...QuestionOption) string {
// Validate if validator provided // Validate if validator provided
if cfg.validator != nil { if cfg.validator != nil {
if err := cfg.validator(response); err != nil { if err := cfg.validator(response); err != nil {
fmt.Printf("Invalid: %v\n", err) core.Print(os.Stdout, "Invalid: %v\n", err)
continue continue
} }
} }
@ -319,28 +322,28 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
} }
cfg := &chooseConfig[T]{ cfg := &chooseConfig[T]{
displayFn: func(item T) string { return fmt.Sprint(item) }, displayFn: func(item T) string { return core.Sprint(item) },
} }
for _, opt := range opts { for _, opt := range opts {
opt(cfg) opt(cfg)
} }
// Display options // Display options
fmt.Println(prompt) core.Println(prompt)
for i, item := range items { for i, item := range items {
marker := " " marker := " "
if i == cfg.defaultN { if i == cfg.defaultN {
marker = "*" marker = "*"
} }
fmt.Printf(" %s%d. %s\n", marker, i+1, cfg.displayFn(item)) core.Print(os.Stdout, " %s%d. %s\n", marker, i+1, cfg.displayFn(item))
} }
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
for { for {
fmt.Printf("Enter number [1-%d]: ", len(items)) core.Print(os.Stdout, "Enter number [1-%d]: ", len(items))
response, _ := reader.ReadString('\n') response, _ := reader.ReadString('\n')
response = strings.TrimSpace(response) response = core.Trim(response)
// Empty response uses default // Empty response uses default
if response == "" { if response == "" {
@ -355,7 +358,7 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T {
} }
} }
fmt.Printf("Please enter a number between 1 and %d\n", len(items)) core.Print(os.Stdout, "Please enter a number between 1 and %d\n", len(items))
} }
} }
@ -384,24 +387,24 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
} }
cfg := &chooseConfig[T]{ cfg := &chooseConfig[T]{
displayFn: func(item T) string { return fmt.Sprint(item) }, displayFn: func(item T) string { return core.Sprint(item) },
} }
for _, opt := range opts { for _, opt := range opts {
opt(cfg) opt(cfg)
} }
// Display options // Display options
fmt.Println(prompt) core.Println(prompt)
for i, item := range items { for i, item := range items {
fmt.Printf(" %d. %s\n", i+1, cfg.displayFn(item)) core.Print(os.Stdout, " %d. %s\n", i+1, cfg.displayFn(item))
} }
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
for { for {
fmt.Printf("Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ") core.Print(os.Stdout, "Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ")
response, _ := reader.ReadString('\n') response, _ := reader.ReadString('\n')
response = strings.TrimSpace(response) response = core.Trim(response)
// Empty response returns no selections // Empty response returns no selections
if response == "" { if response == "" {
@ -411,7 +414,7 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T {
// Parse the selection // Parse the selection
selected, err := parseMultiSelection(response, len(items)) selected, err := parseMultiSelection(response, len(items))
if err != nil { if err != nil {
fmt.Printf("Invalid selection: %v\n", err) core.Print(os.Stdout, "Invalid selection: %v\n", err)
continue continue
} }
@ -431,23 +434,23 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
for part := range strings.FieldsSeq(input) { for part := range strings.FieldsSeq(input) {
// Check for range (e.g., "1-3") // Check for range (e.g., "1-3")
if strings.Contains(part, "-") { if core.Contains(part, "-") {
var rangeParts []string var rangeParts []string
for p := range strings.SplitSeq(part, "-") { for p := range strings.SplitSeq(part, "-") {
rangeParts = append(rangeParts, p) rangeParts = append(rangeParts, p)
} }
if len(rangeParts) != 2 { if len(rangeParts) != 2 {
return nil, fmt.Errorf("invalid range: %s", part) return nil, core.NewError(core.Sprintf("invalid range: %s", part))
} }
var start, end int var start, end int
if _, err := fmt.Sscanf(rangeParts[0], "%d", &start); err != nil { if _, err := fmt.Sscanf(rangeParts[0], "%d", &start); err != nil {
return nil, fmt.Errorf("invalid range start: %s", rangeParts[0]) return nil, core.NewError(core.Sprintf("invalid range start: %s", rangeParts[0]))
} }
if _, err := fmt.Sscanf(rangeParts[1], "%d", &end); err != nil { if _, err := fmt.Sscanf(rangeParts[1], "%d", &end); err != nil {
return nil, fmt.Errorf("invalid range end: %s", rangeParts[1]) return nil, core.NewError(core.Sprintf("invalid range end: %s", rangeParts[1]))
} }
if start < 1 || start > maxItems || end < 1 || end > maxItems || start > end { if start < 1 || start > maxItems || end < 1 || end > maxItems || start > end {
return nil, fmt.Errorf("range out of bounds: %s", part) return nil, core.NewError(core.Sprintf("range out of bounds: %s", part))
} }
for i := start; i <= end; i++ { for i := start; i <= end; i++ {
selected[i-1] = true // Convert to 0-based selected[i-1] = true // Convert to 0-based
@ -456,10 +459,10 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) {
// Single number // Single number
var n int var n int
if _, err := fmt.Sscanf(part, "%d", &n); err != nil { if _, err := fmt.Sscanf(part, "%d", &n); err != nil {
return nil, fmt.Errorf("invalid number: %s", part) return nil, core.NewError(core.Sprintf("invalid number: %s", part))
} }
if n < 1 || n > maxItems { if n < 1 || n > maxItems {
return nil, fmt.Errorf("number out of range: %d", n) return nil, core.NewError(core.Sprintf("number out of range: %d", n))
} }
selected[n-1] = true // Convert to 0-based selected[n-1] = true // Convert to 0-based
} }
@ -487,22 +490,22 @@ func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOpt
// Prefers 'gh repo clone' if authenticated, falls back to SSH. // Prefers 'gh repo clone' if authenticated, falls back to SSH.
func GitClone(ctx context.Context, org, repo, path string) error { func GitClone(ctx context.Context, org, repo, path string) error {
if GhAuthenticated() { if GhAuthenticated() {
httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo) httpsURL := core.Sprintf("https://github.com/%s/%s.git", org, repo)
cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path) cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err == nil { if err == nil {
return nil return nil
} }
errStr := strings.TrimSpace(string(output)) errStr := core.Trim(string(output))
if strings.Contains(errStr, "already exists") { if core.Contains(errStr, "already exists") {
return errors.New(errStr) return core.NewError(errStr)
} }
} }
// Fall back to SSH clone // Fall back to SSH clone
cmd := exec.CommandContext(ctx, "git", "clone", fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path) cmd := exec.CommandContext(ctx, "git", "clone", core.Sprintf("git@github.com:%s/%s.git", org, repo), path)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
return errors.New(strings.TrimSpace(string(output))) return core.NewError(core.Trim(string(output)))
} }
return nil return nil
} }