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
13 changes: 10 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ BIN_DIR=bin
# Apple's Make uses the parent's PATH to spawn bare recipe commands. On POSIX
# shells, exec forces lookup through the shell with our exported PATH instead.
# Native Windows shells must keep plain tool invocations.
# An explicit GOPATH_BIN (including an empty value) skips Go path discovery.
RUN_TOOL :=
ifeq ($(wildcard /dev/null),/dev/null)
DEVNULL := /dev/null
Expand All @@ -38,7 +39,9 @@ ifeq ($(OS),Windows_NT)
# Windows + POSIX Shell (Git Bash / MSYS2 / Cygwin)
RUN_TOOL := exec
# cygpath converts drive-letter paths to the shell's mount layout.
GOPATH_BIN := $(shell if command -v cygpath >/dev/null 2>&1; then cygpath -u "$$(go env GOPATH)/bin"; else printf '%s/bin' "$$(go env GOPATH)"; fi)
ifeq ($(origin GOPATH_BIN),undefined)
GOPATH_BIN := $(shell if command -v cygpath >/dev/null 2>&1; then cygpath -u "$$(go env GOPATH)/bin"; else printf '%s/bin' "$$(go env GOPATH)"; fi)
endif
ifneq ($(shell test -d "$(GOPATH_BIN)" && echo yes),)
export PATH := $(GOPATH_BIN):$(PATH)
endif
Expand All @@ -52,7 +55,9 @@ ifeq ($(OS),Windows_NT)
else
# Windows 原生环境 (cmd.exe / PowerShell)
# 自动将 Go bin 目录加入 PATH (Windows 环境变量使用分号分隔)
GOPATH_BIN := $(shell go env GOPATH 2>$(DEVNULL))\bin
ifeq ($(origin GOPATH_BIN),undefined)
GOPATH_BIN := $(shell go env GOPATH 2>$(DEVNULL))\bin
endif
ifneq ($(wildcard $(GOPATH_BIN)),)
export PATH := $(GOPATH_BIN);$(PATH)
endif
Expand All @@ -67,7 +72,9 @@ else
# Linux / macOS 环境 (POSIX)
RUN_TOOL := exec
SHELL_EXT :=
GOPATH_BIN := $(shell go env GOPATH 2>$(DEVNULL))/bin
ifeq ($(origin GOPATH_BIN),undefined)
GOPATH_BIN := $(shell go env GOPATH 2>$(DEVNULL))/bin
endif
ifneq ($(shell test -d "$(GOPATH_BIN)" && echo yes),)
export PATH := $(GOPATH_BIN):$(PATH)
endif
Expand Down
170 changes: 76 additions & 94 deletions cmd/makefile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,27 +11,17 @@ import (
"time"
)

// Allow subprocess startup on loaded native runners while keeping every fixture bounded.
const makefileTestTimeout = 30 * time.Second

func TestMakefile_LinuxDryRun(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("requires a POSIX host")
}
if _, err := exec.LookPath("make"); err != nil {
t.Skip("make command not available in environment")
}

targets := []string{"build", "clean", "help", "windows", "linux"}
targets := []string{"build", "clean", "windows", "linux"}
for _, target := range targets {
t.Run(target, func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "make", "-n", target)
cmd.Dir = ".."
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("make -n %s failed: %v, output: %s", target, err, string(out))
}
outStr := string(out)
outStr := makefileDryRun(t, target)

switch target {
case "build":
Expand All @@ -55,21 +45,8 @@ func TestMakefile_LinuxDryRun(t *testing.T) {
}

func TestMakefile_WindowsDryRun(t *testing.T) {
if _, err := exec.LookPath("make"); err != nil {
t.Skip("make command not available in environment")
}

t.Run("Windows_NT_build", func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "make", "-n", "OS=Windows_NT", "build")
cmd.Dir = ".."
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("make -n OS=Windows_NT build failed: %v, output: %s", err, string(out))
}
outStr := string(out)
outStr := makefileDryRun(t, "OS=Windows_NT", "build")
if !strings.Contains(outStr, "bin/xops.exe") {
t.Fatalf("expected bin/xops.exe with OS=Windows_NT, got: %s", outStr)
}
Expand All @@ -84,34 +61,14 @@ func TestMakefile_WindowsDryRun(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("requires a POSIX host; native Windows clean is tested separately")
}
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "make", "-n", "OS=Windows_NT", "SHELL=/bin/bash", "clean")
cmd.Dir = ".."
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("make -n OS=Windows_NT SHELL=/bin/bash clean failed: %v, output: %s", err, string(out))
}
outStr := string(out)
outStr := makefileDryRun(t, "OS=Windows_NT", "SHELL=/bin/bash", "clean")
if !strings.Contains(outStr, "rm -rf bin coverage.out") {
t.Fatalf("expected rm -rf for POSIX shell on Windows_NT, got: %s", outStr)
}
})

