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
35 changes: 35 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,32 @@ else
GO_TEST_FLAGS := -v
endif

DEV_COMMAND ?= codex shell

DEV_VERBOSE_FLAG :=
ifneq ($(strip $(V)),)
ifneq (,$(shell printf '%s' "$(V)" | grep -Eq '^(1|[Tt]|[Tt][rR][uU][eE])$$' && echo yes))
DEV_VERBOSE_FLAG := --verbose
endif
endif
ifneq ($(strip $(VERBOSE)),)
ifneq (,$(shell printf '%s' "$(VERBOSE)" | grep -Eq '^(1|[Tt]|[Tt][rR][uU][eE])$$' && echo yes))
DEV_VERBOSE_FLAG := --verbose
endif
endif

DEV_TRACE_ENV :=
ifneq ($(strip $(T)),)
ifneq (,$(shell printf '%s' "$(T)" | grep -Eq '^(1|[Tt]|[Tt][rR][uU][eE])$$' && echo yes))
DEV_TRACE_ENV := TRACE=1
endif
endif
ifneq ($(strip $(TRACE)),)
ifneq (,$(shell printf '%s' "$(TRACE)" | grep -Eq '^(1|[Tt]|[Tt][rR][uU][eE])$$' && echo yes))
DEV_TRACE_ENV := TRACE=1
endif
endif

.PHONY: test-unit test-go
test-unit test-go: precommit ## Run Go unit tests (after UI build + LSM-generate steps)
@echo 'running go tests...'
Expand Down Expand Up @@ -348,3 +374,12 @@ clean-docker:

.PHONY: clean
clean: clean-go clean-ui clean-docker ## Remove build artifacts

.PHONY: dev
dev: build ## Build leash and run a default dev command (V/VERBOSE=1|true adds --verbose; T/TRACE=1|true sets TRACE=1)
@set -euo pipefail; \
TRACE_ENV="$(DEV_TRACE_ENV)"; \
VERBOSE_FLAG="$(DEV_VERBOSE_FLAG)"; \
CMD="$(DEV_COMMAND)"; \
echo "$$TRACE_ENV ./bin/leash $$VERBOSE_FLAG -- $$CMD"; \
$$TRACE_ENV ./bin/leash $$VERBOSE_FLAG -- $$CMD
2 changes: 1 addition & 1 deletion internal/configstore/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ func expandLeadingTilde(path string) (string, error) {
}

