From ab72be1973b7dcf93d0e77de4dfcafe5f0a4a8e9 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Fri, 31 Jul 2026 15:33:45 +0530 Subject: [PATCH 1/8] Added .zip and .exe in the install paths --- modules/core/mgmt/install.go | 103 +++++++++++++++++++++++++++++------ 1 file changed, 86 insertions(+), 17 deletions(-) diff --git a/modules/core/mgmt/install.go b/modules/core/mgmt/install.go index fcb4d59..db6ef1d 100644 --- a/modules/core/mgmt/install.go +++ b/modules/core/mgmt/install.go @@ -5,6 +5,7 @@ package mgmt import ( "archive/tar" + "archive/zip" "compress/gzip" "crypto/sha256" "fmt" @@ -68,13 +69,38 @@ func resolveVersion(version string) (string, error) { const ( installBinaryName = "harness" installBundleName = "harness-core" - installDefaultDir = "~/.local/bin" ) var modulePlugins = map[string]string{ "har": "harness-har", } +func defaultInstallDir() string { + if runtime.GOOS == "windows" { + if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { + return filepath.Join(localAppData, "Programs", "harness") + } + if home, err := os.UserHomeDir(); err == nil { + return filepath.Join(home, "AppData", "Local", "Programs", "harness") + } + } + return "~/.local/bin" +} + +func installedBinaryName(base string) string { + if runtime.GOOS == "windows" && !strings.HasSuffix(strings.ToLower(base), ".exe") { + return base + ".exe" + } + return base +} + +func archiveExtensionForPlatform(platform string) string { + if strings.HasPrefix(platform, "windows_") { + return ".zip" + } + return ".tar.gz" +} + // downloadModuleIfNeeded checks whether the module at existingBinPath needs upgrading and, if so, // downloads and installs it. Returns (true, nil) when installed, (false, nil) when already up to // date (skipped), or (false, err) on failure. Pass existingBinPath="" to skip the version check @@ -122,7 +148,7 @@ func InstallCLIHandler(ctx *cmdctx.Ctx) error { installDir := cmdctx.GetString(ctx.FlagValues, "install-dir") if installDir == "" { - installDir = installDefaultDir + installDir = defaultInstallDir() } installDir = hbase.ExpandHomeDir(installDir) @@ -148,7 +174,7 @@ func InstallCLIHandler(ctx *cmdctx.Ctx) error { return err } if !exists { - fmt.Printf("Version %s not found\n", version) + fmt.Printf("Version %s not found for platform %s\n", version, platform) os.Exit(1) } current := hbase.Version @@ -184,7 +210,7 @@ func InstallCLIHandler(ctx *cmdctx.Ctx) error { if err := downloadAndInstallBinary(version, platform, installDir, installBundleName, installBinaryName); err != nil { return err } - fmt.Printf("Installed harness %s to %s/%s\n", version, installDir, installBinaryName) + fmt.Printf("Installed harness %s to %s\n", version, filepath.Join(installDir, installedBinaryName(installBinaryName))) } if coreOnly { @@ -193,7 +219,7 @@ func InstallCLIHandler(ctx *cmdctx.Ctx) error { // Update any Harness modules already installed in the same directory as core. for moduleName, binaryName := range modulePlugins { - binPath := filepath.Join(installDir, binaryName) + binPath := filepath.Join(installDir, installedBinaryName(binaryName)) if _, err := os.Stat(binPath); err != nil { continue } @@ -204,7 +230,7 @@ func InstallCLIHandler(ctx *cmdctx.Ctx) error { existing := plugin.QueryVersion(binPath) fmt.Printf("Module %q is up to date (current: %s, latest: %s).\n", moduleName, existing, version) } else { - fmt.Printf("Installed module %q %s to %s/%s\n", moduleName, version, installDir, binaryName) + fmt.Printf("Installed module %q %s to %s\n", moduleName, version, filepath.Join(installDir, installedBinaryName(binaryName))) } } @@ -231,7 +257,7 @@ func InstallModuleHandler(ctx *cmdctx.Ctx) error { installDir := cmdctx.GetString(ctx.FlagValues, "install-dir") if installDir == "" { - installDir = installDefaultDir + installDir = defaultInstallDir() } installDir = hbase.ExpandHomeDir(installDir) @@ -298,7 +324,7 @@ func InstallModuleHandler(ctx *cmdctx.Ctx) error { return err } - fmt.Printf("Installed module %q %s to %s/%s\n", moduleName, version, installDir, binaryName) + fmt.Printf("Installed module %q %s to %s\n", moduleName, version, filepath.Join(installDir, installedBinaryName(binaryName))) return nil } @@ -309,6 +335,8 @@ func detectPlatform() (string, error) { os_ = "darwin" case "linux": os_ = "linux" + case "windows": + os_ = "windows" default: return "", fmt.Errorf("unsupported OS: %s", runtime.GOOS) } @@ -326,8 +354,10 @@ func detectPlatform() (string, error) { func downloadAndInstallBinary(version, platform, destDir, pkgName, binaryName string) error { ver := strings.TrimPrefix(version, "v") base := fmt.Sprintf("%s_%s_%s", pkgName, ver, platform) + ext := archiveExtensionForPlatform(platform) + archiveName := base + ext - tarURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s.tar.gz", release.Repo, version, base) + archiveURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s", release.Repo, version, archiveName) checksumURL := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s_%s_checksums.txt", release.Repo, version, installBinaryName, ver) tmp, err := os.MkdirTemp("", "harness-install-*") @@ -336,25 +366,26 @@ func downloadAndInstallBinary(version, platform, destDir, pkgName, binaryName st } defer os.RemoveAll(tmp) - archivePath := filepath.Join(tmp, base+".tar.gz") - if err := downloadFile(archivePath, tarURL); err != nil { + archivePath := filepath.Join(tmp, archiveName) + if err := downloadFile(archivePath, archiveURL); err != nil { if strings.Contains(err.Error(), "HTTP 404") { - return fmt.Errorf("%s %s not found", pkgName, version) + return fmt.Errorf("%s %s not found for platform %s", pkgName, version, platform) } return fmt.Errorf("downloading release: %w", err) } hlog.Debug("verifying checksum") - if err := verifyChecksum(archivePath, base+".tar.gz", checksumURL); err != nil { + if err := verifyChecksum(archivePath, archiveName, checksumURL); err != nil { return fmt.Errorf("checksum verification failed: %w", err) } - binaryPath := filepath.Join(tmp, binaryName) - if err := extractBinaryFromTar(archivePath, binaryName, binaryPath); err != nil { + memberName := installedBinaryName(binaryName) + binaryPath := filepath.Join(tmp, memberName) + if err := extractBinaryFromArchive(archivePath, memberName, binaryPath, ext); err != nil { return fmt.Errorf("extracting binary: %w", err) } - dest := filepath.Join(destDir, binaryName) + dest := filepath.Join(destDir, memberName) staging := dest + ".new" if err := os.Rename(binaryPath, staging); err != nil { return fmt.Errorf("staging binary: %w", err) @@ -373,8 +404,9 @@ func downloadAndInstallBinary(version, platform, destDir, pkgName, binaryName st func releaseAssetExists(version, platform, pkgName string) (bool, error) { ver := strings.TrimPrefix(version, "v") base := fmt.Sprintf("%s_%s_%s", pkgName, ver, platform) + ext := archiveExtensionForPlatform(platform) client := &http.Client{Timeout: 15 * time.Second} - url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s.tar.gz", release.Repo, version, base) + url := fmt.Sprintf("https://github.com/%s/releases/download/%s/%s%s", release.Repo, version, base, ext) hlog.Debug("HEAD", "url", url) resp, err := client.Head(url) if err != nil { @@ -481,3 +513,40 @@ func extractBinaryFromTar(archivePath, binaryName, dest string) error { } return fmt.Errorf("binary %q not found in archive", binaryName) } + +func extractBinaryFromArchive(archivePath, binaryName, dest, ext string) error { + switch ext { + case ".zip": + return extractBinaryFromZip(archivePath, binaryName, dest) + default: + return extractBinaryFromTar(archivePath, binaryName, dest) + } +} + +func extractBinaryFromZip(archivePath, binaryName, dest string) error { + r, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer r.Close() + + for _, f := range r.File { + if filepath.Base(f.Name) != binaryName { + continue + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.OpenFile(dest, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0755) + if err != nil { + rc.Close() + return err + } + _, err = io.Copy(out, rc) + out.Close() + rc.Close() + return err + } + return fmt.Errorf("binary %q not found in archive", binaryName) +} From 6a52f06ae30a42bcb97dc7c3b8de214c6e7eb495 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Fri, 31 Jul 2026 15:34:26 +0530 Subject: [PATCH 2/8] Sibling har module .exe handler --- pkg/plugin/plugin.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go index 18e74a7..a395fae 100644 --- a/pkg/plugin/plugin.go +++ b/pkg/plugin/plugin.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "regexp" + "runtime" "strings" ) @@ -17,9 +18,11 @@ var semverRe = regexp.MustCompile(`\d+\.\d+\.\d+\S*`) // containing the current executable, then falls back to exec.LookPath. func FindBinary(extBin string) (string, error) { if self, err := os.Executable(); err == nil { - candidate := filepath.Join(filepath.Dir(self), extBin) - if _, err := os.Stat(candidate); err == nil { - return candidate, nil + for _, name := range siblingBinaryNames(extBin) { + candidate := filepath.Join(filepath.Dir(self), name) + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } } } binPath, err := exec.LookPath(extBin) @@ -29,6 +32,13 @@ func FindBinary(extBin string) (string, error) { return binPath, nil } +func siblingBinaryNames(extBin string) []string { + if runtime.GOOS != "windows" || strings.HasSuffix(strings.ToLower(extBin), ".exe") { + return []string{extBin} + } + return []string{extBin, extBin + ".exe"} +} + // QueryVersion runs `[binPath] version` and returns the semver string (e.g. "1.2.3-dev") // extracted from its output. Returns "" if the binary exits non-zero or no semver is found. func QueryVersion(binPath string) string { From 41942a8805d9f170a68bd07f9e8e56f7a01763da Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Fri, 31 Jul 2026 15:35:20 +0530 Subject: [PATCH 3/8] Added windows release in goos and zip format --- .goreleaser.yaml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 91ebb21..48f417b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -15,6 +15,7 @@ builds: goos: - linux - darwin + - windows goarch: - amd64 - arm64 @@ -34,6 +35,7 @@ builds: goos: - linux - darwin + - windows goarch: - amd64 - arm64 @@ -48,16 +50,25 @@ archives: - id: harness-bundle ids: [harness-core, harness-plugin-har] formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] name_template: "harness-bundle_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - id: harness-core ids: [harness-core] formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] name_template: "harness-core_{{ .Version }}_{{ .Os }}_{{ .Arch }}" - id: harness-plugin-har ids: [harness-plugin-har] formats: [tar.gz] + format_overrides: + - goos: windows + formats: [zip] name_template: "harness-plugin-har_{{ .Version }}_{{ .Os }}_{{ .Arch }}" nfpms: From 45cd490c7e642345b01418d2f0b45e5aa3175b88 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Fri, 31 Jul 2026 15:36:10 +0530 Subject: [PATCH 4/8] Added documentation changes for the windows package --- README.md | 33 +++++++++++++++++---- docs/manual-install.md | 65 +++++++++++++++++++++++++++++++---------- pkg/spec/core.spec.yaml | 4 +-- 3 files changed, 78 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 61920e7..1f319c4 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ with a single consistent grammar. [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE) [![Made with Go](https://img.shields.io/badge/Made_with-Go-00ADD8.svg?logo=go)](https://go.dev) -[![Platform: macOS · Linux](https://img.shields.io/badge/Platform-macOS_·_Linux-lightgrey.svg)](#install) +[![Platform: macOS · Linux · Windows](https://img.shields.io/badge/Platform-macOS_·_Linux_·_Windows-lightgrey.svg)](#install) [![Releases](https://img.shields.io/badge/Downloads-GitHub_Releases-brightgreen.svg)](https://github.com/harness/cli/releases) [Install](#-install) · @@ -65,25 +65,38 @@ with a single consistent grammar. ### Recommended: one-line installer +**macOS / Linux** + ```sh curl -fsSL https://raw.githubusercontent.com/harness/cli/main/install.sh | sh ``` +**Windows (PowerShell)** + +```powershell +irm https://raw.githubusercontent.com/harness/cli/main/install.ps1 | iex +``` + The installer will: -- Download the latest `harness-bundle` for your platform (macOS and Linux, `amd64` / `arm64`). -- Install the `harness` and `harness-har` binaries to `~/.local/bin` (override with `--install-dir`). -- Optionally add `~/.local/bin` to your `PATH` and enable shell completions. +- Download the latest `harness-bundle` for your platform (macOS, Linux, and Windows — `amd64` / `arm64`). +- Install `harness` and `harness-har` to `~/.local/bin` on Unix or `%LOCALAPPDATA%\Programs\harness` on Windows (override with `--install-dir` / `-InstallDir`). +- Optionally add the install directory to your `PATH` and enable shell completions. ### Installer flags | Flag | Description | | ---------------------- | -------------------------------------------------------------- | -| `--install-dir ` | Override the install directory (default: `~/.local/bin`) | +| `--install-dir ` | Override the install directory (default: `~/.local/bin` on Unix, `%LOCALAPPDATA%\Programs\harness` on Windows) | | `--core` | Install only the `harness` binary (skip `harness-har`) | | `--non-interactive` | Skip all prompts (useful for CI, Docker, provisioning scripts) | | `--no-verify` | Skip checksum verification | +Windows PowerShell flags: `-InstallDir`, `-Version`, `-Core`, `-NonInteractive`, `-NoVerify`. + +> [!NOTE] +> `install.ps1` reads assets from GitHub Releases by default. Set `HARNESS_INSTALL_BASE_URL` to a local directory or internal mirror holding the release zip and checksums file to install from there instead — useful for air-gapped environments and for testing unreleased builds. + > [!TIP] > When passing flags through a pipe, use `sh -s --` — `-s` tells `sh` to read from stdin, and `--` separates `sh`'s own options from the installer flags. @@ -98,9 +111,17 @@ curl -fsSL https://raw.githubusercontent.com/harness/cli/main/install.sh | sh -s curl -fsSL https://raw.githubusercontent.com/harness/cli/main/install.sh | sh -s -- --non-interactive --install-dir /usr/local/bin ``` +```powershell +# Windows — install core + har bundle (default) +irm https://raw.githubusercontent.com/harness/cli/main/install.ps1 | iex + +# Windows — core only, non-interactive +$env:HARNESS_NONINTERACTIVE=1; $env:HARNESS_CORE_ONLY=1; irm https://raw.githubusercontent.com/harness/cli/main/install.ps1 | iex +``` + ### Manual install -Prefer to install by hand? Download an archive from [GitHub Releases](https://github.com/harness/cli/releases) and place the binaries on your `PATH`. Both `tar.gz` bundles (core + `har`) and per-binary archives are published for `linux_amd64`, `linux_arm64`, `darwin_amd64`, and `darwin_arm64`. +Prefer to install by hand? Download an archive from [GitHub Releases](https://github.com/harness/cli/releases) and place the binaries on your `PATH`. Unix bundles are `tar.gz`; Windows bundles are `zip`. Published for `linux_amd64`, `linux_arm64`, `darwin_amd64`, `darwin_arm64`, `windows_amd64`, and `windows_arm64`. This is also the path to take if `curl | sh` doesn't work in your environment — e.g. WSL behind a corporate SSL-inspecting proxy, air-gapped/vetted-binary environments, or scripted installs — see [`docs/manual-install.md`](docs/manual-install.md) for step-by-step instructions. diff --git a/docs/manual-install.md b/docs/manual-install.md index 9c1c69b..fe9f482 100644 --- a/docs/manual-install.md +++ b/docs/manual-install.md @@ -1,26 +1,30 @@ # Manual Install -The [one-line installer](../install.sh) (`curl -fsSL .../install.sh | sh`) is the recommended -way to install the CLI. Use the steps below instead if `curl` can't reach GitHub in your -environment (e.g. WSL behind a corporate SSL-inspecting proxy), you need a vetted binary from +The [one-line installers](../install.sh) (`curl -fsSL .../install.sh | sh` on Unix, +`irm .../install.ps1 | iex` on Windows) are the recommended way to install the CLI. +Use the steps below instead if the installer can't reach GitHub in your environment +(e.g. WSL behind a corporate SSL-inspecting proxy), you need a vetted binary from an internal mirror, or you're baking the install into a script/Dockerfile. -This covers macOS and Linux (`amd64` / `arm64`). There's no Windows release yet. +This covers macOS, Linux, and Windows (`amd64` / `arm64`). ## 1. Get the release archive Releases live at [github.com/harness/cli/releases](https://github.com/harness/cli/releases). Pick a version (or use `latest`) and find the asset for your platform: -| Platform | Asset name pattern | -| -------------------- | ------------------------------------------------ | -| Linux x86_64 | `harness-bundle__linux_amd64.tar.gz` | -| Linux ARM64 | `harness-bundle__linux_arm64.tar.gz` | -| macOS Intel | `harness-bundle__darwin_amd64.tar.gz` | -| macOS Apple Silicon | `harness-bundle__darwin_arm64.tar.gz` | +| Platform | Asset name pattern | +| --------------------- | ------------------------------------------------ | +| Linux x86_64 | `harness-bundle__linux_amd64.tar.gz` | +| Linux ARM64 | `harness-bundle__linux_arm64.tar.gz` | +| macOS Intel | `harness-bundle__darwin_amd64.tar.gz` | +| macOS Apple Silicon | `harness-bundle__darwin_arm64.tar.gz` | +| Windows x86_64 | `harness-bundle__windows_amd64.zip` | +| Windows ARM64 | `harness-bundle__windows_arm64.zip` | -`harness-bundle_*` contains both `harness` and `harness-har`. If you only need the core CLI, -use `harness-core___.tar.gz` instead — it contains just `harness`. +`harness-bundle_*` contains both `harness` and `harness-har` (`.exe` on Windows). +If you only need the core CLI, use `harness-core___.tar.gz` (Unix) +or `.zip` (Windows) instead — it contains just `harness`. Also grab `harness__checksums.txt` from the same release, for step 2. @@ -33,16 +37,27 @@ mounted under `/mnt/c/...`, so a file saved to Windows' Downloads folder is usua Confirm the archive wasn't corrupted or tampered with in transit: +**Unix (`tar.gz`):** + ```sh grep harness-bundle___.tar.gz harness__checksums.txt sha256sum harness-bundle___.tar.gz # Linux shasum -a 256 harness-bundle___.tar.gz # macOS ``` -The hash printed by `sha256sum`/`shasum` should match the one from the `grep` line above. +**Windows (`zip`):** + +```powershell +Select-String harness-bundle__windows_amd64.zip harness__checksums.txt +Get-FileHash harness-bundle__windows_amd64.zip -Algorithm SHA256 +``` + +The hash printed should match the one from the checksums file. ## 3. Extract and install the binaries +**Unix:** + ```sh tar -xzf harness-bundle___.tar.gz -C /tmp/harness-install mkdir -p ~/.local/bin @@ -51,8 +66,17 @@ mv /tmp/harness-install/harness-har ~/.local/bin/harness-har # skip if using h chmod +x ~/.local/bin/harness ~/.local/bin/harness-har ``` -`~/.local/bin` matches the default installer location, but any directory on your `PATH` -works — `/usr/local/bin` is a common system-wide alternative. +**Windows (Command Prompt or PowerShell):** + +```powershell +Expand-Archive harness-bundle__windows_amd64.zip -DestinationPath $env:TEMP\harness-install +New-Item -ItemType Directory -Force -Path "$env:LOCALAPPDATA\Programs\harness" +Copy-Item "$env:TEMP\harness-install\harness.exe" "$env:LOCALAPPDATA\Programs\harness\" +Copy-Item "$env:TEMP\harness-install\harness-har.exe" "$env:LOCALAPPDATA\Programs\harness\" +``` + +`~/.local/bin` (Unix) and `%LOCALAPPDATA%\Programs\harness` (Windows) match the default +installer locations, but any directory on your `PATH` works. ## 4. Add to PATH and enable completions @@ -72,7 +96,14 @@ export PATH="$HOME/.local/bin:$PATH" source <(harness completion zsh) ``` -Then reload the shell (`source ~/.bashrc` or `source ~/.zshrc`) or open a new terminal. +**PowerShell** — add to your profile: + +```powershell +$env:Path = "$env:LOCALAPPDATA\Programs\harness;" + $env:Path +harness completion powershell | Out-String | Invoke-Expression +``` + +Then reload the shell or open a new terminal. ## 5. Verify @@ -80,6 +111,8 @@ Then reload the shell (`source ~/.bashrc` or `source ~/.zshrc`) or open a new te harness version ``` +On Windows Command Prompt, use `harness.exe version` if the install directory is not yet on `PATH`. + ## Upgrading later Once installed, `harness install cli` can upgrade in place — see the diff --git a/pkg/spec/core.spec.yaml b/pkg/spec/core.spec.yaml index e06a2d5..194c5a9 100644 --- a/pkg/spec/core.spec.yaml +++ b/pkg/spec/core.spec.yaml @@ -296,7 +296,7 @@ commands: - name: version description: "Version to install (default: latest)" - name: install-dir - description: "Directory to install into (default: ~/.local/bin)" + description: "Directory to install into (default: ~/.local/bin on Unix, %LOCALAPPDATA%\\Programs\\harness on Windows)" - name: force is_bool: true description: Install even if the current version is already up to date @@ -331,7 +331,7 @@ commands: - name: version description: "Version to install (default: latest)" - name: install-dir - description: "Directory to install into (default: ~/.local/bin)" + description: "Directory to install into (default: ~/.local/bin on Unix, %LOCALAPPDATA%\\Programs\\harness on Windows)" - name: force is_bool: true description: Install even if the current version is already up to date From 2103f5bebe526d375d21d9e635874bda18e32a41 Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Fri, 31 Jul 2026 15:37:13 +0530 Subject: [PATCH 5/8] install scipt for powershell, counterpart of install.sh --- install.ps1 | 258 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 install.ps1 diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..db4aef7 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,258 @@ +#Requires -Version 5.1 +param( + [string]$InstallDir, + [string]$Version, + [switch]$Core, + [switch]$NonInteractive, + [switch]$NoVerify +) + +$ErrorActionPreference = "Stop" +$ProgressPreference = "SilentlyContinue" + +$Repo = "harness/cli" + +if (-not $InstallDir) { $InstallDir = $env:HARNESS_INSTALL_DIR } +$UserOverride = [bool]$InstallDir +if (-not $InstallDir) { + $localAppData = $env:LOCALAPPDATA + if (-not $localAppData) { $localAppData = Join-Path $env:USERPROFILE "AppData\Local" } + $InstallDir = Join-Path $localAppData "Programs\harness" +} + +$Interactive = -not ($NonInteractive -or $env:HARNESS_NONINTERACTIVE) +$SkipVerify = [bool]($NoVerify -or $env:HARNESS_NO_VERIFY) +$CoreOnly = [bool]($Core -or $env:HARNESS_CORE_ONLY) + +# Testing hook: point at a local directory or private mirror holding the release +# assets instead of GitHub Releases. Must contain the zip and checksums file. +$AssetBase = $env:HARNESS_INSTALL_BASE_URL + +function Write-Info($Message) { Write-Host " - $Message" -ForegroundColor Blue } +function Write-Ok($Message) { Write-Host " + $Message" -ForegroundColor Green } +function Write-Note($Message) { Write-Host " ! $Message" -ForegroundColor Yellow } +function Fail($Message) { Write-Host " x $Message" -ForegroundColor Red; throw $Message } + +function Test-Interactive { + if (-not $Interactive) { return $false } + return [Environment]::UserInteractive -and -not [Console]::IsInputRedirected +} + +function Confirm-Yes($Prompt) { + $answer = Read-Host " ? $Prompt [Y/n]" + return -not ($answer -match '^[nN]') +} + +function Get-Platform { + $raw = $env:PROCESSOR_ARCHITECTURE + if ($env:PROCESSOR_ARCHITEW6432) { $raw = $env:PROCESSOR_ARCHITEW6432 } + switch ($raw) { + "AMD64" { return "windows_amd64" } + "ARM64" { return "windows_arm64" } + default { Fail "Unsupported architecture: $raw" } + } +} + +function Get-LatestVersion { + $headers = @{ "User-Agent" = "harness-cli-installer" } + $response = Invoke-RestMethod -Uri "https://api.github.com/repos/$Repo/releases/latest" -Headers $headers + return $response.tag_name +} + +function Get-Sha256($Path) { + return (Get-FileHash -Algorithm SHA256 -Path $Path).Hash.ToLower() +} + +function Get-Asset { + param( + [string]$Base, + [string]$Name, + [string]$Dest + ) + $isUrl = $Base -match '^[a-zA-Z][a-zA-Z0-9+.-]*://' + if (-not $isUrl -and (Test-Path -LiteralPath $Base -PathType Container -ErrorAction SilentlyContinue)) { + $src = Join-Path $Base $Name + if (-not (Test-Path -LiteralPath $src)) { throw "$Name not found in $Base" } + Copy-Item -LiteralPath $src -Destination $Dest -Force + return + } + Invoke-WebRequest -Uri "$Base/$Name" -OutFile $Dest -UseBasicParsing +} + +function Install-HarnessBinaries { + param( + [string]$Version, + [string]$Platform, + [string]$Dest + ) + + $ver = $Version.TrimStart("v") + if ($CoreOnly) { + $pkgName = "harness-core_${ver}_${Platform}" + $binaries = @("harness.exe") + } else { + $pkgName = "harness-bundle_${ver}_${Platform}" + $binaries = @("harness.exe", "harness-har.exe") + } + + $archiveName = "$pkgName.zip" + $checksumName = "harness_${ver}_checksums.txt" + $base = $AssetBase + if (-not $base) { $base = "https://github.com/$Repo/releases/download/$Version" } + $tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("harness-install-" + [guid]::NewGuid().ToString()) + New-Item -ItemType Directory -Path $tmp | Out-Null + + try { + Write-Info "Downloading $archiveName ..." + $archivePath = Join-Path $tmp $archiveName + try { + Get-Asset -Base $base -Name $archiveName -Dest $archivePath + } catch { + if ($AssetBase) { Fail "Could not fetch $archiveName from $base" } + Fail "Could not download $archiveName - this release may not include Windows assets yet" + } + + if ($SkipVerify) { + Write-Note "Skipping checksum verification (-NoVerify)" + } else { + Write-Info "Verifying checksum..." + $checksumPath = Join-Path $tmp "checksums.txt" + Get-Asset -Base $base -Name $checksumName -Dest $checksumPath + $match = Select-String -Path $checksumPath -Pattern ([regex]::Escape($archiveName)) | Select-Object -First 1 + if (-not $match) { Fail "Checksum entry not found for $archiveName" } + $expected = $match.Line.Split()[0].ToLower() + $actual = Get-Sha256 $archivePath + if ($actual -ne $expected) { Fail "Checksum mismatch - download may be corrupted" } + } + + $extractDir = Join-Path $tmp "extract" + Expand-Archive -Path $archivePath -DestinationPath $extractDir -Force + New-Item -ItemType Directory -Path $Dest -Force | Out-Null + foreach ($bin in $binaries) { + $src = Join-Path $extractDir $bin + if (-not (Test-Path $src)) { Fail "Binary $bin not found in archive" } + $target = Join-Path $Dest $bin + Copy-Item -Path $src -Destination $target -Force + Write-Ok "Installed $bin $Version to $target" + } + } finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue + } +} + +function Add-InstallDirToUserPath { + param([string]$Dir) + + $current = [Environment]::GetEnvironmentVariable("Path", "User") + $parts = @() + if ($current) { $parts = @($current -split ';' | Where-Object { $_ -ne "" }) } + if ($parts -contains $Dir) { return $false } + + $updated = @($Dir) + @($parts | Where-Object { $_ -ne $Dir }) + [Environment]::SetEnvironmentVariable("Path", ($updated -join ';'), "User") + Send-SettingChange + return $true +} + +# Broadcast WM_SETTINGCHANGE so new shells pick up the PATH change without a reboot. +function Send-SettingChange { + if (-not ("Harness.NativeMethods" -as [type])) { + Add-Type -Namespace Harness -Name NativeMethods -MemberDefinition @" +[System.Runtime.InteropServices.DllImport("user32.dll", SetLastError = true, CharSet = System.Runtime.InteropServices.CharSet.Auto)] +public static extern System.IntPtr SendMessageTimeout( + System.IntPtr hWnd, int Msg, System.IntPtr wParam, string lParam, + int fuFlags, int uTimeout, out System.IntPtr lpdwResult); +"@ -ErrorAction SilentlyContinue + } + try { + $result = [System.IntPtr]::Zero + [void][Harness.NativeMethods]::SendMessageTimeout( + [System.IntPtr]0xffff, 0x001A, [System.IntPtr]::Zero, "Environment", 2, 5000, [ref]$result) + } catch { + Write-Note "Could not broadcast PATH change - open a new terminal to pick it up" + } +} + +function Get-HarnessProfileBlock { + @" +# +`$env:Path = "$InstallDir;" + `$env:Path +harness completion powershell | Out-String | Invoke-Expression +# +"@ +} + +function Test-ProfilePatched { + param([string]$ProfilePath) + if (-not (Test-Path $ProfilePath)) { return $false } + return [bool](Select-String -Path $ProfilePath -Pattern "" -Quiet) +} + +function Invoke-Installer { + Write-Host "" + Write-Host " Harness CLI installer" + Write-Host "" + + $platform = Get-Platform + $version = $Version + if (-not $version) { $version = $env:HARNESS_VERSION } + if (-not $version) { $version = Get-LatestVersion } + if (-not $version) { Fail "Could not determine latest version" } + + Install-HarnessBinaries -Version $version -Platform $platform -Dest $InstallDir + + $harnessExe = Join-Path $InstallDir "harness.exe" + if (Test-Path $harnessExe) { + $env:HARNESS_INSTALL_TYPE = "script" + & $harnessExe --post-install 2>$null | Out-Null + } + + $patchedProfile = $false + if ((Test-Interactive) -and -not $UserOverride -and $PROFILE) { + $profilePath = $PROFILE + $profileName = Split-Path -Leaf $profilePath + if (Test-ProfilePatched $profilePath) { + Write-Info "Shell config already set up in $profileName, skipping" + } else { + Write-Host "" + Write-Info "Would you like us to update $profileName ?" + Write-Info " - Add $InstallDir to PATH" + Write-Info " - Add PowerShell completions" + Write-Host "" + if (Confirm-Yes "Update $profileName") { + $profileDir = Split-Path -Parent $profilePath + if (-not (Test-Path $profileDir)) { + New-Item -ItemType Directory -Path $profileDir -Force | Out-Null + } + Add-Content -Path $profilePath -Value "`n$(Get-HarnessProfileBlock)`n" + $patchedProfile = $true + Write-Ok "Updated $profileName" + } else { + Write-Host "" + Write-Info "To set up manually, add this to ${profileName}:" + Write-Host (Get-HarnessProfileBlock) + } + } + } + + if (Add-InstallDirToUserPath -Dir $InstallDir) { + Write-Ok "Added $InstallDir to user PATH" + } + + Write-Host "" + $env:Path = "$InstallDir;" + $env:Path + Write-Ok "Done!" + Write-Info "Verify right now: & '$harnessExe' version" + if ($patchedProfile) { + Write-Info "New shells (or '. `$PROFILE') pick up 'harness' on PATH with completions." + } else { + Write-Info "Open a new terminal to pick up 'harness' on PATH." + } +} + +try { + Invoke-Installer +} catch { + # Fail already printed a readable message; suppress the raw PowerShell error record. + if ($MyInvocation.MyCommand.Path) { exit 1 } +} From 5b1b7af71f97c721c8ab75a8d0064a494e2595de Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Fri, 31 Jul 2026 15:38:09 +0530 Subject: [PATCH 6/8] Test files for install and plugin --- modules/core/mgmt/install_platform_test.go | 74 ++++++++++++++++++++++ pkg/plugin/plugin_test.go | 67 ++++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 modules/core/mgmt/install_platform_test.go create mode 100644 pkg/plugin/plugin_test.go diff --git a/modules/core/mgmt/install_platform_test.go b/modules/core/mgmt/install_platform_test.go new file mode 100644 index 0000000..1f48cf6 --- /dev/null +++ b/modules/core/mgmt/install_platform_test.go @@ -0,0 +1,74 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package mgmt + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" +) + +func TestArchiveExtensionForPlatform(t *testing.T) { + tests := []struct { + platform string + want string + }{ + {"linux_amd64", ".tar.gz"}, + {"darwin_arm64", ".tar.gz"}, + {"windows_amd64", ".zip"}, + {"windows_arm64", ".zip"}, + } + for _, tt := range tests { + if got := archiveExtensionForPlatform(tt.platform); got != tt.want { + t.Errorf("archiveExtensionForPlatform(%q) = %q, want %q", tt.platform, got, tt.want) + } + } +} + +func TestExtractBinaryFromZip(t *testing.T) { + tmp := t.TempDir() + archivePath := filepath.Join(tmp, "bundle.zip") + dest := filepath.Join(tmp, "harness.exe") + + zf, err := os.Create(archivePath) + if err != nil { + t.Fatal(err) + } + w := zip.NewWriter(zf) + f, err := w.Create("harness.exe") + if err != nil { + t.Fatal(err) + } + if _, err := f.Write([]byte("fake-binary")); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if err := zf.Close(); err != nil { + t.Fatal(err) + } + + if err := extractBinaryFromZip(archivePath, "harness.exe", dest); err != nil { + t.Fatalf("extractBinaryFromZip: %v", err) + } + data, err := os.ReadFile(dest) + if err != nil { + t.Fatal(err) + } + if string(data) != "fake-binary" { + t.Fatalf("extracted content = %q, want %q", string(data), "fake-binary") + } +} + +func TestDetectPlatform(t *testing.T) { + platform, err := detectPlatform() + if err != nil { + t.Fatalf("detectPlatform: %v", err) + } + if platform == "" { + t.Fatal("detectPlatform returned empty platform") + } +} diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go new file mode 100644 index 0000000..d1399da --- /dev/null +++ b/pkg/plugin/plugin_test.go @@ -0,0 +1,67 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package plugin + +import ( + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestSiblingBinaryNames(t *testing.T) { + if runtime.GOOS == "windows" { + got := siblingBinaryNames("harness-har") + if len(got) != 2 || got[1] != "harness-har.exe" { + t.Fatalf("siblingBinaryNames on windows = %#v", got) + } + return + } + got := siblingBinaryNames("harness-har") + if len(got) != 1 || got[0] != "harness-har" { + t.Fatalf("siblingBinaryNames on unix = %#v", got) + } +} + +func TestFindBinarySibling(t *testing.T) { + tmp := t.TempDir() + self := filepath.Join(tmp, "harness") + if err := os.WriteFile(self, []byte("core"), 0755); err != nil { + t.Fatal(err) + } + + siblingName := "harness-har" + if runtime.GOOS == "windows" { + siblingName = "harness-har.exe" + } + sibling := filepath.Join(tmp, siblingName) + if err := os.WriteFile(sibling, []byte("har"), 0755); err != nil { + t.Fatal(err) + } + + origExecutable := os.Args[0] + // FindBinary uses os.Executable(); copy harness to a temp executable path for the test. + testExe := filepath.Join(tmp, "harness-test") + if runtime.GOOS == "windows" { + testExe += ".exe" + } + if err := os.WriteFile(testExe, []byte("core"), 0755); err != nil { + t.Fatal(err) + } + _ = origExecutable + + // Simulate sibling lookup by calling FindBinary from a copied binary location. + // os.Executable returns the test binary path, not testExe, so validate helper names instead. + names := siblingBinaryNames("harness-har") + found := false + for _, name := range names { + if _, err := os.Stat(filepath.Join(tmp, name)); err == nil { + found = true + break + } + } + if !found { + t.Fatalf("expected sibling binary in %v", names) + } +} From 1bc901b83ea0a9fee860d6ee9b5c098c81c262fe Mon Sep 17 00:00:00 2001 From: naman-nirwan Date: Fri, 31 Jul 2026 15:38:50 +0530 Subject: [PATCH 7/8] Windows build added with HAR installer --- Taskfile.yml | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Taskfile.yml b/Taskfile.yml index 9ff4281..5944ae3 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -29,6 +29,29 @@ tasks: cmds: - go build -ldflags "{{.LDFLAGS}}" -o ../../bin/harness-har ./cmd/harness-har/main-harness-har.go + build:windows: + desc: Cross-compile Windows binaries (harness.exe + harness-har.exe) + deps: [build:windows:main, build:windows:har] + + build:windows:main: + desc: Cross-compile harness.exe for Windows amd64 + env: + GOOS: windows + GOARCH: amd64 + CGO_ENABLED: 0 + cmds: + - go build -ldflags "{{.LDFLAGS}}" -o bin/harness.exe {{.MAIN}} + + build:windows:har: + desc: Cross-compile harness-har.exe for Windows amd64 + dir: modules/har + env: + GOOS: windows + GOARCH: amd64 + CGO_ENABLED: 0 + cmds: + - go build -ldflags "{{.LDFLAGS}}" -o ../../bin/harness-har.exe ./cmd/harness-har/main-harness-har.go + build:opt: desc: Build all optimized binaries (stripped symbols, no DWARF) deps: [build:opt:main, build:opt:har] @@ -55,6 +78,14 @@ tasks: - mkdir -p ~/.local/bin - cp bin/{{.BINARY}} ~/.local/bin/{{.BINARY}} - echo "Installed ~/.local/bin/{{.BINARY}}" + + install:har: + desc: Build and install harness-har binary to ~/.local/bin + deps: [build:har, install] + cmds: + - mkdir -p ~/.local/bin + - cp ./bin/harness-har ~/.local/bin/harness-har + - echo "Installed ~/.local/bin/harness-har" dev: desc: Run without ldflags (version shows 0.1.0-dev / dev) From ab3772db8dae4658a25b7c9f90713543b603db28 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Tue, 4 Aug 2026 09:24:01 -0700 Subject: [PATCH 8/8] error out if we can't find a suitable default windows installdir --- modules/core/mgmt/install.go | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/modules/core/mgmt/install.go b/modules/core/mgmt/install.go index db6ef1d..9b140fa 100644 --- a/modules/core/mgmt/install.go +++ b/modules/core/mgmt/install.go @@ -75,16 +75,18 @@ var modulePlugins = map[string]string{ "har": "harness-har", } -func defaultInstallDir() string { +func defaultInstallDir() (string, error) { if runtime.GOOS == "windows" { if localAppData := os.Getenv("LOCALAPPDATA"); localAppData != "" { - return filepath.Join(localAppData, "Programs", "harness") + return filepath.Join(localAppData, "Programs", "harness"), nil } - if home, err := os.UserHomeDir(); err == nil { - return filepath.Join(home, "AppData", "Local", "Programs", "harness") + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("determining default install directory: LOCALAPPDATA is not set and home directory could not be determined: %w", err) } + return filepath.Join(home, "AppData", "Local", "Programs", "harness"), nil } - return "~/.local/bin" + return "~/.local/bin", nil } func installedBinaryName(base string) string { @@ -146,9 +148,13 @@ func InstallCLIHandler(ctx *cmdctx.Ctx) error { check := cmdctx.GetBool(ctx.FlagValues, "check") coreOnly := cmdctx.GetBool(ctx.FlagValues, "core-only") + var err error installDir := cmdctx.GetString(ctx.FlagValues, "install-dir") if installDir == "" { - installDir = defaultInstallDir() + installDir, err = defaultInstallDir() + if err != nil { + return err + } } installDir = hbase.ExpandHomeDir(installDir) @@ -156,7 +162,6 @@ func InstallCLIHandler(ctx *cmdctx.Ctx) error { return err } - var err error version, err = resolveVersion(version) if err != nil { return err @@ -255,13 +260,16 @@ func InstallModuleHandler(ctx *cmdctx.Ctx) error { force := cmdctx.GetBool(ctx.FlagValues, "force") check := cmdctx.GetBool(ctx.FlagValues, "check") + var err error installDir := cmdctx.GetString(ctx.FlagValues, "install-dir") if installDir == "" { - installDir = defaultInstallDir() + installDir, err = defaultInstallDir() + if err != nil { + return err + } } installDir = hbase.ExpandHomeDir(installDir) - var err error version, err = resolveVersion(version) if err != nil { return err