fix: give Windows a PowerShell installer instead of curl | sh

e0ab5876eb18 · AtlantisPleb · · parent f90390976dc3

fix: give Windows a PowerShell installer instead of curl | sh

PowerShell has no sh, and its curl is Invoke-WebRequest, so the homepage
command failed twice. Windows now copies irm https://openagents.com/install.ps1 | iex.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

pushed
by user · WAL seq 479 · 2026-08-28T15:37:26.798777Z
built
13 modules in 69.5 s
deployed
live · 13 modules on 3 nodes · push→live —
deployed
needs_rolling_replace · 13 modules on 0 nodes · push→live —

Changed files

  • modified assets/js/coder_copy.js
  • modified config/config.exs
  • modified lib/openagents_web.ex
  • added lib/openagents_web/cli_install.ex
  • modified lib/openagents_web/components/landing.ex
  • modified lib/openagents_web/live/coder_live.ex
  • modified lib/openagents_web/live/components_live.ex
  • modified lib/openagents_web/live/home_live.ex
  • modified lib/openagents_web/live/repository_index_live.ex
  • modified priv/docs/install-cli.md
  • modified priv/docs/openagents-cli.md
  • added priv/static/install.ps1
  • modified priv/static/install.sh
  • modified test/openagents_web/controllers/page_controller_test.exs
  • modified test/openagents_web/docs_catalog_test.exs
  • modified test/openagents_web/install_script_test.exs
  • modified test/openagents_web/live/coder_live_test.exs

Diff

17 files changed, +239 -15

assets/js/coder_copy.js modified +11

@@ -1,9 +1,20 @@

