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 @@ -64,12 +64,12 @@ jobs:
- name: Run release quality gate
run: |
make fmt-check vet lint test test-e2e provenance-check check-naming check-surface
go test -race -count=1 ./...
go test -tags dev -race -count=1 ./...

- name: Run govulncheck
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
GOFLAGS=-tags=dev govulncheck ./...

- name: Check CLI surface compatibility
run: |
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
- name: Run gosec
run: |
go install github.com/securego/gosec/v2/cmd/gosec@v2.23.0
gosec -no-fail -fmt sarif -out gosec-results.sarif ./...
gosec -tags dev -no-fail -fmt sarif -out gosec-results.sarif ./...

- name: Upload gosec scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@0d579ffd059c29b07949a3cce3983f0780820c98 # v4
Expand Down Expand Up @@ -114,7 +114,7 @@ jobs:
- name: Build
env:
CODEQL_EXTRACTOR_GO_BUILD_TRACING: 'on'
run: go build ./...
run: go build -tags dev ./...

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4
Expand Down
16 changes: 11 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,21 @@ jobs:
run: make check-naming

- name: Run unit tests
run: go test -v ./...
run: go test -tags dev -v ./...

- name: Build binary
run: go build -o bin/basecamp ./cmd/basecamp
run: go build -tags dev -o bin/basecamp ./cmd/basecamp

- name: Smoke test
run: |
./bin/basecamp --version
./bin/basecamp --help | head -5

- name: Validate release build (no dev tag)
run: |
go build -o bin/basecamp-release ./cmd/basecamp
go test -v ./internal/commands/ -run TestStub

lint:
name: Lint
runs-on: ubuntu-latest
Expand All @@ -59,6 +64,7 @@ jobs:
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9
with:
version: v2.11.1
args: --build-tags dev

security:
name: Security
Expand All @@ -76,7 +82,7 @@ jobs:
# new Go version support for no meaningful reproducibility gain.
run: |
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
govulncheck -tags dev ./...

race-check:
name: Race Detection
Expand All @@ -92,7 +98,7 @@ jobs:
go-version-file: 'go.mod'

- name: Run tests with race detector
run: go test -race -v ./...
run: go test -tags dev -race -v ./...

integration:
name: Integration Tests
Expand Down Expand Up @@ -224,7 +230,7 @@ jobs:

- name: Run benchmarks
if: steps.filter.outputs.bench == 'true'
run: go test -bench=. -benchmem -count=3 -run='^$' ./internal/... | tee benchmarks.txt
run: go test -tags dev -bench=. -benchmem -count=3 -run='^$' ./internal/... | tee benchmarks.txt

- name: Download previous benchmark baseline
if: steps.filter.outputs.bench == 'true'
Expand Down
35 changes: 18 additions & 17 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ VERSION_PKG := github.com/basecamp/basecamp-cli/internal/version

# Build flags
LDFLAGS := -s -w -X $(VERSION_PKG).Version=$(VERSION) -X $(VERSION_PKG).Commit=$(COMMIT) -X $(VERSION_PKG).Date=$(DATE)
BUILD_FLAGS := -trimpath -ldflags "$(LDFLAGS)"
BUILD_TAGS := -tags dev
BUILD_FLAGS := -trimpath $(BUILD_TAGS) -ldflags "$(LDFLAGS)"

# Default target
.PHONY: all
Expand Down Expand Up @@ -70,7 +71,7 @@ build-bsd:
# Run tests
.PHONY: test
test: check-toolchain
BASECAMP_NO_KEYRING=1 $(GOTEST) -v ./...
BASECAMP_NO_KEYRING=1 $(GOTEST) $(BUILD_TAGS) -v ./...

# Run end-to-end tests against Go binary
.PHONY: test-e2e
Expand All @@ -80,12 +81,12 @@ test-e2e: build
# Run tests with race detector
.PHONY: race-test
race-test: check-toolchain
BASECAMP_NO_KEYRING=1 $(GOTEST) -race -count=1 ./...
BASECAMP_NO_KEYRING=1 $(GOTEST) $(BUILD_TAGS) -race -count=1 ./...

