diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 84de521..b10c394 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,9 @@ jobs: # The installer is served over `curl | bash`, and macOS ships bash 3.2, # where a multibyte character sitting next to a $variable under `set -u` - # aborts the script. An installer that dies on its own punctuation is a + # aborts the script. The Windows one is read by Windows PowerShell 5.1, + # which takes a file without a byte order mark to be in the machine's + # legacy code page. An installer that dies on its own punctuation is a # bad first impression, so the scripts are pure ASCII and this is what # keeps them that way. `tr` rather than a grep class, because BSD grep has # no -P and the character classes differ between the two greps. @@ -51,7 +53,7 @@ jobs: run: | set -euo pipefail status=0 - for f in scripts/*.sh examples/*/*.sh; do + for f in scripts/*.sh scripts/*.ps1 examples/*/*.sh; do n="$(tr -d '\000-\177' < "$f" | wc -c | tr -d ' ')" if [ "$n" != "0" ]; then echo "$f carries $n bytes outside ASCII" >&2 @@ -167,3 +169,125 @@ jobs: sk="$("$bin" key generate --hex)" NOSTR_SECRET_KEY="$sk" "$bin" event -c "windows check" | "$bin" verify echo "deed runs on Windows" + + # The Windows installer, end to end against the newest published release, + # under both PowerShells: pwsh, and Windows PowerShell 5.1, which is the one + # every Windows machine has and the one whose syntax the script keeps to. + install-windows: + strategy: + fail-fast: false + matrix: + shell: [pwsh, powershell] + runs-on: windows-latest + timeout-minutes: 15 + defaults: + run: + shell: ${{ matrix.shell }} + steps: + - uses: actions/checkout@v4 + + # The newest PUBLISHED release, not the newest tag, as the check for + # install.sh asks: a tag whose release is still a draft has nothing a + # reader could download. + - name: Find the newest published release + id: release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + tag="$(gh release list --repo "$GITHUB_REPOSITORY" --exclude-drafts --exclude-pre-releases --limit 1 --json tagName --jq '.[0].tagName // empty')" + if [ -z "$tag" ]; then + echo "no release tag exists yet, so the installer was NOT exercised" >&2 + exit 1 + fi + echo "tag=$tag" >> "$GITHUB_OUTPUT" + + - name: The installer parses + run: | + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile("$PWD\scripts\install.ps1", [ref]$null, [ref]$errors) | Out-Null + if ($errors) { $errors | ForEach-Object { Write-Host $_ }; exit 1 } + Write-Host "install.ps1 parses under PowerShell $($PSVersionTable.PSVersion)" + + # The way the README runs it: the script's text handed to + # Invoke-Expression, which is all `irm | iex` does, with the options in + # the environment because a piped run has no command line. The text comes + # from this checkout rather than from main, so it is the change under + # test that runs. + - name: The piped installer installs a release, and it runs + env: + DEED_VERSION: ${{ steps.release.outputs.tag }} + TAG: ${{ steps.release.outputs.tag }} + run: | + $prefix = Join-Path $env:RUNNER_TEMP 'deed-piped' + $env:DEED_PREFIX = $prefix + Get-Content -Raw scripts/install.ps1 | Invoke-Expression + $bin = Join-Path $prefix 'deed.exe' + if (-not (Test-Path -LiteralPath $bin)) { Write-Host "the installer reported success and installed nothing"; exit 1 } + $got = & $bin version + $want = "deed $($env:TAG.Substring(1))" + if ($got -ne $want) { Write-Host "installed binary says '$got', expected '$want'"; exit 1 } + Write-Host "installed $got" + + # The same, run as a file with options on its command line. It runs twice, + # because installing over an existing copy is how an upgrade happens, and + # the second run must not add the folder to PATH a second time. + - name: The installer run as a file installs a release, and PATH gets one entry + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + $prefix = Join-Path $env:RUNNER_TEMP 'deed' + foreach ($run in 1, 2) { + & .\scripts\install.ps1 -Version $env:TAG -Prefix $prefix + if ($LASTEXITCODE -ne 0) { Write-Host "run $run of the installer failed"; exit 1 } + } + $got = & (Join-Path $prefix 'deed.exe') version + $want = "deed $($env:TAG.Substring(1))" + if ($got -ne $want) { Write-Host "installed binary says '$got', expected '$want'"; exit 1 } + $user = [Environment]::GetEnvironmentVariable('Path', 'User') + $entries = @($user -split ';' | Where-Object { $_.TrimEnd('\') -eq $prefix.TrimEnd('\') }) + if ($entries.Count -ne 1) { Write-Host "the user PATH carries $prefix $($entries.Count) times, expected once"; exit 1 } + "bin=$(Join-Path $prefix 'deed.exe')" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding ascii + Write-Host "installed $got, on PATH once" + + # A real gesture rather than just `version`: libsecp256k1, the bech32 + # encoder and the JSON writer and reader in one line, as the check for + # install.sh does. In bash, so the pipe between the two deeds carries + # bytes and not the re-encoded text PowerShell would make of it. + - name: The installed deed signs and verifies + shell: bash + run: | + set -euo pipefail + bin="$(cygpath -u "$bin")" + sk="$("$bin" key generate --hex)" + "$bin" event -c "installer check" --sec "$sk" | "$bin" verify + echo "the installed deed works" + + # The guard above has to be able to fail. A tampered archive must be + # refused and must install nothing, because a checksum that is checked and + # then ignored is worse than none: it reads as a promise. The untampered + # archive goes through first, so a refusal means the digest and not the + # -Archive path. + - name: A download that does not verify is refused + env: + TAG: ${{ steps.release.outputs.tag }} + run: | + $work = Join-Path $env:RUNNER_TEMP 'tamper' + New-Item -ItemType Directory -Force -Path $work | Out-Null + $asset = "deed-$($env:TAG.Substring(1))-windows-x86_64.zip" + $base = "https://github.com/$env:GITHUB_REPOSITORY/releases/download/$env:TAG" + $zip = Join-Path $work $asset + Invoke-WebRequest -Uri "$base/$asset" -OutFile $zip -UseBasicParsing + Invoke-WebRequest -Uri "$base/$asset.sha256" -OutFile "$zip.sha256" -UseBasicParsing + + & .\scripts\install.ps1 -Archive $zip -Prefix (Join-Path $work 'ok') + if ($LASTEXITCODE -ne 0) { Write-Host "the installer refused the archive as published"; exit 1 } + + [IO.File]::AppendAllText($zip, 'x') + $out = Join-Path $work 'out' + & .\scripts\install.ps1 -Archive $zip -Prefix $out + if ($LASTEXITCODE -eq 0) { Write-Host "the installer accepted a tampered archive"; exit 1 } + if (Test-Path -LiteralPath (Join-Path $out 'deed.exe')) { Write-Host "it refused and installed anyway"; exit 1 } + Write-Host "a tampered archive is refused and nothing is installed" + exit 0 diff --git a/AGENTS.md b/AGENTS.md index 842fe61..de6d71f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ src/ storepath.zig # opening the store a --store path names, and saying why one will not open testrelay.zig # a websocket relay on loopback, for tests only bench/ # the benchmark script and its relay -scripts/ # the one-line installer (pure ASCII, CI checks it) +scripts/ # the one-line installers, install.sh and install.ps1 (pure ASCII, CI checks both) skills/deed/ # the skill for agents that operate deed ``` diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 6319020..f62c97d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -105,7 +105,7 @@ Two details matter when adding a test that dials. Use `testrelay.DialAllocator` `bench/` is not a test suite. `bench/run.py` measures size, startup, memory, signing and verifying throughput, store speed, and relays over loopback against a build, using `bench/relay.py` as the relay. [`BENCHMARKS.md`](BENCHMARKS.md) has the numbers and how to reproduce them. -CI (`.github/workflows/ci.yml`) builds, tests and checks formatting on Linux and macOS, builds and tests on Windows, checks that the installer script is pure ASCII and parses, and installs the latest release and runs it. The release workflow (`release.yml`) builds every target from the tag, publishes each as a draft with a SHA-256 beside it, downloads the artifacts back and runs them on matching runners, and lifts the draft only after every check passes. +CI (`.github/workflows/ci.yml`) builds, tests and checks formatting on Linux and macOS, builds and tests on Windows, checks that the installer scripts are pure ASCII and parse, and installs the latest release and runs it, with install.sh on Linux and macOS and with install.ps1 on Windows under both pwsh and Windows PowerShell 5.1. The release workflow (`release.yml`) builds every target from the tag, publishes each as a draft with a SHA-256 beside it, downloads the artifacts back and runs them on matching runners, and lifts the draft only after every check passes. ## Where to start reading diff --git a/README.md b/README.md index fa61ae6..eb8add4 100644 --- a/README.md +++ b/README.md @@ -6,57 +6,23 @@ A deed is two things at once: a signed instrument, and a thing done. So is a nos ## Install -```sh -curl -fsSL https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.sh | bash -``` - -For macOS and Linux, Intel and ARM; Windows is [below](#windows). It works out which build this machine wants, checks the download against the SHA-256 published beside it, and installs into `~/.local/bin`, so nothing needs root and nothing lands outside your home directory. If the digest does not match, it installs nothing and says so. - -`--prefix ` puts it somewhere else, `--version ` installs a particular release, and `--archive ` installs from a tarball you already have, which still wants its `.sha256` beside it. Pass `--help` for the list. - -### By hand - -The script is short and worth reading before you pipe anything into bash. If you would rather do it yourself: +macOS and Linux: ```sh -VERSION=0.4.1 -PLATFORM=macos-aarch64 # or macos-x86_64, linux-x86_64, linux-aarch64 -BASE=https://github.com/zig-nostr/deed/releases/download/v$VERSION - -curl -LO $BASE/deed-$VERSION-$PLATFORM.tar.gz -curl -LO $BASE/deed-$VERSION-$PLATFORM.tar.gz.sha256 -shasum -a 256 -c deed-$VERSION-$PLATFORM.tar.gz.sha256 # sha256sum -c on Linux -tar -xzf deed-$VERSION-$PLATFORM.tar.gz -sudo mv deed /usr/local/bin/ +curl -fsSL https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.sh | bash ``` -The Linux builds are statically linked, so there is no glibc version to satisfy. - -The macOS builds are ad-hoc signed and not notarized. A copy that arrived through a browser carries a quarantine flag, so clear it once: +Windows, in PowerShell: -```sh -xattr -dr com.apple.quarantine deed +```powershell +irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1 | iex ``` -### Windows +Each checks the download against the SHA-256 published beside it, and if the digest does not match it installs nothing and says so. On macOS and Linux deed goes into `~/.local/bin`. On Windows it goes into `%LOCALAPPDATA%\deed`, which is added to your `PATH`, so `deed version` works in a new terminal. Neither needs root or administrator rights. -Windows takes a zip rather than the script. Download `deed--windows-x86_64.zip` and the `.sha256` beside it from the [releases page](https://github.com/zig-nostr/deed/releases), then in PowerShell, from the folder they are in: - -```powershell -$zip = (Get-Item deed-*-windows-*.zip).Name -$want = (Get-Content "$zip.sha256").Split(' ')[0] -$got = (Get-FileHash $zip -Algorithm SHA256).Hash -if ($got -ne $want) { throw "the SHA-256 does not match, do not run this" } - -$dir = "$env:LOCALAPPDATA\deed" -Expand-Archive $zip -DestinationPath $dir -Force -$user = [Environment]::GetEnvironmentVariable('Path', 'User') -if (($user -split ';') -notcontains $dir) { - [Environment]::SetEnvironmentVariable('Path', "$user;$dir".TrimStart(';'), 'User') -} -``` +`--version ` installs a particular release, `--prefix ` puts it somewhere else, and `--archive ` installs from an archive you already have, which still wants its `.sha256` beside it. `--help` lists them. On Windows they are `-Version`, `-Prefix`, `-Archive` and `-Help` when you run the script as a file, and `$env:DEED_VERSION`, `$env:DEED_PREFIX` and `$env:DEED_ARCHIVE` for the piped line. -That checks the download, unpacks `deed.exe`, with the `LICENSE` and `README.md`, into `%LOCALAPPDATA%\deed`, and puts that folder on your `PATH` unless it is there already, so the same lines install a newer version over an older one. Keep only the zip you mean to install in the folder. Open a new terminal and `deed version` confirms it. PowerShell compares the two digests without regard to case, so the capitals `Get-FileHash` prints are fine. The binary is not code-signed, so Windows may show a warning the first time it runs. A store made with `--store` takes 1 GiB of disk on Windows from the first run, because LMDB sizes the file to its whole map up front there; on macOS and Linux it grows with what is kept. deed reads a leading `~` in a `--store` path from `HOME`, which Windows does not usually set, so give a full path there. +On Windows on ARM the installer puts the x86_64 build in place, which Windows 11 runs under emulation. The Windows binary is not code-signed, so Windows may show a warning the first time it runs. A store made with `--store` takes 1 GiB of disk on Windows from the first run, because LMDB sizes the file to its whole map up front there; on macOS and Linux it grows with what is kept. deed reads a leading `~` in a `--store` path from `HOME`, which Windows does not usually set, so give a full path there. ## What it does diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..8476005 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,360 @@ +# +# deed - one-line installer for Windows. +# +# irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1 | iex +# +# The PowerShell counterpart of install.sh. Downloads the Windows release build, +# verifies it against the SHA-256 published beside it, unpacks it into +# %LOCALAPPDATA%\deed and adds that folder to the user PATH. No administrator +# rights, nothing outside your own profile, and nothing is installed that did +# not verify. +# +# Options, when the script runs as a file: +# +# .\install.ps1 -Version v0.4.1 -Prefix C:\tools\deed +# +# A piped run has no command line to put them on, so it reads the same options +# from the environment: +# +# $env:DEED_VERSION = 'v0.4.1' +# irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1 | iex +# +# or takes them when the download is run as a script block: +# +# & ([scriptblock]::Create((irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1))) -Version v0.4.1 +# +# An option given on the command line wins over the environment. +# +# There is no param() block at the top, on purpose. `irm | iex` hands this file +# to Invoke-Expression as one string, where a top-level param() is a parameter +# list for nothing. Everything runs inside one script block instead, called with +# whatever the file was called with. That also keeps the helpers and variables +# below out of the session a piped run executes in, which is the reader's own. +# +# This file is deliberately pure ASCII. Windows PowerShell 5.1 reads a script +# that has no byte order mark in the machine's legacy code page, so any other +# character arrives as something else. CI fails on any byte above 0x7F in here. +# For the same reason of reach it keeps to syntax 5.1 understands: that is the +# PowerShell every Windows machine has. +# +& { + [CmdletBinding()] + param( + [string]$Version, + [string]$Prefix, + [string]$Archive, + [switch]$Help + ) + + # Scoped to this block, so a piped run leaves the session's own settings as + # they were. + $ErrorActionPreference = 'Stop' + # Windows PowerShell 5.1 redraws its progress bar for every chunk a download + # receives, which turns a two-megabyte download into a long wait. + $ProgressPreference = 'SilentlyContinue' + + if (-not $Version) { $Version = $env:DEED_VERSION } + if (-not $Prefix) { $Prefix = $env:DEED_PREFIX } + if (-not $Archive) { $Archive = $env:DEED_ARCHIVE } + + $repo = 'zig-nostr/deed' + + # A file run can end with an exit status. A piped run executes inside the + # reader's own session, where `exit` would close their terminal, so there a + # failure is raised as an error instead. That still stops the run, and still + # makes `powershell -Command` exit non-zero. + $fromFile = [bool]$PSCommandPath + + # A hashtable rather than a variable, so the functions below can record the + # temporary directory where the cleanup at the end can see it. + $state = @{ Work = $null } + + $usage = @' +deed installer for Windows + + irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1 | iex + .\install.ps1 [options] + +Options: + -Version install this release instead of the latest + -Prefix install into (default: %LOCALAPPDATA%\deed) + -Archive install from a zip already on disk, skipping the + download. Its .sha256 is still required, beside it. + -Help this text + +A piped run reads the same options from $env:DEED_VERSION, +$env:DEED_PREFIX and $env:DEED_ARCHIVE. + +Installs deed.exe into and adds to your user PATH. +Nothing needs administrator rights. +'@ + + function Say([string]$msg) { Write-Host "==> $msg" } + function Warn([string]$msg) { Write-Host "note: $msg" } + function Fail([string]$msg) { throw $msg } + + # A path as the reader meant it: relative to where they are, and allowed + # not to exist yet. + function Resolve-UserPath([string]$path) { + $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($path) + } + + # The HTTP status behind a failed request, or 0 when there was no answer at + # all. 5.1 and 7 raise different exception types, and both carry the status + # on .Response. + function Get-Status($err) { + $resp = $err.Exception.Response + if ($resp -and $resp.StatusCode) { return [int]$resp.StatusCode } + return 0 + } + + # Which build this machine wants. + # + # There is no ARM build for Windows. Windows 11 on ARM runs x86_64 programs + # under its own emulation, and the x86_64 build is the one every release is + # checked on, so ARM64 machines get that. + function Get-Platform { + if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) { + Fail 'this script installs the Windows build. On macOS and Linux, run: curl -fsSL https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.sh | bash' + } + # A 32-bit PowerShell on a 64-bit Windows reports x86 here and the real + # architecture in PROCESSOR_ARCHITEW6432. + $arch = $env:PROCESSOR_ARCHITEW6432 + if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE } + switch ($arch) { + 'AMD64' { return 'windows-x86_64' } + 'ARM64' { return 'windows-x86_64' } + default { Fail "no deed build for '$arch'. The published Windows build is x86_64, which also runs on ARM64." } + } + } + + # The tag of the newest published release, from the same API answer + # install.sh reads. + function Get-LatestTag { + $api = "https://api.github.com/repos/$repo/releases/latest" + try { + $release = Invoke-RestMethod -Uri $api -UseBasicParsing + } catch { + $code = Get-Status $_ + switch ($code) { + 0 { Fail 'could not reach GitHub. Check your connection and try again.' } + 403 { Fail 'GitHub rate-limited this machine. Wait a few minutes, or name a release with -Version or $env:DEED_VERSION.' } + 429 { Fail 'GitHub rate-limited this machine. Wait a few minutes, or name a release with -Version or $env:DEED_VERSION.' } + 404 { Fail "no published release found for $repo." } + default { Fail "GitHub answered $code." } + } + } + $tag = [string]$release.tag_name + if (-not $tag) { Fail "could not read the release tag from GitHub's answer." } + return $tag + } + + # Verifies a zip against the digest published beside it. + # + # The digest is read from the .sha256 file that sits next to the artifact, + # not out of an API response, so there is no list to pick the wrong entry + # out of. + function Assert-Digest([string]$file, [string]$sidecar, [string]$name) { + $text = Get-Content -Raw -LiteralPath $sidecar + $want = @(([string]$text).Trim() -split '\s+' | Where-Object { $_ }) + # An empty expected digest compares equal to an empty computed one, and + # the check then reports success over nothing at all. Both sides have to + # exist before either is trusted. + if ($want.Count -eq 0) { Fail "the published SHA-256 for $name is empty. Not installing it." } + $got = (Get-FileHash -LiteralPath $file -Algorithm SHA256).Hash + if (-not $got) { Fail 'could not compute the SHA-256 of the download. Not installing it.' } + # Get-FileHash prints capitals and the .sha256 files carry lower case. + if ($want[0].ToLowerInvariant() -ne $got.ToLowerInvariant()) { + Fail "$name does not match its published SHA-256. Not installing it." + } + } + + # What `deed version` prints, or nothing when the binary will not run. A + # wrong-architecture binary throws rather than exiting, so both are caught. + function Get-Reported([string]$exe) { + $ErrorActionPreference = 'Continue' + try { + $out = & $exe version 2>$null + if ($LASTEXITCODE -ne 0) { return $null } + return [string]($out | Select-Object -First 1) + } catch { + return $null + } + } + + # Adds a folder to the user PATH unless it is there already. Returns whether + # it changed anything. + # + # The registry value is read and written as it is stored. The obvious + # [Environment]::GetEnvironmentVariable('Path', 'User') returns it with every + # %VARIABLE% already expanded, and writing that back replaces each reference + # with what it happens to mean today, and turns the value into a plain + # string. + function Add-UserPath([string]$dir) { + $want = $dir.TrimEnd('\') + $key = [Microsoft.Win32.Registry]::CurrentUser.CreateSubKey('Environment') + try { + $raw = [string]$key.GetValue('Path', '', [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + foreach ($entry in ($raw -split ';')) { + $have = [Environment]::ExpandEnvironmentVariables($entry.Trim()).TrimEnd('\') + # -eq ignores case, as Windows paths do. + if ($have -and $have -eq $want) { return $false } + } + $kind = [Microsoft.Win32.RegistryValueKind]::ExpandString + if ($key.GetValueNames() -contains 'Path') { + $existing = $key.GetValueKind('Path') + if ($existing -eq [Microsoft.Win32.RegistryValueKind]::String) { $kind = $existing } + } + $new = $dir + if ($raw.Trim()) { $new = $raw.TrimEnd(';') + ';' + $dir } + $key.SetValue('Path', $new, $kind) + } finally { + $key.Close() + } + # Writing the registry tells nobody. SetEnvironmentVariable announces the + # change to running programs, so Explorer, and the terminals it opens, + # pick up the new PATH. A throwaway variable is set and removed to make + # that announcement. + [Environment]::SetEnvironmentVariable('DEED_INSTALL_PATH_REFRESH', '1', 'User') + [Environment]::SetEnvironmentVariable('DEED_INSTALL_PATH_REFRESH', $null, 'User') + return $true + } + + function Install-Deed { + $platform = Get-Platform + + # Windows PowerShell 5.1 on an older .NET offers only TLS 1.0 and 1.1 + # unless told otherwise, and GitHub accepts neither. SystemDefault (0) + # already lets the system choose, which includes 1.2. + if ($PSVersionTable.PSVersion.Major -lt 6) { + $current = [int][Net.ServicePointManager]::SecurityProtocol + if ($current -ne 0 -and ($current -band [int][Net.SecurityProtocolType]::Tls12) -eq 0) { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } + } + + if ($Prefix) { + $dest = Resolve-UserPath $Prefix + } elseif ($env:LOCALAPPDATA) { + $dest = Join-Path $env:LOCALAPPDATA 'deed' + } else { + Fail 'LOCALAPPDATA is not set, so there is no default place to install. Pass -Prefix.' + } + + $work = Join-Path ([IO.Path]::GetTempPath()) ('deed-install-' + [guid]::NewGuid().ToString('N')) + try { + New-Item -ItemType Directory -Path $work | Out-Null + } catch { + Fail 'could not make a temporary directory.' + } + $state.Work = $work + $zip = Join-Path $work 'deed.zip' + + if ($Archive) { + $file = Resolve-UserPath $Archive + $name = Split-Path -Leaf $file + if (-not (Test-Path -LiteralPath $file -PathType Leaf)) { Fail "no such file: $Archive" } + if (-not (Test-Path -LiteralPath "$file.sha256" -PathType Leaf)) { Fail "$Archive.sha256 is missing. Download it beside the zip." } + # The copy is what gets verified and unpacked, so the bytes checked + # are the bytes installed. + Copy-Item -LiteralPath $file -Destination $zip + Say "Verifying $name..." + Assert-Digest $zip "$file.sha256" $name + } else { + $tag = $Version + if (-not $tag) { $tag = Get-LatestTag } + if ($tag -cnotlike 'v*') { Fail "a release tag looks like v0.1.0. Got '$tag'." } + $name = "deed-$($tag.Substring(1))-$platform.zip" + $url = "https://github.com/$repo/releases/download/$tag/$name" + + Say "Downloading $name..." + try { + Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing + } catch { + Fail "download failed. There may be no $platform build for $tag." + } + + # Required, not best effort. A verification step that any transient + # failure switches off is not a verification step, and every + # published release has one of these beside every artifact. + $fetched = $false + for ($i = 0; $i -lt 3 -and -not $fetched; $i++) { + try { + Invoke-WebRequest -Uri "$url.sha256" -OutFile "$zip.sha256" -UseBasicParsing + $fetched = $true + } catch { + Start-Sleep -Seconds 1 + } + } + if (-not $fetched) { Fail "could not fetch the published SHA-256 for $name, so the download cannot be verified. Not installing it." } + + Say 'Verifying...' + Assert-Digest $zip "$zip.sha256" $name + } + Say 'SHA-256 verified.' + + $unpacked = Join-Path $work 'unpacked' + try { + Add-Type -AssemblyName System.IO.Compression.FileSystem + [IO.Compression.ZipFile]::ExtractToDirectory($zip, $unpacked) + } catch { + Fail 'the archive could not be unpacked. The download may be incomplete.' + } + $exe = Join-Path $unpacked 'deed.exe' + if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { Fail 'the archive did not contain deed.exe.' } + + # Run it before it replaces anything. An install that reports success + # without ever executing the binary is how a wrong-architecture build, + # or an archive holding only a licence, gets called a success, and + # checking first leaves a working older copy in place. + $reported = Get-Reported $exe + if (-not $reported) { Fail 'deed.exe will not run on this machine. Nothing was installed.' } + + Say "Installing into $dest..." + try { + New-Item -ItemType Directory -Force -Path $dest | Out-Null + } catch { + Fail "could not create $dest." + } + try { + Get-ChildItem -LiteralPath $unpacked -File | Copy-Item -Destination $dest -Force + } catch { + Fail "could not write to $dest. If deed is running, close it and try again, or pass -Prefix to install somewhere you own." + } + Say "Installed $reported to $(Join-Path $dest 'deed.exe')" + + try { + if (Add-UserPath $dest) { + Say "Added $dest to your user PATH." + } else { + Say "$dest is already on your user PATH." + } + } catch { + Warn "could not add $dest to your user PATH: $($_.Exception.Message) Add it yourself, or run deed from there." + return + } + Say 'Open a new terminal and run: deed version' + } + + $failure = $null + try { + if ($Help) { + Write-Host $usage + } else { + Install-Deed + } + } catch { + $failure = $_.Exception.Message + } finally { + if ($state.Work) { Remove-Item -LiteralPath $state.Work -Recurse -Force -ErrorAction SilentlyContinue } + } + + if ($failure) { + if ($fromFile) { + [Console]::Error.WriteLine("error: $failure") + exit 1 + } + $record = New-Object Management.Automation.ErrorRecord (New-Object Exception "error: $failure"), 'DeedInstallFailed', ([Management.Automation.ErrorCategory]::NotSpecified), $null + $PSCmdlet.ThrowTerminatingError($record) + } +} @args diff --git a/scripts/install.sh b/scripts/install.sh index 1b51ae2..86b0065 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -10,7 +10,7 @@ # # One script for both systems rather than two, because deed publishes the same # thing four times for them: a tar.gz holding one static binary, named for the -# platform. Windows builds are zips and are installed by hand, see the README. +# platform. Windows builds are zips, installed by scripts/install.ps1. # Two scripts would share every line that matters and drift in the ones that do # not. # @@ -87,7 +87,7 @@ detect() { case "$os" in darwin) os=macos ;; linux) os=linux ;; - mingw*|msys*|cygwin*) die "this script installs the macOS and Linux builds. On Windows, download the .zip from https://github.com/$repo/releases and follow the README." ;; + mingw*|msys*|cygwin*) die "this script installs the macOS and Linux builds. On Windows, run this in PowerShell: irm https://raw.githubusercontent.com/$repo/main/scripts/install.ps1 | iex" ;; *) die "deed publishes macOS, Linux and Windows builds. This machine reports '$os'." ;; esac diff --git a/skills/deed/SKILL.md b/skills/deed/SKILL.md index a312ac6..3c15e3d 100644 --- a/skills/deed/SKILL.md +++ b/skills/deed/SKILL.md @@ -9,11 +9,19 @@ A fast command line for nostr. One binary, on macOS, Linux and Windows. Every co ## Install +macOS and Linux: + ```sh curl -fsSL https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.sh | bash ``` -Installs into `~/.local/bin` after checking the download's SHA-256. That is for macOS and Linux; on Windows, download the `.zip` from the releases page and put `deed.exe` on `PATH`. `deed version` confirms it. `deed help ` prints the exact usage of any command; check it before guessing a flag. +Windows, in PowerShell: + +```powershell +irm https://raw.githubusercontent.com/zig-nostr/deed/main/scripts/install.ps1 | iex +``` + +Both check the download's SHA-256 before installing. The first installs into `~/.local/bin`; the second into `%LOCALAPPDATA%\deed`, which it adds to `PATH`, so on Windows the new `deed` is found in a new terminal, not the one that ran the installer. `deed version` confirms it. `deed help ` prints the exact usage of any command; check it before guessing a flag. ## Before you act