1 1
const CoderCopy = {
2 2
  mounted() {
3
    this.applyShell()
3 4
    this.onClick = () => this.copy()
4 5
    this.el.addEventListener("click", this.onClick)
5 6
  },
6 7
8
  applyShell() {
9
    const windowsCommand = this.el.dataset.windowsCommand
10
    if (!windowsCommand) return
11
    const platform = navigator.userAgentData?.platform || navigator.platform || ""
12
    if (!/Win/i.test(platform)) return
13
    this.el.dataset.copyText = windowsCommand
14
    const hintEl = document.getElementById("copy-hint")
15
    if (hintEl) hintEl.textContent = " " + windowsCommand + " "
16
  },
17
7 18
  destroyed() {
8 19
    this.el.removeEventListener("click", this.onClick)
9 20
  },
config/config.exs modified +6

@@ -7,6 +7,12 @@

7 7
# General application configuration
8 8
import Config
9 9
10
# `irm | iex` needs a text body. Without this, Plug.Static would serve
11
# `install.ps1` as application/octet-stream.
12
config :mime, :types, %{
13
  "text/plain" => ["ps1"]
14
}
15
10 16
config :phoenix,
11 17
  filter_parameters: [
12 18
    "authorization",
lib/openagents_web.ex modified +1 -1

@@ -19,7 +19,7 @@ defmodule OpenAgentsWeb do

19 19
20 20
  def static_paths,
21 21
    do:
22
      ~w(assets fonts images favicon.ico favicon-32x32.png favicon-16x16.png apple-touch-icon.png robots.txt install.sh)
22
      ~w(assets fonts images favicon.ico favicon-32x32.png favicon-16x16.png apple-touch-icon.png robots.txt install.sh install.ps1)
23 23
24 24
  @doc """
25 25
  Prefixes for static files that are served under a digested name.
lib/openagents_web/cli_install.ex added +15

@@ -0,0 +1,15 @@

1
defmodule OpenAgentsWeb.CliInstall do
2
  @moduledoc """
3
  The published install command for each shell.
4
5
  Unix readers pipe `install.sh` into `sh`. Windows PowerShell has no `sh`,
6
  and its `curl` is `Invoke-WebRequest`, which does not accept `-fsSL`.
7
  Windows readers run `install.ps1` through `irm | iex`.
8
  """
9
10
  @unix "curl -fsSL https://openagents.com/install.sh | sh"
11
  @windows "irm https://openagents.com/install.ps1 | iex"
12
13
  def unix, do: @unix
14
  def windows, do: @windows
15
end
lib/openagents_web/components/landing.ex modified +21 -1

@@ -231,7 +231,9 @@ defmodule OpenAgentsWeb.UI.Landing do

231 231
  """
232 232
  attr :id, :string, required: true
233 233
  attr :command, :string, required: true
234
  attr :windows_command, :string, default: nil
234 235
  attr :prompt, :string, default: "$", doc: "the shell prompt drawn before the command"
236
  attr :windows_prompt, :string, default: "PS>"
235 237
  attr :label, :string, default: "Copy the install command"
236 238
  attr :class, :any, default: nil
237 239

@@ -244,11 +246,15 @@ defmodule OpenAgentsWeb.UI.Landing do

244 246
        class="install-command__button"
245 247
        data-copied="false"
246 248
        data-copy-text={@command}
249
        data-unix-command={@command}
250
        data-windows-command={@windows_command}
251
        data-unix-prompt={@prompt}
252
        data-windows-prompt={@windows_prompt}
247 253
        aria-label={@label}
248 254
        phx-hook=".CopyCommand"
249 255
      >
250 256
        <code class="install-command__text">
251
          <span class="install-command__prompt" aria-hidden="true">{@prompt}</span>{@command}
257
          <span class="install-command__prompt" aria-hidden="true">{@prompt}</span><span class="install-command__body">{@command}</span>
252 258
        </code>
253 259
        <span class="install-command__glyph" aria-hidden="true">
254 260
          <UI.icon name="copy" class="install-command__idle" />

@@ -259,6 +265,7 @@ defmodule OpenAgentsWeb.UI.Landing do

259 265
    <script :type={Phoenix.LiveView.ColocatedHook} name=".CopyCommand">
260 266
      export default {
261 267
        mounted() {
268
          this.applyShell()
262 269
          this.el.addEventListener("click", async () => {
263 270
            try {
264 271
              await navigator.clipboard.writeText(this.el.dataset.copyText)

@@ -272,6 +279,19 @@ defmodule OpenAgentsWeb.UI.Landing do

272 279
            }, 1600)
273 280
          })
274 281
        },
282
        applyShell() {
283
          const windowsCommand = this.el.dataset.windowsCommand
284
          if (!windowsCommand) return
285
          const platform = navigator.userAgentData?.platform || navigator.platform || ""
286
          if (!/Win/i.test(platform)) return
287
          const command = windowsCommand
288
          const prompt = this.el.dataset.windowsPrompt || "PS>"
289
          this.el.dataset.copyText = command
290
          const promptEl = this.el.querySelector(".install-command__prompt")
291
          const bodyEl = this.el.querySelector(".install-command__body")
292
          if (promptEl) promptEl.textContent = prompt
293
          if (bodyEl) bodyEl.textContent = command
294
        },
275 295
        destroyed() {
276 296
          clearTimeout(this.resetTimer)
277 297
        }
lib/openagents_web/live/coder_live.ex modified +5 -1

@@ -25,7 +25,8 @@ defmodule OpenAgentsWeb.CoderLive do

25 25
  """
26 26
  use OpenAgentsWeb, :live_view
27 27
28
  @cmd "curl -fsSL https://openagents.com/install.sh | sh"
28
  @cmd OpenAgentsWeb.CliInstall.unix()
29
  @windows_cmd OpenAgentsWeb.CliInstall.windows()
29 30
30 31
  # Three spaces between the frame and the hint, and the hint carries one space
31 32
  # of its own on each side, which is the gap the copy confirmation replaces.

@@ -100,6 +101,7 @@ defmodule OpenAgentsWeb.CoderLive do

100 101
     socket
101 102
     |> assign(:page_title, "OpenAgents Coder")
102 103
     |> assign(:cmd, @cmd)
104
     |> assign(:windows_cmd, @windows_cmd)
103 105
     |> assign(:frame, @frame)}
104 106
  end
105 107

@@ -110,6 +112,8 @@ defmodule OpenAgentsWeb.CoderLive do

110 112
      id="coder-viewport"
111 113
      phx-hook="CoderCopy"
112 114
      data-copy-text={@cmd}
115
      data-unix-command={@cmd}
116
      data-windows-command={@windows_cmd}
113 117
      class="min-h-screen w-full flex flex-col items-center justify-center p-4 select-none cursor-pointer"
114 118
      style="background-color: var(--background0, #000); color: var(--foreground0, #fff); font-family: var(--font-family, monospace); -webkit-user-select: none; user-select: none;"
115 119
    >
lib/openagents_web/live/components_live.ex modified +4 -2

@@ -1676,7 +1676,8 @@ defmodule OpenAgentsWeb.ComponentsLive do

1676 1676
          <:command>
1677 1677
            <Landing.install_command
1678 1678
              id="demo-hero-install-command"
1679
              command="curl -fsSL https://openagents.com/install.sh | sh"
1679
              command={OpenAgentsWeb.CliInstall.unix()}
1680
              windows_command={OpenAgentsWeb.CliInstall.windows()}
1680 1681
            />
1681 1682
          </:command>
1682 1683
          <:note>Every new account starts with $20 of credit.</:note>

@@ -1724,7 +1725,8 @@ defmodule OpenAgentsWeb.ComponentsLive do

1724 1725
      <div class="demo-frame flex justify-center">
1725 1726
        <Landing.install_command
1726 1727
          id="demo-install-command"
1727
          command="curl -fsSL https://openagents.com/install.sh | sh"
1728
          command={OpenAgentsWeb.CliInstall.unix()}
1729
          windows_command={OpenAgentsWeb.CliInstall.windows()}
1728 1730
        />
1729 1731
      </div>
1730 1732
    </div>
lib/openagents_web/live/home_live.ex modified +13 -7

@@ -44,11 +44,12 @@ defmodule OpenAgentsWeb.HomeLive do

44 44
  alias OpenAgentsWeb.LiveRefresh
45 45
  alias OpenAgentsWeb.UI.Landing
46 46
47
  # The one line the landing page asks a reader to run. It is the command
48
  # `priv/docs/install-cli.md` documents and the script
49
  # `priv/static/install.sh` serves, so the page cannot advertise an installer
50
  # this site does not hand out.
51
  @install_command "curl -fsSL https://openagents.com/install.sh | sh"
47
  # The one line the landing page asks a reader to run. Unix pipes
48
  # `install.sh` into `sh`. Windows PowerShell has no `sh`, so the page
49
  # swaps to `install.ps1` through `irm | iex` in the browser. Both scripts
50
  # are served from this site.
51
  @install_command OpenAgentsWeb.CliInstall.unix()
52
  @windows_install_command OpenAgentsWeb.CliInstall.windows()
52 53
53 54
  @feed_limit 8
54 55
  @project_limit 6

@@ -68,7 +69,8 @@ defmodule OpenAgentsWeb.HomeLive do

68 69
      {:ok,
69 70
       socket
70 71
       |> assign(:device_user_code, device_user_code(params))
71
       |> assign(:install_command, @install_command)}
72
       |> assign(:install_command, @install_command)
73
       |> assign(:windows_install_command, @windows_install_command)}
72 74
    end
73 75
  end
74 76

@@ -496,7 +498,11 @@ defmodule OpenAgentsWeb.HomeLive do

496 498
          --%>
497 499
498 500
          <:command>
499
            <Landing.install_command id="home-install-command" command={@install_command} />
501
            <Landing.install_command
502
              id="home-install-command"
503
              command={@install_command}
504
              windows_command={@windows_install_command}
505
            />
500 506
          </:command>
501 507
502 508
          <%!-- Two lines, not one sentence. The first is what the reader gets
lib/openagents_web/live/repository_index_live.ex modified +1 -1

@@ -29,7 +29,7 @@ defmodule OpenAgentsWeb.RepositoryIndexLive do

29 29
  # they are interpolated rather than written into the template, where the HEEx
30 30
  # parser would read them as tags.
31 31
  @cli_steps [
32
    %{command: "curl -fsSL https://openagents.com/install.sh | sh", note: "install"},
32
    %{command: OpenAgentsWeb.CliInstall.unix(), note: "install"},
33 33
    %{command: "openagents auth login", note: "sign in"},
34 34
    %{command: "openagents repo create <name>", note: "create"},
35 35
    %{command: "openagents repo clone <owner>/<repo>", note: "clone"},
priv/docs/install-cli.md modified +8 -1

@@ -1,11 +1,18 @@

1 1
# Install the OpenAgents CLI
2 2
3
The CLI is a single native binary. Install it with the installer script:
3
The CLI is a single native binary. On macOS and Linux:
4 4
5 5
```sh
6 6
curl -fsSL https://openagents.com/install.sh | sh
7 7
```
8 8
9
On Windows, PowerShell has no `sh`, and its `curl` is not the curl that command
10
names. Run this instead:
11
12
```powershell
13
irm https://openagents.com/install.ps1 | iex
14
```
15
9 16
The installer detects your operating system and processor, downloads the
10 17
matching build, verifies its SHA-256 checksum, and links three names —
11 18
`openagents`, `coder`, and `oa` — into `~/.openagents/bin`. It also adds that
priv/docs/openagents-cli.md modified +6

@@ -36,6 +36,12 @@ curl -fsSL https://openagents.com/install.sh | sh

36 36
openagents --help
37 37
```
38 38
39
On Windows PowerShell:
40
41
```powershell
42
irm https://openagents.com/install.ps1 | iex
43
```
44
39 45
Open a new shell first if the installer has just added `~/.openagents/bin` to
40 46
your `PATH`. `openagents --version` reports the installed build, and
41 47
`openagents update --check` says what the channel currently names without
priv/static/install.ps1 added +115

@@ -0,0 +1,115 @@

1
# OpenAgents CLI installer for Windows PowerShell.
2
# https://openagents.com/install.ps1
3
#
4
# Usage:
5
#   irm https://openagents.com/install.ps1 | iex
6
#
7
# Optional:
8
#   $env:OPENAGENTS_CHANNEL = 'stable'
9
#   $env:OPENAGENTS_VERSION = '0.1.1'
10
#   $env:OPENAGENTS_BIN_DIR = "$env:USERPROFILE\.openagents\bin"
11
#
12
# Do not `exit` from this file. `irm | iex` runs in the reader's PowerShell,
13
# and `exit` closes that window. Throw instead.
14
#
15
# The homepage used to print `curl … | sh`. PowerShell has no `sh`, and its
16
# `curl` is Invoke-WebRequest, which does not accept `-fsSL`.
17
18
$ErrorActionPreference = 'Stop'
19
20
$BaseUrl = 'https://openagents.com/releases'
21
$Channel = if ($env:OPENAGENTS_CHANNEL) { $env:OPENAGENTS_CHANNEL } else { 'stable' }
22
$HomeDir = if ($env:USERPROFILE) { $env:USERPROFILE } else { $HOME }
23
$DownloadDir = Join-Path $HomeDir '.openagents\downloads'
24
$BinDir = if ($env:OPENAGENTS_BIN_DIR) { $env:OPENAGENTS_BIN_DIR } else { Join-Path $HomeDir '.openagents\bin' }
25
26
function Test-OpenAgentsVersion([string]$Value) {
27
    return $Value -match '^[0-9]+\.[0-9]+\.[0-9]+(-[A-Za-z0-9._]+)?$'
28
}
29
30
$Version = $env:OPENAGENTS_VERSION
31
if (-not $Version) {
32
    try {
33
        $Version = (Invoke-RestMethod -Uri "$BaseUrl/$Channel").ToString().Trim()
34
    } catch {
35
        throw "Could not resolve the '$Channel' channel from $BaseUrl/$Channel."
36
    }
37
}
38
39
if (-not (Test-OpenAgentsVersion $Version)) {
40
    throw "Invalid version format: $Version (expected X.Y.Z or X.Y.Z-suffix)"
41
}
42
43
# Only windows-x86_64 is published. Windows on ARM runs that build.
44
$Platform = 'windows-x86_64'
45
$ArtifactUrl = "$BaseUrl/openagents-$Version-$Platform"
46
$SumsName = "openagents-$Version-$Platform.exe"
47
48
New-Item -ItemType Directory -Force -Path $DownloadDir, $BinDir | Out-Null
49
50
$Tmp = Join-Path $DownloadDir "openagents-$Platform.exe.tmp"
51
$Dest = Join-Path $DownloadDir "openagents-$Platform.exe"
52
53
Write-Host "Installing OpenAgents CLI v$Version ($Platform)..."
54
55
try {
56
    Invoke-WebRequest -Uri $ArtifactUrl -OutFile $Tmp -UseBasicParsing
57
} catch {
58
    if (Test-Path $Tmp) { Remove-Item -Force $Tmp }
59
    throw "Could not download $ArtifactUrl. No local fallback is used: an installer must install what it says it did."
60
}
61
62
$SumsTmp = "$Tmp.sums"
63
try {
64
    Invoke-WebRequest -Uri "$BaseUrl/SHA256SUMS-$Version" -OutFile $SumsTmp -UseBasicParsing
65
} catch {
66
    Remove-Item -Force $Tmp, $SumsTmp -ErrorAction SilentlyContinue
67
    throw "Could not download SHA256SUMS-$Version; refusing to install unverified bytes."
68
}
69
70
$Expected = $null
71
Get-Content $SumsTmp | ForEach-Object {
72
    $parts = $_ -split '\s+', 2
73
    if ($parts.Count -eq 2 -and ($parts[1] -eq $SumsName -or $parts[1] -eq "*$SumsName")) {
74
        $Expected = $parts[0].ToLowerInvariant()
75
    }
76
}
77
78
if (-not $Expected) {
79
    Remove-Item -Force $Tmp, $SumsTmp -ErrorAction SilentlyContinue
80
    throw "SHA256SUMS-$Version names no entry for $SumsName; refusing to install."
81
}
82
83
$Actual = (Get-FileHash -Algorithm SHA256 -Path $Tmp).Hash.ToLowerInvariant()
84
if ($Actual -ne $Expected) {
85
    Remove-Item -Force $Tmp, $SumsTmp -ErrorAction SilentlyContinue
86
    throw "Checksum mismatch for $SumsName.`n  expected $Expected`n  actual   $Actual"
87
}
88
89
Remove-Item -Force $SumsTmp
90
Move-Item -Force $Tmp $Dest
91
Write-Host "  Verified sha256 $Actual."
92
93
foreach ($Name in @('openagents', 'coder', 'oa')) {
94
    Copy-Item -Force $Dest (Join-Path $BinDir "$Name.exe")
95
}
96
97
$UserPath = [Environment]::GetEnvironmentVariable('Path', 'User')
98
if (-not $UserPath) { $UserPath = '' }
99
$PathParts = $UserPath -split ';' | Where-Object { $_ -ne '' }
100
if ($PathParts -notcontains $BinDir) {
101
    $NewPath = if ($UserPath) { "$BinDir;$UserPath" } else { $BinDir }
102
    [Environment]::SetEnvironmentVariable('Path', $NewPath, 'User')
103
    Write-Host "  Added $BinDir to your user PATH."
104
}
105
if ($env:Path -notlike "*$BinDir*") {
106
    $env:Path = "$BinDir;$env:Path"
107
}
108
109
Write-Host ""
110
Write-Host "OpenAgents v$Version installed."
111
Write-Host "Open a new terminal, then run: openagents --version"
112
Write-Host "Loading Coder..."
113
114
$Coder = Join-Path $BinDir 'coder.exe'
115
& $Coder
priv/static/install.sh modified +2 -1

@@ -6,7 +6,8 @@

6 6
#   curl -fsSL https://openagents.com/install.sh | sh            # latest stable
7 7
#   curl -fsSL https://openagents.com/install.sh | sh -s 0.1.0   # specific version
8 8
#
9
# Windows: run under Git for Windows / MSYS2 Bash; WSL uses the Linux binary.
9
# Windows PowerShell: irm https://openagents.com/install.ps1 | iex
10
# Git for Windows / MSYS2 Bash can still run this script; WSL uses the Linux binary.
10 11
#
11 12
# This is POSIX shell, not bash, and the difference is the point. The musl
12 13
# builds exist for Alpine above all, and Alpine ships no bash: piping this into
test/openagents_web/controllers/page_controller_test.exs modified +9

@@ -19,10 +19,19 @@ defmodule OpenAgentsWeb.PageControllerTest do

19 19
    # it from the docs run the same installer.
20 20
    assert html =~ ~s(id="home-install-command")
21 21
    assert html =~ "curl -fsSL https://openagents.com/install.sh | sh"
22
    assert html =~ "irm https://openagents.com/install.ps1 | iex"
22 23
23 24
    # `npm i -g @openagentsinc/cli` was a previous answer and is not one now:
24 25
    # there is no npm package, and a homepage that prints an install command
25 26
    # nothing publishes is worse than one that prints none.
26 27
    refute html =~ "npm i -g"
27 28
  end
29
30
  test "GET /install.ps1 serves the Windows installer", %{conn: conn} do
31
    conn = get(conn, "/install.ps1")
32
33
    assert conn.status == 200
34
    assert conn.resp_body =~ "irm https://openagents.com/install.ps1 | iex"
35
    assert conn.resp_body =~ "SHA256SUMS-"
36
  end
28 37
end
test/openagents_web/docs_catalog_test.exs modified +1

@@ -143,6 +143,7 @@ defmodule OpenAgentsWeb.DocsCatalogTest do

143 143
    # way to connect the two.
144 144
    assert install.markdown =~ "`openagents`, `coder`, and `oa`"
145 145
    assert install.markdown =~ "curl -fsSL https://openagents.com/install.sh | sh"
146
    assert install.markdown =~ "irm https://openagents.com/install.ps1 | iex"
146 147
147 148
    # The page must not send anyone to the npm package. It publishes a *different*
148 149
    # program under the same `openagents` name, and having both on one PATH is not
test/openagents_web/install_script_test.exs modified +20

@@ -17,6 +17,7 @@ defmodule OpenAgentsWeb.InstallScriptTest do

17 17
  use ExUnit.Case, async: true
18 18
19 19
  @script Path.join([File.cwd!(), "priv", "static", "install.sh"])
20
  @windows_script Path.join([File.cwd!(), "priv", "static", "install.ps1"])
20 21
21 22
  defp libc(source, arch, root) do
22 23
    {output, 0} =

@@ -46,6 +47,12 @@ defmodule OpenAgentsWeb.InstallScriptTest do

46 47
47 48
    assert Enum.any?(OpenAgentsWeb.static_prefixes(), &String.starts_with?("install.sh", &1)),
48 49
           "no prefix in static_prefixes/0 covers install.sh, so it 404s once digested"
50
51
    assert "install.ps1" in OpenAgentsWeb.static_paths(),
52
           "`install.ps1` is not in static_paths/0, so /install.ps1 is a 404"
53
54
    assert Enum.any?(OpenAgentsWeb.static_prefixes(), &String.starts_with?("install.ps1", &1)),
55
           "no prefix in static_prefixes/0 covers install.ps1, so it 404s once digested"
49 56
  end
50 57
51 58
  test "the script parses" do

@@ -76,6 +83,19 @@ defmodule OpenAgentsWeb.InstallScriptTest do

76 83
    refute body =~ ~r/\[@\]/, "an array expansion is bash-only"
77 84
  end
78 85
86
  test "the PowerShell installer refuses unverified bytes and does not exit the shell" do
87
    script = File.read!(@windows_script)
88
89
    assert script =~ "irm https://openagents.com/install.ps1 | iex"
90
    assert script =~ "SHA256SUMS-"
91
    assert script =~ "refusing to install unverified bytes"
92
    assert script =~ "Checksum mismatch"
93
    body = String.replace(script, ~r/#.*$/m, "")
94
    # `irm | iex` runs in the reader's PowerShell. `exit` would close it.
95
    refute body =~ ~r/\bexit\b/,
96
           "the PowerShell installer must throw, not exit, so irm | iex does not close the window"
97
  end
98
79 99
  test "nothing is installed without a checksum that matches" do
80 100
    script = File.read!(@script)
81 101
test/openagents_web/live/coder_live_test.exs modified +1

@@ -11,6 +11,7 @@ defmodule OpenAgentsWeb.CoderLiveTest do

11 11
    {:ok, _view, html} = live(conn, ~p"/coder")
12 12
13 13
    assert html =~ "curl -fsSL https://openagents.com/install.sh | sh"
14
    assert html =~ "irm https://openagents.com/install.ps1 | iex"
14 15
15 16
    # This assertion used to run the other way. The binary release had been
16 17
    # withdrawn — artifacts and every channel pointer deleted — so the curl

This page updates live while a promote is in flight · changelog