diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15e09417..a6b16873 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,26 @@ jobs: files: coverage.out continue-on-error: true + build-windows: + # CGO build smoke-test on native Windows. tree-sitter needs a C/C++ + # compiler — the GitHub windows runner ships mingw-w64 on PATH, so no + # extra toolchain setup is required. Build-only: `go build ./...` + # compiles every production package without pulling in the + # platform-specific test files a full `go test` would. + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version: '1.26' + + - name: Build CLI + run: go build -o gortex.exe ./cmd/gortex/ + + - name: Build all packages + run: go build ./... + lint: runs-on: ubuntu-latest steps: diff --git a/.github/workflows/install-script.yml b/.github/workflows/install-script.yml index c02a1414..c15ea229 100644 --- a/.github/workflows/install-script.yml +++ b/.github/workflows/install-script.yml @@ -9,11 +9,13 @@ on: pull_request: paths: - "scripts/install.sh" + - "scripts/install.ps1" - ".github/workflows/install-script.yml" push: branches: [main] paths: - "scripts/install.sh" + - "scripts/install.ps1" jobs: posix-syntax: @@ -77,3 +79,20 @@ jobs: # Marker block must appear exactly once. count=$(grep -c '^# >>> gortex installer >>>' "$HOME/.bashrc") test "$count" = "1" + + powershell-syntax: + # Parse-check install.ps1 — the PowerShell counterpart of the + # posix-syntax job. A full install can only run once a release ships + # Windows artifacts, so this validates that the script parses without + # errors on every change. + runs-on: windows-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Parse install.ps1 + shell: pwsh + run: | + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile( + (Resolve-Path scripts/install.ps1), [ref]$null, [ref]$errors) | Out-Null + if ($errors) { $errors; exit 1 } + Write-Host "install.ps1 parses cleanly" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 315d557a..a6b01497 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,6 +98,7 @@ jobs: -w /go/src/gortex \ -e GITHUB_TOKEN \ -e HOMEBREW_TAP_TOKEN \ + -e SCOOP_BUCKET_TOKEN \ ghcr.io/goreleaser/goreleaser-cross:v1.26 \ release --clean env: @@ -105,6 +106,10 @@ jobs: # Personal access token with `repo` scope on zzet/homebrew-tap. # GITHUB_TOKEN can only push to the source repo, not the tap. HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} + # Same story for the Scoop bucket — a PAT with `repo` scope on + # gortexhq/scoop-bucket. Required for the `scoops` block in + # .goreleaser.yml to push the Windows manifest. + SCOOP_BUCKET_TOKEN: ${{ secrets.SCOOP_BUCKET_TOKEN }} # goreleaser-cross runs as root inside the container, so everything # in dist/ is owned by root:root on the host. The subsequent cosign @@ -155,7 +160,7 @@ jobs: run: | cd dist shopt -s nullglob - for f in *.tar.gz *.deb *.rpm *.apk checksums.txt; do + for f in *.tar.gz *.zip *.deb *.rpm *.apk checksums.txt; do echo "Signing $f..." cosign sign-blob \ --output-signature "${f}.sig" \ @@ -189,7 +194,7 @@ jobs: # (one "hash filename" line per artifact). We hash the primary # artifacts only — .sig/.pem are attestations of these files, # they don't need their own provenance. - HASHES=$(sha256sum *.tar.gz *.deb *.rpm *.apk | base64 -w0) + HASHES=$(sha256sum *.tar.gz *.zip *.deb *.rpm *.apk | base64 -w0) echo "hashes=$HASHES" >> "$GITHUB_OUTPUT" # Always wipe signing material — even on failure — so a P12 / .p8 @@ -241,6 +246,7 @@ jobs: mkdir -p dist gh release download "${GITHUB_REF#refs/tags/}" --dir dist \ --pattern '*.tar.gz' \ + --pattern '*.zip' \ --pattern '*.deb' \ --pattern '*.rpm' \ --pattern '*.apk' @@ -251,6 +257,7 @@ jobs: vt_api_key: ${{ secrets.VT_API_KEY }} files: | ./dist/*.tar.gz + ./dist/*.zip ./dist/*.deb ./dist/*.rpm ./dist/*.apk diff --git a/.goreleaser.yml b/.goreleaser.yml index a2967131..99f4ae6b 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -24,9 +24,17 @@ builds: goos: - linux - darwin + - windows goarch: - amd64 - arm64 + ignore: + # windows/arm64 needs an aarch64-w64-mingw32 cross-toolchain that + # the goreleaser-cross image doesn't ship; windows/amd64 covers + # every mainstream Windows dev box. Revisit when the image gains + # the llvm-mingw arm64 target. + - goos: windows + goarch: arm64 # Per-target CC + CXX. goreleaser-cross exposes these cross-toolchains # on PATH; CGO needs both set per target triple because some deps # (tree-sitter yaml scanner, etc.) ship C++. Without CXX, the system @@ -53,6 +61,11 @@ builds: env: - CC=aarch64-linux-gnu-gcc - CXX=aarch64-linux-gnu-g++ + - goos: windows + goarch: amd64 + env: + - CC=x86_64-w64-mingw32-gcc + - CXX=x86_64-w64-mingw32-g++ # Per-target build hook. Fires after each Mach-O / ELF is linked, # before the archive step. The script is a no-op for non-darwin # targets, so we don't need a per-override hook list. @@ -66,6 +79,11 @@ builds: archives: - formats: [tar.gz] + # Windows users expect a .zip, not a .tar.gz — and Scoop only + # understands zip archives. + format_overrides: + - goos: windows + formats: [zip] # Version intentionally omitted — `/releases/latest/download/` URLs # require an exact filename, and users linking to the latest release # shouldn't need to know the version. Version is still in the release @@ -124,3 +142,21 @@ homebrew_casks: generate_completions_from_executable: executable: gortex shell_parameter_format: cobra + +# Scoop manifest — `scoop install gortex` on Windows. goreleaser commits +# the generated manifest (pointing at the signed windows/amd64 .zip in +# this release) to a separate bucket repo on every tagged release, +# exactly like the Homebrew cask above. +scoops: + - name: gortex + repository: + owner: gortexhq + name: scoop-bucket + # GITHUB_TOKEN can only push to the source repo, so the bucket + # needs its own PAT with `repo` scope on gortexhq/scoop-bucket, + # stored as SCOOP_BUCKET_TOKEN in repo secrets. release.yml wires + # it in. + token: "{{ .Env.SCOOP_BUCKET_TOKEN }}" + homepage: "https://github.com/zzet/gortex" + description: "Code intelligence engine that indexes repositories into an in-memory knowledge graph." + license: "Custom" diff --git a/Makefile b/Makefile index 35bc8816..ae2b78c4 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) -.PHONY: build build-onnx build-gomlx build-hugot \ +.PHONY: build build-onnx build-gomlx build-hugot build-windows \ test bench bench-rpi bench-rpi-quick bench-rpi-profile bench-compare \ lint fmt clean install tag-release \ deps-onnx deps-gomlx deps-hugot deps-vectors \ @@ -73,7 +73,7 @@ fmt: gofmt -s -w . clean: - rm -f $(BINARY) gortex-linux gortex-rpi + rm -f $(BINARY) gortex.exe gortex-linux gortex-rpi gortex-rpi32 install: go install -ldflags '$(LDFLAGS)' ./cmd/gortex/ @@ -118,6 +118,17 @@ build-rpi32: go build -ldflags '$(LDFLAGS)' -o gortex-rpi32 ./cmd/gortex/ @echo "✓ Built gortex-rpi32 (linux/arm/v7)" +# Cross-compile for Windows (amd64). Requires the mingw-w64 toolchain +# (`brew install mingw-w64` on macOS, `apt install gcc-mingw-w64` on +# Debian/Ubuntu). CGO stays on because tree-sitter needs a C/C++ +# compiler; the llama tag is omitted — the in-process llama.cpp backend +# isn't part of the Windows build. +build-windows: + CGO_ENABLED=1 GOOS=windows GOARCH=amd64 \ + CC=x86_64-w64-mingw32-gcc CXX=x86_64-w64-mingw32-g++ \ + go build -ldflags '$(LDFLAGS)' -o gortex.exe ./cmd/gortex/ + @echo "✓ Built gortex.exe (windows/amd64)" + # --------------------------------------------------------------------------- # Marketplace plugin bundle # --------------------------------------------------------------------------- diff --git a/README.md b/README.md index c98d3a29..54d5c641 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ [![macOS](https://img.shields.io/badge/macOS-supported-blue.svg)](#) [![Linux](https://img.shields.io/badge/Linux-supported-blue.svg)](#) +[![Windows](https://img.shields.io/badge/Windows-supported-blue.svg)](#) [![Claude Code](https://img.shields.io/badge/Claude_Code-supported-blueviolet.svg)](#) [![Cursor](https://img.shields.io/badge/Cursor-supported-blueviolet.svg)](#) @@ -44,10 +45,16 @@ Built for 15 AI coding agents (Claude Code, Kiro, Cursor, Windsurf, VS Code (and ## Installation ```bash +# macOS / Linux curl -fsSL https://get.gortex.dev | sh ``` -> Detects OS/arch, downloads the signed release tarball, verifies the SHA256 against `checksums.txt` (and cosign if installed), drops the binary in `$HOME/.local/bin`, and adds it to your shell rc. Re-runs upgrade in place. No silent sudo. Linux + macOS, amd64 + arm64. Windows support is planned. +```powershell +# Windows (PowerShell) +irm https://get.gortex.dev/install.ps1 | iex +``` + +> Detects OS/arch, downloads the signed release archive, verifies the SHA256 against `checksums.txt` (and cosign if installed), installs the binary, and puts it on your PATH. Re-runs upgrade in place. No silent sudo. Linux + macOS + Windows, amd64 + arm64. On Windows, `scoop install gortex` works too. For Homebrew, package managers (`.deb` / `.rpm` / `.apk`), direct binary download, supply-chain verification (cosign + SLSA-3 + VirusTotal), and from-source builds — see [docs/installation.md](docs/installation.md). diff --git a/cmd/gortex/daemon.go b/cmd/gortex/daemon.go index 34a45eef..ff4f7bea 100644 --- a/cmd/gortex/daemon.go +++ b/cmd/gortex/daemon.go @@ -10,7 +10,6 @@ import ( "sort" "strconv" "strings" - "syscall" "time" "github.com/jedib0t/go-pretty/v6/table" @@ -21,6 +20,7 @@ import ( "github.com/zzet/gortex/internal/daemon" "github.com/zzet/gortex/internal/graph" "github.com/zzet/gortex/internal/indexer" + "github.com/zzet/gortex/internal/platform" ) var ( @@ -82,7 +82,7 @@ var daemonLogsCmd = &cobra.Command{ func init() { daemonStartCmd.Flags().BoolVar(&daemonDetach, "detach", false, - "fork to background after starting (writes to ~/.cache/gortex/daemon.log)") + "fork to background after starting (logs to the daemon log file — see `gortex daemon logs`)") daemonStartCmd.Flags().BoolVar(&daemonEmbeddings, "embeddings", false, "load a semantic embedding provider (opt-in — adds ~87 MB model download on first use and ~60 ms/symbol warmup)") daemonStartCmd.Flags().StringVar(&daemonHTTPAddr, "http-addr", "", @@ -459,14 +459,18 @@ func spawnDetachedDaemon() error { child.Stdout = logFile child.Stderr = logFile child.Stdin = nil - // Detach from the controlling terminal so Ctrl-C on the parent - // doesn't kill the daemon. - child.SysProcAttr = &syscall.SysProcAttr{Setsid: true} + // Detach the child from the parent's controlling terminal / + // console so Ctrl-C on the parent doesn't kill the daemon. + child.SysProcAttr = platform.DetachSysProcAttr() if err := child.Start(); err != nil { _ = logFile.Close() return fmt.Errorf("spawn daemon: %w", err) } - // Don't wait — the child inherits the log file handle. + // Don't block on the child — it's detached and inherits the log + // file handle. Reap it in a background goroutine so a crash during + // startup surfaces on `exited` instead of stalling the poll loop. + exited := make(chan error, 1) + go func() { exited <- child.Wait() }() // Wait until the socket is live or a timeout hits, so we fail fast // if the child died on startup. The socket opens after buildDaemonState @@ -484,10 +488,11 @@ func spawnDetachedDaemon() error { } // Bail out early if the child has already exited — no point // waiting another 59 seconds for a corpse. - var ws syscall.WaitStatus - if pid, _ := syscall.Wait4(child.Process.Pid, &ws, syscall.WNOHANG, nil); pid == child.Process.Pid { - return fmt.Errorf("daemon exited during startup (status %d); check %s", - ws.ExitStatus(), daemon.LogFilePath()) + select { + case werr := <-exited: + return fmt.Errorf("daemon exited during startup (%v); check %s", + werr, daemon.LogFilePath()) + default: } time.Sleep(50 * time.Millisecond) } @@ -926,8 +931,9 @@ func daemonControlClient() (*daemon.Client, error) { } // killByPID is the fallback stop path for stale daemons that have a PID -// file but don't respond on the socket. Sends SIGTERM, waits, then -// SIGKILL. Silently returns nil if the PID no longer exists. +// file but don't respond on the socket. Asks the process to terminate, +// waits, then force-kills. Silently returns nil if the PID no longer +// exists. func killByPID() error { pidBytes, err := os.ReadFile(daemon.PIDFilePath()) if err != nil { @@ -937,24 +943,24 @@ func killByPID() error { if pid <= 0 { return nil } - _ = syscall.Kill(pid, syscall.SIGTERM) + _ = platform.TerminateProcess(pid) deadline := time.Now().Add(3 * time.Second) for time.Now().Before(deadline) { - if err := syscall.Kill(pid, 0); err != nil { + if !platform.ProcessAlive(pid) { // Process gone. _ = os.Remove(daemon.PIDFilePath()) _ = os.Remove(daemon.SocketPath()) - fmt.Fprintln(os.Stderr, "[gortex daemon] stopped (via SIGTERM)") + fmt.Fprintln(os.Stderr, "[gortex daemon] stopped") return nil } time.Sleep(100 * time.Millisecond) } // Last resort. - _ = syscall.Kill(pid, syscall.SIGKILL) + _ = platform.KillProcess(pid) _ = os.Remove(daemon.PIDFilePath()) _ = os.Remove(daemon.SocketPath()) - fmt.Fprintln(os.Stderr, "[gortex daemon] stopped (via SIGKILL)") + fmt.Fprintln(os.Stderr, "[gortex daemon] stopped (force-killed)") return nil } diff --git a/cmd/gortex/daemon_service.go b/cmd/gortex/daemon_service.go index ea947ee0..e6916318 100644 --- a/cmd/gortex/daemon_service.go +++ b/cmd/gortex/daemon_service.go @@ -89,7 +89,7 @@ func runDaemonInstallService(cmd *cobra.Command, _ []string) error { case "linux": return installSystemd(w, exe) default: - return fmt.Errorf("service install not supported on %s yet (Windows is Phase 4+, contributions welcome)", runtime.GOOS) + return fmt.Errorf("service install is not supported on %s — run 'gortex daemon start --detach' to keep the daemon running in the background", runtime.GOOS) } } diff --git a/cmd/gortex/eval_server.go b/cmd/gortex/eval_server.go index 66e02d3d..e37a1963 100644 --- a/cmd/gortex/eval_server.go +++ b/cmd/gortex/eval_server.go @@ -6,7 +6,6 @@ import ( "os" "os/signal" "path/filepath" - "syscall" "github.com/spf13/cobra" @@ -17,6 +16,7 @@ import ( gortexmcp "github.com/zzet/gortex/internal/mcp" "github.com/zzet/gortex/internal/parser" "github.com/zzet/gortex/internal/parser/languages" + "github.com/zzet/gortex/internal/platform" "github.com/zzet/gortex/internal/query" ) @@ -131,7 +131,7 @@ func runEvalServer(cmd *cobra.Command, args []string) error { // Handle graceful shutdown. sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + signal.Notify(sigCh, platform.ShutdownSignals()...) select { case err := <-errCh: diff --git a/cmd/gortex/mcp.go b/cmd/gortex/mcp.go index 071b11f7..23b6088d 100644 --- a/cmd/gortex/mcp.go +++ b/cmd/gortex/mcp.go @@ -7,7 +7,6 @@ import ( "os/signal" "path/filepath" "strings" - "syscall" "time" "github.com/spf13/cobra" @@ -22,6 +21,7 @@ import ( "github.com/zzet/gortex/internal/parser" "github.com/zzet/gortex/internal/parser/languages" "github.com/zzet/gortex/internal/persistence" + "github.com/zzet/gortex/internal/platform" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/savings" "github.com/zzet/gortex/internal/semantic" @@ -591,7 +591,7 @@ func runMCP(cmd *cobra.Command, args []string) error { // Handle graceful shutdown. sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + signal.Notify(sigCh, platform.ShutdownSignals()...) select { case err := <-errCh: diff --git a/cmd/gortex/server.go b/cmd/gortex/server.go index ed574d44..70426893 100644 --- a/cmd/gortex/server.go +++ b/cmd/gortex/server.go @@ -7,7 +7,6 @@ import ( "os" "os/signal" "path/filepath" - "syscall" "time" "github.com/spf13/cobra" @@ -25,6 +24,7 @@ import ( "github.com/zzet/gortex/internal/parser" "github.com/zzet/gortex/internal/parser/languages" "github.com/zzet/gortex/internal/persistence" + "github.com/zzet/gortex/internal/platform" "github.com/zzet/gortex/internal/query" "github.com/zzet/gortex/internal/semantic" "github.com/zzet/gortex/internal/semantic/goanalysis" @@ -634,7 +634,7 @@ func runServer(_ *cobra.Command, _ []string) error { }() sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + signal.Notify(sigCh, platform.ShutdownSignals()...) select { case err := <-errCh: diff --git a/docs/installation.md b/docs/installation.md index 93fb52c7..b2ba51d6 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,6 +1,6 @@ # Installing Gortex -Pre-built binaries are published to [GitHub Releases](https://github.com/zzet/gortex/releases) for linux/amd64, linux/arm64, darwin/amd64 (Intel Mac), and darwin/arm64 (Apple Silicon). Every release is **cosign-signed**, ships **SLSA-3 provenance**, and is **VirusTotal-scanned** — see [Verifying releases](#verifying-releases-supply-chain-security) below. Windows support is planned. +Pre-built binaries are published to [GitHub Releases](https://github.com/zzet/gortex/releases) for linux/amd64, linux/arm64, darwin/amd64 (Intel Mac), darwin/arm64 (Apple Silicon), and windows/amd64. Every release is **cosign-signed**, ships **SLSA-3 provenance**, and is **VirusTotal-scanned** — see [Verifying releases](#verifying-releases-supply-chain-security) below. **New to Gortex?** After installing, see [onboarding.md](onboarding.md) for the 15-minute walkthrough: `gortex install` (once per machine) → `gortex init` (once per repo) → verify your AI assistant uses graph tools → what to do if it doesn't. @@ -24,6 +24,27 @@ brew install zzet/tap/gortex Homebrew strips the `homebrew-` prefix from tap repositories, so `zzet/homebrew-tap` is installed as `zzet/tap`. Updates via `brew upgrade`. No Gatekeeper prompt — `brew` doesn't set the quarantine attribute on downloads. +## Windows — one-line install (PowerShell) + +```powershell +irm https://get.gortex.dev/install.ps1 | iex +``` + +Detects the architecture, downloads the signed `gortex_windows_amd64.zip`, verifies the SHA256 against `checksums.txt`, installs `gortex.exe` to `%LOCALAPPDATA%\Programs\gortex`, and adds that directory to your user `PATH`. Re-runs upgrade in place and back up the previous binary as `gortex.exe.previous`. + +Override defaults via environment variables: `GORTEX_VERSION=v0.15.0` (pin a version), `GORTEX_INSTALL_DIR` (custom install directory), `GORTEX_NO_PATH=1` (skip the PATH update), `GORTEX_NO_VERIFY=1` (skip checksum verification), `GORTEX_FORCE=1` (overwrite without backup). Source: [`scripts/install.ps1`](../scripts/install.ps1). + +Windows on ARM is supported via x64 emulation — the installer downloads the amd64 build there too. The daemon uses an AF_UNIX socket, which requires Windows 10 1803 or newer. + +## Windows — Scoop + +```powershell +scoop bucket add gortex https://github.com/gortexhq/scoop-bucket +scoop install gortex +``` + +The bucket holds the `gortex` manifest, which points at the signed GitHub Release archives. `scoop update gortex` upgrades in place. + ## Linux — Debian / Ubuntu (.deb) ```bash diff --git a/go.mod b/go.mod index 8fd838bf..5e8e8799 100644 --- a/go.mod +++ b/go.mod @@ -270,6 +270,7 @@ require ( github.com/yalue/onnxruntime_go v1.30.1 github.com/zeebo/blake3 v0.2.4 go.uber.org/zap v1.28.0 + golang.org/x/sys v0.44.0 golang.org/x/term v0.43.0 golang.org/x/tools v0.45.0 gopkg.in/yaml.v3 v3.0.1 @@ -360,7 +361,6 @@ require ( golang.org/x/image v0.40.0 // indirect golang.org/x/mod v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.44.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.11 // indirect k8s.io/klog/v2 v2.140.0 // indirect @@ -373,3 +373,10 @@ replace github.com/tree-sitter/tree-sitter-elixir => github.com/elixir-lang/tree // Parser.ParseWithOptions hits it on every Parse, capping parser // throughput to one goroutine at a time. See internal/thirdparty/go-pointer. replace github.com/mattn/go-pointer => ./internal/thirdparty/go-pointer + +// Vendored copy of github.com/google/renameio v1.0.1 with an added +// Windows implementation. Upstream v1.0.1 builds only on non-Windows +// platforms (tempfile.go / writefile.go are `+build !windows`), which +// blocked the Windows build because github.com/coder/hnsw imports it +// unconditionally. See internal/thirdparty/renameio. +replace github.com/google/renameio => ./internal/thirdparty/renameio diff --git a/go.sum b/go.sum index d70aa2a6..21417243 100644 --- a/go.sum +++ b/go.sum @@ -554,8 +554,6 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/renameio v1.0.1 h1:Lh/jXZmvZxb0BBeSY5VKEfidcbcbenKjZFzM/q0fSeU= -github.com/google/renameio v1.0.1/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gortexhq/gcx-go v0.1.0 h1:yUemJwpe8Xqf8u5Q5ADIztHVrGsGc050iMnuSXMxp0k= diff --git a/internal/daemon/fdlimit_windows.go b/internal/daemon/fdlimit_windows.go new file mode 100644 index 00000000..a15e1e1c --- /dev/null +++ b/internal/daemon/fdlimit_windows.go @@ -0,0 +1,25 @@ +//go:build windows + +package daemon + +// isEMFILE reports whether err is a file-descriptor-exhaustion error. +// Windows has no RLIMIT_NOFILE and its socket layer surfaces exhaustion +// differently, so the accept loop never takes the EMFILE branch there — +// this is a constant false. +func isEMFILE(error) bool { + return false +} + +// FDLimit mirrors the Unix struct so callers compile unchanged. On +// Windows both fields stay zero — there is no per-process descriptor +// cap to report. +type FDLimit struct { + Soft uint64 + Hard uint64 +} + +// RaiseFDLimit is a no-op on Windows: the OS imposes no RLIMIT_NOFILE +// equivalent on a user process, so there is nothing to raise. +func RaiseFDLimit() (FDLimit, error) { + return FDLimit{}, nil +} diff --git a/internal/daemon/paths.go b/internal/daemon/paths.go index 2fb75cbf..2597de90 100644 --- a/internal/daemon/paths.go +++ b/internal/daemon/paths.go @@ -6,19 +6,44 @@ import ( "runtime" ) -// SocketPath returns the Unix socket path the daemon listens on. +// stateDir returns the directory the daemon keeps its runtime state in +// (socket, PID file, logs, snapshot) and whether it could be resolved. +// +// - Windows: %LocalAppData%\gortex (via os.UserCacheDir). +// - macOS / Linux: $HOME/.cache/gortex. +// +// The boolean is false when the home / cache directory can't be +// resolved at all, in which case callers fall back to the temp dir. +func stateDir() (string, bool) { + if runtime.GOOS == "windows" { + dir, err := os.UserCacheDir() + if err != nil { + return "", false + } + return filepath.Join(dir, "gortex"), true + } + home, err := os.UserHomeDir() + if err != nil { + return "", false + } + return filepath.Join(home, ".cache", "gortex"), true +} + +// SocketPath returns the socket path the daemon listens on. The socket +// is an AF_UNIX socket on every supported OS — Windows has supported +// AF_UNIX since Windows 10 1803, so the same transport works there. // // Order of preference: // 1. $GORTEX_DAEMON_SOCKET — explicit override (tests, custom deployments). // 2. $XDG_RUNTIME_DIR/gortex.sock — Linux standard for user runtime files. // This path is cleaned automatically on logout and has sensible perms. -// 3. $HOME/.cache/gortex/daemon.sock — universal fallback used on macOS -// (which has no $XDG_RUNTIME_DIR) and on Linux without systemd-logind. +// 3. The per-user state dir — $HOME/.cache/gortex on macOS/Linux, +// %LocalAppData%\gortex on Windows. // -// Unix socket paths have a length limit (~104 bytes on macOS, 108 on Linux). -// We don't enforce that here — the listener will fail loudly if the path -// is too long, and the fix is to set $GORTEX_DAEMON_SOCKET to a shorter -// path rather than silently truncating. +// AF_UNIX socket paths have a length limit (~104 bytes on macOS, 108 on +// Linux and Windows). We don't enforce that here — the listener fails +// loudly if the path is too long, and the fix is to set +// $GORTEX_DAEMON_SOCKET to a shorter path rather than silently truncating. func SocketPath() string { if override := os.Getenv("GORTEX_DAEMON_SOCKET"); override != "" { return override @@ -26,26 +51,26 @@ func SocketPath() string { if rt := os.Getenv("XDG_RUNTIME_DIR"); rt != "" && runtime.GOOS == "linux" { return filepath.Join(rt, "gortex.sock") } - home, err := os.UserHomeDir() - if err != nil { - // Fall back to /tmp as a last resort; the daemon must start somewhere. - return filepath.Join(os.TempDir(), "gortex.sock") + if dir, ok := stateDir(); ok { + return filepath.Join(dir, "daemon.sock") } - return filepath.Join(home, ".cache", "gortex", "daemon.sock") + // Fall back to the temp dir as a last resort; the daemon must start + // somewhere. + return filepath.Join(os.TempDir(), "gortex.sock") } // PIDFilePath returns the path of the daemon PID file. The daemon writes // this on startup and removes it on graceful shutdown. Staleness detection -// (for crashed daemons that never removed their PID) is a `kill -0` check. +// (for crashed daemons that never removed their PID) is a process-liveness +// probe — see platform.ProcessAlive. func PIDFilePath() string { if override := os.Getenv("GORTEX_DAEMON_PIDFILE"); override != "" { return override } - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(os.TempDir(), "gortex-daemon.pid") + if dir, ok := stateDir(); ok { + return filepath.Join(dir, "daemon.pid") } - return filepath.Join(home, ".cache", "gortex", "daemon.pid") + return filepath.Join(os.TempDir(), "gortex-daemon.pid") } // LogFilePath returns the path the daemon writes logs to when running in @@ -54,11 +79,10 @@ func LogFilePath() string { if override := os.Getenv("GORTEX_DAEMON_LOGFILE"); override != "" { return override } - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(os.TempDir(), "gortex-daemon.log") + if dir, ok := stateDir(); ok { + return filepath.Join(dir, "daemon.log") } - return filepath.Join(home, ".cache", "gortex", "daemon.log") + return filepath.Join(os.TempDir(), "gortex-daemon.log") } // SnapshotPath returns the path the daemon saves graph snapshots to on @@ -67,16 +91,16 @@ func SnapshotPath() string { if override := os.Getenv("GORTEX_DAEMON_SNAPSHOT"); override != "" { return override } - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(os.TempDir(), "gortex-daemon.gob.gz") + if dir, ok := stateDir(); ok { + return filepath.Join(dir, "daemon.gob.gz") } - return filepath.Join(home, ".cache", "gortex", "daemon.gob.gz") + return filepath.Join(os.TempDir(), "gortex-daemon.gob.gz") } // EnsureParentDir creates the parent directory of path with permissions // 0o700 (user only). Daemon state files live under the user's cache dir -// and should not be world-readable. +// and should not be world-readable. The mode is advisory on Windows, +// where filesystem ACLs already scope %LocalAppData% to the user. func EnsureParentDir(path string) error { dir := filepath.Dir(path) return os.MkdirAll(dir, 0o700) diff --git a/internal/daemon/server.go b/internal/daemon/server.go index 482b0fa8..6a19e483 100644 --- a/internal/daemon/server.go +++ b/internal/daemon/server.go @@ -11,12 +11,14 @@ import ( "net/http" "os" "os/signal" + "runtime" "strconv" "sync" - "syscall" "time" "go.uber.org/zap" + + "github.com/zzet/gortex/internal/platform" ) // Server is the long-living Gortex daemon. It owns the Unix socket @@ -116,9 +118,11 @@ func New(socketPath, version string, logger *zap.Logger) *Server { } } -// Listen creates the socket, writes the PID file, and installs SIGINT/SIGTERM -// handlers for graceful shutdown. The socket permissions are 0o600 — the -// daemon is user-local and nothing else on the machine should reach it. +// Listen creates the socket, writes the PID file, and installs the +// shutdown-signal handlers for graceful shutdown. The socket permissions +// are 0o600 on Unix — the daemon is user-local and nothing else on the +// machine should reach it; on Windows, %LocalAppData% ACLs scope it to +// the user instead. func (s *Server) Listen() error { if err := EnsureParentDir(s.SocketPath); err != nil { return fmt.Errorf("ensure socket dir: %w", err) @@ -137,9 +141,14 @@ func (s *Server) Listen() error { _ = os.Remove(PIDFilePath()) return fmt.Errorf("listen: %w", err) } - if err := os.Chmod(s.SocketPath, 0o600); err != nil { - _ = l.Close() - return fmt.Errorf("chmod socket: %w", err) + // chmod the socket to user-only on Unix. Windows has no POSIX mode + // bits — the socket inherits the ACLs of %LocalAppData%, which is + // already user-scoped — so skip it there. + if runtime.GOOS != "windows" { + if err := os.Chmod(s.SocketPath, 0o600); err != nil { + _ = l.Close() + return fmt.Errorf("chmod socket: %w", err) + } } s.listener = l s.started = time.Now() @@ -167,7 +176,7 @@ func (s *Server) Listen() error { // Install signal handlers once the listener is live. sigCh := make(chan os.Signal, 1) - signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + signal.Notify(sigCh, platform.ShutdownSignals()...) go func() { <-sigCh s.Logger.Info("daemon: received signal, shutting down") @@ -554,7 +563,7 @@ func (s *Server) writePIDFile() error { } if existing, err := os.ReadFile(path); err == nil { if pid, _ := strconv.Atoi(string(existing)); pid > 0 { - if err := syscall.Kill(pid, 0); err == nil { + if platform.ProcessAlive(pid) { return fmt.Errorf("daemon already running (pid %d)", pid) } // Stale pid file — old daemon crashed without cleanup. diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go new file mode 100644 index 00000000..e3cc8974 --- /dev/null +++ b/internal/platform/platform_test.go @@ -0,0 +1,42 @@ +package platform + +import ( + "os" + "testing" +) + +func TestProcessAlive(t *testing.T) { + if !ProcessAlive(os.Getpid()) { + t.Error("ProcessAlive(self) = false, want true") + } + // Non-positive PIDs are never valid processes. + if ProcessAlive(0) { + t.Error("ProcessAlive(0) = true, want false") + } + if ProcessAlive(-1) { + t.Error("ProcessAlive(-1) = true, want false") + } +} + +func TestShutdownSignals(t *testing.T) { + if len(ShutdownSignals()) == 0 { + t.Error("ShutdownSignals() returned no signals") + } +} + +func TestDetachSysProcAttr(t *testing.T) { + if DetachSysProcAttr() == nil { + t.Error("DetachSysProcAttr() = nil, want non-nil SysProcAttr") + } +} + +func TestTerminateAndKillGuardNonPositivePID(t *testing.T) { + // pid <= 0 must be a guarded no-op on every platform — callers rely + // on this when a PID file is missing or malformed. + if err := TerminateProcess(0); err != nil { + t.Errorf("TerminateProcess(0) = %v, want nil", err) + } + if err := KillProcess(-1); err != nil { + t.Errorf("KillProcess(-1) = %v, want nil", err) + } +} diff --git a/internal/platform/platform_unix.go b/internal/platform/platform_unix.go new file mode 100644 index 00000000..b46bb8c6 --- /dev/null +++ b/internal/platform/platform_unix.go @@ -0,0 +1,55 @@ +//go:build !windows + +// Package platform isolates the handful of OS-specific primitives Gortex +// needs at runtime — the set of signals that trigger a graceful +// shutdown, process liveness and termination, and the SysProcAttr that +// detaches a spawned daemon. Keeping these behind one package is what +// lets the rest of the tree compile unchanged on every supported OS. +package platform + +import ( + "os" + "syscall" +) + +// ShutdownSignals returns the signals a long-running process should trap +// to begin a graceful shutdown. On Unix that's SIGINT (Ctrl-C) and +// SIGTERM (the default `kill` / supervisor stop signal). +func ShutdownSignals() []os.Signal { + return []os.Signal{syscall.SIGINT, syscall.SIGTERM} +} + +// ProcessAlive reports whether a process with the given PID currently +// exists. Signalling 0 is the canonical Unix liveness probe: it runs +// every permission check but delivers nothing, so a nil error means the +// process is there (and reachable). +func ProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + return syscall.Kill(pid, 0) == nil +} + +// TerminateProcess asks the process to exit gracefully (SIGTERM). +func TerminateProcess(pid int) error { + if pid <= 0 { + return nil + } + return syscall.Kill(pid, syscall.SIGTERM) +} + +// KillProcess forcibly terminates the process (SIGKILL). +func KillProcess(pid int) error { + if pid <= 0 { + return nil + } + return syscall.Kill(pid, syscall.SIGKILL) +} + +// DetachSysProcAttr returns the SysProcAttr that detaches a spawned +// child from the parent's controlling terminal — Setsid puts the child +// in its own session, so Ctrl-C in the parent shell isn't forwarded to +// the daemon. +func DetachSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{Setsid: true} +} diff --git a/internal/platform/platform_windows.go b/internal/platform/platform_windows.go new file mode 100644 index 00000000..e13e101a --- /dev/null +++ b/internal/platform/platform_windows.go @@ -0,0 +1,71 @@ +//go:build windows + +package platform + +import ( + "os" + "syscall" + + "golang.org/x/sys/windows" +) + +// stillActive is the exit code GetExitCodeProcess reports while a +// process is still running (Win32 STILL_ACTIVE / STATUS_PENDING). +const stillActive = 259 + +// ShutdownSignals returns the signals a long-running process should trap +// to begin a graceful shutdown. Windows has no SIGTERM; os.Interrupt — +// delivered for Ctrl-C and Ctrl-Break — is the only portable trigger. +func ShutdownSignals() []os.Signal { + return []os.Signal{os.Interrupt} +} + +// ProcessAlive reports whether a process with the given PID currently +// exists and has not yet exited. It opens a query handle and inspects +// the exit code: only a still-running process reports STILL_ACTIVE. +func ProcessAlive(pid int) bool { + if pid <= 0 { + return false + } + h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid)) + if err != nil { + return false + } + defer windows.CloseHandle(h) //nolint:errcheck // best-effort handle close + var code uint32 + if err := windows.GetExitCodeProcess(h, &code); err != nil { + return false + } + return code == stillActive +} + +// TerminateProcess asks the process to exit. Windows offers no graceful +// signal a console-less detached process can receive, so this is a hard +// TerminateProcess — the same as KillProcess. The daemon's preferred +// stop path is the control-socket RPC; this is only the fallback for a +// daemon that no longer answers the socket. +func TerminateProcess(pid int) error { + return KillProcess(pid) +} + +// KillProcess forcibly terminates the process. +func KillProcess(pid int) error { + if pid <= 0 { + return nil + } + p, err := os.FindProcess(pid) + if err != nil { + return err + } + return p.Kill() +} + +// DetachSysProcAttr returns the SysProcAttr that fully detaches a +// spawned child: a new process group, so a Ctrl-C in the parent console +// isn't forwarded, plus DETACHED_PROCESS so the child runs with no +// inherited console. +func DetachSysProcAttr() *syscall.SysProcAttr { + return &syscall.SysProcAttr{ + CreationFlags: windows.CREATE_NEW_PROCESS_GROUP | windows.DETACHED_PROCESS, + } +} diff --git a/internal/thirdparty/renameio/LICENSE b/internal/thirdparty/renameio/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/internal/thirdparty/renameio/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/internal/thirdparty/renameio/README.md b/internal/thirdparty/renameio/README.md new file mode 100644 index 00000000..0ec2d2b2 --- /dev/null +++ b/internal/thirdparty/renameio/README.md @@ -0,0 +1,37 @@ +# Vendored: github.com/google/renameio + +This directory is a vendored copy of +[`github.com/google/renameio`](https://github.com/google/renameio) +**v1.0.1**, licensed under Apache-2.0 (see `LICENSE`). + +It is wired into the build via a `replace` directive in the repository +root `go.mod`: + +``` +replace github.com/google/renameio => ./internal/thirdparty/renameio +``` + +## Why it is vendored + +`renameio` v1.0.1 builds only on non-Windows platforms — `tempfile.go` +and `writefile.go` are tagged `// +build !windows`, so the package +exports nothing for `GOOS=windows`. `github.com/coder/hnsw` (a +transitive dependency of Gortex's `internal/search` vector index) +imports `renameio` unconditionally, which made the whole module +impossible to compile for Windows. Upstream moved Windows support to the +separate `renameio/v2` module and froze the v1 line, so a plain version +bump is not an option. + +## Modifications by the Gortex project + +The upstream files — `doc.go`, `tempfile.go`, `writefile.go`, `go.mod`, +`LICENSE` — are reproduced **verbatim**. Two files were **added** to +provide the missing Windows implementation: + +- `tempfile_windows.go` — `TempDir`, `tempDir`, `PendingFile`, + `TempFile`, and `Symlink` for Windows, built on `os.CreateTemp` plus + `os.Rename` (which maps to `MoveFileEx` with + `MOVEFILE_REPLACE_EXISTING`, an atomic replace). +- `writefile_windows.go` — the Windows half of `WriteFile`. + +No upstream file was changed. diff --git a/internal/thirdparty/renameio/doc.go b/internal/thirdparty/renameio/doc.go new file mode 100644 index 00000000..67416df4 --- /dev/null +++ b/internal/thirdparty/renameio/doc.go @@ -0,0 +1,21 @@ +// Copyright 2018 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package renameio provides a way to atomically create or replace a file or +// symbolic link. +// +// Caveat: this package requires the file system rename(2) implementation to be +// atomic. Notably, this is not the case when using NFS with multiple clients: +// https://stackoverflow.com/a/41396801 +package renameio diff --git a/internal/thirdparty/renameio/go.mod b/internal/thirdparty/renameio/go.mod new file mode 100644 index 00000000..7a04df08 --- /dev/null +++ b/internal/thirdparty/renameio/go.mod @@ -0,0 +1,3 @@ +module github.com/google/renameio + +go 1.13 diff --git a/internal/thirdparty/renameio/tempfile.go b/internal/thirdparty/renameio/tempfile.go new file mode 100644 index 00000000..0f0eaf7e --- /dev/null +++ b/internal/thirdparty/renameio/tempfile.go @@ -0,0 +1,187 @@ +// Copyright 2018 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// +build !windows + +package renameio + +import ( + "io/ioutil" + "os" + "path/filepath" +) + +// TempDir checks whether os.TempDir() can be used as a temporary directory for +// later atomically replacing files within dest. If no (os.TempDir() resides on +// a different mount point), dest is returned. +// +// Note that the returned value ceases to be valid once either os.TempDir() +// changes (e.g. on Linux, once the TMPDIR environment variable changes) or the +// file system is unmounted. +func TempDir(dest string) string { + return tempDir("", filepath.Join(dest, "renameio-TempDir")) +} + +func tempDir(dir, dest string) string { + if dir != "" { + return dir // caller-specified directory always wins + } + + // Chose the destination directory as temporary directory so that we + // definitely can rename the file, for which both temporary and destination + // file need to point to the same mount point. + fallback := filepath.Dir(dest) + + // The user might have overridden the os.TempDir() return value by setting + // the TMPDIR environment variable. + tmpdir := os.TempDir() + + testsrc, err := ioutil.TempFile(tmpdir, "."+filepath.Base(dest)) + if err != nil { + return fallback + } + cleanup := true + defer func() { + if cleanup { + os.Remove(testsrc.Name()) + } + }() + testsrc.Close() + + testdest, err := ioutil.TempFile(filepath.Dir(dest), "."+filepath.Base(dest)) + if err != nil { + return fallback + } + defer os.Remove(testdest.Name()) + testdest.Close() + + if err := os.Rename(testsrc.Name(), testdest.Name()); err != nil { + return fallback + } + cleanup = false // testsrc no longer exists + return tmpdir +} + +// PendingFile is a pending temporary file, waiting to replace the destination +// path in a call to CloseAtomicallyReplace. +type PendingFile struct { + *os.File + + path string + done bool + closed bool +} + +// Cleanup is a no-op if CloseAtomicallyReplace succeeded, and otherwise closes +// and removes the temporary file. +// +// This method is not safe for concurrent use by multiple goroutines. +func (t *PendingFile) Cleanup() error { + if t.done { + return nil + } + // An error occurred. Close and remove the tempfile. Errors are returned for + // reporting, there is nothing the caller can recover here. + var closeErr error + if !t.closed { + closeErr = t.Close() + } + if err := os.Remove(t.Name()); err != nil { + return err + } + return closeErr +} + +// CloseAtomicallyReplace closes the temporary file and atomically replaces +// the destination file with it, i.e., a concurrent open(2) call will either +// open the file previously located at the destination path (if any), or the +// just written file, but the file will always be present. +// +// This method is not safe for concurrent use by multiple goroutines. +func (t *PendingFile) CloseAtomicallyReplace() error { + // Even on an ordered file system (e.g. ext4 with data=ordered) or file + // systems with write barriers, we cannot skip the fsync(2) call as per + // Theodore Ts'o (ext2/3/4 lead developer): + // + // > data=ordered only guarantees the avoidance of stale data (e.g., the previous + // > contents of a data block showing up after a crash, where the previous data + // > could be someone's love letters, medical records, etc.). Without the fsync(2) + // > a zero-length file is a valid and possible outcome after the rename. + if err := t.Sync(); err != nil { + return err + } + t.closed = true + if err := t.Close(); err != nil { + return err + } + if err := os.Rename(t.Name(), t.path); err != nil { + return err + } + t.done = true + return nil +} + +// TempFile wraps ioutil.TempFile for the use case of atomically creating or +// replacing the destination file at path. +// +// If dir is the empty string, TempDir(filepath.Base(path)) is used. If you are +// going to write a large number of files to the same file system, store the +// result of TempDir(filepath.Base(path)) and pass it instead of the empty +// string. +// +// The file's permissions will be 0600 by default. You can change these by +// explicitly calling Chmod on the returned PendingFile. +func TempFile(dir, path string) (*PendingFile, error) { + f, err := ioutil.TempFile(tempDir(dir, path), "."+filepath.Base(path)) + if err != nil { + return nil, err + } + + return &PendingFile{File: f, path: path}, nil +} + +// Symlink wraps os.Symlink, replacing an existing symlink with the same name +// atomically (os.Symlink fails when newname already exists, at least on Linux). +func Symlink(oldname, newname string) error { + // Fast path: if newname does not exist yet, we can skip the whole dance + // below. + if err := os.Symlink(oldname, newname); err == nil || !os.IsExist(err) { + return err + } + + // We need to use ioutil.TempDir, as we cannot overwrite a ioutil.TempFile, + // and removing+symlinking creates a TOCTOU race. + d, err := ioutil.TempDir(filepath.Dir(newname), "."+filepath.Base(newname)) + if err != nil { + return err + } + cleanup := true + defer func() { + if cleanup { + os.RemoveAll(d) + } + }() + + symlink := filepath.Join(d, "tmp.symlink") + if err := os.Symlink(oldname, symlink); err != nil { + return err + } + + if err := os.Rename(symlink, newname); err != nil { + return err + } + + cleanup = false + return os.RemoveAll(d) +} diff --git a/internal/thirdparty/renameio/tempfile_windows.go b/internal/thirdparty/renameio/tempfile_windows.go new file mode 100644 index 00000000..e657261f --- /dev/null +++ b/internal/thirdparty/renameio/tempfile_windows.go @@ -0,0 +1,129 @@ +//go:build windows +// +build windows + +// This file is NOT part of upstream github.com/google/renameio v1.0.1, +// which builds only on non-Windows platforms. It was added by the +// Gortex project to give the vendored package a Windows implementation. +// See README.md in this directory. + +package renameio + +import ( + "os" + "path/filepath" +) + +// TempDir returns a directory suitable for holding a temporary file that +// will later be renamed over a file in dest's directory. On Windows the +// only requirement is that the temporary file sits on the same volume as +// the destination, so the destination's own directory is always used. +func TempDir(dest string) string { + return filepath.Dir(dest) +} + +// tempDir mirrors the non-Windows helper: a caller-specified directory +// always wins, otherwise the destination's directory is used so the +// later rename stays within one volume. +func tempDir(dir, dest string) string { + if dir != "" { + return dir + } + return filepath.Dir(dest) +} + +// PendingFile is a pending temporary file, waiting to replace the +// destination path in a call to CloseAtomicallyReplace. +type PendingFile struct { + *os.File + + path string + done bool + closed bool +} + +// Cleanup is a no-op if CloseAtomicallyReplace succeeded, and otherwise +// closes and removes the temporary file. +// +// This method is not safe for concurrent use by multiple goroutines. +func (t *PendingFile) Cleanup() error { + if t.done { + return nil + } + var closeErr error + if !t.closed { + closeErr = t.Close() + } + if err := os.Remove(t.Name()); err != nil { + return err + } + return closeErr +} + +// CloseAtomicallyReplace closes the temporary file and renames it over +// the destination path. Windows requires a file to be closed before it +// can be renamed; os.Rename maps to MoveFileEx with +// MOVEFILE_REPLACE_EXISTING, so the replacement is atomic with respect +// to other processes opening the destination path. +// +// This method is not safe for concurrent use by multiple goroutines. +func (t *PendingFile) CloseAtomicallyReplace() error { + if err := t.Sync(); err != nil { + return err + } + t.closed = true + if err := t.Close(); err != nil { + return err + } + if err := os.Rename(t.Name(), t.path); err != nil { + return err + } + t.done = true + return nil +} + +// TempFile creates a temporary file for atomically creating or replacing +// the destination file at path. +// +// If dir is the empty string, TempDir is used. The file's permissions +// will be 0600 by default; change them with Chmod on the returned +// PendingFile. +func TempFile(dir, path string) (*PendingFile, error) { + f, err := os.CreateTemp(tempDir(dir, path), "."+filepath.Base(path)) + if err != nil { + return nil, err + } + return &PendingFile{File: f, path: path}, nil +} + +// Symlink wraps os.Symlink, replacing an existing symlink with the same +// name atomically. Note that creating symlinks on Windows requires +// either Developer Mode or the SeCreateSymbolicLinkPrivilege; without +// it, os.Symlink's error is surfaced unchanged. +func Symlink(oldname, newname string) error { + if err := os.Symlink(oldname, newname); err == nil || !os.IsExist(err) { + return err + } + + d, err := os.MkdirTemp(filepath.Dir(newname), "."+filepath.Base(newname)) + if err != nil { + return err + } + cleanup := true + defer func() { + if cleanup { + os.RemoveAll(d) + } + }() + + symlink := filepath.Join(d, "tmp.symlink") + if err := os.Symlink(oldname, symlink); err != nil { + return err + } + + if err := os.Rename(symlink, newname); err != nil { + return err + } + + cleanup = false + return os.RemoveAll(d) +} diff --git a/internal/thirdparty/renameio/writefile.go b/internal/thirdparty/renameio/writefile.go new file mode 100644 index 00000000..fbf5c798 --- /dev/null +++ b/internal/thirdparty/renameio/writefile.go @@ -0,0 +1,40 @@ +// Copyright 2018 Google Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// +build !windows + +package renameio + +import "os" + +// WriteFile mirrors ioutil.WriteFile, replacing an existing file with the same +// name atomically. +func WriteFile(filename string, data []byte, perm os.FileMode) error { + t, err := TempFile("", filename) + if err != nil { + return err + } + defer t.Cleanup() + + // Set permissions before writing data, in case the data is sensitive. + if err := t.Chmod(perm); err != nil { + return err + } + + if _, err := t.Write(data); err != nil { + return err + } + + return t.CloseAtomicallyReplace() +} diff --git a/internal/thirdparty/renameio/writefile_windows.go b/internal/thirdparty/renameio/writefile_windows.go new file mode 100644 index 00000000..a011f6bf --- /dev/null +++ b/internal/thirdparty/renameio/writefile_windows.go @@ -0,0 +1,31 @@ +//go:build windows +// +build windows + +// This file is NOT part of upstream github.com/google/renameio v1.0.1, +// which builds only on non-Windows platforms. It was added by the +// Gortex project. See README.md in this directory. + +package renameio + +import "os" + +// WriteFile mirrors os.WriteFile, replacing an existing file with the +// same name atomically. +func WriteFile(filename string, data []byte, perm os.FileMode) error { + t, err := TempFile("", filename) + if err != nil { + return err + } + defer t.Cleanup() + + // Set permissions before writing data, in case the data is sensitive. + if err := t.Chmod(perm); err != nil { + return err + } + + if _, err := t.Write(data); err != nil { + return err + } + + return t.CloseAtomicallyReplace() +} diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 00000000..dfa0eee9 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,177 @@ +<# +.SYNOPSIS + Gortex one-line installer for Windows (PowerShell). + +.DESCRIPTION + Downloads the signed Windows release archive, verifies its SHA-256 + checksum, installs the binary, and puts it on the user PATH. + + Usage: + irm https://get.gortex.dev/install.ps1 | iex + + Or, from a checkout: + powershell -ExecutionPolicy Bypass -File scripts/install.ps1 + + Configuration via environment variables (all optional): + GORTEX_VERSION Release tag to install ("latest" or "v0.15.0") + GORTEX_INSTALL_DIR Install directory (default: %LOCALAPPDATA%\Programs\gortex) + GORTEX_NO_VERIFY Set to skip the SHA-256 checksum verification + GORTEX_NO_PATH Set to skip the user PATH update + GORTEX_FORCE Set to overwrite an existing binary without backup + GORTEX_DOWNLOAD_BASE Override the release download base URL (for testing) +#> + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$Repo = 'zzet/gortex' +$BinName = 'gortex.exe' +$DownloadBase = if ($env:GORTEX_DOWNLOAD_BASE) { $env:GORTEX_DOWNLOAD_BASE } ` + else { "https://github.com/$Repo/releases" } + +function Write-Info($msg) { Write-Host "==> $msg" -ForegroundColor Blue } +function Write-Ok($msg) { Write-Host " ok $msg" -ForegroundColor Green } +function Write-Warn($msg) { Write-Host " ! $msg" -ForegroundColor Yellow } +function Die($msg) { Write-Host " x $msg" -ForegroundColor Red; exit 1 } + +function Get-Arch { + # We publish a single windows/amd64 archive. Windows on ARM runs x64 + # binaries transparently under emulation, so amd64 is the right asset + # everywhere except genuine 32-bit hosts. + switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { return 'amd64' } + 'ARM64' { return 'amd64' } + 'x86' { Die 'unsupported architecture: x86 (64-bit Windows required)' } + default { Die "unsupported architecture: $($env:PROCESSOR_ARCHITECTURE)" } + } +} + +function Add-ToUserPath($dir) { + $current = [Environment]::GetEnvironmentVariable('Path', 'User') + $parts = @() + if ($current) { $parts = $current -split ';' | Where-Object { $_ -ne '' } } + if ($parts -contains $dir) { + Write-Ok "$dir already on the user PATH" + return + } + $updated = (($parts + $dir) -join ';') + [Environment]::SetEnvironmentVariable('Path', $updated, 'User') + # Refresh the current session so the version banner below resolves. + $env:Path = "$env:Path;$dir" + Write-Ok "added $dir to the user PATH (open a new shell to pick it up)" +} + +function Main { + $arch = Get-Arch + $version = if ($env:GORTEX_VERSION) { $env:GORTEX_VERSION } else { 'latest' } + $installDir = if ($env:GORTEX_INSTALL_DIR) { $env:GORTEX_INSTALL_DIR } ` + else { Join-Path $env:LOCALAPPDATA 'Programs\gortex' } + + Write-Host '' + Write-Host 'Gortex installer' -ForegroundColor White + Write-Host " os: windows" + Write-Host " arch: $arch" + Write-Host " version: $version" + Write-Host " target: $installDir\$BinName" + Write-Host '' + + $asset = "gortex_windows_${arch}.zip" + if ($version -eq 'latest') { + $baseUrl = "$DownloadBase/latest/download" + } else { + $tag = if ($version.StartsWith('v')) { $version } else { "v$version" } + $baseUrl = "$DownloadBase/download/$tag" + } + $assetUrl = "$baseUrl/$asset" + $checksumsUrl = "$baseUrl/checksums.txt" + + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("gortex-install-" + [System.Guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $tmp -Force | Out-Null + try { + $zipPath = Join-Path $tmp $asset + Write-Info "downloading $asset" + try { + Invoke-WebRequest -Uri $assetUrl -OutFile $zipPath -UseBasicParsing + } catch { + Die "download failed: $assetUrl`n $($_.Exception.Message)" + } + + if (-not $env:GORTEX_NO_VERIFY) { + Write-Info 'downloading checksums.txt' + $checksumsPath = Join-Path $tmp 'checksums.txt' + try { + Invoke-WebRequest -Uri $checksumsUrl -OutFile $checksumsPath -UseBasicParsing + # checksums.txt is ` ` per goreleaser default. + $expected = $null + foreach ($line in Get-Content $checksumsPath) { + $cols = $line -split '\s+' + if ($cols.Count -ge 2 -and ($cols[1] -eq $asset -or $cols[1] -eq "*$asset")) { + $expected = $cols[0] + break + } + } + if (-not $expected) { + Write-Warn "checksums.txt did not contain $asset; skipping verification" + } else { + $actual = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected.ToLower()) { + Die "checksum mismatch on $asset`n expected: $expected`n actual: $actual" + } + Write-Ok "sha256 verified ($asset)" + } + } catch { + Write-Warn 'could not fetch checksums.txt; skipping verification' + } + } else { + Write-Warn 'verification disabled (GORTEX_NO_VERIFY)' + } + + Write-Info 'extracting' + Expand-Archive -Path $zipPath -DestinationPath $tmp -Force + $extracted = Join-Path $tmp $BinName + if (-not (Test-Path $extracted)) { + Die "archive did not contain a $BinName binary" + } + + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + $target = Join-Path $installDir $BinName + if ((Test-Path $target) -and (-not $env:GORTEX_FORCE)) { + $backup = "$target.previous" + Write-Info "backing up existing binary to $backup" + Move-Item -Path $target -Destination $backup -Force + } + Move-Item -Path $extracted -Destination $target -Force + Write-Ok "installed $target" + + if (-not $env:GORTEX_NO_PATH) { + Add-ToUserPath $installDir + } + + # If a daemon is already running an older binary, restart it onto + # the new one. Best-effort — never block the install on this. + & $target daemon status *> $null + if ($LASTEXITCODE -eq 0) { + Write-Info 'restarting running daemon onto new binary' + & $target daemon restart *> $null + if ($LASTEXITCODE -ne 0) { + Write-Warn "daemon restart failed; run 'gortex daemon restart' manually" + } + } + + $versionOut = (& $target version) 2>$null + if ($versionOut) { Write-Ok "$versionOut" } + + Write-Host '' + Write-Host 'Next steps:' -ForegroundColor White + Write-Host " - gortex install one-time machine setup (MCP, skills, slash commands)" + Write-Host " - gortex init run inside a repo to wire up your AI assistant" + Write-Host '' + Write-Host "Docs: https://github.com/$Repo" + Write-Host '' + } + finally { + Remove-Item -Path $tmp -Recurse -Force -ErrorAction SilentlyContinue + } +} + +Main diff --git a/scripts/install.sh b/scripts/install.sh index 1aa7b78d..36820e79 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -60,9 +60,12 @@ detect_os() { case "$uname_s" in Linux) echo linux ;; Darwin) echo darwin ;; - # Catch users running this in WSL — Windows binaries aren't published yet, - # but the Linux build runs fine under WSL. - MINGW*|MSYS*|CYGWIN*) die "Windows native install isn't supported yet; run this from WSL or use the manual download from https://github.com/${GORTEX_REPO}/releases" ;; + # Native Windows shells (Git Bash / MSYS / Cygwin) can't run this + # POSIX installer — point users at the PowerShell installer. The + # Linux build still runs fine under WSL. + MINGW*|MSYS*|CYGWIN*) die "this POSIX installer can't run on native Windows. + In PowerShell, run: irm https://get.gortex.dev/install.ps1 | iex + (or 'scoop install gortex', or run this script from inside WSL)" ;; *) die "unsupported OS: $uname_s (Linux and macOS supported)" ;; esac }