# Run tests with coverage
.PHONY: test-coverage
test-coverage: check-toolchain
$(GOTEST) -v -coverprofile=coverage.out ./...
$(GOTEST) $(BUILD_TAGS) -v -coverprofile=coverage.out ./...
$(GOCMD) tool cover -html=coverage.out -o coverage.html

# Coverage with browser open
Expand All @@ -100,14 +101,14 @@ coverage: test-coverage
# Run all benchmarks
.PHONY: bench
bench:
BASECAMP_NO_KEYRING=1 $(GOTEST) -bench=. -benchmem ./internal/...
BASECAMP_NO_KEYRING=1 $(GOTEST) $(BUILD_TAGS) -bench=. -benchmem ./internal/...

# Run benchmarks with CPU profiling (profiles first package only due to Go limitation)
.PHONY: bench-cpu
bench-cpu:
@mkdir -p profiles
@echo "Profiling internal/names (primary hot path)..."
BASECAMP_NO_KEYRING=1 $(GOTEST) -bench=. -benchtime=1s -cpuprofile=profiles/cpu.pprof ./internal/names
BASECAMP_NO_KEYRING=1 $(GOTEST) $(BUILD_TAGS) -bench=. -benchtime=1s -cpuprofile=profiles/cpu.pprof ./internal/names
@echo "CPU profile saved to profiles/cpu.pprof"
@echo "View with: go tool pprof -http=:8080 profiles/cpu.pprof"

Expand All @@ -116,14 +117,14 @@ bench-cpu:
bench-mem:
@mkdir -p profiles
@echo "Profiling internal/names (primary hot path)..."
BASECAMP_NO_KEYRING=1 $(GOTEST) -bench=. -benchtime=1s -benchmem -memprofile=profiles/mem.pprof ./internal/names
BASECAMP_NO_KEYRING=1 $(GOTEST) $(BUILD_TAGS) -bench=. -benchtime=1s -benchmem -memprofile=profiles/mem.pprof ./internal/names
@echo "Memory profile saved to profiles/mem.pprof"
@echo "View with: go tool pprof -http=:8080 profiles/mem.pprof"

# Save current benchmarks as baseline for comparison
.PHONY: bench-save
bench-save:
BASECAMP_NO_KEYRING=1 $(GOTEST) -bench=. -benchmem -count=5 ./internal/... > benchmarks-baseline.txt
BASECAMP_NO_KEYRING=1 $(GOTEST) $(BUILD_TAGS) -bench=. -benchmem -count=5 ./internal/... > benchmarks-baseline.txt
@echo "Baseline saved to benchmarks-baseline.txt"

# Compare current benchmarks against baseline
Expand All @@ -133,7 +134,7 @@ bench-compare:
echo "No baseline found. Run 'make bench-save' first."; \
exit 1; \
fi
BASECAMP_NO_KEYRING=1 $(GOTEST) -bench=. -benchmem -count=5 ./internal/... > benchmarks-current.txt
BASECAMP_NO_KEYRING=1 $(GOTEST) $(BUILD_TAGS) -bench=. -benchmem -count=5 ./internal/... > benchmarks-current.txt
@command -v benchstat >/dev/null 2>&1 || go install golang.org/x/perf/cmd/benchstat@latest
benchstat benchmarks-baseline.txt benchmarks-current.txt

Expand Down Expand Up @@ -186,7 +187,7 @@ provenance-check:
# Run go vet
.PHONY: vet
vet: check-toolchain
$(GOVET) ./...
$(GOVET) $(BUILD_TAGS) ./...

# Format code
.PHONY: fmt
Expand All @@ -210,7 +211,7 @@ endif

.PHONY: lint
lint:
$(GOLANGCI_LINT) run ./...
$(GOLANGCI_LINT) run --build-tags dev ./...

# Tidy dependencies
.PHONY: tidy
Expand Down Expand Up @@ -255,7 +256,7 @@ clean-all: clean
# Install to GOPATH/bin
.PHONY: install
install:
$(GOCMD) install ./cmd/basecamp
$(GOCMD) install $(BUILD_TAGS) ./cmd/basecamp