if len(path) == 1 || isPathSeparator(path[1]) {
home, err := os.UserHomeDir()
home, err := resolveHomeDir()
if err != nil {
return "", fmt.Errorf("resolve home directory: %w", err)
}
Expand Down
36 changes: 36 additions & 0 deletions internal/configstore/homedir.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package configstore

import (
"fmt"
"os"
"path/filepath"
"strings"
)

// resolveHomeDir evaluates HOME-style environment variables on each call to
// avoid relying on os.UserHomeDir's cached value, which can be stale in tests
// that mutate the process environment.
func resolveHomeDir() (string, error) {
home := strings.TrimSpace(os.Getenv("HOME"))
if home == "" {
drive := strings.TrimSpace(os.Getenv("HOMEDRIVE"))
path := strings.TrimSpace(os.Getenv("HOMEPATH"))
if drive != "" && path != "" {
home = filepath.Join(drive, path)
} else {
home = strings.TrimSpace(os.Getenv("USERPROFILE"))
}
}
if home != "" {
return filepath.Clean(home), nil
}

resolved, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(resolved) == "" {
if err == nil {
err = fmt.Errorf("home directory not found")
}
return "", fmt.Errorf("resolve home dir: %w", err)
}
return filepath.Clean(resolved), nil
}
9 changes: 3 additions & 6 deletions internal/configstore/hostdirs.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,9 @@ func HostDirForCommand(cmd string) (string, error) {
if _, ok := supportedCommands[cmd]; !ok {
return "", fmt.Errorf("unsupported command %q", cmd)
}
home, err := os.UserHomeDir()
if err != nil || home == "" {
if err == nil {
err = fmt.Errorf("home directory not found")
}
return "", fmt.Errorf("resolve home dir: %w", err)
home, err := resolveHomeDir()
if err != nil {
return "", err
}

if cmd == "claude" {
Expand Down
32 changes: 32 additions & 0 deletions internal/configstore/hostdirs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ import (
func TestHostDirsAllCommandsCovered(t *testing.T) {
testSetEnv(t, "LEASH_HOME", "")
testSetEnv(t, "CLAUDE_CONFIG_DIR", "")
testSetEnv(t, "XDG_CONFIG_HOME", "")
testSetEnv(t, "XDG_DATA_HOME", "")
testSetEnv(t, "XDG_STATE_HOME", "")
home := t.TempDir()
setHome(t, home)

Expand All @@ -30,6 +33,35 @@ func TestHostDirsAllCommandsCovered(t *testing.T) {
}
}

// This test primes os.UserHomeDir before updating HOME to ensure our resolution
// logic ignores the cached value.
func TestHostDirsIgnoreCachedHome(t *testing.T) {
testSetEnv(t, "LEASH_HOME", "")
testSetEnv(t, "CLAUDE_CONFIG_DIR", "")
testSetEnv(t, "XDG_CONFIG_HOME", "")
testSetEnv(t, "XDG_DATA_HOME", "")
testSetEnv(t, "XDG_STATE_HOME", "")

originalHome := t.TempDir()
setHome(t, originalHome)

if _, err := os.UserHomeDir(); err != nil {
t.Fatalf("prime os.UserHomeDir cache: %v", err)
}

newHome := t.TempDir()
setHome(t, newHome)

dir, err := HostDirForCommand("opencode")
if err != nil {
t.Fatalf("HostDirForCommand returned error: %v", err)
}
want := filepath.Join(newHome, ".config", "opencode")
if dir != want {
t.Fatalf("HostDirForCommand = %q, want %q", dir, want)
}
}

// This test modifies HOME while validating error handling, so it cannot run in
// parallel with other environment-sensitive tests.
func TestUnsupportedCommandPanics(t *testing.T) {
Expand Down
9 changes: 3 additions & 6 deletions internal/configstore/mounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,9 @@ func ComputeExtraMountsFor(cmd string, outcome PromptOutcome, statFn func(string
}

func computeOpencodeMounts(outcome PromptOutcome, statFn func(string) (os.FileInfo, error)) ([]Mount, error) {
home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
if err == nil {
err = fmt.Errorf("home directory not found")
}
return nil, fmt.Errorf("resolve home dir: %w", err)
home, err := resolveHomeDir()
if err != nil {
return nil, err
}

paths := opencodePaths(home)
Expand Down
9 changes: 3 additions & 6 deletions internal/configstore/path.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,9 @@ func GetConfigPath() (string, string, error) {
return dir, filepath.Join(dir, configFileName), nil
}

home, err := os.UserHomeDir()
if err != nil || strings.TrimSpace(home) == "" {
if err == nil {
err = fmt.Errorf("home directory not found")
}
return "", "", fmt.Errorf("resolve home dir: %w", err)
home, err := resolveHomeDir()
if err != nil {
return "", "", err
}
base = filepath.Join(home, ".config")
dir := buildConfigDir(base)
Expand Down
8 changes: 5 additions & 3 deletions internal/darwind/runtime_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/strongdm/leash/internal/lsm"
"github.com/strongdm/leash/internal/macsync"
"github.com/strongdm/leash/internal/messages"
"github.com/strongdm/leash/internal/openflag"
"github.com/strongdm/leash/internal/policy"
"github.com/strongdm/leash/internal/proxy"
"github.com/strongdm/leash/internal/telemetry/statsig"
Expand Down Expand Up @@ -224,9 +225,10 @@ func parseConfig(args []string) (*runtimeConfig, error) {
wsPort := fs.String("ws-port", "18080", "WebSocket server port")

serveAddr := fs.String("serve", "", "Serve Control UI and API on bind address (e.g. :18080, 0.0.0.0:8127)")
openBrowser := false
fs.BoolVar(&openBrowser, "open", false, "Open Control UI in default browser after startup")
fs.BoolVar(&openBrowser, "o", false, "Open Control UI in default browser after startup (shorthand)")
openDefault := openflag.Enabled()
openBrowser := openDefault
fs.BoolVar(&openBrowser, "open", openDefault, "Open Control UI in default browser after startup")
fs.BoolVar(&openBrowser, "o", openDefault, "Open Control UI in default browser after startup (shorthand)")

historySize := fs.Int("history-size", 10000, "Number of events to keep in memory for new connections")
cgroupFlag := fs.String("cgroup", "", "Cgroup path to monitor")
Expand Down
24 changes: 24 additions & 0 deletions internal/darwind/runtime_darwin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,30 @@ func TestIsExecHelpRequest(t *testing.T) {
}
}

func TestParseConfigOpenDefaultsFromEnv(t *testing.T) {
t.Setenv("OPEN", "true")

cfg, err := parseConfig(nil)
if err != nil {
t.Fatalf("parseConfig returned error: %v", err)
}
if !cfg.OpenBrowser {
t.Fatalf("expected OpenBrowser to be true when OPEN is truthy")
}
}

func TestParseConfigOpenEnvOverriddenByFlag(t *testing.T) {
t.Setenv("OPEN", "true")

cfg, err := parseConfig([]string{"--open=false"})
if err != nil {
t.Fatalf("parseConfig returned error: %v", err)
}
if cfg.OpenBrowser {
t.Fatalf("expected OpenBrowser to be false when explicitly disabled via flag")
}
}

func TestPreFlightSetsDefaultPrivateDir(t *testing.T) {
// t.Parallel avoided: this test mutates process-wide environment variables and
// log sinks; running in parallel would race with other tests that rely on the
Expand Down
28 changes: 28 additions & 0 deletions internal/openflag/openflag.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package openflag

import (
"os"
"strings"
)

// Enabled reports whether the OPEN environment variable requests that the
// Control UI be opened automatically.
func Enabled() bool {
value, ok := os.LookupEnv("OPEN")
if !ok {
return false
}
return IsTruthy(value)
}

// IsTruthy returns true when the provided value matches an accepted truthy
// form for the OPEN environment variable.
func IsTruthy(value string) bool {
trimmed := strings.TrimSpace(value)
switch strings.ToLower(trimmed) {
case "1", "t", "true":
return true
default:
return false
}
}
52 changes: 52 additions & 0 deletions internal/openflag/openflag_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package openflag

import "testing"

func TestIsTruthy(t *testing.T) {
t.Parallel()

tests := []struct {
value string
expected bool
}{
{value: "", expected: false},
{value: "0", expected: false},
{value: "false", expected: false},
{value: "nope", expected: false},
{value: "1", expected: true},
{value: "t", expected: true},
{value: "T", expected: true},
{value: "true", expected: true},
{value: "TRUE", expected: true},
{value: " True ", expected: true},
}

for _, tt := range tests {
if got := IsTruthy(tt.value); got != tt.expected {
t.Fatalf("IsTruthy(%q) = %v, want %v", tt.value, got, tt.expected)
}
}
}

func TestEnabledReadsEnvironment(t *testing.T) {
// Avoid t.Parallel because environment variables are process-wide.
t.Setenv("OPEN", "1")
if !Enabled() {
t.Fatalf("Enabled() = false with OPEN=1")
}

t.Setenv("OPEN", "t")
if !Enabled() {
t.Fatalf("Enabled() = false with OPEN=t")
}

t.Setenv("OPEN", "True")
if !Enabled() {
t.Fatalf("Enabled() = false with OPEN=True")
}

t.Setenv("OPEN", "0")
if Enabled() {
t.Fatalf("Enabled() = true with OPEN=0")
}
}
3 changes: 3 additions & 0 deletions internal/runner/bubbletea_prompter.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ func newBubbleTeaPrompter(in io.Reader, out io.Writer, project string) *bubbleTe
}

func (p *bubbleTeaPrompter) ConfirmMount(ctx context.Context, cmd, hostDir string) (bool, error) {
restoreTerm := normalizeTERMForBubbleTea()
defer restoreTerm()

model := newWizardModel(cmd, hostDir, p.project, p.version, p.theme, p.logo)
prog := tea.NewProgram(model, tea.WithInput(p.in), tea.WithOutput(p.out), tea.WithContext(ctx))

Expand Down
14 changes: 13 additions & 1 deletion internal/runner/runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/strongdm/leash/internal/configstore"
"github.com/strongdm/leash/internal/entrypoint"
"github.com/strongdm/leash/internal/leashd/listen"
"github.com/strongdm/leash/internal/openflag"
"github.com/strongdm/leash/internal/telemetry/statsig"
)

Expand Down Expand Up @@ -187,6 +188,8 @@ func execute(cmdName string, args []string) error {
return err
}

applyOpenEnv(&opts)

if len(opts.command) == 0 {
return fmt.Errorf("a command is required; provide one after '--'")
}
Expand Down Expand Up @@ -310,7 +313,7 @@ Flags:
-P, --publish-all Publish all EXPOSEd ports (host same as container when free, auto-bump on conflicts).
--image <name[:tag]> Override the target container image (defaults to %s).
--leash-image <name[:tag]> Override the leash manager image (defaults to %s).
-V, --verbose Enable verbose logging.
-V, --verbose Enable verbose logging (also set when -v is provided without a mount spec).

Environment variables:
LEASH_TARGET_IMAGE Default target image (overridden by --image).
Expand Down Expand Up @@ -513,6 +516,15 @@ func finalizeOptions(opts options) (options, error) {
return opts, nil
}

func applyOpenEnv(opts *options) {
if opts == nil || opts.openUI {
return
}
if openflag.Enabled() {
opts.openUI = true
}
}

func appendEnvSpec(opts *options, spec string) error {
spec = strings.TrimSpace(spec)
if spec == "" {
Expand Down
Loading
Loading