diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..ebc01d6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Run tests + run: go test ./... + + - name: GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78b7275 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/mcstatus +/mcstatus.exe diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..07f6627 --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,30 @@ +version: 2 + +project_name: mcstatus + +builds: + - id: mcstatus + main: ./cmd/mcstatus + binary: mcstatus + env: + - CGO_ENABLED=0 + goos: + - windows + - linux + - darwin + goarch: + - amd64 + - arm64 + +archives: + - id: default + formats: [zip] + name_template: >- + {{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }} + +checksum: + name_template: checksums.txt + +changelog: + use: git + sort: asc diff --git a/README.md b/README.md index b7f9a04..eeea058 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,28 @@ Small Go library for querying Minecraft Java and Bedrock server status. go get github.com/VexoraDevelopment/mcstatus ``` +## CLI + +The repository also ships a small CLI in `cmd/mcstatus`. + +Build locally: + +```bash +go build -o mcstatus ./cmd/mcstatus +``` + +Examples: + +```bash +mcstatus mc.hypixel.net +mcstatus java -json mc.hypixel.net +mcstatus bedrock -query play.example.com:19132 +mcstatus auto -pretty localhost +mcstatus java -motd-raw mc.hypixel.net +``` + +If you prefer prebuilt binaries, use the files attached to GitHub Releases. + ## Quick Start ```go @@ -52,6 +74,16 @@ func main() { - `github.com/VexoraDevelopment/mcstatus/bedrock` - `github.com/VexoraDevelopment/mcstatus/text` +## Releases + +Pushing a tag like `v1.0.1` triggers GitHub Actions and publishes release archives for: + +- Windows +- Linux +- macOS + +using the `mcstatus` CLI binary from `cmd/mcstatus`. + ## Notes - `ServerInfo` is a plain struct and can be stored however you want. diff --git a/cmd/mcstatus/main.go b/cmd/mcstatus/main.go new file mode 100644 index 0000000..3c316d6 --- /dev/null +++ b/cmd/mcstatus/main.go @@ -0,0 +1,258 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "strconv" + "strings" + "time" + + "github.com/VexoraDevelopment/mcstatus" + "github.com/VexoraDevelopment/mcstatus/text" +) + +func main() { + cfg, target, err := parseCLI(os.Args[1:]) + if err != nil { + fatal(err) + } + if target == "" { + printUsage() + os.Exit(2) + } + + host, port, err := parseTarget(target) + if err != nil { + fatal(err) + } + + client := mcstatus.NewAll(cfg.timeout) + + info, err := connect(client, cfg.edition, host, port, cfg.useQuery) + if err != nil { + fatal(err) + } + + if cfg.asJSON { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(info); err != nil { + fatal(err) + } + return + } + + printText(info, cfg.pretty, cfg.motdRaw) +} + +type cliConfig struct { + edition string + timeout time.Duration + asJSON bool + useQuery bool + pretty bool + motdRaw bool +} + +func parseCLI(args []string) (cliConfig, string, error) { + cfg := cliConfig{ + edition: "auto", + timeout: 3 * time.Second, + } + + fs := flag.NewFlagSet("mcstatus", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + edition := fs.String("edition", "auto", "server edition: auto, java, bedrock") + timeout := fs.Duration("timeout", 3*time.Second, "request timeout") + asJSON := fs.Bool("json", false, "print JSON output") + useQuery := fs.Bool("query", false, "use full query when supported") + pretty := fs.Bool("pretty", false, "use compact human-friendly output") + motdRaw := fs.Bool("motd-raw", false, "print raw MOTD without cleanup") + + if len(args) > 0 { + switch strings.ToLower(strings.TrimSpace(args[0])) { + case "auto", "java", "bedrock": + cfg.edition = strings.ToLower(strings.TrimSpace(args[0])) + args = args[1:] + } + } + + if err := fs.Parse(args); err != nil { + return cfg, "", err + } + + cfg.edition = strings.ToLower(strings.TrimSpace(*edition)) + if cfg.edition == "auto" && len(args) > 0 { + // Preserve subcommand semantics while still allowing -edition overrides. + switch strings.ToLower(strings.TrimSpace(os.Args[1])) { + case "auto", "java", "bedrock": + cfg.edition = strings.ToLower(strings.TrimSpace(os.Args[1])) + } + } + cfg.timeout = *timeout + cfg.asJSON = *asJSON + cfg.useQuery = *useQuery + cfg.pretty = *pretty + cfg.motdRaw = *motdRaw + + if fs.NArg() != 1 { + return cfg, "", nil + } + return cfg, fs.Arg(0), nil +} + +func printUsage() { + fmt.Fprintf(os.Stderr, "Usage: %s [auto|java|bedrock] [flags] \n\n", os.Args[0]) + fmt.Fprintln(os.Stderr, "Examples:") + fmt.Fprintf(os.Stderr, " %s mc.hypixel.net\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s java mc.hypixel.net\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s bedrock -query play.example.com:19132\n\n", os.Args[0]) + fmt.Fprintln(os.Stderr, "Flags:") + fmt.Fprintln(os.Stderr, " -edition string") + fmt.Fprintln(os.Stderr, " server edition: auto, java, bedrock") + fmt.Fprintln(os.Stderr, " -timeout duration") + fmt.Fprintln(os.Stderr, " request timeout (default 3s)") + fmt.Fprintln(os.Stderr, " -json") + fmt.Fprintln(os.Stderr, " print JSON output") + fmt.Fprintln(os.Stderr, " -query") + fmt.Fprintln(os.Stderr, " use full query when supported") + fmt.Fprintln(os.Stderr, " -pretty") + fmt.Fprintln(os.Stderr, " use compact human-friendly output") + fmt.Fprintln(os.Stderr, " -motd-raw") + fmt.Fprintln(os.Stderr, " print raw MOTD without cleanup") +} + +func connect(client *mcstatus.Client, edition, host string, port int, useQuery bool) (*mcstatus.ServerInfo, error) { + switch edition { + case "auto": + if info, err := connectJava(client, host, port, useQuery); err == nil { + return info, nil + } + return connectBedrock(client, host, port, useQuery) + case "java": + return connectJava(client, host, port, useQuery) + case "bedrock": + return connectBedrock(client, host, port, useQuery) + default: + return nil, fmt.Errorf("unsupported edition %q", edition) + } +} + +func connectJava(client *mcstatus.Client, host string, port int, useQuery bool) (*mcstatus.ServerInfo, error) { + if useQuery { + return client.Java.QueryFull(host, port) + } + return client.Java.Connect(host, port) +} + +func connectBedrock(client *mcstatus.Client, host string, port int, useQuery bool) (*mcstatus.ServerInfo, error) { + if useQuery { + return client.Bedrock.QueryFull(host, port) + } + return client.Bedrock.Connect(host, port) +} + +func parseTarget(target string) (string, int, error) { + host := strings.TrimSpace(target) + if host == "" { + return "", 0, fmt.Errorf("empty target") + } + + if strings.Count(host, ":") == 1 && !strings.HasSuffix(host, "]") { + parts := strings.SplitN(host, ":", 2) + p, err := strconv.Atoi(parts[1]) + if err != nil { + return "", 0, fmt.Errorf("invalid port %q", parts[1]) + } + return parts[0], p, nil + } + + if strings.HasPrefix(host, "[") { + end := strings.LastIndex(host, "]") + if end == -1 { + return "", 0, fmt.Errorf("invalid IPv6 target %q", target) + } + ipv6Host := host[1:end] + if len(host) == end+1 { + return ipv6Host, 0, nil + } + if !strings.HasPrefix(host[end+1:], ":") { + return "", 0, fmt.Errorf("invalid IPv6 target %q", target) + } + p, err := strconv.Atoi(host[end+2:]) + if err != nil { + return "", 0, fmt.Errorf("invalid port %q", host[end+2:]) + } + return ipv6Host, p, nil + } + + return host, 0, nil +} + +func printText(info *mcstatus.ServerInfo, pretty bool, motdRaw bool) { + motd := info.MOTD + if !motdRaw { + motd = text.CleanMOTD(motd) + } + if pretty { + printPretty(info, motd) + return + } + + fmt.Printf("Edition: %s\n", info.Edition) + fmt.Printf("Address: %s:%d\n", info.Address, info.Port) + if info.IP != "" { + fmt.Printf("IP: %s\n", info.IP) + } + if info.Version != "" { + fmt.Printf("Version: %s\n", info.Version) + } + if motd != "" { + fmt.Printf("MOTD: %s\n", motd) + } + fmt.Printf("Players: %d/%d\n", info.Online, info.Slots) + if info.GameMode != "" { + fmt.Printf("GameMode: %s\n", info.GameMode) + } + if info.Map != "" { + fmt.Printf("Map: %s\n", info.Map) + } + if info.Software != "" { + fmt.Printf("Software: %s\n", info.Software) + } + if len(info.Plugins) > 0 { + fmt.Printf("Plugins: %s\n", strings.Join(info.Plugins, ", ")) + } + if len(info.Players) > 0 { + fmt.Printf("Sample Players: %s\n", strings.Join(info.Players, ", ")) + } + if info.Latency > 0 { + fmt.Printf("Latency: %s\n", info.Latency) + } + fmt.Printf("Query: %t\n", info.Query) +} + +func printPretty(info *mcstatus.ServerInfo, motd string) { + fmt.Printf("%s %s:%d\n", strings.ToUpper(string(info.Edition)), info.Address, info.Port) + if motd != "" { + fmt.Printf("%s\n", motd) + } + fmt.Printf("Players %d/%d", info.Online, info.Slots) + if info.Version != "" { + fmt.Printf(" | Version %s", info.Version) + } + if info.Software != "" { + fmt.Printf(" | %s", info.Software) + } + if info.Latency > 0 { + fmt.Printf(" | %s", info.Latency) + } + fmt.Println() +} + +func fatal(err error) { + fmt.Fprintln(os.Stderr, "mcstatus:", err) + os.Exit(1) +}