diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 76ca068..8974670 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -24,3 +24,5 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SCOOP_TOKEN: ${{ secrets.SCOOP_TOKEN }} + WINGET_TOKEN: ${{ secrets.WINGET_TOKEN }} diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8ec431d..09fc76d 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -47,3 +47,44 @@ release: changelog: use: github + +# Windows package managers. Both publish to separate repos, so they need a PAT +# (repo-scoped GITHUB_TOKEN cannot push cross-repo). Until the tokens are set as +# secrets, skip_upload keeps releases green - binaries still ship, only the +# manifest push is skipped. +scoops: + - name: asobi + repository: + owner: widgrensit + name: scoop-bucket + branch: main + token: '{{ index .Env "SCOOP_TOKEN" }}' + directory: bucket + homepage: "https://github.com/widgrensit/asobi" + description: "Command-line tool for building and deploying multiplayer games on Asobi." + license: Apache-2.0 + skip_upload: '{{ if ne (index .Env "SCOOP_TOKEN") "" }}false{{ else }}true{{ end }}' + +winget: + - name: asobi + package_identifier: widgrensit.asobi + publisher: widgrensit + short_description: "Command-line tool for building and deploying multiplayer games on Asobi." + license: Apache-2.0 + homepage: "https://github.com/widgrensit/asobi" + publisher_url: https://github.com/widgrensit + publisher_support_url: "https://github.com/widgrensit/asobi-cli/issues" + license_url: "https://github.com/widgrensit/asobi-cli/blob/main/LICENSE" + skip_upload: '{{ if ne (index .Env "WINGET_TOKEN") "" }}false{{ else }}true{{ end }}' + repository: + owner: widgrensit + name: winget-pkgs + branch: "asobi-{{ .Version }}" + token: '{{ index .Env "WINGET_TOKEN" }}' + pull_request: + enabled: true + draft: false + base: + owner: microsoft + name: winget-pkgs + branch: master diff --git a/README.md b/README.md index 0bbc192..f5d2529 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,31 @@ Command-line tool for building and deploying multiplayer games on [Asobi](https: ## Install +**Linux / macOS:** + ```bash curl -fsSL https://raw.githubusercontent.com/widgrensit/asobi-cli/main/install.sh | sh ``` -This downloads the latest release binary for your OS and architecture (Linux and -macOS, amd64 and arm64), verifies its checksum, and installs it to -`~/.local/bin` (override with `ASOBI_INSTALL_DIR`). Pin a version with -`ASOBI_VERSION=v0.1.0`. Windows users download the zip from the -[Releases](https://github.com/widgrensit/asobi-cli/releases) page. +**Windows (PowerShell):** + +```powershell +irm https://raw.githubusercontent.com/widgrensit/asobi-cli/main/install.ps1 | iex +``` + +Or with a package manager: + +```powershell +winget install widgrensit.asobi +# or +scoop bucket add asobi https://github.com/widgrensit/scoop-bucket +scoop install asobi +``` + +The scripts download the latest release binary for your OS and architecture +(amd64 and arm64), verify its checksum, and install it to `~/.local/bin` on +Linux/macOS or `%LOCALAPPDATA%\asobi\bin` on Windows (override with +`ASOBI_INSTALL_DIR`). Pin a version with `ASOBI_VERSION=v0.1.0`. Verify the install: diff --git a/SECURITY.md b/SECURITY.md index 0d194b8..9f376eb 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -41,7 +41,14 @@ Either of these channels work: ## Credential storage -`asobi-cli` stores credentials at `~/.asobi/credentials.json` with mode -`0600`, in a directory with mode `0700`. The CLI does not transmit -credentials over unencrypted channels and uses ECDH+AES-GCM for the -initial device-code login exchange. +`asobi-cli` stores credentials at `~/.asobi/credentials.json` +(`%USERPROFILE%\.asobi\credentials.json` on Windows). The CLI does not +transmit credentials over unencrypted channels and uses ECDH+AES-GCM for +the initial device-code login exchange. + +On Linux and macOS the file is written with mode `0600` in a `0700` +directory, restricting it to the owning user. On Windows those modes only +set the read-only attribute; the file is instead protected by the ACL it +inherits from your user profile (owner, SYSTEM, and Administrators). +Encrypting the credential file at rest with DPAPI is tracked as a +hardening enhancement. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..593a6f0 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,95 @@ +# Asobi CLI installer for Windows. +# +# Usage: +# irm https://raw.githubusercontent.com/widgrensit/asobi-cli/main/install.ps1 | iex +# +# Environment variables: +# ASOBI_VERSION Install a specific tag (e.g. v0.1.0). Default: latest release. +# ASOBI_INSTALL_DIR Install directory. Default: $env:LOCALAPPDATA\asobi\bin. + +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$repo = 'widgrensit/asobi-cli' +$installDir = if ($env:ASOBI_INSTALL_DIR) { $env:ASOBI_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'asobi\bin' } + +function Fail($msg) { + Write-Error $msg + exit 1 +} + +function Detect-Arch { + switch ($env:PROCESSOR_ARCHITECTURE) { + 'AMD64' { 'amd64' } + 'ARM64' { 'arm64' } + 'x86' { Fail 'unsupported architecture: x86 (32-bit). asobi ships amd64 and arm64 builds only.' } + default { Fail "unsupported architecture: $($env:PROCESSOR_ARCHITECTURE)" } + } +} + +function Latest-Version { + try { + $release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest" -Headers @{ 'User-Agent' = 'asobi-install' } + } catch { + Fail "could not query the latest release: $($_.Exception.Message)" + } + $release.tag_name +} + +function Verify-Checksum($file, $sumsFile, $name) { + $line = Select-String -Path $sumsFile -Pattern " $([regex]::Escape($name))$" | Select-Object -First 1 + if (-not $line) { Fail "no checksum found for $name in checksums.txt" } + $expected = ($line.Line -split '\s+')[0] + $actual = (Get-FileHash -Path $file -Algorithm SHA256).Hash.ToLower() + if ($expected.ToLower() -ne $actual) { + Fail "checksum mismatch for $name (expected $expected, got $actual)" + } +} + +function Add-ToUserPath($dir) { + $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') + $entries = if ($userPath) { $userPath -split ';' } else { @() } + if ($entries -notcontains $dir) { + $newPath = if ($userPath) { "$userPath;$dir" } else { $dir } + [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') + Write-Host '' + Write-Host "Added $dir to your user PATH. Open a new terminal, then run 'asobi version'." + } else { + Write-Host "Run 'asobi version' to get started." + } +} + +$arch = Detect-Arch +$version = if ($env:ASOBI_VERSION) { $env:ASOBI_VERSION } else { Latest-Version } +if (-not $version) { Fail 'could not resolve a release version' } + +$asset = "asobi_windows_$arch.zip" +$base = "https://github.com/$repo/releases/download/$version" + +$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("asobi-" + [System.IO.Path]::GetRandomFileName()) +New-Item -ItemType Directory -Path $tmp | Out-Null +try { + Write-Host "Downloading asobi $version (windows/$arch)..." + try { + Invoke-WebRequest -Uri "$base/$asset" -OutFile (Join-Path $tmp $asset) -UseBasicParsing + } catch { + Fail "failed to download $asset - check that $version has a windows/$arch build" + } + Invoke-WebRequest -Uri "$base/checksums.txt" -OutFile (Join-Path $tmp 'checksums.txt') -UseBasicParsing + + Write-Host 'Verifying checksum...' + Verify-Checksum (Join-Path $tmp $asset) (Join-Path $tmp 'checksums.txt') $asset + + Expand-Archive -Path (Join-Path $tmp $asset) -DestinationPath $tmp -Force + $binary = Join-Path $tmp 'asobi.exe' + if (-not (Test-Path $binary)) { Fail "asobi.exe not found in $asset" } + + New-Item -ItemType Directory -Path $installDir -Force | Out-Null + $installPath = Join-Path $installDir 'asobi.exe' + Move-Item -Path $binary -Destination $installPath -Force + + Write-Host "Installed asobi to $installPath" + Add-ToUserPath $installDir +} finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +} diff --git a/internal/auth/device.go b/internal/auth/device.go index 1e5cc81..d0679c6 100644 --- a/internal/auth/device.go +++ b/internal/auth/device.go @@ -195,7 +195,9 @@ func openBrowser(url string) { case "darwin": cmd = exec.Command("open", url) case "windows": - cmd = exec.Command("cmd", "/c", "start", url) + // Not `cmd /c start`: cmd re-parses the URL and treats & as a + // command separator, breaking any query string. + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) default: return }