diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..7eec859 --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,95 @@ +# ax installer for Windows — usage: irm https://ax.yusuke.run/install.ps1 | iex +# Pins the release, verifies SHA-256 against checksums.txt, and only replaces +# an existing ax.exe after verification succeeds. + +$ErrorActionPreference = 'Stop' + +# pwsh can run on Unix, but this installer only supports Windows. +if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { + [Console]::Error.WriteLine('ax: this installer is only supported on Windows.') + exit 1 +} + +$repo = 'yusukebe/ax' +$asset = 'ax-windows-x64.exe' +$installDir = if ($env:AX_INSTALL_DIR) { $env:AX_INSTALL_DIR } else { Join-Path $HOME '.local\bin' } + +# Resolve "latest" to a concrete tag so the binary and checksums.txt are +# guaranteed to come from the same release. +$tag = $env:AX_VERSION +if (-not $tag) { + try { + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest" + $tag = $release.tag_name + } + catch { + [Console]::Error.WriteLine("ax: could not resolve latest release: $_") + exit 1 + } +} +if ($tag -notmatch '^v') { + [Console]::Error.WriteLine("ax: could not resolve release tag (got: '$tag')") + exit 1 +} + +$downloadBase = if ($env:AX_DOWNLOAD_BASE) { $env:AX_DOWNLOAD_BASE.TrimEnd('/') } else { "https://github.com/$repo/releases/download" } +$base = "$downloadBase/$tag" +$tmp = Join-Path ([IO.Path]::GetTempPath()) ([Guid]::NewGuid().ToString()) + +try { + New-Item -ItemType Directory -Path $tmp | Out-Null + $binaryPath = Join-Path $tmp $asset + $checksumsPath = Join-Path $tmp 'checksums.txt' + + Write-Host "Downloading $asset ($tag) ..." + Invoke-WebRequest -Uri "$base/$asset" -OutFile $binaryPath + Invoke-WebRequest -Uri "$base/checksums.txt" -OutFile $checksumsPath + + # Verify SHA-256 before anything touches the install directory. + $checksumLine = Get-Content -LiteralPath $checksumsPath | Where-Object { $_ -match ("\s+" + [regex]::Escape($asset) + '$') } | Select-Object -First 1 + if (-not $checksumLine -or $checksumLine -notmatch '^([0-9a-fA-F]{64})\s+') { + [Console]::Error.WriteLine("ax: $asset not found in checksums.txt for $tag") + exit 1 + } + $expected = $Matches[1].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $binaryPath -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $expected) { + [Console]::Error.WriteLine("ax: SHA-256 mismatch for $asset ($tag)") + [Console]::Error.WriteLine(" expected: $expected") + [Console]::Error.WriteLine(" actual: $actual") + [Console]::Error.WriteLine(' refusing to install.') + exit 1 + } + Write-Host 'SHA-256 verified.' + + New-Item -ItemType Directory -Force -Path $installDir | Out-Null + $target = Join-Path $installDir 'ax.exe' + $staged = Join-Path $installDir ("ax.tmp.$PID.exe") + Copy-Item -LiteralPath $binaryPath -Destination $staged + try { + if (Test-Path -LiteralPath $target) { + [IO.File]::Replace($staged, $target, $null) + } + else { + [IO.File]::Move($staged, $target) + } + } + finally { + if (Test-Path -LiteralPath $staged) { Remove-Item -LiteralPath $staged -Force } + } + + Write-Host "Installed ax $tag to $target" + $pathEntries = $env:PATH -split ';' + if ($pathEntries -notcontains $installDir) { + Write-Host '' + Write-Host "NOTE: $installDir is not in your PATH. Add it to your user or system PATH." + } + + Write-Host '' + & $target --version + if ($LASTEXITCODE -ne 0) { throw 'ax --version failed.' } + Write-Host "Run 'ax --help' to get started." +} +finally { + if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Recurse -Force } +} diff --git a/website/gen.ts b/website/gen.ts index 10b2f5e..d300c39 100644 --- a/website/gen.ts +++ b/website/gen.ts @@ -7,8 +7,9 @@ // in the repo files. const read = (p: string) => Bun.file(new URL(p, import.meta.url)).text() -const [installSh, llmsTxt, skillMd] = await Promise.all([ +const [installSh, installPs1, llmsTxt, skillMd] = await Promise.all([ read('../scripts/install.sh'), + read('../scripts/install.ps1'), read('../src/agent-context.txt'), read('../skills/ax/SKILL.md'), ]) @@ -37,6 +38,7 @@ const touchIconPng = await img('src/apple-touch-icon.png') const out = `// AUTO-GENERATED by gen.ts — do not edit. Regenerate with: bun gen.ts const d = (s: string) => new TextDecoder().decode(Uint8Array.from(atob(s), (c) => c.charCodeAt(0))) export const installSh = d('${b64(installSh)}') +export const installPs1 = d('${b64(installPs1)}') export const llmsTxt = d('${b64(llmsTxt)}') export const skillMd = d('${b64(skillMd)}') export const duelBefore = d('${b64(duelBefore)}') diff --git a/website/src/index.tsx b/website/src/index.tsx index 2aa7030..6f64b2c 100644 --- a/website/src/index.tsx +++ b/website/src/index.tsx @@ -3,6 +3,7 @@ import { duelBefore, faviconPngB64, installSh, + installPs1, llmsTxt, ogPngB64, skillMd, @@ -47,6 +48,7 @@ app.use('*', async (c, next) => { const TEXT_CACHE = { 'Cache-Control': 'public, max-age=300' } app.get('/install', (c) => c.text(installSh, 200, TEXT_CACHE)) +app.get('/install.ps1', (c) => c.text(installPs1, 200, TEXT_CACHE)) app.get('/llms.txt', (c) => c.text(llmsTxt, 200, TEXT_CACHE)) app.get('/skill.md', (c) => c.text(skillMd, 200, TEXT_CACHE)) const PNG_HEADERS = { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' } @@ -549,6 +551,10 @@ ax url --md --budget 800 # docs as markdown`} nix nix run github:yusukebe/ax +
  • + PowerShell(Win) + irm https://ax.yusuke.run/install.ps1 | iex +
  • source