From bf532706318b5c2181c348d41b7b02aaca574513 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 8 Apr 2026 11:08:02 +0100 Subject: [PATCH] refactor(cli): replace stdlib imports with core/go primitives Replace fmt, errors, path/filepath, strings, and strconv usages with core/go equivalents where available. Fully removes "errors" and "path/filepath" imports. Retains fmt (Fprint/Fprintf/Fscanln/Sscanf/Stringer), strings (Builder/Repeat/LastIndex/NewReplacer/FieldsSeq/SplitSeq), and strconv (Atoi/ParseUint) where core/go has no equivalent. Co-Authored-By: Virgil --- pkg/cli/ansi.go | 12 +- pkg/cli/daemon_process.go | 26 ++-- pkg/cli/errors.go | 31 +++-- pkg/cli/frame.go | 88 ++----------- pkg/cli/layout.go | 9 +- pkg/cli/log.go | 5 +- pkg/cli/output.go | 50 ++++---- pkg/cli/prompt.go | 25 ++-- pkg/cli/render.go | 27 +--- pkg/cli/stream.go | 7 +- pkg/cli/styles.go | 113 +++-------------- pkg/cli/tracker.go | 47 +------ pkg/cli/tree.go | 24 ---- pkg/cli/utils.go | 253 +++++++------------------------------- 14 files changed, 160 insertions(+), 557 deletions(-) diff --git a/pkg/cli/ansi.go b/pkg/cli/ansi.go index 7ce87db..ff87302 100644 --- a/pkg/cli/ansi.go +++ b/pkg/cli/ansi.go @@ -1,11 +1,11 @@ package cli import ( - "fmt" "os" "strconv" - "strings" "sync" + + "dappco.re/go/core" ) // ANSI escape codes @@ -147,24 +147,24 @@ func (s *AnsiStyle) Render(text string) string { 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. func fgColorHex(hex string) string { 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. func bgColorHex(hex string) string { 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. func hexToRGB(hex string) (int, int, int) { - hex = strings.TrimPrefix(hex, "#") + hex = core.TrimPrefix(hex, "#") if len(hex) != 6 { return 255, 255, 255 } diff --git a/pkg/cli/daemon_process.go b/pkg/cli/daemon_process.go index a724012..1c74ec6 100644 --- a/pkg/cli/daemon_process.go +++ b/pkg/cli/daemon_process.go @@ -2,18 +2,16 @@ package cli import ( "context" - "errors" - "fmt" "io" "net" "net/http" "os" - "path/filepath" "strconv" - "strings" "sync" "syscall" "time" + + "dappco.re/go/core" ) // DaemonOptions configures a background process helper. @@ -68,7 +66,7 @@ var ( return false } err = proc.Signal(syscall.Signal(0)) - return err == nil || errors.Is(err, syscall.EPERM) + return err == nil || core.Is(err, syscall.EPERM) } processSignal = func(pid int, sig syscall.Signal) error { proc, err := os.FindProcess(pid) @@ -188,9 +186,9 @@ func StopPIDFile(pidFile string, timeout time.Duration) error { return err } - pid, err := parsePID(strings.TrimSpace(string(rawPID))) + pid, err := parsePID(core.Trim(string(rawPID))) if err != nil { - return fmt.Errorf("parse pid file %q: %w", pidFile, err) + return core.E("StopPIDFile", core.Sprintf("parse pid file %q", pidFile), err) } if err := processSignal(pid, syscall.SIGTERM); err != nil && !isProcessGone(err) { @@ -213,7 +211,7 @@ func StopPIDFile(pidFile string, timeout time.Duration) error { } if processAlive(pid) { - return fmt.Errorf("process %d did not exit after SIGKILL", pid) + return core.E("StopPIDFile", core.Sprintf("process %d did not exit after SIGKILL", pid), nil) } } @@ -222,20 +220,20 @@ func StopPIDFile(pidFile string, timeout time.Duration) error { func parsePID(raw string) (int, error) { if raw == "" { - return 0, fmt.Errorf("empty pid") + return 0, core.NewError("empty pid") } pid, err := strconv.Atoi(raw) if err != nil { return 0, err } if pid <= 0 { - return 0, fmt.Errorf("invalid pid %d", pid) + return 0, core.E("parsePID", core.Sprintf("invalid pid %d", pid), nil) } return pid, nil } func isProcessGone(err error) bool { - return errors.Is(err, os.ErrProcessDone) || errors.Is(err, syscall.ESRCH) + return core.Is(err, os.ErrProcessDone) || core.Is(err, syscall.ESRCH) } func (d *Daemon) writePIDFile() error { @@ -243,10 +241,10 @@ func (d *Daemon) writePIDFile() error { return nil } - if err := os.MkdirAll(filepath.Dir(d.opts.PIDFile), 0o755); err != nil { + if err := os.MkdirAll(core.PathDir(d.opts.PIDFile), 0o755); err != nil { return err } - return os.WriteFile(d.opts.PIDFile, []byte(strconv.Itoa(os.Getpid())+"\n"), 0o644) + return os.WriteFile(d.opts.PIDFile, []byte(core.Sprintf("%d", os.Getpid())+"\n"), 0o644) } func (d *Daemon) removePIDFile() error { @@ -318,5 +316,5 @@ func isClosedServerError(err error) bool { } func isListenerClosedError(err error) bool { - return err == nil || errors.Is(err, net.ErrClosed) + return err == nil || core.Is(err, net.ErrClosed) } diff --git a/pkg/cli/errors.go b/pkg/cli/errors.go index f3ba284..96d453d 100644 --- a/pkg/cli/errors.go +++ b/pkg/cli/errors.go @@ -1,10 +1,9 @@ package cli import ( - "errors" - "fmt" "os" + "dappco.re/go/core" "dappco.re/go/core/i18n" ) @@ -15,7 +14,7 @@ import ( // Err creates a new error from a format string. // This is a direct replacement for fmt.Errorf. func Err(format string, args ...any) error { - return fmt.Errorf(format, args...) + return core.E("cli", core.Sprintf(format, args...), nil) } // Wrap wraps an error with a message. @@ -26,7 +25,7 @@ func Wrap(err error, msg string) error { if err == nil { return nil } - return fmt.Errorf("%s: %w", msg, err) + return core.E("cli", msg, err) } // 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 } msg := i18n.ActionFailed(verb, subject) - return fmt.Errorf("%s: %w", msg, err) + return core.E("cli", msg, err) } // WrapAction wraps an error using i18n grammar for "Failed to verb". @@ -52,7 +51,7 @@ func WrapAction(err error, verb string) error { return nil } msg := i18n.ActionFailed(verb, "") - return fmt.Errorf("%s: %w", msg, err) + return core.E("cli", msg, err) } // ───────────────────────────────────────────────────────────────────────────── @@ -62,19 +61,19 @@ func WrapAction(err error, verb string) error { // Is reports whether any error in err's tree matches target. // This is a re-export of errors.Is for convenience. 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. // This is a re-export of errors.As for convenience. 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. // This is a re-export of errors.Join for convenience. 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. @@ -120,7 +119,7 @@ func Exit(code int, err error) error { func Fatal(err error) { if err != nil { LogError("Fatal error", "err", err) - fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+err.Error())) + core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+err.Error())) os.Exit(1) } } @@ -129,9 +128,9 @@ func Fatal(err error) { // // Deprecated: return an error from the command instead. func Fatalf(format string, args ...any) { - msg := fmt.Sprintf(format, args...) + msg := core.Sprintf(format, args...) LogError("Fatal error", "msg", msg) - fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+msg)) + core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+msg)) os.Exit(1) } @@ -146,8 +145,8 @@ func FatalWrap(err error, msg string) { return } LogError("Fatal error", "msg", msg, "err", err) - fullMsg := fmt.Sprintf("%s: %v", msg, err) - fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) + fullMsg := core.Sprintf("%s: %v", msg, err) + core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) os.Exit(1) } @@ -163,7 +162,7 @@ func FatalWrapVerb(err error, verb, subject string) { } msg := i18n.ActionFailed(verb, subject) LogError("Fatal error", "msg", msg, "err", err, "verb", verb, "subject", subject) - fullMsg := fmt.Sprintf("%s: %v", msg, err) - fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) + fullMsg := core.Sprintf("%s: %v", msg, err) + core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+fullMsg)) os.Exit(1) } diff --git a/pkg/cli/frame.go b/pkg/cli/frame.go index a89c80c..a89fa51 100644 --- a/pkg/cli/frame.go +++ b/pkg/cli/frame.go @@ -4,10 +4,10 @@ import ( "fmt" "io" "os" - "strings" "sync" "time" + "dappco.re/go/core" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/x/ansi" @@ -27,24 +27,15 @@ type ModelFunc func(width, height int) string func (f ModelFunc) View(width, height int) string { return f(width, height) } // Frame is a live compositional AppShell for TUI. -// Uses HLCRF variant strings for region layout — same as the static Layout system, -// but with live-updating Model components instead of static strings. -// -// frame := cli.NewFrame("HCF") -// frame.Header(cli.StatusLine("core dev", "18 repos", "main")) -// frame.Content(myTableModel) -// frame.Footer(cli.KeyHints("↑/↓ navigate", "enter select", "q quit")) -// frame.Run() type Frame struct { variant string layout *Composite models map[Region]Model - history []Model // content region stack for Navigate/Back + history []Model out io.Writer done chan struct{} mu sync.Mutex - // Focus management (bubbletea upgrade) focused Region keyMap KeyMap width int @@ -53,9 +44,6 @@ type Frame struct { } // NewFrame creates a new Frame with the given HLCRF variant string. -// -// frame := cli.NewFrame("HCF") // header, content, footer -// frame := cli.NewFrame("H[LC]F") // header, [left + content], footer func NewFrame(variant string) *Frame { return &Frame{ variant: variant, @@ -70,8 +58,6 @@ func NewFrame(variant string) *Frame { } } -// WithOutput sets the destination writer for rendered output. -// Pass nil to keep the current writer unchanged. func (f *Frame) WithOutput(out io.Writer) *Frame { if out != nil { f.out = out @@ -79,20 +65,11 @@ func (f *Frame) WithOutput(out io.Writer) *Frame { return f } -// Header sets the Header region model. -func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f } - -// Left sets the Left sidebar region model. -func (f *Frame) Left(m Model) *Frame { f.setModel(RegionLeft, m); return f } - -// Content sets the Content region model. +func (f *Frame) Header(m Model) *Frame { f.setModel(RegionHeader, m); return f } +func (f *Frame) Left(m Model) *Frame { f.setModel(RegionLeft, m); return f } func (f *Frame) Content(m Model) *Frame { f.setModel(RegionContent, m); return f } - -// Right sets the Right sidebar region model. -func (f *Frame) Right(m Model) *Frame { f.setModel(RegionRight, m); return f } - -// Footer sets the Footer region model. -func (f *Frame) Footer(m Model) *Frame { f.setModel(RegionFooter, m); return f } +func (f *Frame) Right(m Model) *Frame { f.setModel(RegionRight, m); return f } +func (f *Frame) Footer(m Model) *Frame { f.setModel(RegionFooter, m); return f } func (f *Frame) setModel(r Region, m Model) { f.mu.Lock() @@ -100,8 +77,6 @@ func (f *Frame) setModel(r Region, m Model) { f.models[r] = m } -// Navigate replaces the Content region with a new model, pushing the current one -// onto the history stack for Back(). func (f *Frame) Navigate(m Model) { f.mu.Lock() defer f.mu.Unlock() @@ -111,8 +86,6 @@ func (f *Frame) Navigate(m Model) { f.models[RegionContent] = m } -// Back pops the content history stack, restoring the previous Content model. -// Returns false if the history is empty. func (f *Frame) Back() bool { f.mu.Lock() defer f.mu.Unlock() @@ -124,7 +97,6 @@ func (f *Frame) Back() bool { return true } -// Stop signals the Frame to exit its Run loop. func (f *Frame) Stop() { if f.program != nil { f.program.Quit() @@ -137,29 +109,23 @@ func (f *Frame) Stop() { } } -// Send injects a message into the Frame's tea.Program. -// Safe to call before Run() (message is discarded). func (f *Frame) Send(msg tea.Msg) { if f.program != nil { f.program.Send(msg) } } -// WithKeyMap sets custom key bindings for Frame navigation. func (f *Frame) WithKeyMap(km KeyMap) *Frame { f.keyMap = km return f } -// Focused returns the currently focused region. func (f *Frame) Focused() Region { f.mu.Lock() defer f.mu.Unlock() return f.focused } -// Focus sets focus to a specific region. -// Ignores the request if the region is not in this Frame's variant. func (f *Frame) Focus(r Region) { f.mu.Lock() defer f.mu.Unlock() @@ -168,8 +134,6 @@ func (f *Frame) Focus(r Region) { } } -// buildFocusRing returns the ordered list of regions in this Frame's variant. -// Order follows HLCRF convention. func (f *Frame) buildFocusRing() []Region { order := []Region{RegionHeader, RegionLeft, RegionContent, RegionRight, RegionFooter} var ring []Region @@ -181,11 +145,9 @@ func (f *Frame) buildFocusRing() []Region { return ring } -// Init implements tea.Model. Collects Init() from all FrameModel regions. func (f *Frame) Init() tea.Cmd { f.mu.Lock() defer f.mu.Unlock() - var cmds []tea.Cmd for _, m := range f.models { fm := adaptModel(m) @@ -196,7 +158,6 @@ func (f *Frame) Init() tea.Cmd { return tea.Batch(cmds...) } -// Update implements tea.Model. Routes messages based on type and focus. func (f *Frame) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.mu.Lock() defer f.mu.Unlock() @@ -206,52 +167,39 @@ func (f *Frame) Update(msg tea.Msg) (tea.Model, tea.Cmd) { f.width = msg.Width f.height = msg.Height return f, f.broadcastLocked(msg) - case tea.KeyMsg: switch msg.Type { case f.keyMap.Quit: return f, tea.Quit - case f.keyMap.Back: f.backLocked() return f, nil - case f.keyMap.FocusNext: f.cycleFocusLocked(1) return f, nil - case f.keyMap.FocusPrev: f.cycleFocusLocked(-1) return f, nil - case f.keyMap.FocusUp: f.spatialFocusLocked(RegionHeader) return f, nil - case f.keyMap.FocusDown: f.spatialFocusLocked(RegionFooter) return f, nil - case f.keyMap.FocusLeft: f.spatialFocusLocked(RegionLeft) return f, nil - case f.keyMap.FocusRight: f.spatialFocusLocked(RegionRight) return f, nil - default: - // Forward to focused region return f, f.updateFocusedLocked(msg) } - default: - // Broadcast non-key messages to all regions return f, f.broadcastLocked(msg) } } -// View implements tea.Model. Composes region views using lipgloss. func (f *Frame) View() string { f.mu.Lock() defer f.mu.Unlock() @@ -264,7 +212,6 @@ func (f *Frame) viewLocked() string { w, h = f.termSize() } - // Calculate region dimensions headerH, footerH := 0, 0 if _, ok := f.layout.regions[RegionHeader]; ok { if _, ok := f.models[RegionHeader]; ok { @@ -278,11 +225,9 @@ func (f *Frame) viewLocked() string { } middleH := max(h-headerH-footerH, 1) - // Render each region header := f.renderRegionLocked(RegionHeader, w, headerH) footer := f.renderRegionLocked(RegionFooter, w, footerH) - // Calculate sidebar widths leftW, rightW := 0, 0 if _, ok := f.layout.regions[RegionLeft]; ok { if _, ok := f.models[RegionLeft]; ok { @@ -300,7 +245,6 @@ func (f *Frame) viewLocked() string { right := f.renderRegionLocked(RegionRight, rightW, middleH) content := f.renderRegionLocked(RegionContent, contentW, middleH) - // Compose middle row var middleParts []string if leftW > 0 { middleParts = append(middleParts, left) @@ -315,7 +259,6 @@ func (f *Frame) viewLocked() string { middle = lipgloss.JoinHorizontal(lipgloss.Top, middleParts...) } - // Compose full layout var verticalParts []string if headerH > 0 { verticalParts = append(verticalParts, header) @@ -340,8 +283,6 @@ func (f *Frame) renderRegionLocked(r Region, w, h int) string { return fm.View(w, h) } -// cycleFocusLocked moves focus forward (+1) or backward (-1) in the focus ring. -// Must be called with f.mu held. func (f *Frame) cycleFocusLocked(dir int) { ring := f.buildFocusRing() if len(ring) == 0 { @@ -358,15 +299,12 @@ func (f *Frame) cycleFocusLocked(dir int) { f.focused = ring[idx] } -// spatialFocusLocked moves focus to a specific region if it exists in the layout. -// Must be called with f.mu held. func (f *Frame) spatialFocusLocked(target Region) { if _, exists := f.layout.regions[target]; exists { f.focused = target } } -// backLocked pops the content history. Must be called with f.mu held. func (f *Frame) backLocked() { if len(f.history) == 0 { return @@ -375,8 +313,6 @@ func (f *Frame) backLocked() { f.history = f.history[:len(f.history)-1] } -// broadcastLocked sends a message to all FrameModel regions. -// Must be called with f.mu held. func (f *Frame) broadcastLocked(msg tea.Msg) tea.Cmd { var cmds []tea.Cmd for r, m := range f.models { @@ -390,8 +326,6 @@ func (f *Frame) broadcastLocked(msg tea.Msg) tea.Cmd { return tea.Batch(cmds...) } -// updateFocusedLocked sends a message to only the focused region. -// Must be called with f.mu held. func (f *Frame) updateFocusedLocked(msg tea.Msg) tea.Cmd { m, ok := f.models[f.focused] if !ok { @@ -403,8 +337,7 @@ func (f *Frame) updateFocusedLocked(msg tea.Msg) tea.Cmd { return cmd } -// Run renders the frame and blocks. In TTY mode, it live-refreshes at ~12fps. -// In non-TTY mode, it renders once and returns immediately. +// Run renders the frame and blocks. func (f *Frame) Run() { if !f.isTTY() { fmt.Fprint(f.out, f.String()) @@ -414,7 +347,6 @@ func (f *Frame) Run() { } // RunFor runs the frame for a fixed duration, then stops. -// Useful for dashboards that refresh periodically. func (f *Frame) RunFor(d time.Duration) { go func() { timer := time.NewTimer(d) @@ -429,7 +361,6 @@ func (f *Frame) RunFor(d time.Duration) { } // String renders the frame as a static string (no ANSI, no live updates). -// This is the non-TTY fallback path. func (f *Frame) String() string { f.mu.Lock() defer f.mu.Unlock() @@ -439,8 +370,7 @@ func (f *Frame) String() string { return "" } view = ansi.Strip(view) - // Ensure trailing newline for non-TTY consistency - if !strings.HasSuffix(view, "\n") { + if !core.HasSuffix(view, "\n") { view += "\n" } return view @@ -460,7 +390,7 @@ func (f *Frame) termSize() (int, int) { return w, h } } - return 80, 24 // sensible default + return 80, 24 } func (f *Frame) runLive() { diff --git a/pkg/cli/layout.go b/pkg/cli/layout.go index fb5ffd6..851af24 100644 --- a/pkg/cli/layout.go +++ b/pkg/cli/layout.go @@ -1,8 +1,9 @@ package cli import ( - "fmt" "iter" + + "dappco.re/go/core" ) // Region represents one of the 5 HLCRF regions. @@ -91,7 +92,7 @@ func ParseVariant(variant string) (*Composite, error) { for i < len(variant) { r := Region(variant[i]) if !isValidRegion(r) { - return nil, fmt.Errorf("invalid region: %c", r) + return nil, core.E("ParseVariant", core.Sprintf("invalid region: %c", r), nil) } slot := &Slot{region: r, path: string(r)} @@ -101,7 +102,7 @@ func ParseVariant(variant string) (*Composite, error) { if i < len(variant) && variant[i] == '[' { end := findMatchingBracket(variant, i) if end == -1 { - return nil, fmt.Errorf("unmatched bracket at %d", i) + return nil, core.E("ParseVariant", core.Sprintf("unmatched bracket at %d", i), nil) } nested, err := ParseVariant(variant[i+1 : end]) if err != nil { @@ -168,6 +169,6 @@ func toRenderable(item any) Renderable { case string: return StringBlock(v) default: - return StringBlock(fmt.Sprint(v)) + return StringBlock(core.Sprint(v)) } } diff --git a/pkg/cli/log.go b/pkg/cli/log.go index 5330c36..657bafd 100644 --- a/pkg/cli/log.go +++ b/pkg/cli/log.go @@ -1,8 +1,7 @@ package cli import ( - "fmt" - + "dappco.re/go/core" "dappco.re/go/core/log" ) @@ -46,5 +45,5 @@ func LogSecurity(msg string, keyvals ...any) { log.Security(msg, keyvals...) } // // cli.LogSecurityf("login attempt from %s", username) func LogSecurityf(format string, args ...any) { - log.Security(fmt.Sprintf(format, args...)) + log.Security(core.Sprintf(format, args...)) } diff --git a/pkg/cli/output.go b/pkg/cli/output.go index f33c16c..d60279b 100644 --- a/pkg/cli/output.go +++ b/pkg/cli/output.go @@ -2,58 +2,58 @@ package cli import ( "fmt" - "strings" + "dappco.re/go/core" "dappco.re/go/core/i18n" ) // Blank prints an empty line. func Blank() { - fmt.Fprintln(stdoutWriter()) + core.Print(stdoutWriter(), "") } // Echo translates a key via i18n.T and prints with newline. // No automatic styling - use Success/Error/Warn/Info for styled output. func Echo(key string, args ...any) { - fmt.Fprintln(stdoutWriter(), compileGlyphs(i18n.T(key, args...))) + core.Print(stdoutWriter(), "%s", compileGlyphs(i18n.T(key, args...))) } // Print outputs formatted text (no newline). // Glyph shortcodes like :check: are converted. func Print(format string, args ...any) { - fmt.Fprint(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...))) + fmt.Fprint(stdoutWriter(), compileGlyphs(core.Sprintf(format, args...))) } // Println outputs formatted text with newline. // Glyph shortcodes like :check: are converted. func Println(format string, args ...any) { - fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprintf(format, args...))) + core.Print(stdoutWriter(), "%s", compileGlyphs(core.Sprintf(format, args...))) } // Text prints arguments like fmt.Println, but handling glyphs. func Text(args ...any) { - fmt.Fprintln(stdoutWriter(), compileGlyphs(fmt.Sprint(args...))) + core.Print(stdoutWriter(), "%s", compileGlyphs(core.Sprint(args...))) } // Success prints a success message with checkmark (green). func Success(msg string) { - fmt.Fprintln(stdoutWriter(), SuccessStyle.Render(Glyph(":check:")+" "+compileGlyphs(msg))) + core.Print(stdoutWriter(), "%s", SuccessStyle.Render(Glyph(":check:")+" "+compileGlyphs(msg))) } // Successf prints a formatted success message. 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. func Error(msg string) { LogError(msg) - fmt.Fprintln(stderrWriter(), ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg))) + core.Print(stderrWriter(), "%s", ErrorStyle.Render(Glyph(":cross:")+" "+compileGlyphs(msg))) } // Errorf prints a formatted error message to stderr and logs it. 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. @@ -61,7 +61,7 @@ func ErrorWrap(err error, msg string) { if err == nil { 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. @@ -70,7 +70,7 @@ func ErrorWrapVerb(err error, verb, subject string) { return } 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. @@ -79,33 +79,33 @@ func ErrorWrapAction(err error, verb string) { return } 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. func Warn(msg string) { LogWarn(msg) - fmt.Fprintln(stderrWriter(), WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg))) + core.Print(stderrWriter(), "%s", WarningStyle.Render(Glyph(":warn:")+" "+compileGlyphs(msg))) } // Warnf prints a formatted warning message to stderr and logs it. 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). func Info(msg string) { - fmt.Fprintln(stdoutWriter(), InfoStyle.Render(Glyph(":info:")+" "+compileGlyphs(msg))) + core.Print(stdoutWriter(), "%s", InfoStyle.Render(Glyph(":info:")+" "+compileGlyphs(msg))) } // Infof prints a formatted info message. func Infof(format string, args ...any) { - Info(fmt.Sprintf(format, args...)) + Info(core.Sprintf(format, args...)) } // Dim prints dimmed text. func Dim(msg string) { - fmt.Fprintln(stdoutWriter(), DimStyle.Render(compileGlyphs(msg))) + core.Print(stdoutWriter(), "%s", DimStyle.Render(compileGlyphs(msg))) } // Progress prints a progress indicator that overwrites the current line. @@ -126,7 +126,7 @@ func ProgressDone() { // Label prints a "Label: value" line. func Label(word, value string) { - fmt.Fprintf(stdoutWriter(), "%s %s\n", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value)) + core.Print(stdoutWriter(), "%s %s", KeyStyle.Render(compileGlyphs(i18n.Label(word))), compileGlyphs(value)) } // Scanln reads from stdin. @@ -139,7 +139,7 @@ func Scanln(a ...any) (int, error) { // cli.Task("php", "Running tests...") // [php] Running tests... // cli.Task("go", i18n.Progress("build")) // [go] Building... func Task(label, message string) { - fmt.Fprintf(stdoutWriter(), "%s %s\n\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message)) + core.Print(stdoutWriter(), "%s %s\n", DimStyle.Render("["+compileGlyphs(label)+"]"), compileGlyphs(message)) } // Section prints a section header: "── SECTION ──" @@ -147,8 +147,8 @@ func Task(label, message string) { // cli.Section("audit") // ── AUDIT ── func Section(name string) { dash := Glyph(":dash:") - header := dash + dash + " " + strings.ToUpper(compileGlyphs(name)) + " " + dash + dash - fmt.Fprintln(stdoutWriter(), AccentStyle.Render(header)) + header := dash + dash + " " + core.Upper(compileGlyphs(name)) + " " + dash + dash + core.Print(stdoutWriter(), "%s", AccentStyle.Render(header)) } // Hint prints a labelled hint: "label: message" @@ -156,7 +156,7 @@ func Section(name string) { // cli.Hint("install", "composer require vimeo/psalm") // cli.Hint("fix", "core php fmt --fix") func Hint(label, message string) { - fmt.Fprintf(stdoutWriter(), " %s %s\n", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message)) + core.Print(stdoutWriter(), " %s %s", DimStyle.Render(compileGlyphs(label)+":"), compileGlyphs(message)) } // Severity prints a severity-styled message. @@ -167,7 +167,7 @@ func Hint(label, message string) { // cli.Severity("low", "Debug enabled") // gray func Severity(level, message string) { var style *AnsiStyle - switch strings.ToLower(level) { + switch core.Lower(level) { case "critical": style = NewStyle().Bold().Foreground(ColourRed500) case "high": @@ -179,7 +179,7 @@ func Severity(level, message string) { default: style = DimStyle } - fmt.Fprintf(stdoutWriter(), " %s %s\n", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message)) + core.Print(stdoutWriter(), " %s %s", style.Render("["+compileGlyphs(level)+"]"), compileGlyphs(message)) } // Result prints a result line: "✓ message" or "✗ message" diff --git a/pkg/cli/prompt.go b/pkg/cli/prompt.go index 867b053..6dfd975 100644 --- a/pkg/cli/prompt.go +++ b/pkg/cli/prompt.go @@ -2,14 +2,13 @@ package cli import ( "bufio" - "errors" "fmt" "io" "strconv" - "strings" + + "dappco.re/go/core" ) -// newReader wraps stdin in a bufio.Reader if it isn't one already. func newReader() *bufio.Reader { if br, ok := stdinReader().(*bufio.Reader); ok { return br @@ -29,9 +28,9 @@ func Prompt(label, defaultVal string) (string, error) { r := newReader() input, err := r.ReadString('\n') - input = strings.TrimSpace(input) + input = core.Trim(input) if err != nil { - if !errors.Is(err, io.EOF) { + if !core.Is(err, io.EOF) { return "", err } if input == "" { @@ -53,7 +52,7 @@ func Select(label string, options []string) (string, error) { return "", nil } - fmt.Fprintln(stderrWriter(), compileGlyphs(label)) + core.Print(stderrWriter(), "%s", compileGlyphs(label)) for i, opt := range options { fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt)) } @@ -61,15 +60,15 @@ func Select(label string, options []string) (string, error) { r := newReader() input, err := r.ReadString('\n') - if err != nil && strings.TrimSpace(input) == "" { + if err != nil && core.Trim(input) == "" { promptHint("No input received. Selection cancelled.") return "", Wrap(err, "selection cancelled") } - trimmed := strings.TrimSpace(input) + trimmed := core.Trim(input) n, err := strconv.Atoi(trimmed) if err != nil || n < 1 || n > len(options) { - promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(options))) + promptHint(core.Sprintf("Please enter a number between 1 and %d.", len(options))) return "", Err("invalid selection %q: choose a number between 1 and %d", trimmed, len(options)) } return options[n-1], nil @@ -81,7 +80,7 @@ func MultiSelect(label string, options []string) ([]string, error) { return []string{}, nil } - fmt.Fprintln(stderrWriter(), compileGlyphs(label)) + core.Print(stderrWriter(), "%s", compileGlyphs(label)) for i, opt := range options { fmt.Fprintf(stderrWriter(), " %d. %s\n", i+1, compileGlyphs(opt)) } @@ -89,17 +88,17 @@ func MultiSelect(label string, options []string) ([]string, error) { r := newReader() input, err := r.ReadString('\n') - trimmed := strings.TrimSpace(input) + trimmed := core.Trim(input) if err != nil && trimmed == "" { return []string{}, nil } - if err != nil && !errors.Is(err, io.EOF) { + if err != nil && !core.Is(err, io.EOF) { return nil, err } selected, parseErr := parseMultiSelection(trimmed, len(options)) if parseErr != nil { - return nil, Wrap(parseErr, fmt.Sprintf("invalid selection %q", trimmed)) + return nil, Wrap(parseErr, core.Sprintf("invalid selection %q", trimmed)) } selectedOptions := make([]string, 0, len(selected)) diff --git a/pkg/cli/render.go b/pkg/cli/render.go index 42f14ea..41a45ee 100644 --- a/pkg/cli/render.go +++ b/pkg/cli/render.go @@ -3,41 +3,24 @@ package cli import ( "fmt" "strings" + + "dappco.re/go/core" ) // RenderStyle controls how layouts are rendered. -// -// cli.UseRenderBoxed() -// frame := cli.NewFrame("HCF") -// fmt.Print(frame.String()) type RenderStyle int -// Render style constants for layout output. const ( - // RenderFlat uses no borders or decorations. RenderFlat RenderStyle = iota - // RenderSimple uses --- separators between sections. RenderSimple - // RenderBoxed uses Unicode box drawing characters. RenderBoxed ) var currentRenderStyle = RenderFlat -// UseRenderFlat sets the render style to flat (no borders). -// -// cli.UseRenderFlat() -func UseRenderFlat() { currentRenderStyle = RenderFlat } - -// UseRenderSimple sets the render style to simple (--- separators). -// -// cli.UseRenderSimple() +func UseRenderFlat() { currentRenderStyle = RenderFlat } func UseRenderSimple() { currentRenderStyle = RenderSimple } - -// UseRenderBoxed sets the render style to boxed (Unicode box drawing). -// -// cli.UseRenderBoxed() -func UseRenderBoxed() { currentRenderStyle = RenderBoxed } +func UseRenderBoxed() { currentRenderStyle = RenderBoxed } // Render outputs the layout to terminal. func (c *Composite) Render() { @@ -85,7 +68,7 @@ func (c *Composite) renderSeparator(sb *strings.Builder, depth int) { func (c *Composite) renderSlot(sb *strings.Builder, slot *Slot, depth int) { indent := strings.Repeat(" ", depth) for _, block := range slot.blocks { - for _, line := range strings.Split(block.Render(), "\n") { + for _, line := range core.Split(block.Render(), "\n") { if line != "" { sb.WriteString(indent + line + "\n") } diff --git a/pkg/cli/stream.go b/pkg/cli/stream.go index a324bba..ee7ca24 100644 --- a/pkg/cli/stream.go +++ b/pkg/cli/stream.go @@ -6,6 +6,7 @@ import ( "strings" "sync" + "dappco.re/go/core" "github.com/mattn/go-runewidth" ) @@ -75,14 +76,14 @@ func (s *Stream) Write(text string) { for _, r := range text { if r == '\n' { - fmt.Fprintln(s.out) + core.Print(s.out, "") s.col = 0 continue } rw := runewidth.RuneWidth(r) if rw > 0 && s.col > 0 && s.col+rw > s.wrap { - fmt.Fprintln(s.out) + core.Print(s.out, "") s.col = 0 } @@ -113,7 +114,7 @@ func (s *Stream) Done() { s.once.Do(func() { s.mu.Lock() if s.col > 0 { - fmt.Fprintln(s.out) // ensure trailing newline + core.Print(s.out, "") // ensure trailing newline } s.mu.Unlock() close(s.done) diff --git a/pkg/cli/styles.go b/pkg/cli/styles.go index 8a9aa7f..fadcdf6 100644 --- a/pkg/cli/styles.go +++ b/pkg/cli/styles.go @@ -6,6 +6,7 @@ import ( "strings" "time" + "dappco.re/go/core" "github.com/charmbracelet/x/ansi" "github.com/mattn/go-runewidth" ) @@ -49,7 +50,6 @@ const ( ColourGray900 = "#111827" ) -// Core styles var ( SuccessStyle = NewStyle().Bold().Foreground(ColourGreen500) ErrorStyle = NewStyle().Bold().Foreground(ColourRed500) @@ -70,7 +70,6 @@ var ( RepoStyle = NewStyle().Bold().Foreground(ColourBlue500) ) -// Truncate shortens a string to max length with ellipsis. func Truncate(s string, max int) string { if max <= 0 || s == "" { return "" @@ -84,7 +83,6 @@ func Truncate(s string, max int) string { return truncateByWidth(s, max-3) + "..." } -// Pad right-pads a string to width. func Pad(s string, width int) string { if displayWidth(s) >= width { return s @@ -100,12 +98,10 @@ func truncateByWidth(s string, max int) string { if max <= 0 || s == "" { return "" } - plain := ansi.Strip(s) if displayWidth(plain) <= max { return plain } - var ( width int out strings.Builder @@ -121,50 +117,39 @@ func truncateByWidth(s string, max int) string { return out.String() } -// FormatAge formats a time as human-readable age (e.g., "2h ago", "3d ago"). func FormatAge(t time.Time) string { d := time.Since(t) switch { case d < time.Minute: return "just now" 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: - return fmt.Sprintf("%dh ago", int(d.Hours())) + return core.Sprintf("%dh ago", int(d.Hours())) 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: - return fmt.Sprintf("%dw ago", int(d.Hours()/(24*7))) + return core.Sprintf("%dw ago", int(d.Hours()/(24*7))) default: - return fmt.Sprintf("%dmo ago", int(d.Hours()/(24*30))) + return core.Sprintf("%dmo ago", int(d.Hours()/(24*30))) } } -// ───────────────────────────────────────────────────────────────────────────── -// Border Styles -// ───────────────────────────────────────────────────────────────────────────── - -// BorderStyle selects the box-drawing character set for table borders. type BorderStyle int const ( - // BorderNone disables borders (default). BorderNone BorderStyle = iota - // BorderNormal uses standard box-drawing: ┌─┬┐ │ ├─┼┤ └─┴┘ BorderNormal - // BorderRounded uses rounded corners: ╭─┬╮ │ ├─┼┤ ╰─┴╯ BorderRounded - // BorderHeavy uses heavy box-drawing: ┏━┳┓ ┃ ┣━╋┫ ┗━┻┛ BorderHeavy - // BorderDouble uses double-line box-drawing: ╔═╦╗ ║ ╠═╬╣ ╚═╩╝ BorderDouble ) type borderSet struct { - tl, tr, bl, br string // corners - h, v string // horizontal, vertical - tt, bt, lt, rt string // tees (top, bottom, left, right) - x string // cross + tl, tr, bl, br string + h, v string + tt, bt, lt, rt string + x string } var borderSets = map[BorderStyle]borderSet{ @@ -181,25 +166,8 @@ var borderSetsASCII = map[BorderStyle]borderSet{ BorderDouble: {"+", "+", "+", "+", "=", "|", "+", "+", "+", "+", "+"}, } -// CellStyleFn returns a style based on the cell's raw value. -// Return nil to use the table's default CellStyle. type CellStyleFn func(value string) *AnsiStyle -// ───────────────────────────────────────────────────────────────────────────── -// Table -// ───────────────────────────────────────────────────────────────────────────── - -// Table renders tabular data with aligned columns. -// Supports optional box-drawing borders and per-column cell styling. -// -// t := cli.NewTable("REPO", "STATUS", "BRANCH"). -// WithBorders(cli.BorderRounded). -// WithCellStyle(1, func(val string) *cli.AnsiStyle { -// if val == "clean" { return cli.SuccessStyle } -// return cli.WarningStyle -// }) -// t.AddRow("core-php", "clean", "main") -// t.Render() type Table struct { Headers []string Rows [][]string @@ -209,44 +177,24 @@ type Table struct { maxWidth int } -// TableStyle configures the appearance of table output. type TableStyle struct { HeaderStyle *AnsiStyle CellStyle *AnsiStyle Separator string } -// DefaultTableStyle returns sensible defaults. func DefaultTableStyle() TableStyle { - return TableStyle{ - HeaderStyle: HeaderStyle, - CellStyle: nil, - Separator: " ", - } + return TableStyle{HeaderStyle: HeaderStyle, CellStyle: nil, Separator: " "} } -// NewTable creates a table with headers. func NewTable(headers ...string) *Table { - return &Table{ - Headers: headers, - Style: DefaultTableStyle(), - } + return &Table{Headers: headers, Style: DefaultTableStyle()} } -// AddRow adds a row to the table. -func (t *Table) AddRow(cells ...string) *Table { - t.Rows = append(t.Rows, cells) - return t -} +func (t *Table) AddRow(cells ...string) *Table { t.Rows = append(t.Rows, cells); return t } -// WithBorders enables box-drawing borders on the table. -func (t *Table) WithBorders(style BorderStyle) *Table { - t.borders = style - return t -} +func (t *Table) WithBorders(style BorderStyle) *Table { t.borders = style; return t } -// WithCellStyle sets a per-column style function. -// The function receives the raw cell value and returns a style. func (t *Table) WithCellStyle(col int, fn CellStyleFn) *Table { if t.cellStyleFns == nil { t.cellStyleFns = make(map[int]CellStyleFn) @@ -255,25 +203,18 @@ func (t *Table) WithCellStyle(col int, fn CellStyleFn) *Table { return t } -// WithMaxWidth sets the maximum table width, truncating columns to fit. -func (t *Table) WithMaxWidth(w int) *Table { - t.maxWidth = w - return t -} +func (t *Table) WithMaxWidth(w int) *Table { t.maxWidth = w; return t } -// String renders the table. func (t *Table) String() string { if len(t.Headers) == 0 && len(t.Rows) == 0 { return "" } - if t.borders != BorderNone { return t.renderBordered() } return t.renderPlain() } -// Render prints the table to stdout. func (t *Table) Render() { fmt.Fprint(stdoutWriter(), t.String()) } @@ -289,7 +230,6 @@ func (t *Table) colCount() int { func (t *Table) columnWidths() []int { cols := t.colCount() widths := make([]int, cols) - for i, h := range t.Headers { if w := displayWidth(compileGlyphs(h)); w > widths[i] { widths[i] = w @@ -304,7 +244,6 @@ func (t *Table) columnWidths() []int { } } } - if t.maxWidth > 0 { t.constrainWidths(widths) } @@ -315,23 +254,17 @@ func (t *Table) constrainWidths(widths []int) { cols := len(widths) overhead := 0 if t.borders != BorderNone { - // │ cell │ cell │ = (cols+1) verticals + 2*cols padding spaces overhead = (cols + 1) + (cols * 2) } else { - // separator between columns overhead = (cols - 1) * len(t.Style.Separator) } - total := overhead for _, w := range widths { total += w } - if total <= t.maxWidth { return } - - // Shrink widest columns first until we fit. budget := max(t.maxWidth-overhead, cols) for total-overhead > budget { maxIdx, maxW := 0, 0 @@ -358,10 +291,8 @@ func (t *Table) resolveStyle(col int, value string) *AnsiStyle { func (t *Table) renderPlain() string { widths := t.columnWidths() - var sb strings.Builder sep := t.Style.Separator - if len(t.Headers) > 0 { for i, h := range t.Headers { if i > 0 { @@ -375,7 +306,6 @@ func (t *Table) renderPlain() string { } sb.WriteByte('\n') } - for _, row := range t.Rows { for i := range t.colCount() { if i > 0 { @@ -393,7 +323,6 @@ func (t *Table) renderPlain() string { } sb.WriteByte('\n') } - return sb.String() } @@ -401,10 +330,7 @@ func (t *Table) renderBordered() string { b := tableBorderSet(t.borders) widths := t.columnWidths() cols := t.colCount() - var sb strings.Builder - - // Top border: ╭──────┬──────╮ sb.WriteString(b.tl) for i := range cols { sb.WriteString(strings.Repeat(b.h, widths[i]+2)) @@ -414,8 +340,6 @@ func (t *Table) renderBordered() string { } sb.WriteString(b.tr) sb.WriteByte('\n') - - // Header row if len(t.Headers) > 0 { sb.WriteString(b.v) for i := range cols { @@ -433,8 +357,6 @@ func (t *Table) renderBordered() string { sb.WriteString(b.v) } sb.WriteByte('\n') - - // Header separator: ├──────┼──────┤ sb.WriteString(b.lt) for i := range cols { sb.WriteString(strings.Repeat(b.h, widths[i]+2)) @@ -445,8 +367,6 @@ func (t *Table) renderBordered() string { sb.WriteString(b.rt) sb.WriteByte('\n') } - - // Data rows for _, row := range t.Rows { sb.WriteString(b.v) for i := range cols { @@ -465,8 +385,6 @@ func (t *Table) renderBordered() string { } sb.WriteByte('\n') } - - // Bottom border: ╰──────┴──────╯ sb.WriteString(b.bl) for i := range cols { sb.WriteString(strings.Repeat(b.h, widths[i]+2)) @@ -476,7 +394,6 @@ func (t *Table) renderBordered() string { } sb.WriteString(b.br) sb.WriteByte('\n') - return sb.String() } diff --git a/pkg/cli/tracker.go b/pkg/cli/tracker.go index 6f0a91c..026ca5a 100644 --- a/pkg/cli/tracker.go +++ b/pkg/cli/tracker.go @@ -9,14 +9,13 @@ import ( "sync" "time" + "dappco.re/go/core" "golang.org/x/term" ) -// Spinner frames for the live tracker. var spinnerFramesUnicode = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} var spinnerFramesASCII = []string{"-", "\\", "|", "/"} -// taskState tracks the lifecycle of a tracked task. type taskState int const ( @@ -26,8 +25,6 @@ const ( taskFailed ) -// TrackedTask represents a single task in a TaskTracker. -// Safe for concurrent use — call Update, Done, or Fail from any goroutine. type TrackedTask struct { name string status string @@ -36,7 +33,6 @@ type TrackedTask struct { mu sync.Mutex } -// Update sets the task status message and marks it as running. func (t *TrackedTask) Update(status string) { t.mu.Lock() t.status = status @@ -44,7 +40,6 @@ func (t *TrackedTask) Update(status string) { t.mu.Unlock() } -// Done marks the task as successfully completed with a final message. func (t *TrackedTask) Done(message string) { t.mu.Lock() t.status = message @@ -52,7 +47,6 @@ func (t *TrackedTask) Done(message string) { t.mu.Unlock() } -// Fail marks the task as failed with an error message. func (t *TrackedTask) Fail(message string) { t.mu.Lock() t.status = message @@ -66,18 +60,6 @@ func (t *TrackedTask) snapshot() (string, string, taskState) { return t.name, t.status, t.state } -// TaskTracker displays multiple concurrent tasks with individual spinners. -// -// tracker := cli.NewTaskTracker() -// for _, repo := range repos { -// t := tracker.Add(repo.Name) -// go func(t *cli.TrackedTask) { -// t.Update("pulling...") -// // ... -// t.Done("up to date") -// }(t) -// } -// tracker.Wait() type TaskTracker struct { tasks []*TrackedTask out io.Writer @@ -85,14 +67,12 @@ type TaskTracker struct { started bool } -// Tasks returns an iterator over the tasks in the tracker. func (tr *TaskTracker) Tasks() iter.Seq[*TrackedTask] { return func(yield func(*TrackedTask) bool) { tr.mu.Lock() tasks := make([]*TrackedTask, len(tr.tasks)) copy(tasks, tr.tasks) tr.mu.Unlock() - for _, t := range tasks { if !yield(t) { return @@ -101,14 +81,12 @@ func (tr *TaskTracker) Tasks() iter.Seq[*TrackedTask] { } } -// Snapshots returns an iterator over snapshots of tasks in the tracker. func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] { return func(yield func(string, string) bool) { tr.mu.Lock() tasks := make([]*TrackedTask, len(tr.tasks)) copy(tasks, tr.tasks) tr.mu.Unlock() - for _, t := range tasks { name, status, _ := t.snapshot() if !yield(name, status) { @@ -118,13 +96,10 @@ func (tr *TaskTracker) Snapshots() iter.Seq2[string, string] { } } -// NewTaskTracker creates a new parallel task tracker. func NewTaskTracker() *TaskTracker { return &TaskTracker{out: stderrWriter()} } -// WithOutput sets the destination writer for tracker output. -// Pass nil to keep the current writer unchanged. func (tr *TaskTracker) WithOutput(out io.Writer) *TaskTracker { if out != nil { tr.out = out @@ -132,23 +107,14 @@ func (tr *TaskTracker) WithOutput(out io.Writer) *TaskTracker { return tr } -// Add registers a task and returns it for goroutine use. func (tr *TaskTracker) Add(name string) *TrackedTask { - t := &TrackedTask{ - name: name, - status: "waiting", - state: taskPending, - tracker: tr, - } + t := &TrackedTask{name: name, status: "waiting", state: taskPending, tracker: tr} tr.mu.Lock() tr.tasks = append(tr.tasks, t) tr.mu.Unlock() return t } -// Wait renders the task display and blocks until all tasks complete. -// Uses ANSI cursor manipulation for live updates when connected to a terminal. -// Falls back to line-by-line output for non-TTY. func (tr *TaskTracker) Wait() { if !tr.isTTY() { tr.waitStatic() @@ -165,7 +131,6 @@ func (tr *TaskTracker) isTTY() bool { } func (tr *TaskTracker) waitStatic() { - // Non-TTY: print final state of each task when it completes. reported := make(map[int]bool) for { tr.mu.Lock() @@ -203,7 +168,6 @@ func (tr *TaskTracker) waitLive() { n := len(tr.tasks) tr.mu.Unlock() - // Print initial lines. frame := 0 for i := range n { tr.renderLine(i, frame) @@ -223,7 +187,6 @@ func (tr *TaskTracker) waitLive() { count := len(tr.tasks) tr.mu.Unlock() - // Move cursor up to redraw all lines. fmt.Fprintf(tr.out, "\033[%dA", count) for i := range count { tr.renderLine(i, frame) @@ -294,7 +257,6 @@ func (tr *TaskTracker) allDone() bool { return true } -// Summary returns a one-line summary of task results. func (tr *TaskTracker) Summary() string { tr.mu.Lock() defer tr.mu.Unlock() @@ -312,12 +274,11 @@ func (tr *TaskTracker) Summary() string { total := len(tr.tasks) 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). func (tr *TaskTracker) String() string { tr.mu.Lock() tasks := tr.tasks diff --git a/pkg/cli/tree.go b/pkg/cli/tree.go index 21a4a6f..08898de 100644 --- a/pkg/cli/tree.go +++ b/pkg/cli/tree.go @@ -7,56 +7,38 @@ import ( ) // TreeNode represents a node in a displayable tree structure. -// Use NewTree to create a root, then Add children. -// -// tree := cli.NewTree("core-php") -// tree.Add("core-tenant").Add("core-bio") -// tree.Add("core-admin") -// tree.Add("core-api") -// fmt.Print(tree) -// // core-php -// // ├── core-tenant -// // │ └── core-bio -// // ├── core-admin -// // └── core-api type TreeNode struct { label string style *AnsiStyle children []*TreeNode } -// NewTree creates a new tree with the given root label. func NewTree(label string) *TreeNode { return &TreeNode{label: label} } -// Add appends a child node and returns the child for chaining. func (n *TreeNode) Add(label string) *TreeNode { child := &TreeNode{label: label} n.children = append(n.children, child) return child } -// AddStyled appends a styled child node and returns the child for chaining. func (n *TreeNode) AddStyled(label string, style *AnsiStyle) *TreeNode { child := &TreeNode{label: label, style: style} n.children = append(n.children, child) return child } -// AddTree appends an existing tree as a child and returns the parent for chaining. func (n *TreeNode) AddTree(child *TreeNode) *TreeNode { n.children = append(n.children, child) return n } -// WithStyle sets the style on this node and returns it for chaining. func (n *TreeNode) WithStyle(style *AnsiStyle) *TreeNode { n.style = style return n } -// Children returns an iterator over the node's children. func (n *TreeNode) Children() iter.Seq[*TreeNode] { return func(yield func(*TreeNode) bool) { for _, child := range n.children { @@ -67,8 +49,6 @@ func (n *TreeNode) Children() iter.Seq[*TreeNode] { } } -// String renders the tree with box-drawing characters. -// Implements fmt.Stringer. func (n *TreeNode) String() string { var sb strings.Builder sb.WriteString(n.renderLabel()) @@ -77,7 +57,6 @@ func (n *TreeNode) String() string { return sb.String() } -// Render prints the tree to stdout. func (n *TreeNode) Render() { fmt.Fprint(stdoutWriter(), n.String()) } @@ -97,19 +76,16 @@ func (n *TreeNode) writeChildren(sb *strings.Builder, prefix string) { for i, child := range n.children { last := i == len(n.children)-1 - connector := tee next := pipe if last { connector = corner next = " " } - sb.WriteString(prefix) sb.WriteString(connector) sb.WriteString(child.renderLabel()) sb.WriteByte('\n') - child.writeChildren(sb, prefix+next) } } diff --git a/pkg/cli/utils.go b/pkg/cli/utils.go index 761d051..735405b 100644 --- a/pkg/cli/utils.go +++ b/pkg/cli/utils.go @@ -2,38 +2,29 @@ package cli import ( "context" - "errors" "fmt" "os/exec" "strings" "time" "unicode" + "dappco.re/go/core" "dappco.re/go/core/i18n" "dappco.re/go/core/log" ) -// GhAuthenticated checks if the GitHub CLI is authenticated. -// Returns true if 'gh auth status' indicates a logged-in user. func GhAuthenticated() bool { cmd := exec.Command("gh", "auth", "status") output, _ := cmd.CombinedOutput() - authenticated := strings.Contains(string(output), "Logged in") - + authenticated := core.Contains(string(output), "Logged in") if authenticated { LogWarn("GitHub CLI authenticated", "user", log.Username()) } else { LogWarn("GitHub CLI not authenticated", "user", log.Username()) } - return authenticated } -// ConfirmOption configures Confirm behaviour. -// -// if cli.Confirm("Proceed?", cli.DefaultYes()) { -// cli.Success("continuing") -// } type ConfirmOption func(*confirmConfig) type confirmConfig struct { @@ -43,50 +34,25 @@ type confirmConfig struct { } func promptHint(msg string) { - fmt.Fprintln(stderrWriter(), DimStyle.Render(compileGlyphs(msg))) + core.Print(stderrWriter(), "%s", DimStyle.Render(compileGlyphs(msg))) } func promptWarning(msg string) { - fmt.Fprintln(stderrWriter(), WarningStyle.Render(compileGlyphs(msg))) + core.Print(stderrWriter(), "%s", WarningStyle.Render(compileGlyphs(msg))) } -// DefaultYes sets the default response to "yes" (pressing Enter confirms). func DefaultYes() ConfirmOption { - return func(c *confirmConfig) { - c.defaultYes = true - } + return func(c *confirmConfig) { c.defaultYes = true } } -// Required prevents empty responses; user must explicitly type y/n. func Required() ConfirmOption { - return func(c *confirmConfig) { - c.required = true - } + return func(c *confirmConfig) { c.required = true } } -// Timeout sets a timeout after which the default response is auto-selected. -// If no default is set (not Required and not DefaultYes), defaults to "no". -// -// Confirm("Continue?", Timeout(30*time.Second)) // Auto-no after 30s -// Confirm("Continue?", DefaultYes(), Timeout(10*time.Second)) // Auto-yes after 10s func Timeout(d time.Duration) ConfirmOption { - return func(c *confirmConfig) { - c.timeout = d - } + return func(c *confirmConfig) { c.timeout = d } } -// Confirm prompts the user for yes/no confirmation. -// Returns true if the user enters "y" or "yes" (case-insensitive). -// -// Basic usage: -// -// if Confirm("Delete file?") { ... } -// -// With options: -// -// if Confirm("Save changes?", DefaultYes()) { ... } -// if Confirm("Dangerous!", Required()) { ... } -// if Confirm("Auto-continue?", Timeout(30*time.Second)) { ... } func Confirm(prompt string, opts ...ConfirmOption) bool { cfg := &confirmConfig{} for _, opt := range opts { @@ -95,7 +61,6 @@ func Confirm(prompt string, opts ...ConfirmOption) bool { prompt = compileGlyphs(prompt) - // Build the prompt suffix var suffix string if cfg.required { suffix = "[y/n] " @@ -105,9 +70,8 @@ func Confirm(prompt string, opts ...ConfirmOption) bool { suffix = "[y/N] " } - // Add timeout indicator if set 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 := newReader() @@ -119,7 +83,6 @@ func Confirm(prompt string, opts ...ConfirmOption) bool { var readErr error if cfg.timeout > 0 { - // Use timeout-based reading resultChan := make(chan string, 1) errChan := make(chan error, 1) go func() { @@ -131,9 +94,9 @@ func Confirm(prompt string, opts ...ConfirmOption) bool { select { case response = <-resultChan: readErr = <-errChan - response = strings.ToLower(strings.TrimSpace(response)) + response = core.Lower(core.Trim(response)) case <-time.After(cfg.timeout): - fmt.Fprintln(stderrWriter()) // New line after timeout + core.Print(stderrWriter(), "") return cfg.defaultYes } } else { @@ -143,10 +106,9 @@ func Confirm(prompt string, opts ...ConfirmOption) bool { return cfg.defaultYes } response = line - response = strings.ToLower(strings.TrimSpace(response)) + response = core.Lower(core.Trim(response)) } - // Handle empty response if response == "" { if readErr == nil && cfg.required { promptHint("Please enter y or n, then press Enter.") @@ -158,7 +120,6 @@ func Confirm(prompt string, opts ...ConfirmOption) bool { return cfg.defaultYes } - // Check for yes/no responses if response == "y" || response == "yes" { return true } @@ -166,43 +127,28 @@ func Confirm(prompt string, opts ...ConfirmOption) bool { return false } - // Invalid response if cfg.required { promptHint("Please enter y or n, then press Enter.") continue } - - // Non-required: treat invalid as default return cfg.defaultYes } } -// ConfirmAction prompts for confirmation of an action using grammar composition. -// -// if ConfirmAction("delete", "config.yaml") { ... } -// if ConfirmAction("save", "changes", DefaultYes()) { ... } func ConfirmAction(verb, subject string, opts ...ConfirmOption) bool { question := i18n.Title(verb) + " " + subject + "?" return Confirm(question, opts...) } -// ConfirmDangerousAction prompts for double confirmation of a dangerous action. -// Shows initial question, then a "Really verb subject?" confirmation. -// -// if ConfirmDangerousAction("delete", "config.yaml") { ... } func ConfirmDangerousAction(verb, subject string) bool { question := i18n.Title(verb) + " " + subject + "?" if !Confirm(question, Required()) { return false } - confirm := "Really " + verb + " " + subject + "?" return Confirm(confirm, Required()) } -// QuestionOption configures Question behaviour. -// -// name := cli.Question("Project name:", cli.WithDefault("my-app")) type QuestionOption func(*questionConfig) type questionConfig struct { @@ -211,57 +157,36 @@ type questionConfig struct { validator func(string) error } -// WithDefault sets the default value shown in brackets. func WithDefault(value string) QuestionOption { - return func(c *questionConfig) { - c.defaultValue = value - } + return func(c *questionConfig) { c.defaultValue = value } } -// WithValidator adds a validation function for the response. func WithValidator(fn func(string) error) QuestionOption { - return func(c *questionConfig) { - c.validator = fn - } + return func(c *questionConfig) { c.validator = fn } } -// RequiredInput prevents empty responses. func RequiredInput() QuestionOption { - return func(c *questionConfig) { - c.required = true - } + return func(c *questionConfig) { c.required = true } } -// Question prompts the user for text input. -// -// name := Question("Enter your name:") -// name := Question("Enter your name:", WithDefault("Anonymous")) -// name := Question("Enter your name:", RequiredInput()) func Question(prompt string, opts ...QuestionOption) string { cfg := &questionConfig{} for _, opt := range opts { opt(cfg) } - prompt = compileGlyphs(prompt) - reader := newReader() - for { - // Build prompt with default if cfg.defaultValue != "" { fmt.Fprintf(stderrWriter(), "%s [%s] ", prompt, compileGlyphs(cfg.defaultValue)) } else { fmt.Fprintf(stderrWriter(), "%s ", prompt) } - response, err := reader.ReadString('\n') - response = strings.TrimSpace(response) + response = core.Trim(response) if err != nil && response == "" { return cfg.defaultValue } - - // Handle empty response if response == "" { if cfg.required { promptHint("Please enter a value, then press Enter.") @@ -269,100 +194,63 @@ func Question(prompt string, opts ...QuestionOption) string { } response = cfg.defaultValue } - - // Validate if validator provided if cfg.validator != nil { if err := cfg.validator(response); err != nil { - promptWarning(fmt.Sprintf("Invalid: %v", err)) + promptWarning(core.Sprintf("Invalid: %v", err)) continue } } - return response } } -// QuestionAction prompts for text input using grammar composition. -// -// name := QuestionAction("rename", "old.txt") func QuestionAction(verb, subject string, opts ...QuestionOption) string { question := i18n.Title(verb) + " " + subject + "?" return Question(question, opts...) } -// ChooseOption configures Choose behaviour. -// -// choice := cli.Choose("Pick one:", items, cli.Display(func(v Item) string { -// return v.Name -// })) type ChooseOption[T any] func(*chooseConfig[T]) type chooseConfig[T any] struct { displayFn func(T) string - defaultN int // 0-based index of default selection - filter bool // Enable type-to-filter selection - multi bool // Allow multiple selection + defaultN int + filter bool + multi bool } -// WithDisplay sets a custom display function for items. func WithDisplay[T any](fn func(T) string) ChooseOption[T] { - return func(c *chooseConfig[T]) { - c.displayFn = fn - } + return func(c *chooseConfig[T]) { c.displayFn = fn } } -// WithDefaultIndex sets the default selection index (0-based). func WithDefaultIndex[T any](idx int) ChooseOption[T] { - return func(c *chooseConfig[T]) { - c.defaultN = idx - } + return func(c *chooseConfig[T]) { c.defaultN = idx } } -// Filter enables type-to-filter functionality. -// When enabled, typed text narrows the visible options before selection. func Filter[T any]() ChooseOption[T] { - return func(c *chooseConfig[T]) { - c.filter = true - } + return func(c *chooseConfig[T]) { c.filter = true } } -// Multi allows multiple selections. -// Use ChooseMulti instead of Choose when this option is needed. func Multi[T any]() ChooseOption[T] { - return func(c *chooseConfig[T]) { - c.multi = true - } + return func(c *chooseConfig[T]) { c.multi = true } } -// Display sets a custom display function for items. -// Alias for WithDisplay for shorter syntax. -// -// Choose("Select:", items, Display(func(f File) string { return f.Name })) func Display[T any](fn func(T) string) ChooseOption[T] { return WithDisplay[T](fn) } -// Choose prompts the user to select from a list of items. -// Returns the selected item. Uses simple numbered selection for terminal compatibility. -// -// choice := Choose("Select a file:", files) -// choice := Choose("Select a file:", files, WithDisplay(func(f File) string { return f.Name })) func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T { var zero T if len(items) == 0 { return zero } - cfg := &chooseConfig[T]{ - displayFn: func(item T) string { return fmt.Sprint(item) }, + displayFn: func(item T) string { return core.Sprint(item) }, defaultN: -1, } for _, opt := range opts { opt(cfg) } - prompt = compileGlyphs(prompt) - reader := newReader() visible := make([]int, len(items)) for i := range items { @@ -372,15 +260,13 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T { for { renderChoices(prompt, items, visible, cfg.displayFn, cfg.defaultN, cfg.filter) - if cfg.filter { fmt.Fprintf(stderrWriter(), "Enter number [1-%d] or filter: ", len(visible)) } else { fmt.Fprintf(stderrWriter(), "Enter number [1-%d]: ", len(visible)) } response, err := reader.ReadString('\n') - response = strings.TrimSpace(response) - + response = core.Trim(response) if err != nil && response == "" { if idx, ok := defaultVisibleIndex(visible, cfg.defaultN); ok { return items[idx] @@ -388,7 +274,6 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T { var zero T return zero } - if response == "" { if cfg.filter && len(visible) != len(allVisible) { visible = append([]int(nil), allVisible...) @@ -402,106 +287,78 @@ func Choose[T any](prompt string, items []T, opts ...ChooseOption[T]) T { promptHint("Default selection is not available in the current list. Narrow the list or choose another number.") continue } - promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible))) + promptHint(core.Sprintf("Please enter a number between 1 and %d.", len(visible))) continue } - var n int if _, err := fmt.Sscanf(response, "%d", &n); err == nil { if n >= 1 && n <= len(visible) { return items[visible[n-1]] } - promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible))) + promptHint(core.Sprintf("Please enter a number between 1 and %d.", len(visible))) continue } - if cfg.filter { nextVisible := filterVisible(items, visible, response, cfg.displayFn) if len(nextVisible) == 0 { - promptHint(fmt.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response)) + promptHint(core.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response)) continue } visible = nextVisible continue } - - promptHint(fmt.Sprintf("Please enter a number between 1 and %d.", len(visible))) + promptHint(core.Sprintf("Please enter a number between 1 and %d.", len(visible))) } } -// ChooseAction prompts for selection using grammar composition. -// -// file := ChooseAction("select", "file", files) func ChooseAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) T { question := i18n.Title(verb) + " " + subject + ":" return Choose(question, items, opts...) } -// ChooseMulti prompts the user to select multiple items from a list. -// Returns the selected items. Uses space-separated numbers or ranges. -// -// choices := ChooseMulti("Select files:", files) -// choices := ChooseMulti("Select files:", files, WithDisplay(func(f File) string { return f.Name })) -// -// Input format: -// - "1 3 5" - select items 1, 3, and 5 -// - "1-3" - select items 1, 2, and 3 -// - "1 3-5" - select items 1, 3, 4, and 5 -// - "" (empty) - select none func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T { if len(items) == 0 { return nil } - cfg := &chooseConfig[T]{ - displayFn: func(item T) string { return fmt.Sprint(item) }, + displayFn: func(item T) string { return core.Sprint(item) }, defaultN: -1, } for _, opt := range opts { opt(cfg) } - prompt = compileGlyphs(prompt) - reader := newReader() visible := make([]int, len(items)) for i := range items { visible[i] = i } - for { renderChoices(prompt, items, visible, cfg.displayFn, -1, cfg.filter) - if cfg.filter { fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3), or filter text, or empty for none: ") } else { fmt.Fprint(stderrWriter(), "Enter numbers (e.g., 1 3 5 or 1-3) or empty for none: ") } response, _ := reader.ReadString('\n') - response = strings.TrimSpace(response) - - // Empty response returns no selections. + response = core.Trim(response) if response == "" { return nil } - - // Parse the selection. selected, err := parseMultiSelection(response, len(visible)) if err != nil { if cfg.filter && !looksLikeMultiSelectionInput(response) { nextVisible := filterVisible(items, visible, response, cfg.displayFn) if len(nextVisible) == 0 { - promptHint(fmt.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response)) + promptHint(core.Sprintf("No matches for %q. Try a shorter search term or clear the filter.", response)) continue } visible = nextVisible continue } - promptWarning(fmt.Sprintf("Invalid selection %q: enter numbers like 1 3 or 1-3.", response)) + promptWarning(core.Sprintf("Invalid selection %q: enter numbers like 1 3 or 1-3.", response)) continue } - - // Build result result := make([]T, 0, len(selected)) for _, idx := range selected { result = append(result, items[visible[idx]]) @@ -511,7 +368,7 @@ func ChooseMulti[T any](prompt string, items []T, opts ...ChooseOption[T]) []T { } func renderChoices[T any](prompt string, items []T, visible []int, displayFn func(T) string, defaultN int, filter bool) { - fmt.Fprintln(stderrWriter(), prompt) + core.Print(stderrWriter(), "%s", prompt) for i, idx := range visible { marker := " " if defaultN >= 0 && idx == defaultN { @@ -520,7 +377,7 @@ func renderChoices[T any](prompt string, items []T, visible []int, displayFn fun fmt.Fprintf(stderrWriter(), " %s%d. %s\n", marker, i+1, compileGlyphs(displayFn(items[idx]))) } if filter { - fmt.Fprintln(stderrWriter(), " (type to filter the list)") + core.Print(stderrWriter(), " (type to filter the list)") } } @@ -537,14 +394,13 @@ func defaultVisibleIndex(visible []int, defaultN int) (int, bool) { } func filterVisible[T any](items []T, visible []int, query string, displayFn func(T) string) []int { - q := strings.ToLower(strings.TrimSpace(query)) + q := core.Lower(core.Trim(query)) if q == "" { return visible } - filtered := make([]int, 0, len(visible)) for _, idx := range visible { - if strings.Contains(strings.ToLower(displayFn(items[idx])), q) { + if core.Contains(core.Lower(displayFn(items[idx])), q) { filtered = append(filtered, idx) } } @@ -566,17 +422,11 @@ func looksLikeMultiSelectionInput(input string) bool { return hasDigit } -// parseMultiSelection parses a multi-selection string like "1 3 5", "1,3,5", -// or "1-3 5". -// Returns 0-based indices. func parseMultiSelection(input string, maxItems int) ([]int, error) { selected := make(map[int]bool) - normalized := strings.NewReplacer(",", " ").Replace(input) - for part := range strings.FieldsSeq(normalized) { - // Check for range (e.g., "1-3") - if strings.Contains(part, "-") { + if core.Contains(part, "-") { var rangeParts []string for p := range strings.SplitSeq(part, "-") { rangeParts = append(rangeParts, p) @@ -595,10 +445,9 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) { return nil, Err("range out of bounds: %s", part) } for i := start; i <= end; i++ { - selected[i-1] = true // Convert to 0-based + selected[i-1] = true } } else { - // Single number var n int if _, err := fmt.Sscanf(part, "%d", &n); err != nil { return nil, Err("invalid number: %s", part) @@ -606,11 +455,9 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) { if n < 1 || n > maxItems { return nil, Err("number out of range: %d", n) } - selected[n-1] = true // Convert to 0-based + selected[n-1] = true } } - - // Convert map to sorted slice result := make([]int, 0, len(selected)) for i := range maxItems { if selected[i] { @@ -620,25 +467,18 @@ func parseMultiSelection(input string, maxItems int) ([]int, error) { return result, nil } -// ChooseMultiAction prompts for multiple selections using grammar composition. -// -// files := ChooseMultiAction("select", "files", files) func ChooseMultiAction[T any](verb, subject string, items []T, opts ...ChooseOption[T]) []T { question := i18n.Title(verb) + " " + subject + ":" return ChooseMulti(question, items, opts...) } -// GitClone clones a GitHub repository to the specified path. -// Prefers 'gh repo clone' if authenticated, falls back to SSH. func GitClone(ctx context.Context, org, repo, path string) error { return GitCloneRef(ctx, org, repo, path, "") } -// GitCloneRef clones a GitHub repository at a specific ref to the specified path. -// Prefers 'gh repo clone' if authenticated, falls back to SSH. func GitCloneRef(ctx context.Context, org, repo, path, ref string) error { if GhAuthenticated() { - httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo) + httpsURL := core.Sprintf("https://github.com/%s/%s.git", org, repo) args := []string{"repo", "clone", httpsURL, path} if ref != "" { args = append(args, "--", "--branch", ref, "--single-branch") @@ -648,21 +488,20 @@ func GitCloneRef(ctx context.Context, org, repo, path, ref string) error { if err == nil { return nil } - errStr := strings.TrimSpace(string(output)) - if strings.Contains(errStr, "already exists") { - return errors.New(errStr) + errStr := core.Trim(string(output)) + if core.Contains(errStr, "already exists") { + return core.NewError(errStr) } } - // Fall back to SSH clone args := []string{"clone"} if ref != "" { args = append(args, "--branch", ref, "--single-branch") } - args = append(args, fmt.Sprintf("git@github.com:%s/%s.git", org, repo), path) + args = append(args, core.Sprintf("git@github.com:%s/%s.git", org, repo), path) cmd := exec.CommandContext(ctx, "git", args...) output, err := cmd.CombinedOutput() if err != nil { - return errors.New(strings.TrimSpace(string(output))) + return core.NewError(core.Trim(string(output))) } return nil }