t.Run("Windows_NT_CMD_clean", func(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()

// Avoid metadata subprocesses so the CMD dry run also works on POSIX hosts.
cmd := exec.CommandContext(ctx, "make", "-n", "OS=Windows_NT", "SHELL=cmd.exe",
"GOPATH_BIN=", "VERSION=test", "COMMIT=test", "DATE=test", "clean")
cmd.Dir = ".."
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("native Windows clean dry run failed: %v, output: %s", err, out)
}
outStr := string(out)
outStr := makefileDryRun(t, "OS=Windows_NT", "SHELL=cmd.exe", "clean")
if !strings.Contains(outStr, "rmdir /s /q bin") {
t.Fatalf("expected rmdir /s /q bin for CMD shell on Windows_NT, got: %s", outStr)
}
Expand All @@ -122,21 +79,7 @@ func TestMakefile_WindowsDryRun(t *testing.T) {
}

func TestMakefile_CrossCompilationTargets(t *testing.T) {
if _, err := exec.LookPath("make"); err != nil {
t.Skip("make command not available in environment")
}

ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()

cmd := exec.CommandContext(ctx, "make", "-n", "windows", "windows-arm64", "linux", "linux-arm64", "darwin-amd64", "darwin-arm64")
cmd.Dir = ".."
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("make -n cross compilation targets failed: %v, output: %s", err, string(out))
}

outStr := string(out)
outStr := makefileDryRun(t, "windows", "windows-arm64", "linux", "linux-arm64", "darwin-amd64", "darwin-arm64")
expectedArtifacts := []string{
"bin/xops.exe",
"bin/xops-arm64.exe",
Expand All @@ -153,22 +96,10 @@ func TestMakefile_CrossCompilationTargets(t *testing.T) {
}
}

func TestMakefile_TimeoutContextSafety(t *testing.T) {
if _, err := exec.LookPath("make"); err != nil {
t.Skip("make command not available in environment")
}
// Bound make execution even when the suite has no timeout.
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()

start := time.Now()
cmd := exec.CommandContext(ctx, "make", "-n", "help")
cmd.Dir = ".."
if err := cmd.Run(); err != nil {
t.Fatalf("make -n help failed: %v", err)
}
if time.Since(start) > 5*time.Second {
t.Fatalf("make -n help took unexpectedly long: %v", time.Since(start))
func TestMakefile_HelpDryRun(t *testing.T) {
out := makefileDryRun(t, "help")
if !strings.Contains(out, "make [target]") || !strings.Contains(out, "Targets:") {
t.Fatalf("help dry run omitted usage or targets: %s", out)
}
}

Expand Down Expand Up @@ -199,7 +130,7 @@ func TestMakefile_InstallSkillPOSIX(t *testing.T) {
t.Fatal(err)
}
homeDir := filepath.Join(root, "home with spaces")
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
ctx, cancel := context.WithTimeout(t.Context(), makefileTestTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "make", "OS="+platform, "SHELL=/bin/sh", "GOPATH_BIN=", "HOME="+homeDir, "install-skill")
cmd.Dir = root
Expand All @@ -218,21 +149,72 @@ func TestMakefile_InstallSkillPOSIX(t *testing.T) {
}

func TestMakefile_InstallSkillWindowsDryRun(t *testing.T) {
if _, err := exec.LookPath("make"); err != nil {
out := makefileDryRun(t, "OS=Windows_NT", "SHELL=cmd.exe", "install-skill")
if strings.Count(out, "(Join-Path $HOME '.gemini/skills/xops-agent')") != 2 {
t.Fatalf("expected expandable home paths for both installation commands, got: %s", out)
}
}

// Dry runs check recipes with fixed metadata, without measuring Go/Git startup
// on a loaded native runner. Real tool execution and PATH discovery have separate
// fixture tests below. Bound both the command and inherited output pipes.
func makefileDryRun(t *testing.T, args ...string) string {
t.Helper()
makePath, err := exec.LookPath("make")
if err != nil {
t.Skip("make command not available in environment")
}
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
ctx, cancel := context.WithTimeout(t.Context(), makefileTestTimeout)
defer cancel()
// Override shell-evaluated metadata so this dry run also works on POSIX hosts.
cmd := exec.CommandContext(ctx, "make", "-n", "OS=Windows_NT", "SHELL=cmd.exe",
"GOPATH_BIN=", "VERSION=test", "COMMIT=test", "DATE=test", "install-skill")
options := []string{"-n", "GOPATH_BIN=", "VERSION=test", "COMMIT=test", "DATE=test"}
cmd := exec.CommandContext(ctx, makePath, append(options, args...)...)
cmd.Dir = ".."
cmd.WaitDelay = time.Second
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("Windows install-skill dry run failed: %v, output: %s", err, out)
t.Fatalf("make dry run %v failed: %v (context: %v), output: %s", args, err, ctx.Err(), out)
}
if strings.Count(string(out), "(Join-Path $HOME '.gemini/skills/xops-agent')") != 2 {
t.Fatalf("expected expandable home paths for both installation commands, got: %s", out)
return string(out)
}

func TestMakefile_GOPATHOverrideSkipsDiscovery(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("requires a POSIX host for shell fixtures")
}
makePath, err := exec.LookPath("make")
if err != nil {
t.Skip("make command not available in environment")
}
for _, platform := range []struct {
name, os, shellMode string
}{
{"POSIX", "Darwin", "yes"},
{"Windows_POSIX", "Windows_NT", "yes"},
{"Windows_CMD", "Windows_NT", ""},
} {
for _, bin := range []string{"", "/explicit go/bin"} {
t.Run(platform.name+"/"+bin, func(t *testing.T) {
root := makefileFixture(t)
marker := filepath.Join(root, "discovery")
writeMakefileFixture(t, root, "tools/go", "#!/bin/sh\nprintf 'called' >> \"$XOPS_MAKE_DISCOVERY\"\nprintf '/unused'\n")
t.Setenv("PATH", filepath.Join(root, "tools"))
t.Setenv("XOPS_MAKE_DISCOVERY", marker)
ctx, cancel := context.WithTimeout(t.Context(), makefileTestTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, makePath, "-n", "OS="+platform.os, "SHELL=/bin/sh",
"POSIX_SHELL="+platform.shellMode, "GOPATH_BIN="+bin, "VERSION=test", "COMMIT=test", "DATE=test", "help")
cmd.Dir = root
cmd.WaitDelay = time.Second
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("help with explicit GOPATH_BIN failed: %v, output: %s", err, out)
}
if _, err := os.Stat(marker); err == nil {
t.Fatal("explicit GOPATH_BIN still invoked Go discovery")
} else if !os.IsNotExist(err) {
t.Fatalf("check Go discovery marker: %v", err)
}
})
}
}
}

Expand Down Expand Up @@ -302,7 +284,7 @@ func TestMakefile_WindowsPOSIXPath(t *testing.T) {
gopath = `C:\Go Home`
}
t.Setenv("FIXTURE_GOPATH", gopath)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
ctx, cancel := context.WithTimeout(t.Context(), makefileTestTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, makePath, "--no-print-directory", "-f", "probe.mk", "OS=Windows_NT", "SHELL=/bin/sh",
"VERSION=test", "COMMIT=test", "DATE=test", "probe")
Expand Down Expand Up @@ -343,7 +325,7 @@ func TestMakefile_LintUsesExportedPOSIXPath(t *testing.T) {
t.Setenv("FIXTURE_EXIT", "7")
}
t.Setenv("MAKELEVEL", "1")
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
ctx, cancel := context.WithTimeout(t.Context(), makefileTestTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, makePath, "--no-print-directory", "OS="+platform, "SHELL=/bin/sh", "VERSION=test", "COMMIT=test", "DATE=test", "lint")
cmd.Dir = root
Expand Down Expand Up @@ -397,7 +379,7 @@ case "$2" in
*) printf 'unexpected shell command: %s\n' "$2" >&2; exit 1 ;;
esac
`)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
ctx, cancel := context.WithTimeout(t.Context(), makefileTestTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, "make", "-n", "OS=Windows_NT",
"SHELL="+filepath.Join(root, "sh.exe"), "GOPATH_BIN=",
Expand Down
2 changes: 2 additions & 0 deletions docs/en/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ make build

On Windows, `make build` automatically produces `bin/xops.exe`. You can also cross-build Windows binaries on any platform with `make windows`.

Use `make GOPATH_BIN=/path/to/bin build` to specify the Go tool directory. Setting `GOPATH_BIN=` skips automatic Go tool directory discovery and preserves the existing `PATH`.

## Initialize configuration

```bash
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ make build

在 Windows 系统上,`make build` 会自动生成 `bin/xops.exe`;亦可在任意平台上通过 `make windows` 交叉编译 Windows 产物。

可用 `make GOPATH_BIN=/path/to/bin build` 指定 Go 工具目录;设置 `GOPATH_BIN=` 会跳过 Go 工具目录的自动探测,并保留现有 `PATH`。

## 初始化配置

```bash
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ require (
charm.land/lipgloss/v2 v2.0.5
github.com/charmbracelet/colorprofile v0.4.3
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7
github.com/charmbracelet/x/ansi v0.11.7
github.com/charmbracelet/x/ansi v0.11.8
github.com/charmbracelet/x/conpty v0.2.0
github.com/charmbracelet/x/term v0.2.2
github.com/charmbracelet/x/termios v0.1.1
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw=
github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
github.com/charmbracelet/x/conpty v0.2.0 h1:eKtA2hm34qNfgJCDp/M6Dc0gLy7e07YEK4qAdNGOvVY=
github.com/charmbracelet/x/conpty v0.2.0/go.mod h1:fexgUnVrZgw8scD49f6VSi0Ggj9GWYIrpedRthAwW/8=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
Expand Down
Loading