Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ body:
attributes:
label: CLI version
description: Output of `agora --version`
placeholder: "e.g. agora-cli-go 0.1.7 (commit abc1234, built 2026-04-29)"
placeholder: "e.g. agora-cli-go 0.1.8 (commit abc1234, built 2026-04-30)"
validations:
required: true

Expand Down
31 changes: 29 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,15 +190,42 @@ jobs:
$hash = (Get-FileHash -Path (Join-Path $downloadDir $archive) -Algorithm SHA256).Hash.ToLowerInvariant()
Set-Content -Path (Join-Path $downloadDir 'checksums.txt') -Value "$hash $archive"

$server = Start-Process -FilePath python -ArgumentList '-m', 'http.server', '18081', '--directory', $fixtureRoot -PassThru
Start-Sleep -Seconds 2
$serverOutLog = Join-Path $fixtureRoot 'http-server.out.log'
$serverErrLog = Join-Path $fixtureRoot 'http-server.err.log'
$server = Start-Process -FilePath python -ArgumentList '-m', 'http.server', '18081', '--directory', $fixtureRoot -RedirectStandardOutput $serverOutLog -RedirectStandardError $serverErrLog -PassThru
$archiveUrl = "http://127.0.0.1:18081/download/v$version/$archive"
$serverReady = $false
for ($attempt = 1; $attempt -le 20; $attempt++) {
if ($server.HasExited) {
if (Test-Path -LiteralPath $serverOutLog) { Get-Content -Path $serverOutLog | ForEach-Object { Write-Host $_ } }
if (Test-Path -LiteralPath $serverErrLog) { Get-Content -Path $serverErrLog | ForEach-Object { Write-Host $_ } }
throw "Fixture HTTP server exited before serving $archiveUrl."
}
try {
$response = Invoke-WebRequest -Uri $archiveUrl -Method Head -UseBasicParsing
if ($response.StatusCode -eq 200) {
$serverReady = $true
break
}
} catch {
Start-Sleep -Milliseconds 500
}
}
if (-not $serverReady) {
if (Test-Path -LiteralPath $serverOutLog) { Get-Content -Path $serverOutLog | ForEach-Object { Write-Host $_ } }
if (Test-Path -LiteralPath $serverErrLog) { Get-Content -Path $serverErrLog | ForEach-Object { Write-Host $_ } }
throw "Fixture HTTP server did not serve $archiveUrl."
}

