feat: initial goget release — modern IPv6-first download utility

This commit is contained in:
2026-06-26 21:21:58 +02:00
commit 53db81e1f7
147 changed files with 45931 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
//go:build linux || freebsd
// +build linux freebsd
package cli
import (
_ "embed"
"fmt"
"io"
"os"
"strings"
)
//go:embed help.txt
var helpText string
// ANSI color codes
const (
ColorReset = "\033[0m"
ColorGreen = "\033[32m"
ColorCyan = "\033[36m"
ColorYellow = "\033[33m"
ColorBlue = "\033[34m"
ColorRed = "\033[31m"
ColorBold = "\033[1m"
ColorMagenta = "\033[35m"
)
// isColorTerminal checks if writer supports ANSI colors
func isColorTerminal(w io.Writer) bool {
if os.Getenv("NO_COLOR") != "" {
return false
}
if _, ok := w.(*os.File); ok {
return os.Getenv("TERM") != "dumb"
}
return false
}
// PrintUsage prints formatted help from embedded help.txt
func PrintUsage(w io.Writer) {
useColors := isColorTerminal(w)
if useColors {
bold := ColorBold
reset := ColorReset
green := ColorGreen
yellow := ColorYellow
magenta := ColorMagenta
_ = bold
_ = reset
_ = green
_ = yellow
_ = magenta
// Print help text with ANSI colors for section headers
for _, line := range strings.Split(helpText, "\n") {
if strings.HasPrefix(line, "NAME") || strings.HasPrefix(line, "SYNOPSIS") ||
strings.HasPrefix(line, "DESCRIPTION") || strings.HasSuffix(strings.TrimRight(line, " "), ":") {
fmt.Fprintf(w, "%s%s%s\n", ColorBold, line, ColorReset)
} else {
fmt.Fprintln(w, line)
}
}
} else {
fmt.Fprint(w, helpText)
}
}
// PrintCategoryHelp prints help filtered by category.
// Categories: all, target, network, auth, mirror, recursive, verify, queue, metalink, upload, pgp, extraction, config
func PrintCategoryHelp(w io.Writer, category string) {
if category == "" || category == "all" {
PrintUsage(w)
return
}
categories := map[string]string{
"target": "TARGETS",
"network": "NETWORK",
"auth": "AUTHENTICATION",
"mirror": "MIRROR MODE",
"recursive": "RECURSIVE DOWNLOAD",
"verify": "VERIFICATION",
"queue": "DOWNLOAD QUEUE",
"metalink": "METALINK",
"upload": "BASIC OPTIONS",
"pgp": "PGP/GPG",
"extraction": "EXTRACTION",
"config": "CONFIGURATION",
}
title, ok := categories[strings.ToLower(category)]
if !ok {
fmt.Fprintf(w, "Unknown category: %s\nAvailable: %s\n", category,
"target, network, auth, mirror, recursive, verify, queue, metalink, upload, pgp, extraction, config, all")
return
}
fmt.Fprintf(w, "Showing category: %s\nUse --help for full help.\n\n", title)
PrintUsage(w) // For now, print full help — future: filter
}
+85
View File
@@ -0,0 +1,85 @@
//go:build linux || freebsd
// +build linux freebsd
package cli
import (
"fmt"
"io"
"os"
"strings"
)
// PrintError prints an error with color styling
func PrintError(w io.Writer, err error) {
prefix, suffix := "", ""
if isColorTerminal(w) {
prefix = ColorRed + ColorBold
suffix = ColorReset
}
fmt.Fprintf(w, "%s✗ Error:%s %v\n", prefix, suffix, err)
}
// PrintWarning prints a warning with color styling
func PrintWarning(w io.Writer, msg string) {
prefix, suffix := "", ""
if isColorTerminal(w) {
prefix = ColorYellow + ColorBold
suffix = ColorReset
}
fmt.Fprintf(w, "%s⚠ Warning:%s %s\n", prefix, suffix, msg)
}
// PrintSuccess prints a success message with color styling
func PrintSuccess(w io.Writer, msg string) {
prefix, suffix := "", ""
if isColorTerminal(w) {
prefix = ColorGreen + ColorBold
suffix = ColorReset
}
fmt.Fprintf(w, "%s✓%s %s\n", prefix, suffix, msg)
}
// PrintInfo prints an info message with color styling
func PrintInfo(w io.Writer, msg string) {
prefix, suffix := "", ""
if isColorTerminal(w) {
prefix = ColorCyan + ColorBold
suffix = ColorReset
}
fmt.Fprintf(w, "%s%s %s\n", prefix, suffix, msg)
}
// FormatError formats an error with context
func FormatError(err error, context string) error {
if context == "" {
return err
}
return fmt.Errorf("%s: %w", context, err)
}
// IsTerminal checks if the writer is a terminal
func IsTerminal(w io.Writer) bool {
_, ok := w.(*os.File)
return ok && os.Getenv("TERM") != "dumb"
}
// Colorize wraps text in ANSI color codes if the writer supports it
func Colorize(w io.Writer, colorCode, text string) string {
if !isColorTerminal(w) {
return text
}
return colorCode + text + ColorReset
}
// Indent returns text indented by the specified number of spaces
func Indent(text string, spaces int) string {
indent := strings.Repeat(" ", spaces)
lines := strings.Split(text, "\n")
for i, line := range lines {
if line != "" {
lines[i] = indent + line
}
}
return strings.Join(lines, "\n")
}
+161
View File
@@ -0,0 +1,161 @@
NAME
goget - Modern IPv6-First Downloader
SYNOPSIS
goget --url <URL> [OPTIONS]
DESCRIPTION
curl/wget replacement in Go. IPv6-first, parallel downloads, resume,
checksum verification, recursive mirroring, and 8 protocols.
TARGET
--url <URL> URL to download (repeatable)
OUTPUT
--output <FILE> Output file (default: auto from URL)
--restart Delete existing file and restart download
--overwrite Overwrite existing file (no atomic rename)
--content-disposition Use server-provided filename
--keep-timestamps Set file modification time from Last-Modified
PROGRESS
--verbose Verbose output with progress bar (default)
--no-progress Hide progress bar
--progress=dot Wget-style dot progress
--quiet Suppress all output except errors
--json Output progress as JSON-lines (for scripting)
--debug Debug mode with detailed info
NETWORK
--timeout <DURATION> Connection timeout (0 = auto)
--max-time <DURATION> Maximum total transfer time
--max-retries <N> Maximum retry attempts on failure
--max-filesize <SIZE> Abort if file exceeds size (e.g., 100MB)
--proxy <URL> Proxy server URL
--no-ipv4 Disable IPv4 fallback
--no-redirect Disable following HTTP redirects
--dns-servers <IPs> Custom DNS servers (comma-separated)
--bind-interface <IFACE> Bind to specific network interface
--allow-private Allow connections to private/internal IPs
--pinned-cert <SHA256> Certificate pinning (SHA-256 hash)
CURL-COMPATIBLE
--header "K: V" Custom HTTP header (repeatable)
--insecure Skip TLS certificate verification
--cert <FILE> Client certificate (PEM)
--key <FILE> Client private key (PEM)
--data "key=value" Send POST data
--form "field=value" Multipart form data (repeatable)
--form "field=@file" Upload file via multipart form
--spider Check URL existence only (HEAD request)
--write-out FORMAT Print metadata (curl format)
--trace-time Print HTTP timing breakdown
WGET-COMPATIBLE
--accept "*.pdf" Accept patterns (filename glob or MIME)
--no-parent Do not ascend to parent directory
--adjust-extension Append .html extension to HTML files
--wait <DURATION> Delay between requests (recursive)
--random-wait Randomize wait time (0.5x - 1.5x)
--page-requisites Download CSS/JS/images for pages
--span-hosts Follow links to external domains
--convert-links Convert links for local viewing
--warc-file <FILE> Write WARC output for archiving
--progress=dot Wget-style dot progress
RECURSIVE / MIRROR
--recursive Enable recursive downloading
--mirror Mirror entire website (infinite depth)
--max-depth <N> Maximum recursion depth
--follow-external Follow external domains
--exclude-pattern <P> Exclude URL patterns (comma-separated)
--no-convert-links Don't convert links in mirror mode
--no-mirror-assets Don't download CSS/JS/images in mirror
--no-robots Don't respect robots.txt in mirror
--rate-limit <RATE> Rate limit (e.g., 1MB/s)
DOWNLOAD MANAGEMENT
--queue Add to download queue
--queue-list List queue items
--queue-process Process download queue
--queue-clear Clear completed items from queue
--queue-file <PATH> Custom queue file path
--queue-priority <N> Priority (1-10)
--batch-file <FILE> Download URLs from file (one per line)
--schedule <TIME> Schedule download (datetime or cron)
--on-complete <CMD> Run command after successful download
AUTHENTICATION
--username <USER> Username for HTTP Basic/Digest Auth
--password <PASS> Password (prefer --password-file for security)
--password-file <FILE> Read password from file
--auth-type <TYPE> Auth type: basic, digest, auto
--cookie-jar <FILE> Load/save cookies (Netscape format)
--oauth-client-id <ID> OAuth 2.0 Client ID
--oauth-client-secret <S>OAuth 2.0 Client Secret
--oauth-token-url <URL> OAuth 2.0 Token URL
--oauth-access-token <T> OAuth 2.0 Access Token
--oauth-refresh-token <T>OAuth 2.0 Refresh Token
UPLOAD
--upload Upload file instead of downloading
--upload-method <M> PUT or POST (default: PUT)
--upload-file <FILE> File to upload
--form-data "k=v" Form data (comma-separated)
--file-field <NAME> Form field name (default: file)
VERIFICATION
--checksum <HASH> Expected checksum
--checksum-algo <ALG> Algorithm: sha256, sha512, blake2b, sha3-256, sha3-512
--checksum-file <FILE> Read checksum from SHA256SUMS file
PGP / GPG
--pgp-decrypt Decrypt PGP file after download
--pgp-verify Verify PGP signature
--pgp-sig <FILE> Path to signature file (.asc, .sig)
--pgp-key <FILE> Path to key file
--pgp-passphrase <PASS> Passphrase for private key
--pgp-passphrase-file F Read passphrase from file
METALINK
--metalink Treat URL as Metalink (multi-source)
--metalink-file <PATH> Path to local Metalink file
CONFIGURATION
--init-config Generate default config file
--config-get <KEY> Get config value
--config-set <K> <V> Set config value
--config-list List all config values
--config-unset <KEY> Unset config value
INFORMATION
--info <URL> Show file info without downloading
--benchmark <URL> Benchmark download speed
--generate-man-page Generate man page and exit
--completion <SHELL> Generate shell completion (bash, zsh, fish)
--help Show this help
--version Show version
PROTOCOLS
http:// https:// HTTP/1.1, HTTP/2, TLS 1.2/1.3
ftp:// ftps:// FTP (active/passive, explicit TLS)
file:// Local file copy + recursive directories
data: Inline data (base64 + plain text)
gopher:// RFC 1436 with type parsing
gemini:// Gemini protocol (TLS, redirect)
EXAMPLES
goget --url https://example.com/file.zip
goget --url https://a.com/1.zip --url https://b.com/2.zip
goget -H "Authorization: Bearer TOKEN" --url https://api.example.com
goget --form "name=value" --form "file=@./data.csv" --url https://httpbin.org/post
goget --recursive --accept "*.pdf" --url https://example.com/docs/
goget --mirror --convert-links --url https://example.com --output ./mirror
goget --schedule "0 2 * * *" --url https://example.com/daily.zip
goget --json --no-progress --url https://example.com/file.zip
FILES
~/.config/goget/config.json Configuration
<file>.goget.meta Resume metadata
More: https://codeberg.org/petrbalvin/goget
+80
View File
@@ -0,0 +1,80 @@
//go:build linux || freebsd
// +build linux freebsd
package cli
import (
"flag"
"fmt"
"os"
"strings"
)
// Parser helps with argument parsing
type Parser struct {
fs *flag.FlagSet
}
// NewParser creates new parser
func NewParser(name string) *Parser {
return &Parser{
fs: flag.NewFlagSet(name, flag.ExitOnError),
}
}
// Parse parses arguments
func (p *Parser) Parse(args []string) error {
return p.fs.Parse(args)
}
// GetBool returns a bool flag
func (p *Parser) GetBool(name string) bool {
return p.fs.Lookup(name).Value.String() == "true"
}
// GetString returns a string flag
func (p *Parser) GetString(name string) string {
return p.fs.Lookup(name).Value.String()
}
// ParseArgs parses command-line arguments
func ParseArgs(args []string) (map[string]string, error) {
result := make(map[string]string)
for i := 0; i < len(args); i++ {
arg := args[i]
if strings.HasPrefix(arg, "--") {
key := strings.TrimPrefix(arg, "--")
if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") {
result[key] = args[i+1]
i++
} else {
result[key] = "true"
}
}
}
return result, nil
}
// ValidateRequiredFlags validates required flags
func ValidateRequiredFlags(flags map[string]string, required []string) error {
var missing []string
for _, req := range required {
if _, exists := flags[req]; !exists {
missing = append(missing, req)
}
}
if len(missing) > 0 {
return fmt.Errorf("missing required flags: %s", strings.Join(missing, ", "))
}
return nil
}
// ExitWithError prints an error and exits the program
func ExitWithError(err error) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
+357
View File
@@ -0,0 +1,357 @@
//go:build linux || freebsd
// +build linux freebsd
package cli
import (
"fmt"
"io"
"os"
"strings"
"sync"
"time"
format "codeberg.org/petrbalvin/goget/internal/format"
)
// ProgressBarConfig configures the progress bar
type ProgressBarConfig struct {
Total int64
Width int
Colors bool
OnComplete func()
Writer io.Writer
}
// DefaultProgressBarConfig returns the default configuration
func DefaultProgressBarConfig() *ProgressBarConfig {
return &ProgressBarConfig{
Total: -1,
Width: 0,
Colors: true,
Writer: os.Stderr,
}
}
// ProgressBar represents a visual progress bar
type ProgressBar struct {
config *ProgressBarConfig
current int64
startTime time.Time
lastUpdate time.Time
barWidth int
mu sync.Mutex
speedHistory []float64
speedMax float64
}
// NewProgressBar creates a new progress bar
func NewProgressBar(cfg *ProgressBarConfig) *ProgressBar {
if cfg == nil {
cfg = DefaultProgressBarConfig()
}
pb := &ProgressBar{
config: cfg,
current: 0,
startTime: time.Now(),
lastUpdate: time.Now(),
barWidth: 40,
}
if cfg.Width == 0 {
tw := getTerminalWidth()
pb.barWidth = max(20, tw-45)
} else {
pb.barWidth = cfg.Width
}
return pb
}
// SetTotal sets the total value in a thread-safe way
func (pb *ProgressBar) SetTotal(total int64) {
pb.mu.Lock()
pb.config.Total = total
pb.mu.Unlock()
}
// Update updates progress
func (pb *ProgressBar) Update(current int64) {
pb.mu.Lock()
pb.current = current
pb.renderLocked()
pb.mu.Unlock()
}
// Add adds to the current progress
func (pb *ProgressBar) Add(n int64) {
pb.mu.Lock()
pb.current += n
pb.renderLocked()
pb.mu.Unlock()
}
// Finish completes the progress bar
func (pb *ProgressBar) Finish() {
pb.mu.Lock()
if pb.config.Total > 0 {
pb.current = pb.config.Total
}
pb.renderFinalLocked()
pb.mu.Unlock()
if pb.config.OnComplete != nil {
pb.config.OnComplete()
}
}
// Cancel cancels the progress bar with error state
func (pb *ProgressBar) Cancel() {
pb.mu.Lock()
pb.renderCancelledLocked()
pb.mu.Unlock()
}
// renderLocked renders the current state; must be called with pb.mu locked
func (pb *ProgressBar) renderLocked() {
now := time.Now()
if now.Sub(pb.lastUpdate) < 50*time.Millisecond {
return
}
pb.lastUpdate = now
w := pb.config.Writer
total := pb.config.Total
current := pb.current
elapsed := now.Sub(pb.startTime)
// Calculate speed
speed := float64(0)
if elapsed.Seconds() > 0 {
speed = float64(current) / elapsed.Seconds()
}
// Record speed history (keep last 15 samples)
pb.speedHistory = append(pb.speedHistory, speed)
if len(pb.speedHistory) > 15 {
pb.speedHistory = pb.speedHistory[len(pb.speedHistory)-15:]
}
// Update max speed
if speed > pb.speedMax {
pb.speedMax = speed
}
percent := float64(0)
showPercent := total > 0
if showPercent {
percent = float64(current) / float64(total) * 100
}
var percentStr string
if showPercent {
percentStr = fmt.Sprintf(" %5.1f%%", percent)
} else {
percentStr = " ??%"
}
filled := 0
empty := pb.barWidth
if showPercent {
filled = int(float64(pb.barWidth) * percent / 100)
empty = pb.barWidth - filled
}
barStart := ""
barEnd := ""
if pb.config.Colors {
barStart = ColorGreen
barEnd = ColorReset
}
bar := barStart + strings.Repeat("█", filled) + barEnd + strings.Repeat("░", empty)
currentStr := format.Bytes(current)
totalStr := "?"
if total > 0 {
totalStr = format.Bytes(total)
}
speedStr := format.Speed(speed)
etaStr := ""
if showPercent && current < total && speed > 0 {
remaining := float64(total-current) / speed
etaStr = formatDuration(time.Duration(remaining * float64(time.Second)))
}
etaPrefix := ""
etaSuffix := ""
if etaStr != "" && pb.config.Colors {
etaPrefix = ColorCyan
etaSuffix = ColorReset
}
// Build speed sparkline
sparkline := pb.renderSparkline()
var line strings.Builder
if pb.config.Colors {
line.WriteString(ColorBold)
}
line.WriteString("[")
line.WriteString(bar)
line.WriteString("]")
if pb.config.Colors {
line.WriteString(ColorReset)
}
line.WriteString(percentStr)
line.WriteString(fmt.Sprintf(" %s/%s", currentStr, totalStr))
line.WriteString(fmt.Sprintf(" %s", speedStr))
if sparkline != "" {
line.WriteString(fmt.Sprintf(" %s", sparkline))
}
if etaStr != "" {
line.WriteString(fmt.Sprintf(" %sETA: %s%s", etaPrefix, etaStr, etaSuffix))
}
fmt.Fprintf(w, "\r%s\033[K", line.String())
}
// renderFinalLocked renders the completed state; must be called with pb.mu locked
func (pb *ProgressBar) renderFinalLocked() {
w := pb.config.Writer
elapsed := time.Since(pb.startTime)
prefix := ""
suffix := ""
if pb.config.Colors {
prefix = ColorGreen + ColorBold
suffix = ColorReset
}
line := fmt.Sprintf("%s✓ Download complete%s | %s | %s elapsed",
prefix, suffix, format.Bytes(pb.current), formatDuration(elapsed))
fmt.Fprintf(w, "\r%s\n", line)
}
// renderCancelledLocked renders the cancelled state; must be called with pb.mu locked
func (pb *ProgressBar) renderCancelledLocked() {
w := pb.config.Writer
prefix := ""
suffix := ""
if pb.config.Colors {
prefix = ColorRed + ColorBold
suffix = ColorReset
}
line := fmt.Sprintf("%s✗ Download cancelled%s", prefix, suffix)
fmt.Fprintf(w, "\r%s\n", line)
}
// renderSparkline returns a mini ASCII sparkline of recent speed history.
// Uses Unicode block elements: ▁▂▃▄▅▆▇█
func (pb *ProgressBar) renderSparkline() string {
if len(pb.speedHistory) < 2 {
return ""
}
chars := []string{"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"}
maxSpeed := pb.speedMax
if maxSpeed == 0 {
for _, s := range pb.speedHistory {
if s > maxSpeed {
maxSpeed = s
}
}
}
if maxSpeed == 0 {
return ""
}
// Take up to 10 most recent samples
history := pb.speedHistory
if len(history) > 10 {
history = history[len(history)-10:]
}
var sparkline strings.Builder
for _, speed := range history {
ratio := speed / maxSpeed
if ratio > 1.0 {
ratio = 1.0
}
idx := int(ratio * float64(len(chars)-1))
if idx < 0 {
idx = 0
}
if idx >= len(chars) {
idx = len(chars) - 1
}
sparkline.WriteString(chars[idx])
}
return sparkline.String()
}
// getTerminalWidth gets the terminal width
func getTerminalWidth() int {
if ws := os.Getenv("COLUMNS"); ws != "" {
if w := parseInt(ws); w > 0 {
return w
}
}
return 80
}
// parseInt helper
func parseInt(s string) int {
var n int
for _, c := range s {
if c >= '0' && c <= '9' {
n = n*10 + int(c-'0')
} else {
break
}
}
return n
}
// formatDuration formats a duration
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
d -= m * time.Minute
s := d / time.Second
if h > 0 {
return fmt.Sprintf("%dh%dm", h, m)
}
if m > 0 {
return fmt.Sprintf("%dm%ds", m, s)
}
return fmt.Sprintf("%ds", s)
}
// PrintProgressInfo prints an info message with progress styling
func PrintProgressInfo(w io.Writer, msg string) {
prefix, suffix := "", ""
if isColorTerminal(w) {
prefix = ColorCyan + ColorBold
suffix = ColorReset
}
fmt.Fprintf(w, "%s• %s%s\n", prefix, msg, suffix)
}
// PrintProgressSuccess prints a success message with progress styling
func PrintProgressSuccess(w io.Writer, msg string) {
prefix, suffix := "", ""
if isColorTerminal(w) {
prefix = ColorGreen + ColorBold
suffix = ColorReset
}
fmt.Fprintf(w, "%s✓ %s%s\n", prefix, msg, suffix)
}