Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,14 @@ jobs:
run: git config --global url."https://x-access-token:${{ steps.app-token.outputs.token }}@github.com/".insteadOf "https://github.com/"

- name: Install golangci-lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
with:
version: v2.9.0
install-only: true

- name: Cache BATS
id: cache-bats
uses: actions/cache@v5
uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5
with:
path: /usr/local/libexec/bats-core
key: bats-1.11.0
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ on:
branches: [main]
workflow_dispatch:

permissions:
contents: read

jobs:
test:
name: Tests
Expand Down
3 changes: 2 additions & 1 deletion internal/commands/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package commands
import (
"fmt"
"os"
"strings"
"time"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -277,7 +278,7 @@ Output modes:
}

// Raw output: print token directly, with optional stats on stderr
fmt.Println(token)
fmt.Println(strings.ReplaceAll(strings.ReplaceAll(token, "\n", ""), "\r", ""))
return nil
},
}
Expand Down
27 changes: 22 additions & 5 deletions internal/commands/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -780,14 +780,21 @@ func checkShellCompletion(verbose bool) Check {
var completionInstalled bool
var completionPath string

home := os.Getenv("HOME")
if home != "" {
home = filepath.Clean(home)
}

switch shell {
case "bash":
// Check common bash completion paths
paths := []string{
"/opt/homebrew/etc/bash_completion.d/basecamp",
"/usr/local/etc/bash_completion.d/basecamp",
"/etc/bash_completion.d/basecamp",
filepath.Join(os.Getenv("HOME"), ".local/share/bash-completion/completions/basecamp"),
}
if home != "" {
paths = append(paths, filepath.Join(home, ".local/share/bash-completion/completions/basecamp"))
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
Expand All @@ -801,7 +808,9 @@ func checkShellCompletion(verbose bool) Check {
paths := []string{
"/opt/homebrew/share/zsh/site-functions/_basecamp",
"/usr/local/share/zsh/site-functions/_basecamp",
filepath.Join(os.Getenv("HOME"), ".zsh/completions/_basecamp"),
}
if home != "" {
paths = append(paths, filepath.Join(home, ".zsh/completions/_basecamp"))
}
for _, p := range paths {
if _, err := os.Stat(p); err == nil {
Expand All @@ -818,9 +827,11 @@ func checkShellCompletion(verbose bool) Check {
}
}
case "fish":
completionPath = filepath.Join(os.Getenv("HOME"), ".config/fish/completions/basecamp.fish")
if _, err := os.Stat(completionPath); err == nil {
completionInstalled = true
if home != "" {
completionPath = filepath.Join(home, ".config/fish/completions/basecamp.fish")
if _, err := os.Stat(completionPath); err == nil {
completionInstalled = true
}
}
}

Expand Down Expand Up @@ -861,6 +872,7 @@ func zshrcHasCompletionEval() bool {
if home == "" {
return false
}
home = filepath.Clean(home)
f, err := os.Open(filepath.Join(home, ".zshrc")) //nolint:gosec // G304: trusted path
if err != nil {
return false
Expand Down Expand Up @@ -1038,11 +1050,14 @@ func checkLegacyInstall() *Check {
if err != nil {
return nil
}
home = filepath.Clean(home)

// Check marker first — if already migrated, skip
configBase := os.Getenv("XDG_CONFIG_HOME")
if configBase == "" {
configBase = filepath.Join(home, ".config")
} else {
configBase = filepath.Clean(configBase)
}
markerPath := filepath.Join(configBase, "basecamp", ".migrated")
if _, err := os.Stat(markerPath); err == nil {
Expand All @@ -1052,6 +1067,8 @@ func checkLegacyInstall() *Check {
cacheBase := os.Getenv("XDG_CACHE_HOME")
if cacheBase == "" {
cacheBase = filepath.Join(home, ".cache")
} else {
cacheBase = filepath.Clean(cacheBase)
}

var found []string
Expand Down
6 changes: 6 additions & 0 deletions internal/commands/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,13 @@ func migrateCache(result *MigrateResult) {
if err != nil {
return
}
home = filepath.Clean(home)

cacheBase := os.Getenv("XDG_CACHE_HOME")
if cacheBase == "" {
cacheBase = filepath.Join(home, ".cache")
} else {
cacheBase = filepath.Clean(cacheBase)
}

oldDir := filepath.Join(cacheBase, "bcq")
Expand Down Expand Up @@ -325,10 +328,13 @@ func migrateTheme(result *MigrateResult) {
if err != nil {
return
}
home = filepath.Clean(home)

configBase := os.Getenv("XDG_CONFIG_HOME")
if configBase == "" {
configBase = filepath.Join(home, ".config")
} else {
configBase = filepath.Clean(configBase)
}

oldDir := filepath.Join(configBase, "bcq", "theme")
Expand Down
13 changes: 10 additions & 3 deletions internal/completion/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,13 @@ func NewStore(dir string) *Store {
func defaultCacheDir() string {
cacheDir := os.Getenv("XDG_CACHE_HOME")
if cacheDir == "" {
home, _ := os.UserHomeDir()
cacheDir = filepath.Join(home, ".cache")
if home, _ := os.UserHomeDir(); home != "" {
cacheDir = filepath.Join(filepath.Clean(home), ".cache")
} else {
cacheDir = os.TempDir()
}
} else {
cacheDir = filepath.Clean(cacheDir)
}
return filepath.Join(cacheDir, "basecamp")
}
Expand Down Expand Up @@ -351,8 +356,10 @@ func loadConfigForCompletion() *configForCompletion {
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
if home, err := os.UserHomeDir(); err == nil && home != "" {
configDir = filepath.Join(home, ".config")
configDir = filepath.Join(filepath.Clean(home), ".config")
}
} else {
configDir = filepath.Clean(configDir)
}
if configDir != "" {
globalPath := filepath.Join(configDir, "basecamp", "config.json")
Expand Down
27 changes: 21 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,13 @@ type FlagOverrides struct {
func Default() *Config {
cacheDir := os.Getenv("XDG_CACHE_HOME")
if cacheDir == "" {
home, _ := os.UserHomeDir()
cacheDir = filepath.Join(home, ".cache")
if home, _ := os.UserHomeDir(); home != "" {
cacheDir = filepath.Join(filepath.Clean(home), ".cache")
} else {
cacheDir = os.TempDir()
}
} else {
cacheDir = filepath.Clean(cacheDir)
}

return &Config{
Expand Down Expand Up @@ -400,8 +405,13 @@ func systemConfigPath() string {
func globalConfigPath() string {
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
home, _ := os.UserHomeDir()
configDir = filepath.Join(home, ".config")
if home, _ := os.UserHomeDir(); home != "" {
configDir = filepath.Join(filepath.Clean(home), ".config")
} else {
configDir = os.TempDir()
}
} else {
configDir = filepath.Clean(configDir)
}
return filepath.Join(configDir, "basecamp", "config.json")
}
Expand Down Expand Up @@ -528,8 +538,13 @@ func localConfigPaths(repoConfigPath string) []string {
func GlobalConfigDir() string {
configDir := os.Getenv("XDG_CONFIG_HOME")
if configDir == "" {
home, _ := os.UserHomeDir()
configDir = filepath.Join(home, ".config")
if home, _ := os.UserHomeDir(); home != "" {
configDir = filepath.Join(filepath.Clean(home), ".config")
} else {
configDir = os.TempDir()
}
} else {
configDir = filepath.Clean(configDir)
}
return filepath.Join(configDir, "basecamp")
}
Expand Down
3 changes: 2 additions & 1 deletion internal/harness/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ func DetectClaude() bool {
if err != nil {
return false
}
home = filepath.Clean(home)
info, err := os.Stat(filepath.Join(home, ".claude"))
return err == nil && info.IsDir()
}
Expand All @@ -27,7 +28,7 @@ func CheckClaudePlugin() *StatusCheck {
}
}

pluginsPath := filepath.Join(home, ".claude", "plugins", "installed_plugins.json")
pluginsPath := filepath.Join(filepath.Clean(home), ".claude", "plugins", "installed_plugins.json")
data, err := os.ReadFile(pluginsPath) //nolint:gosec // G304: trusted path
if err != nil {
if os.IsNotExist(err) {
Expand Down
48 changes: 48 additions & 0 deletions internal/output/output_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"bytes"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -666,6 +668,52 @@ func TestFormatCellWithJSONNumber(t *testing.T) {
assert.Equal(t, "3.14", result)
}

func TestFormatCellFloatBoundary(t *testing.T) {
const maxSafe = float64(1 << 53) // 2^53: largest exact float64 integer

tests := []struct {
name string
val float64
want string
}{
{"integer", 42.0, "42"},
{"negative integer", -7.0, "-7"},
{"fractional", 3.14, "3.14"},
{"zero", 0.0, "0"},
{"large safe int", 1e15, "1000000000000000"},
{"max safe int (2^53)", maxSafe, fmt.Sprintf("%d", int64(maxSafe))},
{"negative max safe int", -maxSafe, fmt.Sprintf("%d", int64(-maxSafe))},
// maxSafe+1 rounds to maxSafe in float64, so it still formats as int.
// Use maxSafe*2 to get a value truly beyond the safe range.
{"beyond safe int (2^54)", maxSafe * 2, fmt.Sprintf("%.2f", maxSafe*2)},
{"2^63 (MaxInt64 boundary)", math.Pow(2, 63), fmt.Sprintf("%.2f", math.Pow(2, 63))},
{"just below 2^63", math.Nextafter(math.Pow(2, 63), 0), fmt.Sprintf("%.2f", math.Nextafter(math.Pow(2, 63), 0))},
{"negative 2^63", -math.Pow(2, 63), fmt.Sprintf("%.2f", -math.Pow(2, 63))},
{"very large float beyond int64", 1e20, "100000000000000000000.00"},
{"huge float beyond int64", 1e19, "10000000000000000000.00"},
{"NaN", math.NaN(), "NaN"},
{"positive infinity", math.Inf(1), "+Inf"},
{"negative infinity", math.Inf(-1), "-Inf"},
{"max float64", math.MaxFloat64, fmt.Sprintf("%.2f", math.MaxFloat64)},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatCell(tt.val)
assert.Equal(t, tt.want, got)
})
}
}

func TestFormatCellFloatBoundaryInArray(t *testing.T) {
arr := []any{math.NaN(), math.Inf(1), 42.0, 3.14, math.MaxFloat64}
got := formatCell(arr)
assert.Contains(t, got, "42")
assert.Contains(t, got, "3.14")
// NaN and Inf should not crash
assert.NotEmpty(t, got)
}

func TestFormatCellWithScalarArray(t *testing.T) {
// Test string arrays (e.g., tags)
tags := []any{"frontend", "bug", "urgent"}
Expand Down
14 changes: 10 additions & 4 deletions internal/output/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"math"
"os"
"sort"
"strings"
Expand Down Expand Up @@ -349,6 +350,11 @@ func toMapSlice(slice []any) []map[string]any {
return result
}

// maxSafeInt is the largest integer that float64 can represent exactly (2^53).
// Beyond this, consecutive integers have gaps, so int64(f) may silently
// round to the wrong value.
const maxSafeInt = 1 << 53

// Column priority for table rendering (lower = higher priority)
var columnPriority = map[string]int{
"id": 1,
Expand Down Expand Up @@ -670,8 +676,8 @@ func formatCell(val any) string {
case json.Number:
return v.String()
case float64:
if v == float64(int(v)) {
return fmt.Sprintf("%d", int(v))
if v == math.Trunc(v) && v >= -maxSafeInt && v <= maxSafeInt {
return fmt.Sprintf("%d", int64(v))
}
return fmt.Sprintf("%.2f", v)
case int, int64:
Expand All @@ -689,8 +695,8 @@ func formatCell(val any) string {
case json.Number:
items = append(items, elem.String())
case float64:
if elem == float64(int(elem)) {
items = append(items, fmt.Sprintf("%d", int(elem)))
if elem == math.Trunc(elem) && elem >= -maxSafeInt && elem <= maxSafeInt {
items = append(items, fmt.Sprintf("%d", int64(elem)))
} else {
items = append(items, fmt.Sprintf("%.2f", elem))
}
Expand Down
6 changes: 3 additions & 3 deletions internal/resilience/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,20 +43,20 @@ func NewStore(dir string) *Store {
func defaultStateDir() string {
// First, check XDG_CACHE_HOME (Linux/BSD convention)
if cacheDir := os.Getenv("XDG_CACHE_HOME"); cacheDir != "" {
return filepath.Join(cacheDir, "basecamp", DefaultDirName)
return filepath.Join(filepath.Clean(cacheDir), "basecamp", DefaultDirName)
}

// Use os.UserCacheDir() which handles platform-specific paths:
// - macOS: ~/Library/Caches
// - Linux: ~/.cache (respects XDG_CACHE_HOME)
// - Windows: %LocalAppData%
if cacheDir, err := os.UserCacheDir(); err == nil && cacheDir != "" {
return filepath.Join(cacheDir, "basecamp", DefaultDirName)
return filepath.Join(filepath.Clean(cacheDir), "basecamp", DefaultDirName)
}

// Fall back to home directory
if home, err := os.UserHomeDir(); err == nil && home != "" {
return filepath.Join(home, ".cache", "basecamp", DefaultDirName)
return filepath.Join(filepath.Clean(home), ".cache", "basecamp", DefaultDirName)
}

// Last resort: use temp directory to avoid relative paths
Expand Down
6 changes: 6 additions & 0 deletions internal/richtext/mime.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ var mimeByExt = map[string]string{
// DetectMIME returns the MIME type for a file path.
// It uses the extension map first, then falls back to reading file header bytes.
func DetectMIME(path string) string {
if path != "" {
path = filepath.Clean(path)
}
ext := strings.ToLower(filepath.Ext(path))
if mime, ok := mimeByExt[ext]; ok {
return mime
Expand All @@ -80,6 +83,9 @@ func DetectMIME(path string) string {
// ValidateFile checks that a path refers to an existing, regular, readable file
// within the size limit. Returns nil on success.
func ValidateFile(path string) error {
if path != "" {
path = filepath.Clean(path)
}
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("cannot access %s: %w", filepath.Base(path), err)
Expand Down
Loading
Loading