try {
$env:VERSION = $version
$env:RELEASES_DOWNLOAD_BASE_URL = 'http://127.0.0.1:18081/download'
$env:RELEASES_PAGE_URL = 'http://127.0.0.1:18081'

& ./install.ps1 -InstallDir $installDir
if ($LASTEXITCODE -ne 0) {
throw "install.ps1 failed with exit code $LASTEXITCODE."
}
& (Join-Path $installDir 'agora.exe') --help *> $null

Set-Content -Path (Join-Path $downloadDir 'checksums.txt') -Value ('0' * 64 + " $archive")
Expand Down
13 changes: 11 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

When tagging a new release, rename the `[Unreleased]` section to the new version
(e.g. `[0.1.7] - 2026-04-30`), add a fresh empty `[Unreleased]` heading at the top,
(e.g. `[0.1.8] - 2026-04-30`), add a fresh empty `[Unreleased]` heading at the top,
and update the link references at the bottom of this file.

When adding a new entry, link the change to the PR or commit that introduced it
Expand All @@ -15,6 +15,14 @@ Earlier entries pre-date this convention and only carry their version's compare

## [Unreleased]

## [0.1.8] - 2026-04-30

### Fixed

- Preserve OAuth PKCE query parameters on Windows by opening browser login URLs through `rundll32 url.dll,FileProtocolHandler` instead of `cmd /c start`.
- Accept OAuth callbacks on both IPv4 and IPv6 localhost loopback addresses so Windows `localhost` resolution does not strand successful browser sign-ins.
- Update the release workflow output wiring to avoid self-referencing step outputs during dry-run and publish-mode setup.

## [0.1.7] - 2026-04-30

### Added
Expand Down Expand Up @@ -105,7 +113,8 @@ Earlier entries pre-date this convention and only carry their version's compare
- Support machine-readable JSON output for automation and agent workflows.
- Ship automated release packaging through GoReleaser, including cross-platform archives, Linux packages, Homebrew, Scoop, npm wrapper packages, Docker images, and install scripts.

[Unreleased]: https://github.com/AgoraIO/cli/compare/v0.1.7...HEAD
[Unreleased]: https://github.com/AgoraIO/cli/compare/v0.1.8...HEAD
[0.1.8]: https://github.com/AgoraIO/cli/compare/v0.1.7...v0.1.8
[0.1.7]: https://github.com/AgoraIO/cli/compare/v0.1.6...v0.1.7
[0.1.6]: https://github.com/AgoraIO/cli/compare/v0.1.5...v0.1.6
[0.1.5]: https://github.com/AgoraIO/cli/compare/v0.1.4...v0.1.5
Expand Down
4 changes: 2 additions & 2 deletions RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ Releases are fully automated via GoReleaser. Pushing a `v*` tag is the only manu
## Release

```bash
git tag v0.1.7
git push origin v0.1.7
git tag v0.1.8
git push origin v0.1.8
```

The release workflow (`.github/workflows/release.yml`) then:
Expand Down
18 changes: 9 additions & 9 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ agora --help
Install a pinned version:

```bash
curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --version 0.1.7 --add-to-path
curl -fsSL https://raw.githubusercontent.com/AgoraIO/cli/main/install.sh | sh -s -- --version 0.1.8 --add-to-path
agora --help
```

Expand Down Expand Up @@ -50,7 +50,7 @@ agora --help
Install a pinned version and add the default install directory to your user PATH:

```powershell
$env:VERSION = "0.1.7"
$env:VERSION = "0.1.8"
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1))) -AddToPath
agora --help
```
Expand Down Expand Up @@ -86,7 +86,7 @@ If another managed `agora` install is detected, the installer refuses by default
Both direct installers support these core overrides:

- `GITHUB_REPO`: install from a fork or alternate repository.
- `VERSION`: install a specific version. Both `0.1.7` and `v0.1.7` are accepted.
- `VERSION`: install a specific version. Both `0.1.8` and `v0.1.8` are accepted.
- `INSTALL_DIR`: install to a custom directory.
- `GITHUB_TOKEN` or `GH_TOKEN`: optional GitHub token to avoid API rate limits when resolving the latest release.

Expand Down Expand Up @@ -172,7 +172,7 @@ agora --help
npx agoraio-cli --help

# Pin a specific version
npm install -g agoraio-cli@0.1.7
npm install -g agoraio-cli@0.1.8

# Update to the latest published version
npm update -g agoraio-cli
Expand Down Expand Up @@ -200,12 +200,12 @@ For one-off shell sessions, source the generated script according to your shell'
If latest-version resolution fails, retry with a pinned version or provide `GITHUB_TOKEN` / `GH_TOKEN`:

```bash
GITHUB_TOKEN=your-token-here VERSION=0.1.7 sh install.sh
GITHUB_TOKEN=your-token-here VERSION=0.1.8 sh install.sh
```

```powershell
$env:GITHUB_TOKEN = "your-token-here"
$env:VERSION = "0.1.7"
$env:VERSION = "0.1.8"
& ([scriptblock]::Create((irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1)))
```

Expand Down Expand Up @@ -251,7 +251,7 @@ For CI, automation, and reproducible environments, pin `VERSION` explicitly inst
Every release is signed with [Cosign](https://docs.sigstore.dev/cosign/overview/) using GitHub Actions OIDC (keyless mode) and ships an [SPDX 2.3](https://spdx.dev/) SBOM per archive and per Linux package. To verify the `checksums.txt` file before trusting any artifact:

```bash
TAG=v0.1.7
TAG=v0.1.8
ASSET_BASE="https://github.com/AgoraIO/cli/releases/download/${TAG}"
curl -fsSLO "${ASSET_BASE}/checksums.txt"
curl -fsSLO "${ASSET_BASE}/checksums.txt.sig"
Expand All @@ -275,8 +275,8 @@ cosign verify "ghcr.io/agoraio/agora-cli:${TAG#v}" \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'
```

To audit dependencies, download the `*.spdx.json` SBOM that ships next to each archive (e.g. `agora-cli-go_v0.1.7_linux_amd64.tar.gz.spdx.json`) and feed it to a scanner such as [Grype](https://github.com/anchore/grype):
To audit dependencies, download the `*.spdx.json` SBOM that ships next to each archive (e.g. `agora-cli-go_v0.1.8_linux_amd64.tar.gz.spdx.json`) and feed it to a scanner such as [Grype](https://github.com/anchore/grype):

```bash
grype sbom:agora-cli-go_v0.1.7_linux_amd64.tar.gz.spdx.json
grype sbom:agora-cli-go_v0.1.8_linux_amd64.tar.gz.spdx.json
```
2 changes: 1 addition & 1 deletion install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# irm https://raw.githubusercontent.com/AgoraIO/cli/main/install.ps1 | iex
#
# Pin a version:
# $env:VERSION = '0.1.7'; & ([scriptblock]::Create((irm .../install.ps1)))
# $env:VERSION = '0.1.8'; & ([scriptblock]::Create((irm .../install.ps1)))
#
[CmdletBinding()]
param(
Expand Down
31 changes: 31 additions & 0 deletions internal/cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"regexp"
Expand Down Expand Up @@ -561,6 +562,36 @@ func TestWaitForOAuthCallbackMismatchAndTimeout(t *testing.T) {
}
}

func TestWaitForOAuthCallbackAcceptsIPv4WhenRedirectUsesLocalhost(t *testing.T) {
server, err := waitForOAuthCallback("expected-state", time.Second)
if err != nil {
t.Fatal(err)
}
defer server.Close()
if !strings.HasPrefix(server.RedirectURI, "http://localhost:") {
t.Fatalf("expected localhost redirect URI for OAuth compatibility, got %s", server.RedirectURI)
}
if len(server.listeners) == 0 {
t.Fatal("expected at least one loopback listener")
}
parsed, err := url.Parse(server.RedirectURI)
if err != nil {
t.Fatal(err)
}
resp, err := http.Get("http://127.0.0.1:" + parsed.Port() + "/oauth/callback?code=test-code&state=expected-state")
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
payload, err := server.Wait()
if err != nil {
t.Fatal(err)
}
if payload.Code != "test-code" || payload.State != "expected-state" {
t.Fatalf("unexpected callback payload: %+v", payload)
}
}

func TestExchangeAuthorizationCodeFailureAndScopeArray(t *testing.T) {
failServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusBadRequest)
Expand Down
51 changes: 36 additions & 15 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,24 +158,29 @@ func randomToken(n int) (string, error) {
}

func openBrowser(target string) bool {
var cmd *exec.Cmd
switch runtime.GOOS {
name, args := browserOpenCommand(runtime.GOOS, target)
return exec.Command(name, args...).Start() == nil
}

func browserOpenCommand(goos, target string) (string, []string) {
switch goos {
case "darwin":
cmd = exec.Command("open", target)
return "open", []string{target}
case "windows":
cmd = exec.Command("cmd", "/c", "start", "", target)
// Avoid `cmd /c start` because unescaped '&' in OAuth query strings can
// be interpreted by cmd.exe, truncating the URL before PKCE parameters.
return "rundll32", []string{"url.dll,FileProtocolHandler", target}
default:
cmd = exec.Command("xdg-open", target)
return "xdg-open", []string{target}
}
return cmd.Start() == nil
}

type callbackServer struct {
RedirectURI string
wait chan callbackPayload
errs chan error
server *http.Server
listener net.Listener
listeners []net.Listener
}

type callbackPayload struct {
Expand All @@ -190,19 +195,24 @@ func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbac
srv := &http.Server{
Handler: mux,
// Set ReadHeaderTimeout to mitigate Slowloris attacks (gosec G112).
// Even though this listens only on 127.0.0.1, we still bound it.
// Even though this listens only on loopback interfaces, we still bound it.
ReadHeaderTimeout: 10 * time.Second,
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
ln4, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return nil, err
}
port := ln4.Addr().(*net.TCPAddr).Port
listeners := []net.Listener{ln4}
if ln6, err := net.Listen("tcp6", fmt.Sprintf("[::1]:%d", port)); err == nil {
listeners = append(listeners, ln6)
}
cs := &callbackServer{
RedirectURI: fmt.Sprintf("http://localhost:%d/oauth/callback", ln.Addr().(*net.TCPAddr).Port),
RedirectURI: fmt.Sprintf("http://localhost:%d/oauth/callback", port),
wait: wait,
errs: errs,
server: srv,
listener: ln,
listeners: listeners,
}
mux.HandleFunc("/oauth/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
Expand All @@ -223,9 +233,11 @@ func waitForOAuthCallback(expectedState string, timeout time.Duration) (*callbac
wait <- callbackPayload{Code: code, State: state}
}
})
go func() {
_ = srv.Serve(ln)
}()
for _, listener := range listeners {
go func(ln net.Listener) {
_ = srv.Serve(ln)
}(listener)
}
go func() {
<-time.After(timeout)
errs <- errors.New("Timed out waiting for the OAuth callback. Re-run with --no-browser to copy the URL manually, or check that your browser completed the login flow.")
Expand All @@ -243,7 +255,16 @@ func (c *callbackServer) Wait() (callbackPayload, error) {
}

func (c *callbackServer) Close() error {
return c.server.Close()
var firstErr error
if err := c.server.Close(); err != nil {
firstErr = err
}
for _, listener := range c.listeners {
if err := listener.Close(); err != nil && !errors.Is(err, net.ErrClosed) && firstErr == nil {
firstErr = err
}
}
return firstErr
}

type tokenResponse struct {
Expand Down
18 changes: 18 additions & 0 deletions internal/cli/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"bytes"
"reflect"
"strings"
"testing"
)
Expand Down Expand Up @@ -56,3 +57,20 @@ func TestEnsureValidAccessTokenSkipsPromptInJSONMode(t *testing.T) {
t.Fatalf("expected missing session error, got %v", err)
}
}

func TestBrowserOpenCommandWindowsPreservesOAuthQuery(t *testing.T) {
target := "https://sso.example/authorize?response_type=code&code_challenge=abc&code_challenge_method=S256&state=xyz"
name, args := browserOpenCommand("windows", target)
if name != "rundll32" {
t.Fatalf("expected rundll32 opener, got %s", name)
}
expected := []string{"url.dll,FileProtocolHandler", target}
if !reflect.DeepEqual(args, expected) {
t.Fatalf("unexpected args: %#v", args)
}
for _, arg := range args {
if arg == "cmd" || arg == "/c" || arg == "start" {
t.Fatalf("windows opener must not shell through cmd.exe, got %#v", args)
}
}
}
6 changes: 6 additions & 0 deletions internal/cli/integration_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ func TestCLILoginAndWhoAmIParity(t *testing.T) {
if len(oauth.authorizeRedirectURIs) != 1 || !strings.Contains(oauth.authorizeRedirectURIs[0], "http://localhost:") {
t.Fatalf("expected localhost redirect URI, got %+v", oauth.authorizeRedirectURIs)
}
if len(oauth.authorizeRawQueries) != 1 || !strings.Contains(oauth.authorizeRawQueries[0], "code_challenge=") || !strings.Contains(oauth.authorizeRawQueries[0], "code_challenge_method=S256") {
t.Fatalf("expected authorize URL to include PKCE challenge, got %+v", oauth.authorizeRawQueries)
}
if len(oauth.tokenRequests) != 1 || !strings.Contains(oauth.tokenRequests[0], "code_verifier=") {
t.Fatalf("expected token request to include PKCE verifier, got %+v", oauth.tokenRequests)
}
var envelope map[string]any
if err := json.Unmarshal([]byte(status.stdout), &envelope); err != nil {
t.Fatal(err)
Expand Down
2 changes: 2 additions & 0 deletions internal/cli/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,7 @@ type fakeOAuthServer struct {
server *http.Server
baseURL string
authorizeRedirectURIs []string
authorizeRawQueries []string
tokenRequests []string
}

Expand All @@ -302,6 +303,7 @@ func newFakeOAuthServer() *fakeOAuthServer {
return
}
oauth.authorizeRedirectURIs = append(oauth.authorizeRedirectURIs, redirectURI)
oauth.authorizeRawQueries = append(oauth.authorizeRawQueries, r.URL.RawQuery)
http.Redirect(w, r, redirectURI+"?code=test-auth-code&state="+state, http.StatusFound)
case r.Method == http.MethodPost && r.URL.Path == "/api/v0/oauth/token":
body, _ := io.ReadAll(r.Body)
Expand Down
4 changes: 2 additions & 2 deletions internal/cli/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import "fmt"
// Build-time injected version variables. These are populated by ldflags at
// release time:
//
// go build -ldflags '-X github.com/.../internal/cli.version=v0.1.7
// go build -ldflags '-X github.com/.../internal/cli.version=v0.1.8
// -X github.com/.../internal/cli.commit=abc1234
// -X github.com/.../internal/cli.date=2026-04-29'
// -X github.com/.../internal/cli.date=2026-04-30'
//
// Snapshot/local builds keep the placeholder values below.
var (
Expand Down
Loading