# Guard against local replace directives in go.mod
.PHONY: replace-check
Expand Down Expand Up @@ -365,7 +366,7 @@ security: lint vuln secrets
.PHONY: vuln
vuln:
@echo "Running govulncheck..."
govulncheck ./...
govulncheck $(BUILD_TAGS) ./...

# Run secret scanner
.PHONY: secrets
Expand All @@ -377,15 +378,15 @@ secrets:
.PHONY: fuzz
fuzz:
@echo "Running dateparse fuzz test..."
go test -fuzz=FuzzParseFrom -fuzztime=30s ./internal/dateparse/
go test $(BUILD_TAGS) -fuzz=FuzzParseFrom -fuzztime=30s ./internal/dateparse/
@echo "Running URL parsing fuzz test..."
go test -fuzz=FuzzURLPathParsing -fuzztime=30s ./internal/commands/
go test $(BUILD_TAGS) -fuzz=FuzzURLPathParsing -fuzztime=30s ./internal/commands/

# Run quick fuzz tests (10s each, for CI)
.PHONY: fuzz-quick
fuzz-quick:
go test -fuzz=FuzzParseFrom -fuzztime=10s ./internal/dateparse/
go test -fuzz=FuzzURLPathParsing -fuzztime=10s ./internal/commands/
go test $(BUILD_TAGS) -fuzz=FuzzParseFrom -fuzztime=10s ./internal/dateparse/
go test $(BUILD_TAGS) -fuzz=FuzzURLPathParsing -fuzztime=10s ./internal/commands/

# Install development tools
.PHONY: tools
Expand Down
6 changes: 6 additions & 0 deletions internal/commands/bonfire.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,12 @@ func newBonfireLayoutListCmd() *cobra.Command {
}

