From 7f0e75810fa819068863f674553e5ec348206d6b Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Sun, 31 May 2026 21:22:11 -0500 Subject: [PATCH 1/4] PROT-6: protostar CLI as a self-contained installable binary Introduce the protostar CLI so a harness can invoke it. Built with Spectre.Console.Cli; `protostar` prints a styled status, `--version` and `--help` work out of the box. Commands (auth, sync, hooks) land in later tickets. - src/Protostar.Cli: Spectre.Console.Cli CommandApp; binary named `protostar`, InvariantGlobalization for a smaller self-contained build. - install.ps1: publishes a self-contained, single-file executable (no .NET runtime needed to run), installs to %LOCALAPPDATA%\Programs\protostar, and puts it on PATH. Verified: installed binary runs standalone (36.6 MB, ~218 ms cold start). Lean/fast optimization (trimming/AOT) is deferred to a separate performance-tuning ticket, since Spectre.Console.Cli's reflection-based binding is not trim/AOT-safe. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 43 ++++++++++++- install.ps1 | 67 ++++++++++++++++++++ protostar.sln | 25 ++++++++ src/Protostar.Cli/CliInfo.cs | 7 ++ src/Protostar.Cli/Commands/DefaultCommand.cs | 23 +++++++ src/Protostar.Cli/Program.cs | 14 ++++ src/Protostar.Cli/Protostar.Cli.csproj | 20 ++++++ 7 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 install.ps1 create mode 100644 src/Protostar.Cli/CliInfo.cs create mode 100644 src/Protostar.Cli/Commands/DefaultCommand.cs create mode 100644 src/Protostar.Cli/Program.cs create mode 100644 src/Protostar.Cli/Protostar.Cli.csproj diff --git a/README.md b/README.md index f441c53..91a6fd2 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,50 @@ The loop: **use → sync → refine → suggest → adopt → use.** ## Status Built incrementally, one ticket at a time (Jira project `PROT`). The first component is the -**CLI** — see [`src/Protostar.Cli`](src/Protostar.Cli) and the install instructions below as it -lands. +**CLI** (`protostar`) — a self-contained binary you can run without installing the .NET runtime. + +## Install the CLI + +Requires the .NET 10 SDK to build. From the repo root: + +```powershell +./install.ps1 +``` + +This publishes a self-contained, single-file `protostar.exe`, installs it to +`%LOCALAPPDATA%\Programs\protostar`, and adds that directory to your user PATH. Restart your shell, +then: + +```console +$ protostar +protostar v0.1.0 +Live, continuous refinement of agent skills. + +Run protostar --help to see available commands. + +$ protostar --version +0.1.0 +``` + +The installer takes `-Rid` (e.g. `win-arm64`, `linux-x64`), `-Configuration`, and `-InstallDir`. +The published binary needs no .NET runtime on the target machine. + +> Startup is currently JIT (self-contained, untrimmed); making the binary lean and fast is tracked +> as a separate performance-tuning unit of work. + +## Build from source + +```bash +dotnet build # build the solution +dotnet run --project src/Protostar.Cli # run the CLI in place +``` ## Repository layout ```text protostar/ -├─ src/ # source projects (Protostar.*) +├─ src/ +│ └─ Protostar.Cli/ # the `protostar` CLI (Spectre.Console.Cli) +├─ install.ps1 # self-contained build + install └─ protostar.sln ``` diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..24314f8 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,67 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Build and install the protostar CLI as a self-contained binary (no .NET runtime required to run). + +.DESCRIPTION + Publishes src/Protostar.Cli as a self-contained, single-file executable for the current (or + specified) runtime identifier, copies it to a per-user install directory, and ensures that + directory is on the user PATH. Re-running is idempotent. + +.PARAMETER Configuration + Build configuration. Default: Release. + +.PARAMETER Rid + Runtime identifier (e.g. win-x64, win-arm64, linux-x64, osx-arm64). Defaults to the current OS/arch. + +.PARAMETER InstallDir + Where to install protostar.exe. Default: %LOCALAPPDATA%\Programs\protostar. + +.EXAMPLE + ./install.ps1 +#> +[CmdletBinding()] +param( + [string]$Configuration = 'Release', + [string]$Rid, + [string]$InstallDir = (Join-Path $env:LOCALAPPDATA 'Programs\protostar') +) + +$ErrorActionPreference = 'Stop' +$root = $PSScriptRoot +$project = Join-Path $root 'src/Protostar.Cli/Protostar.Cli.csproj' + +if (-not $Rid) { + $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() + $Rid = "win-$arch" +} + +$publishDir = Join-Path $root "artifacts/cli/$Rid" + +Write-Host "Publishing protostar ($Rid, self-contained single-file)..." -ForegroundColor Cyan +dotnet publish $project ` + -c $Configuration ` + -r $Rid ` + --self-contained true ` + -p:PublishSingleFile=true ` + -p:EnableCompressionInSingleFile=true ` + -o $publishDir +if ($LASTEXITCODE -ne 0) { throw "publish failed (exit $LASTEXITCODE)" } + +$exe = Join-Path $publishDir 'protostar.exe' +if (-not (Test-Path $exe)) { throw "expected binary not found: $exe" } + +New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +Copy-Item $exe $InstallDir -Force +$installedExe = Join-Path $InstallDir 'protostar.exe' +Write-Host "Installed protostar -> $installedExe" -ForegroundColor Green + +# Ensure the install dir is on the user PATH (idempotent). +$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') +if (($userPath -split ';') -notcontains $InstallDir) { + [Environment]::SetEnvironmentVariable('Path', "$userPath;$InstallDir", 'User') + Write-Host "Added $InstallDir to your user PATH. Restart your shell to pick it up." -ForegroundColor Yellow +} + +Write-Host "" +& $installedExe --version diff --git a/protostar.sln b/protostar.sln index 58ea566..101b3e0 100644 --- a/protostar.sln +++ b/protostar.sln @@ -3,12 +3,37 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Protostar.Cli", "src\Protostar.Cli\Protostar.Cli.csproj", "{B94673CE-1472-46C5-8538-82062FDD51E6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {B94673CE-1472-46C5-8538-82062FDD51E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B94673CE-1472-46C5-8538-82062FDD51E6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B94673CE-1472-46C5-8538-82062FDD51E6}.Debug|x64.ActiveCfg = Debug|Any CPU + {B94673CE-1472-46C5-8538-82062FDD51E6}.Debug|x64.Build.0 = Debug|Any CPU + {B94673CE-1472-46C5-8538-82062FDD51E6}.Debug|x86.ActiveCfg = Debug|Any CPU + {B94673CE-1472-46C5-8538-82062FDD51E6}.Debug|x86.Build.0 = Debug|Any CPU + {B94673CE-1472-46C5-8538-82062FDD51E6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B94673CE-1472-46C5-8538-82062FDD51E6}.Release|Any CPU.Build.0 = Release|Any CPU + {B94673CE-1472-46C5-8538-82062FDD51E6}.Release|x64.ActiveCfg = Release|Any CPU + {B94673CE-1472-46C5-8538-82062FDD51E6}.Release|x64.Build.0 = Release|Any CPU + {B94673CE-1472-46C5-8538-82062FDD51E6}.Release|x86.ActiveCfg = Release|Any CPU + {B94673CE-1472-46C5-8538-82062FDD51E6}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {B94673CE-1472-46C5-8538-82062FDD51E6} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + EndGlobalSection EndGlobal diff --git a/src/Protostar.Cli/CliInfo.cs b/src/Protostar.Cli/CliInfo.cs new file mode 100644 index 0000000..1550da9 --- /dev/null +++ b/src/Protostar.Cli/CliInfo.cs @@ -0,0 +1,7 @@ +namespace Protostar.Cli; + +/// Static CLI metadata. Version is surfaced via protostar --version. +internal static class CliInfo +{ + public const string Version = "0.1.0"; +} diff --git a/src/Protostar.Cli/Commands/DefaultCommand.cs b/src/Protostar.Cli/Commands/DefaultCommand.cs new file mode 100644 index 0000000..761cccf --- /dev/null +++ b/src/Protostar.Cli/Commands/DefaultCommand.cs @@ -0,0 +1,23 @@ +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Protostar.Cli.Commands; + +/// +/// Runs when protostar is invoked with no command. Prints a short, styled status so an +/// operator can confirm the CLI is installed and working. Real commands (auth, sync, hooks) land +/// in later tickets. +/// +internal sealed class DefaultCommand : Command +{ + public sealed class Settings : CommandSettings; + + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation) + { + AnsiConsole.MarkupLine($"[aqua]protostar[/] [grey]v{CliInfo.Version}[/]"); + AnsiConsole.MarkupLine("[grey]Live, continuous refinement of agent skills.[/]"); + AnsiConsole.WriteLine(); + AnsiConsole.MarkupLine("Run [yellow]protostar --help[/] to see available commands."); + return 0; + } +} diff --git a/src/Protostar.Cli/Program.cs b/src/Protostar.Cli/Program.cs new file mode 100644 index 0000000..7fb050a --- /dev/null +++ b/src/Protostar.Cli/Program.cs @@ -0,0 +1,14 @@ +using Protostar.Cli; +using Protostar.Cli.Commands; +using Spectre.Console.Cli; + +// Spectre.Console.Cli command app. `protostar` runs DefaultCommand; `--version`/`-v` and `--help` +// are provided by the framework. Future tickets register commands (auth, sync, hooks) here. +var app = new CommandApp(); +app.Configure(config => +{ + config.SetApplicationName("protostar"); + config.SetApplicationVersion(CliInfo.Version); +}); + +return app.Run(args); diff --git a/src/Protostar.Cli/Protostar.Cli.csproj b/src/Protostar.Cli/Protostar.Cli.csproj new file mode 100644 index 0000000..11f646c --- /dev/null +++ b/src/Protostar.Cli/Protostar.Cli.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + Protostar.Cli + + protostar + + true + + + + + + + + From f04a6ea59f9209160cca6c9154d6c9b552091e12 Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Sun, 31 May 2026 21:33:32 -0500 Subject: [PATCH 2/4] PROT-6: self-install command + curl-able release installers Make the CLI install itself, and provide one-line installers that fetch the latest GitHub release. - `protostar install` / `protostar uninstall`: the running binary copies itself into a per-user dir (%LOCALAPPDATA%\Programs\protostar on Windows, ~/.local/bin on Unix) and manages PATH. Options: --dir, --no-modify-path. Cross-platform. - scripts/install.ps1 + scripts/install.sh: detect OS/arch, download the matching asset from releases/latest/download, and run `protostar install`. Curl-able and surfaced in the README. - Remove the source-build install.ps1 (superseded; dev flow documented in README). Release assets are expected as protostar-(.exe) (e.g. protostar-win-x64.exe, protostar-linux-x64); the CI to publish them is upcoming work. Verified the install/uninstall cycle end-to-end and both bootstrap scripts' arch/URL resolution. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 46 ++++++++-- install.ps1 | 67 -------------- scripts/install.ps1 | 41 +++++++++ scripts/install.sh | 37 ++++++++ src/Protostar.Cli/Commands/InstallCommand.cs | 87 +++++++++++++++++++ .../Commands/UninstallCommand.cs | 46 ++++++++++ .../Install/InstallEnvironment.cs | 75 ++++++++++++++++ src/Protostar.Cli/Program.cs | 5 ++ 8 files changed, 328 insertions(+), 76 deletions(-) delete mode 100644 install.ps1 create mode 100644 scripts/install.ps1 create mode 100644 scripts/install.sh create mode 100644 src/Protostar.Cli/Commands/InstallCommand.cs create mode 100644 src/Protostar.Cli/Commands/UninstallCommand.cs create mode 100644 src/Protostar.Cli/Install/InstallEnvironment.cs diff --git a/README.md b/README.md index 91a6fd2..e4b4877 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,24 @@ Built incrementally, one ticket at a time (Jira project `PROT`). The first compo ## Install the CLI -Requires the .NET 10 SDK to build. From the repo root: +The CLI is a self-contained binary — no .NET runtime needed to run it. Install the latest release +with a one-liner: + +**Windows (PowerShell)** ```powershell -./install.ps1 +irm https://raw.githubusercontent.com/voidprojectssoftware/protostar/main/scripts/install.ps1 | iex +``` + +**Linux / macOS** + +```bash +curl -fsSL https://raw.githubusercontent.com/voidprojectssoftware/protostar/main/scripts/install.sh | sh ``` -This publishes a self-contained, single-file `protostar.exe`, installs it to -`%LOCALAPPDATA%\Programs\protostar`, and adds that directory to your user PATH. Restart your shell, -then: +These download the right binary from the latest [GitHub release](https://github.com/voidprojectssoftware/protostar/releases) +and run `protostar install`, which places `protostar` in a per-user directory and adds it to PATH. +Restart your shell, then: ```console $ protostar @@ -35,17 +44,34 @@ $ protostar --version 0.1.0 ``` -The installer takes `-Rid` (e.g. `win-arm64`, `linux-x64`), `-Configuration`, and `-InstallDir`. -The published binary needs no .NET runtime on the target machine. +> Requires a published release. (The CI that builds and publishes release binaries is upcoming work.) + +### Already have the binary? + +If you downloaded `protostar` directly, it installs itself: + +```console +$ protostar install # copy into a per-user dir + add to PATH +$ protostar install --dir # custom location +$ protostar install --no-modify-path +$ protostar uninstall # remove it +``` > Startup is currently JIT (self-contained, untrimmed); making the binary lean and fast is tracked > as a separate performance-tuning unit of work. ## Build from source +Requires the .NET 10 SDK. + ```bash dotnet build # build the solution dotnet run --project src/Protostar.Cli # run the CLI in place + +# produce a self-contained binary and self-install it: +dotnet publish src/Protostar.Cli -c Release -r win-x64 --self-contained true \ + -p:PublishSingleFile=true -o out +./out/protostar install ``` ## Repository layout @@ -53,7 +79,9 @@ dotnet run --project src/Protostar.Cli # run the CLI in place ```text protostar/ ├─ src/ -│ └─ Protostar.Cli/ # the `protostar` CLI (Spectre.Console.Cli) -├─ install.ps1 # self-contained build + install +│ └─ Protostar.Cli/ # the `protostar` CLI (Spectre.Console.Cli); `install`/`uninstall` commands +├─ scripts/ +│ ├─ install.ps1 # curl-able release installer (Windows) +│ └─ install.sh # curl-able release installer (Linux/macOS) └─ protostar.sln ``` diff --git a/install.ps1 b/install.ps1 deleted file mode 100644 index 24314f8..0000000 --- a/install.ps1 +++ /dev/null @@ -1,67 +0,0 @@ -#Requires -Version 5.1 -<# -.SYNOPSIS - Build and install the protostar CLI as a self-contained binary (no .NET runtime required to run). - -.DESCRIPTION - Publishes src/Protostar.Cli as a self-contained, single-file executable for the current (or - specified) runtime identifier, copies it to a per-user install directory, and ensures that - directory is on the user PATH. Re-running is idempotent. - -.PARAMETER Configuration - Build configuration. Default: Release. - -.PARAMETER Rid - Runtime identifier (e.g. win-x64, win-arm64, linux-x64, osx-arm64). Defaults to the current OS/arch. - -.PARAMETER InstallDir - Where to install protostar.exe. Default: %LOCALAPPDATA%\Programs\protostar. - -.EXAMPLE - ./install.ps1 -#> -[CmdletBinding()] -param( - [string]$Configuration = 'Release', - [string]$Rid, - [string]$InstallDir = (Join-Path $env:LOCALAPPDATA 'Programs\protostar') -) - -$ErrorActionPreference = 'Stop' -$root = $PSScriptRoot -$project = Join-Path $root 'src/Protostar.Cli/Protostar.Cli.csproj' - -if (-not $Rid) { - $arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() - $Rid = "win-$arch" -} - -$publishDir = Join-Path $root "artifacts/cli/$Rid" - -Write-Host "Publishing protostar ($Rid, self-contained single-file)..." -ForegroundColor Cyan -dotnet publish $project ` - -c $Configuration ` - -r $Rid ` - --self-contained true ` - -p:PublishSingleFile=true ` - -p:EnableCompressionInSingleFile=true ` - -o $publishDir -if ($LASTEXITCODE -ne 0) { throw "publish failed (exit $LASTEXITCODE)" } - -$exe = Join-Path $publishDir 'protostar.exe' -if (-not (Test-Path $exe)) { throw "expected binary not found: $exe" } - -New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null -Copy-Item $exe $InstallDir -Force -$installedExe = Join-Path $InstallDir 'protostar.exe' -Write-Host "Installed protostar -> $installedExe" -ForegroundColor Green - -# Ensure the install dir is on the user PATH (idempotent). -$userPath = [Environment]::GetEnvironmentVariable('Path', 'User') -if (($userPath -split ';') -notcontains $InstallDir) { - [Environment]::SetEnvironmentVariable('Path', "$userPath;$InstallDir", 'User') - Write-Host "Added $InstallDir to your user PATH. Restart your shell to pick it up." -ForegroundColor Yellow -} - -Write-Host "" -& $installedExe --version diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..ea6663b --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,41 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + protostar installer (Windows). Downloads the latest release binary and self-installs it. + +.DESCRIPTION + Resolves the right release asset for this machine, downloads it from the latest GitHub release, + and runs `protostar install`. Run via the one-liner: + + irm https://raw.githubusercontent.com/voidprojectssoftware/protostar/main/scripts/install.ps1 | iex + + Or download and run directly to pass options (e.g. -Dir), which are forwarded to `protostar install`. +#> +[CmdletBinding()] +param([Parameter(ValueFromRemainingArguments = $true)] [string[]]$InstallArgs) + +$ErrorActionPreference = 'Stop' +$repo = 'voidprojectssoftware/protostar' + +$arch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +switch ($arch) { + 'x64' { $rid = 'win-x64' } + 'arm64' { $rid = 'win-arm64' } + default { throw "Unsupported architecture: $arch" } +} + +$asset = "protostar-$rid.exe" +$url = "https://github.com/$repo/releases/latest/download/$asset" + +$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("protostar-download-" + [System.IO.Path]::GetRandomFileName()) +New-Item -ItemType Directory -Force -Path $tmp | Out-Null +$exe = Join-Path $tmp 'protostar.exe' + +try { + Write-Host "Downloading $asset ..." -ForegroundColor Cyan + Invoke-WebRequest -Uri $url -OutFile $exe -UseBasicParsing + & $exe install @InstallArgs +} +finally { + Remove-Item $tmp -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 0000000..b8c67c0 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# protostar installer (Linux/macOS). +# Downloads the latest release binary and self-installs it via `protostar install`. +# +# curl -fsSL https://raw.githubusercontent.com/voidprojectssoftware/protostar/main/scripts/install.sh | sh +# +# Any extra args are forwarded to `protostar install` (e.g. --dir, --no-modify-path) when the +# script is run directly (not via the piped one-liner). +set -e + +repo="voidprojectssoftware/protostar" + +os=$(uname -s) +arch=$(uname -m) + +case "$os" in + Linux) rid_os="linux" ;; + Darwin) rid_os="osx" ;; + *) echo "Unsupported OS: $os" >&2; exit 1 ;; +esac + +case "$arch" in + x86_64 | amd64) rid_arch="x64" ;; + aarch64 | arm64) rid_arch="arm64" ;; + *) echo "Unsupported architecture: $arch" >&2; exit 1 ;; +esac + +asset="protostar-${rid_os}-${rid_arch}" +url="https://github.com/${repo}/releases/latest/download/${asset}" + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +echo "Downloading ${asset} ..." +curl -fsSL "$url" -o "$tmp/protostar" +chmod +x "$tmp/protostar" +"$tmp/protostar" install "$@" diff --git a/src/Protostar.Cli/Commands/InstallCommand.cs b/src/Protostar.Cli/Commands/InstallCommand.cs new file mode 100644 index 0000000..36d2dce --- /dev/null +++ b/src/Protostar.Cli/Commands/InstallCommand.cs @@ -0,0 +1,87 @@ +using System.ComponentModel; +using Protostar.Cli.Install; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Protostar.Cli.Commands; + +/// +/// Self-installs the running binary: copies this executable into a per-user directory and (unless +/// told not to) ensures that directory is on PATH. Designed to be run from the downloaded +/// self-contained binary — `protostar install`. +/// +internal sealed class InstallCommand : Command +{ + public sealed class Settings : CommandSettings + { + [CommandOption("-d|--dir ")] + [Description("Install directory. Defaults to a per-user location.")] + public string? Dir { get; init; } + + [CommandOption("--no-modify-path")] + [Description("Do not add the install directory to PATH.")] + public bool NoModifyPath { get; init; } + } + + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation) + { + var source = Environment.ProcessPath; + if (source is null || !File.Exists(source)) + { + AnsiConsole.MarkupLine("[red]Could not determine the running executable to install.[/]"); + return 1; + } + + var dir = settings.Dir ?? InstallLocations.DefaultDir(); + var dest = Path.Combine(dir, InstallLocations.ExecutableName); + + if (PathsEqual(source, dest)) + { + AnsiConsole.MarkupLine($"[green]protostar[/] is already installed at [grey]{Markup.Escape(dest)}[/]."); + ReportPath(dir, settings.NoModifyPath); + return 0; + } + + try + { + Directory.CreateDirectory(dir); + File.Copy(source, dest, overwrite: true); + if (!OperatingSystem.IsWindows()) + { + File.SetUnixFileMode(dest, + UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | + UnixFileMode.OtherRead | UnixFileMode.OtherExecute); + } + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Install failed:[/] {Markup.Escape(ex.Message)}"); + return 1; + } + + AnsiConsole.MarkupLine($"Installed [aqua]protostar[/] [grey]v{CliInfo.Version}[/] → [grey]{Markup.Escape(dest)}[/]"); + ReportPath(dir, settings.NoModifyPath); + return 0; + } + + private static void ReportPath(string dir, bool noModifyPath) + { + if (noModifyPath) + { + if (!PathManager.IsOnPath(dir)) + AnsiConsole.MarkupLine($"[grey]{Markup.Escape(dir)} is not on PATH (left unchanged).[/]"); + return; + } + + var hint = PathManager.EnsureOnPath(dir); + if (hint is not null) + AnsiConsole.MarkupLine($"[yellow]{Markup.Escape(hint)}[/]"); + } + + private static bool PathsEqual(string a, string b) + { + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + return string.Equals(Path.GetFullPath(a), Path.GetFullPath(b), comparison); + } +} diff --git a/src/Protostar.Cli/Commands/UninstallCommand.cs b/src/Protostar.Cli/Commands/UninstallCommand.cs new file mode 100644 index 0000000..de8356e --- /dev/null +++ b/src/Protostar.Cli/Commands/UninstallCommand.cs @@ -0,0 +1,46 @@ +using System.ComponentModel; +using Protostar.Cli.Install; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace Protostar.Cli.Commands; + +/// Removes an installed protostar binary and (on Windows) its PATH entry. +internal sealed class UninstallCommand : Command +{ + public sealed class Settings : CommandSettings + { + [CommandOption("-d|--dir ")] + [Description("Install directory to remove from. Defaults to the per-user location.")] + public string? Dir { get; init; } + } + + protected override int Execute(CommandContext context, Settings settings, CancellationToken cancellation) + { + var dir = settings.Dir ?? InstallLocations.DefaultDir(); + var dest = Path.Combine(dir, InstallLocations.ExecutableName); + + if (!File.Exists(dest)) + { + AnsiConsole.MarkupLine($"[grey]Nothing to remove — {Markup.Escape(dest)} does not exist.[/]"); + return 0; + } + + try + { + File.Delete(dest); + // Remove the directory only if we created it and it is now empty. + if (Directory.Exists(dir) && !Directory.EnumerateFileSystemEntries(dir).Any()) + Directory.Delete(dir); + } + catch (Exception ex) + { + AnsiConsole.MarkupLine($"[red]Uninstall failed:[/] {Markup.Escape(ex.Message)}"); + return 1; + } + + PathManager.RemoveFromPath(dir); + AnsiConsole.MarkupLine($"Removed [aqua]protostar[/] from [grey]{Markup.Escape(dir)}[/]."); + return 0; + } +} diff --git a/src/Protostar.Cli/Install/InstallEnvironment.cs b/src/Protostar.Cli/Install/InstallEnvironment.cs new file mode 100644 index 0000000..9843643 --- /dev/null +++ b/src/Protostar.Cli/Install/InstallEnvironment.cs @@ -0,0 +1,75 @@ +namespace Protostar.Cli.Install; + +/// Where protostar installs to, per OS. +internal static class InstallLocations +{ + public static string ExecutableName => OperatingSystem.IsWindows() ? "protostar.exe" : "protostar"; + + /// Default per-user install directory (no admin rights needed). + public static string DefaultDir() + { + if (OperatingSystem.IsWindows()) + { + var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); + return Path.Combine(localAppData, "Programs", "protostar"); + } + + // XDG-ish: ~/.local/bin is conventional and often already on PATH. + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + return Path.Combine(home, ".local", "bin"); + } +} + +/// Reads/updates PATH so the install directory is resolvable as protostar. +internal static class PathManager +{ + private static StringComparison Comparison => + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + + /// True if is on the current process PATH. + public static bool IsOnPath(string dir) + { + var path = Environment.GetEnvironmentVariable("PATH") ?? string.Empty; + var sep = OperatingSystem.IsWindows() ? ';' : ':'; + return path.Split(sep, StringSplitOptions.RemoveEmptyEntries).Any(p => Same(p, dir)); + } + + /// + /// Ensures is on PATH. On Windows this persists to the user PATH + /// environment variable. On Unix we do not edit shell rc files automatically. Returns a + /// human-readable next-step hint, or null if nothing more is needed. + /// + public static string? EnsureOnPath(string dir) + { + if (OperatingSystem.IsWindows()) + { + var userPath = Environment.GetEnvironmentVariable("Path", EnvironmentVariableTarget.User) ?? string.Empty; + var parts = userPath.Split(';', StringSplitOptions.RemoveEmptyEntries); + if (!parts.Any(p => Same(p, dir))) + { + var updated = string.IsNullOrEmpty(userPath) ? dir : $"{userPath};{dir}"; + Environment.SetEnvironmentVariable("Path", updated, EnvironmentVariableTarget.User); + } + return IsOnPath(dir) ? null : "Restart your shell to pick up the updated PATH."; + } + + if (IsOnPath(dir)) + return null; + + return $"Add it to your PATH: export PATH=\"{dir}:$PATH\" (e.g. append to ~/.profile)"; + } + + /// Removes from the persisted user PATH (Windows only). + public static void RemoveFromPath(string dir) + { + if (!OperatingSystem.IsWindows()) + return; + + var userPath = Environment.GetEnvironmentVariable("Path", EnvironmentVariableTarget.User) ?? string.Empty; + var kept = userPath.Split(';', StringSplitOptions.RemoveEmptyEntries).Where(p => !Same(p, dir)); + Environment.SetEnvironmentVariable("Path", string.Join(';', kept), EnvironmentVariableTarget.User); + } + + private static bool Same(string a, string b) => + string.Equals(Path.TrimEndingDirectorySeparator(a), Path.TrimEndingDirectorySeparator(b), Comparison); +} diff --git a/src/Protostar.Cli/Program.cs b/src/Protostar.Cli/Program.cs index 7fb050a..860ee90 100644 --- a/src/Protostar.Cli/Program.cs +++ b/src/Protostar.Cli/Program.cs @@ -9,6 +9,11 @@ { config.SetApplicationName("protostar"); config.SetApplicationVersion(CliInfo.Version); + + config.AddCommand("install") + .WithDescription("Install protostar to a per-user directory and add it to PATH."); + config.AddCommand("uninstall") + .WithDescription("Remove an installed protostar binary."); }); return app.Run(args); From 64a6ec9f7bef2c3f2710497d55c1c0f65283d757 Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Sun, 31 May 2026 21:34:14 -0500 Subject: [PATCH 3/4] Add .gitattributes to pin shell scripts to LF Guarantees scripts/install.sh stays LF-terminated regardless of core.autocrlf, so the Linux/macOS curl installer's shebang never breaks; PowerShell scripts pinned CRLF. Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitattributes | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..7696c6c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +# Normalize line endings. Git stores LF; checkout is native unless pinned below. +* text=auto + +# Shell scripts run on Linux/macOS — they must be LF, even when checked out on Windows. +*.sh text eol=lf + +# Windows scripts are conventionally CRLF. +*.ps1 text eol=crlf +*.cmd text eol=crlf +*.bat text eol=crlf From 2770922847c2b6810b833063acf11cdd3055e063 Mon Sep 17 00:00:00 2001 From: Blake Hastings Date: Sun, 31 May 2026 22:15:58 -0500 Subject: [PATCH 4/4] feat: add MinVer versioning and release-please automation Version is derived from git tags by MinVer and read from the assembly at runtime, replacing the hardcoded CliInfo constant. release-please manages the release flow: a Release PR bumps version.txt + CHANGELOG, and merging it tags main and triggers a build that attaches self-contained binaries (win-x64, win-arm64, linux-x64, osx-arm64) to the GitHub Release. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/release-please.yml | 67 ++++++++++++++++++++++++++++ .release-please-manifest.json | 3 ++ Directory.Build.props | 17 +++++++ README.md | 41 +++++++++++++++-- release-please-config.json | 10 +++++ src/Protostar.Cli/CliInfo.cs | 19 +++++++- version.txt | 1 + 7 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/release-please.yml create mode 100644 .release-please-manifest.json create mode 100644 Directory.Build.props create mode 100644 release-please-config.json create mode 100644 version.txt diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..c32a7e5 --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,67 @@ +name: release-please + +# Release automation. On every push to main, release-please maintains a "Release PR" that bumps the +# version (from Conventional Commits) and updates the changelog. Merging that Release PR creates the +# vX.Y.Z tag and a GitHub Release; the build job below then attaches the self-contained binaries. +# MinVer reads the tag release-please creates and stamps it into the binaries — the two compose. +on: + push: + branches: [main] + +permissions: + contents: write + issues: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.rp.outputs.release_created }} + tag_name: ${{ steps.rp.outputs.tag_name }} + steps: + # Manifest-driven config (release-please-config.json + .release-please-manifest.json). + # Uses the default GITHUB_TOKEN: the build job runs in this same workflow run (gated on + # release_created), so the "GITHUB_TOKEN does not trigger downstream workflows" limitation + # does not apply and no PAT is needed. + - uses: googleapis/release-please-action@v4 + id: rp + + build: + needs: release-please + if: ${{ needs.release-please.outputs.release_created }} + strategy: + matrix: + include: + - { rid: win-x64, os: windows-latest, ext: .exe } + - { rid: win-arm64, os: windows-latest, ext: .exe } + - { rid: linux-x64, os: ubuntu-latest, ext: '' } + - { rid: osx-arm64, os: macos-latest, ext: '' } + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # MinVer needs full history + the tag release-please just created. + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: '10.0.x' + + - name: Publish + run: > + dotnet publish src/Protostar.Cli/Protostar.Cli.csproj + -c Release -r ${{ matrix.rid }} --self-contained true + -p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true + -o publish + + # Raw, uncompressed binary named exactly as scripts/install.{ps1,sh} expect to fetch from + # releases/latest/download/ : protostar-[.exe]. + - name: Stage asset + shell: bash + run: mv "publish/protostar${{ matrix.ext }}" "protostar-${{ matrix.rid }}${{ matrix.ext }}" + + - name: Upload asset to release + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "${{ needs.release-please.outputs.tag_name }}" "protostar-${{ matrix.rid }}${{ matrix.ext }}" --clobber diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..e18ee07 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..5a26311 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,17 @@ + + + + + v + + + + + + + diff --git a/README.md b/README.md index e4b4877..84ddd3d 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,9 @@ $ protostar --version 0.1.0 ``` -> Requires a published release. (The CI that builds and publishes release binaries is upcoming work.) +> The version is derived from git tags at build time (via MinVer), not hardcoded. A binary built +> from a tagged commit reports that tag (e.g. `0.1.0`); a local build from an untagged commit reports +> a pre-release like `0.1.0-alpha.0.4`. See [Releasing](#releasing). ### Already have the binary? @@ -74,14 +76,45 @@ dotnet publish src/Protostar.Cli -c Release -r win-x64 --self-contained true \ ./out/protostar install ``` +## Releasing + +Releases are automated with [release-please](https://github.com/googleapis/release-please). You never +tag by hand — you just write [Conventional Commits](https://www.conventionalcommits.org) and merge a +Release PR. + +**The version flows like this:** Conventional Commit messages (`feat:`, `fix:`, `feat!:` for +breaking) tell release-please how to bump the version. release-please keeps an open "Release PR" that +bumps `version.txt` + `CHANGELOG.md`. Merging that Release PR creates the `vMAJOR.MINOR.PATCH` tag and +a GitHub Release; [MinVer](https://github.com/adamralph/minver) reads that tag at build time and +stamps it into the binaries, which the workflow attaches to the release. + +**Day-to-day:** + +1. Open a PR for your change. Give it a [Conventional Commit](https://www.conventionalcommits.org) + title (e.g. `feat: add sync command`, `fix: handle missing config`). +2. **Squash-merge** it into `main`. The squash commit takes the PR title, so the title is what + release-please reads — keep it conventional. +3. release-please opens or updates a **Release PR** ("chore: release X.Y.Z"). Review it. +4. **Merge the Release PR** when you want to ship. That tags `main`, creates the GitHub Release, and + the `release-please` workflow builds and attaches the `win-x64`, `win-arm64`, `linux-x64`, and + `osx-arm64` binaries. The released binaries self-report the version via `protostar --version`. + +> Tags are created by release-please on `main`, so they are always reachable through history — this +> is what makes MinVer reliable regardless of squash/rebase merges. Do not tag manually. + ## Repository layout ```text protostar/ ├─ src/ -│ └─ Protostar.Cli/ # the `protostar` CLI (Spectre.Console.Cli); `install`/`uninstall` commands +│ └─ Protostar.Cli/ # the `protostar` CLI (Spectre.Console.Cli); `install`/`uninstall` commands ├─ scripts/ -│ ├─ install.ps1 # curl-able release installer (Windows) -│ └─ install.sh # curl-able release installer (Linux/macOS) +│ ├─ install.ps1 # curl-able release installer (Windows) +│ └─ install.sh # curl-able release installer (Linux/macOS) +├─ .github/workflows/ +│ └─ release-please.yml # release-please: Release PR -> tag -> build + attach binaries +├─ release-please-config.json # release-please configuration +├─ .release-please-manifest.json # release-please version tracker +├─ Directory.Build.props # MinVer git-tag versioning └─ protostar.sln ``` diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..bd2c1e3 --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", + "include-component-in-tag": false, + "packages": { + ".": { + "release-type": "simple", + "package-name": "protostar" + } + } +} diff --git a/src/Protostar.Cli/CliInfo.cs b/src/Protostar.Cli/CliInfo.cs index 1550da9..659041b 100644 --- a/src/Protostar.Cli/CliInfo.cs +++ b/src/Protostar.Cli/CliInfo.cs @@ -1,7 +1,22 @@ +using System.Reflection; + namespace Protostar.Cli; -/// Static CLI metadata. Version is surfaced via protostar --version. +/// Static CLI metadata. Version is stamped at build time by MinVer from the latest +/// reachable git tag and surfaced via protostar --version. internal static class CliInfo { - public const string Version = "0.1.0"; + public static string Version { get; } = ResolveVersion(); + + private static string ResolveVersion() + { + var info = typeof(CliInfo).Assembly + .GetCustomAttribute()? + .InformationalVersion; + if (string.IsNullOrEmpty(info)) + return "0.0.0"; + // MinVer/SourceLink append "+" build metadata; trim it for display. + var plus = info.IndexOf('+'); + return plus >= 0 ? info[..plus] : info; + } } diff --git a/version.txt b/version.txt new file mode 100644 index 0000000..77d6f4c --- /dev/null +++ b/version.txt @@ -0,0 +1 @@ +0.0.0