Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ builds:
ldflags:
- -s -w
- -X github.com/SurgeDM/Surge/cmd.Version={{.Version}}
- -X github.com/SurgeDM/Surge/cmd.Commit={{.Commit}}
- -X github.com/SurgeDM/Surge/cmd.BuildTime={{.Date}}

before:
Expand Down
34 changes: 34 additions & 0 deletions cmd/bugreport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package cmd

import (
"fmt"

"github.com/SurgeDM/Surge/internal/bugreport"
"github.com/SurgeDM/Surge/internal/utils"
"github.com/spf13/cobra"
)

var bugReportCmd = &cobra.Command{
Use: "bug-report",
Short: "Open a pre-filled GitHub bug report",
Long: `Open a GitHub bug report with version, commit, and environment details pre-filled.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
reportURL := bugreport.BugReportURL(Version, Commit)
if reportURL == "" {
return fmt.Errorf("failed to build bug report URL")
}

fmt.Println("Opening browser to file bug report...")
if err := utils.OpenBrowser(reportURL); err != nil {
fmt.Printf("Could not open browser. Please open this URL manually:\n%s\n", reportURL)
return nil
}

return nil
},
}

func init() {
rootCmd.AddCommand(bugReportCmd)
}
19 changes: 19 additions & 0 deletions cmd/cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,12 @@ func TestBuildTime_DefaultValue(t *testing.T) {
}
}

func TestCommit_DefaultValue(t *testing.T) {
if Commit == "" {
t.Error("Commit should not be empty")
}
}

// =============================================================================
// rootCmd Tests
// =============================================================================
Expand All @@ -561,6 +567,19 @@ func TestRootCmd_HasSubcommands(t *testing.T) {
}
}

func TestRootCmd_HasBugReportSubcommand(t *testing.T) {
found := false
for _, cmd := range rootCmd.Commands() {
if cmd.Name() == "bug-report" {
found = true
break
}
}
if !found {
t.Error("'bug-report' subcommand not found")
}
}

func TestRootCmd_Use(t *testing.T) {
if rootCmd.Use != "surge [url]..." {
t.Errorf("Expected Use='surge [url]...', got %q", rootCmd.Use)
Expand Down
2 changes: 1 addition & 1 deletion cmd/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func connectAndRunTUI(cmd *cobra.Command, target string) error {
}

func newRemoteRootModel(port int, service core.DownloadService, serverHost string) tui.RootModel {
m := tui.InitialRootModel(port, Version, service, nil, false)
m := tui.InitialRootModel(port, Version, service, nil, false, Commit)
m.ServerHost = serverHost
m.IsRemote = true
return m
Expand Down
24 changes: 20 additions & 4 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,16 +31,32 @@ import (
// Version information - set via ldflags during build
var (
Version = "dev"
Commit = "unknown"
BuildTime = "unknown"
)

func init() {
// Override with build info if ldflags didn't inject a version
if Version == "dev" || Version == "" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "" && info.Main.Version != "(devel)" {
if info, ok := debug.ReadBuildInfo(); ok {
// Override with build info if ldflags didn't inject a version.
if (Version == "dev" || Version == "") && info.Main.Version != "" && info.Main.Version != "(devel)" {
Version = strings.TrimPrefix(info.Main.Version, "v")
}

if Commit == "" || Commit == "unknown" {
if rev := buildInfoSetting(info.Settings, "vcs.revision"); rev != "" {
Commit = rev
}
}
}
}

func buildInfoSetting(settings []debug.BuildSetting, key string) string {
for _, setting := range settings {
if setting.Key == key {
return strings.TrimSpace(setting.Value)
}
}
return ""
}

// activeDownloads tracks in-flight downloads for headless/server exit logic.
Expand Down Expand Up @@ -460,7 +476,7 @@ func startTUI(port int, exitWhenDone bool, noResume bool) error {
// Initialize TUI
// GlobalService and GlobalProgressCh are already initialized in PersistentPreRun or Run

m := tui.InitialRootModel(port, Version, GlobalService, currentLifecycle(), noResume)
m := tui.InitialRootModel(port, Version, GlobalService, currentLifecycle(), noResume, Commit)
m = m.WithEnqueueContext(currentEnqueueContext(), currentEnqueueCancel())
m.ServerHost = serverBindHost
if m.ServerHost == "" {
Expand Down
13 changes: 7 additions & 6 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ Surge provides a robust Command Line Interface for automation and scripting. For

## Command Table

| Command | What it does | Key flags | Notes |
| :-------------------------- | :------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :------------------------------------------------ |
| Command | What it does | Key flags | Notes |
| :-------------------------- | :------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------- | :---------------------------------------------------------------------- |
| `surge [url]...` | Launches local TUI. Queues optional URLs. | `--batch, -b`<br>`--port, -p`<br>`--output, -o`<br>`--no-resume`<br>`--exit-when-done` | `-o` defaults to CWD. If `--host` is set, this becomes remote TUI mode. |
| `surge server [url]...` | Launches headless server. Queues optional URLs. | `--batch, -b`<br>`--port, -p`<br>`--output, -o`<br>`--exit-when-done`<br>`--no-resume`<br>`--token` | `-o` defaults to CWD. Primary headless mode command. |
| `surge connect [host:port]` | Launches TUI connected to a server. Auto-detects local server when no target is given. | `--insecure-http` | Convenience alias for remote TUI usage. |
| `surge add <url>...` | Queues downloads via CLI/API. | `--batch, -b`<br>`--output, -o` | `-o` defaults to CWD. Alias: `get`. |
| `surge ls [id]` | Lists downloads, or shows one download detail. | `--json`<br>`--watch` | Alias: `l`. |
| `surge pause <id>` | Pauses a download by ID/prefix. | `--all` | |
| `surge resume <id>` | Resumes a paused download by ID/prefix. | `--all` | |
| `surge refresh <id> <url>` | Updates the source URL of a paused or errored download. | None | Reconnects using the new link. |
| `surge rm <id>` | Removes a download by ID/prefix. | `--clean` | Alias: `kill`. |
| `surge token` | Prints current API auth token. (Also visible in TUI > Settings > Extension) | None | Useful for remote clients. |
| `surge resume <id>` | Resumes a paused download by ID/prefix. | `--all` | |
| `surge refresh <id> <url>` | Updates the source URL of a paused or errored download. | None | Reconnects using the new link. |
| `surge rm <id>` | Removes a download by ID/prefix. | `--clean` | Alias: `kill`. |
| `surge token` | Prints current API auth token. (Also visible in TUI > Settings > Extension) | None | Useful for remote clients. |
| `surge bug-report` | Opens a pre-filled GitHub bug report with environment details. | None | Prints a manual URL fallback if browser open fails. |

## Server Subcommands (Compatibility)

Expand Down
71 changes: 71 additions & 0 deletions internal/bugreport/bugreport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package bugreport

import (
"fmt"
"net/url"
"runtime"
"strings"
)

const newIssueURL = "https://github.com/SurgeDM/Surge/issues/new"

// BugReportURL builds a GitHub new-issue URL pre-populated with system metadata.
// version and commit should be values injected at build time via ldflags.
func BugReportURL(version, commit string) string {
issueURL, err := url.Parse(newIssueURL)
if err != nil {
return ""
}

body := fmt.Sprintf(`**Describe the bug**
A clear and concise description of what the bug is.

**To Reproduce**
Steps to reproduce the behavior:

1. Go to '...'
2. Press '....'
3. Scroll down to '....'
4. See error/unexpected behaviour

**Expected behavior**
A clear and concise description of what you expected to happen.

**Screenshots**
If applicable, add screenshots to help explain your problem.

**Logs**
Surge automatically writes debug log files.

1. The log file is written to:
- **Linux:** ~/.local/state/surge/logs/
- **macOS:** ~/Library/Application Support/surge/logs/
- **Windows:** %%APPDATA%%\surge\logs\
2. Attach the most recent debug-*.log file by dragging it into this issue, or paste relevant excerpts in a code block.

**Please complete the following information:**

- OS: %s/%s
- Surge Version: %s
- Commit: %s
- Installed From: [e.g. Brew / GitHub Release / built from source]

**Additional context**
Add any other context about the problem here.
`, runtime.GOOS, runtime.GOARCH, normalizeValue(version), normalizeValue(commit))

params := url.Values{}
params.Set("title", "Bug: ")
params.Set("body", body)
issueURL.RawQuery = params.Encode()

return issueURL.String()
}

func normalizeValue(value string) string {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return "unknown"
}
return trimmed
}
99 changes: 99 additions & 0 deletions internal/bugreport/bugreport_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package bugreport

import (
"net/url"
"runtime"
"strings"
"testing"
)

func TestBugReportURLContainsExpectedFields(t *testing.T) {
reportURL := BugReportURL("1.2.3", "abc123")
if !strings.HasPrefix(reportURL, "https://github.com/SurgeDM/Surge/issues/new") {
t.Fatalf("unexpected bug-report URL: %s", reportURL)
}

parsed, err := url.Parse(reportURL)
if err != nil {
t.Fatalf("failed to parse bug-report URL: %v", err)
}

query := parsed.Query()
title, ok := query["title"]
if !ok || len(title) == 0 {
t.Fatalf("missing title query parameter")
}
if title[0] != "Bug: " {
t.Fatalf("unexpected title value: %q", title[0])
}

body := query.Get("body")
if body == "" {
t.Fatalf("missing body query parameter")
}

if !strings.Contains(body, "**Describe the bug**") {
t.Fatalf("body missing bug description section: %q", body)
}
if !strings.Contains(body, "**Please complete the following information:**") {
t.Fatalf("body missing environment details section: %q", body)
}
if !strings.Contains(body, "- OS: "+runtime.GOOS+"/"+runtime.GOARCH) {
t.Fatalf("body missing os/arch: %q", body)
}
if !strings.Contains(body, "- Surge Version: 1.2.3") {
t.Fatalf("body missing version: %q", body)
}
if !strings.Contains(body, "- Commit: abc123") {
t.Fatalf("body missing commit: %q", body)
}
if !strings.Contains(body, "- Installed From: [e.g. Brew / GitHub Release / built from source]") {
t.Fatalf("body missing installed-from placeholder: %q", body)
}
}

func TestBugReportURLRoundTripsSpecialCharacters(t *testing.T) {
version := "v1.2.3 beta+rc"
commit := "abc 123/+?&"

reportURL := BugReportURL(version, commit)
parsed, err := url.Parse(reportURL)
if err != nil {
t.Fatalf("failed to parse bug-report URL: %v", err)
}

query := parsed.Query()
body := query.Get("body")
if !strings.Contains(body, "- Surge Version: "+version) {
t.Fatalf("version did not round-trip through URL encoding: %q", body)
}
if !strings.Contains(body, "- Commit: "+commit) {
t.Fatalf("commit did not round-trip through URL encoding: %q", body)
}
}

func TestBugReportURLNormalizesEmptyInputs(t *testing.T) {
tests := []struct {
version string
commit string
}{
{"", ""},
{" ", " "},
}

for _, tc := range tests {
reportURL := BugReportURL(tc.version, tc.commit)
parsed, err := url.Parse(reportURL)
if err != nil {
t.Fatalf("failed to parse bug-report URL: %v", err)
}

body := parsed.Query().Get("body")
if !strings.Contains(body, "- Surge Version: unknown") {
t.Errorf("expected unknown version fallback, got: %q", body)
}
if !strings.Contains(body, "- Commit: unknown") {
t.Errorf("expected unknown commit fallback, got: %q", body)
}
}
}
7 changes: 6 additions & 1 deletion internal/tui/keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type DashboardKeyMap struct {
Settings key.Binding
Log key.Binding
ToggleHelp key.Binding
ReportBug key.Binding
OpenFile key.Binding
Quit key.Binding
ForceQuit key.Binding
Expand Down Expand Up @@ -196,6 +197,10 @@ var Keys = KeyMap{
key.WithKeys("h"),
key.WithHelp("h", "keybindings"),
),
ReportBug: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "report bug"),
),
OpenFile: key.NewBinding(
key.WithKeys("o"),
key.WithHelp("o", "open file"),
Expand Down Expand Up @@ -457,7 +462,7 @@ func (k DashboardKeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.TabQueued, k.TabActive, k.TabDone, k.NextTab},
{k.Add, k.Search, k.CategoryFilter, k.Pause, k.Refresh, k.Delete, k.Settings},
{k.Log, k.Quit},
{k.Log, k.ReportBug, k.Quit},
}
}

Expand Down
10 changes: 9 additions & 1 deletion internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ type RootModel struct {
// Update check
UpdateInfo *version.UpdateInfo // Update information (nil if no update available)
CurrentVersion string // Current version of Surge
CurrentCommit string // Current commit hash of Surge

InitialDarkBackground bool // Captured at startup for "System" theme

Expand Down Expand Up @@ -198,8 +199,14 @@ func NewDownloadModel(id string, url string, filename string, total int64) *Down
}
}

func InitialRootModel(serverPort int, currentVersion string, service core.DownloadService, orchestrator *processing.LifecycleManager, noResume bool) RootModel {
func InitialRootModel(serverPort int, currentVersion string, service core.DownloadService, orchestrator *processing.LifecycleManager, noResume bool, currentCommit ...string) RootModel {
initialDarkBackground := lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
commitValue := "unknown"
if len(currentCommit) > 0 {
if trimmed := strings.TrimSpace(currentCommit[0]); trimmed != "" {
commitValue = trimmed
}
}

// Initialize inputs
urlInput := textinput.New()
Expand Down Expand Up @@ -386,6 +393,7 @@ func InitialRootModel(serverPort int, currentVersion string, service core.Downlo
keys: Keys,
ServerPort: serverPort,
CurrentVersion: currentVersion,
CurrentCommit: commitValue,
InitialDarkBackground: initialDarkBackground,
enqueueCtx: enqueueCtx,
cancelEnqueue: cancelEnqueue,
Expand Down
Loading
Loading