func requireBonfireExperimental(app *appctx.App) error {
if !devBuild {
return output.ErrUsageHint(
"bonfire is only available in development builds",
"build with: make build (or go build -tags dev ./cmd/basecamp)",
)
}
if !app.Config.IsExperimental("bonfire") {
return output.ErrUsage(
"experimental feature \"bonfire\" is not enabled; run: basecamp config set experimental.bonfire true --global")
Expand Down
16 changes: 11 additions & 5 deletions internal/commands/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type CommandInfo struct {
Description string `json:"description"`
Actions []string `json:"actions,omitempty"`
Experimental bool `json:"experimental,omitempty"`
DevOnly bool `json:"dev_only,omitempty"`
}

// CommandCategory groups commands by category.
Expand Down Expand Up @@ -133,8 +134,8 @@ func CommandCategories() []CommandCategory {
{Name: "mcp", Category: "additional", Description: "MCP server integration", Actions: []string{"server"}},
{Name: "tools", Category: "additional", Description: "Manage project dock tools", Actions: []string{"show", "create", "update", "trash", "enable", "disable", "reposition"}},
{Name: "skill", Category: "additional", Description: "Manage the embedded agent skill file", Actions: []string{"install"}},
{Name: "tui", Category: "additional", Description: "Launch the Basecamp workspace", Experimental: true},
{Name: "bonfire", Category: "additional", Description: "Multi-campfire orchestration", Actions: []string{"split", "layout"}, Experimental: true},
{Name: "tui", Category: "additional", Description: "Launch the Basecamp workspace", DevOnly: true},
{Name: "bonfire", Category: "additional", Description: "Multi-campfire orchestration", Actions: []string{"split", "layout"}, Experimental: true, DevOnly: true},
{Name: "api", Category: "additional", Description: "Raw API access"},
{Name: "help", Category: "additional", Description: "Show help"},
{Name: "version", Category: "additional", Description: "Show version"},
Expand Down Expand Up @@ -190,10 +191,11 @@ func renderCommandsStyled(w io.Writer, categories []CommandCategory) {
bold := lipgloss.NewStyle().Bold(true)
muted := lipgloss.NewStyle().Foreground(lipgloss.Color("#888"))

devPrefix := "[dev] "
experimentalPrefix := "[experimental] "

// Find max widths across all categories for alignment,
// accounting for the experimental prefix in the description column.
// accounting for badge prefixes in the description column.
maxName := 0
maxDesc := 0
for _, cat := range categories {
Expand All @@ -202,7 +204,9 @@ func renderCommandsStyled(w io.Writer, categories []CommandCategory) {
maxName = len(cmd.Name)
}
descWidth := len(cmd.Description)
if cmd.Experimental {
if cmd.DevOnly {
descWidth += len(devPrefix)
} else if cmd.Experimental {
descWidth += len(experimentalPrefix)
}
if descWidth > maxDesc {
Expand All @@ -222,7 +226,9 @@ func renderCommandsStyled(w io.Writer, categories []CommandCategory) {
actions = strings.Join(cmd.Actions, ", ")
}
desc := cmd.Description
if cmd.Experimental {
if cmd.DevOnly {
desc = devPrefix + desc
} else if cmd.Experimental {
desc = experimentalPrefix + desc
}
line := fmt.Sprintf(" %-*s %-*s", maxName, cmd.Name, maxDesc, desc)
Expand Down
5 changes: 5 additions & 0 deletions internal/commands/dev_tag.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//go:build dev

package commands

const devBuild = true
5 changes: 5 additions & 0 deletions internal/commands/dev_tag_stub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
//go:build !dev

package commands

const devBuild = false
18 changes: 10 additions & 8 deletions internal/commands/tui.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//go:build dev

package commands

import (
Expand All @@ -21,18 +23,18 @@ import (
func NewTUICmd() *cobra.Command {
cmd := &cobra.Command{
Use: "tui [url]",
Short: "Launch the Basecamp workspace [experimental]",
Short: "Launch the Basecamp workspace [dev]",
Long: "Launch a persistent, full-screen terminal workspace for Basecamp.\n" +
"Optionally pass a Basecamp URL to jump directly to a project or recording.\n\n" +
"This feature is under active development and may change between releases.",
Annotations: map[string]string{"experimental": "true"},
Annotations: map[string]string{"dev_only": "true"},
Args: cobra.MaximumNArgs(1),
PreRunE: func(cmd *cobra.Command, args []string) error {
app := appctx.FromContext(cmd.Context())
if app == nil {
return fmt.Errorf("app not initialized")
}
printExperimentalNotice(app.Config.CacheDir)
printDevNotice(app.Config.CacheDir)
return ensureAccount(cmd, app)
},
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -186,22 +188,22 @@ func viewFactory(target workspace.ViewTarget, session *workspace.Session, scope
}
}

// printExperimentalNotice prints a one-time-per-version advisory to stderr.
// printDevNotice prints a one-time-per-version advisory to stderr.
// The sentinel file resets on version upgrade so the notice resurfaces when
// experimental features are most likely to have changed.
func printExperimentalNotice(cacheDir string) {
// the TUI is most likely to have changed.
func printDevNotice(cacheDir string) {
if cacheDir == "" {
return
}
v := version.Version
sentinel := filepath.Join(cacheDir, "experimental-tui-"+v)
sentinel := filepath.Join(cacheDir, "dev-tui-"+v)

if _, err := os.Stat(sentinel); err == nil {
return // already shown for this version
}

_, _ = fmt.Fprintf(os.Stderr,
"Note: The TUI workspace is experimental in %s.\n"+
"Note: The TUI workspace is a development preview in %s.\n"+
"Behavior may change between releases. Report issues at https://github.com/basecamp/basecamp-cli/issues\n\n",
v)

Expand Down
28 changes: 28 additions & 0 deletions internal/commands/tui_stub.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//go:build !dev

package commands

import (
"github.com/spf13/cobra"

"github.com/basecamp/basecamp-cli/internal/output"
)

// NewTUICmd returns a stub tui command for release builds.
func NewTUICmd() *cobra.Command {
cmd := &cobra.Command{
Use: "tui [url]",
Short: "Launch the Basecamp workspace [dev]",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return output.ErrUsageHint(
"the tui workspace is only available in development builds",
"build with: make build (or go build -tags dev ./cmd/basecamp)",
)
},
}

cmd.Flags().Bool("trace", false, "Enable trace logging to file")

return cmd
}
Loading
Loading