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
16 changes: 16 additions & 0 deletions apps/marketing/src/content/docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,22 @@ For `curl … | bash` pipelines you can set `PLANNOTATOR_MINIMAL=1` in the envir

</details>

<details>
<summary><strong>Installing behind a rate-limited IP</strong></summary>

The installer queries the GitHub API (`api.github.com`) to resolve the latest release tag. Unauthenticated API requests are capped at **60 per hour per source IP**, so installs can fail on shared egress IPs (corporate proxies, NAT/CGNAT, CI runners) or when retrying/debugging within the same hour, with an opaque `Failed to fetch latest version` error.

If you hit this, export a token before running the installer - it reads `GITHUB_TOKEN`, then `GH_TOKEN`, and falls back to `gh auth token` (github.com credentials only) when the `gh` CLI is authenticated. A personal access token raises the limit to 5,000/hour; the built-in `GITHUB_TOKEN` in GitHub Actions gets 1,000/hour per repository. The repository is public, so a token with no scopes is sufficient - prefer a fine-grained or zero-scope token over a broad classic PAT:

```bash
export GITHUB_TOKEN=ghp_xxx
curl -fsSL https://plannotator.ai/install.sh | bash
```

Or authenticate once with `gh auth login` - no env var needed, the installer picks the token up automatically. Only the version-resolution API call is authenticated; release downloads and `git clone` are unaffected. See [Troubleshooting](/docs/guides/troubleshooting/) for details.

</details>

Every release includes SHA256 checksums (verified automatically) and optional [SLSA build provenance](/docs/reference/verifying-your-install/) attestations.

## Claude Code
Expand Down
17 changes: 17 additions & 0 deletions apps/marketing/src/content/docs/guides/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ sidebar:
section: "Guides"
---

## Installer fails with "Failed to fetch latest version"

The installer queries the GitHub API (`api.github.com`) to resolve the latest release tag. Unauthenticated API requests are capped at **60 per hour per source IP** - not per user - so the install fails (with `Failed to fetch latest version` on macOS/Linux/WSL, `Failed to get latest version` on Windows) on shared egress IPs (corporate proxies, NAT/CGNAT, CI runners) or when retrying within the same hour.

Provide a token and the installer attaches it to that API call automatically. A personal access token raises the limit to 5,000/hour; the built-in `GITHUB_TOKEN` in GitHub Actions gets 1,000/hour per repository. The repository is public, so a token with no scopes is sufficient - prefer a fine-grained or zero-scope token over a broad classic PAT. The installer reads, in order:

1. `GITHUB_TOKEN` env var
2. `GH_TOKEN` env var
3. `gh auth token` for github.com (when the `gh` CLI is installed and authenticated; a GitHub Enterprise default host is never used for this call)

```bash
export GITHUB_TOKEN=ghp_xxx
curl -fsSL https://plannotator.ai/install.sh | bash
```

Or run `gh auth login` once - no env var needed. To pin a specific version instead (which skips the API call entirely), pass `--version vX.Y.Z` on macOS/Linux (`curl -fsSL https://plannotator.ai/install.sh | bash -s -- --version vX.Y.Z`) or `-Version vX.Y.Z` with the PowerShell installer. Only the version-resolution call is authenticated; release downloads and `git clone` are unaffected. See [issue #1156](https://github.com/backnotprop/plannotator/issues/1156).

## Lost a Plannotator tab?

If you accidentally close a Plannotator browser tab, the server is still running in the background. You can find and reopen it:
Expand Down
74 changes: 73 additions & 1 deletion scripts/install.cmd
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,87 @@ REM Get version to install
if /i "!VERSION!"=="latest" (
echo Fetching latest version...

REM api.github.com caps unauthenticated requests at 60/hour per source IP,
REM which fails installs behind shared egress IPs (NAT/CGNAT/corporate
REM proxies) and during repeated/debug runs within an hour. Attach an
REM Authorization header when a token is available (raises the limit to
REM 5000/hour); when none is found, fall back to anonymous (unchanged
REM behavior). Precedence matches `gh`: GITHUB_TOKEN > GH_TOKEN > gh auth token.
REM Read the env vars via delayed expansion, never percent expansion, so
REM the value is not re-parsed by cmd's phase-1 expansion - a value
REM containing metacharacters or exclamation marks can neither inject
REM commands nor be corrupted here.
set "GH_TOKEN_VAL="
if defined GITHUB_TOKEN set "GH_TOKEN_VAL=!GITHUB_TOKEN!"
if not defined GH_TOKEN_VAL if defined GH_TOKEN set "GH_TOKEN_VAL=!GH_TOKEN!"
if not defined GH_TOKEN_VAL (
REM Resolve gh via `where` and invoke the absolute path so the for /f
REM command line never runs a bare `gh` name, which cmd would resolve
REM from the current directory first. (`where` itself also searches
REM the CWD first - accepted limitation, documented here.)
REM --hostname github.com scopes the fallback to github.com
REM credentials, so a gh setup whose default host is a GitHub
REM Enterprise server never leaks a GHES token to api.github.com. On
REM an ancient gh without the flag, stderr is swallowed and we fall
REM back to anonymous.
set "GH_EXE="
for /f "delims=" %%g in ('where gh 2^>nul') do if not defined GH_EXE set "GH_EXE=%%g"
if defined GH_EXE (
for /f "delims=" %%i in ('"!GH_EXE!" auth token --hostname github.com 2^>nul') do set "GH_TOKEN_VAL=%%i"
)
set "GH_EXE="
)
REM Charset allowlist: GitHub tokens are [A-Za-z0-9_] (plus - to be
REM safe). Strip every allowed character (cmd substitution is
REM case-insensitive, covering A-Z too); anything left over means an
REM unexpected character - a quote in particular could break out of the
REM quoted Authorization header on the curl line below - so drop the
REM token and go anonymous. Done with pure delayed-expansion
REM substitutions: a findstr pipe cannot be used because delayed
REM expansion does not survive into pipe children, and writing the token
REM to a temp file for findstr would leak the secret to disk.
if defined GH_TOKEN_VAL (
set "TOKEN_RESIDUE=!GH_TOKEN_VAL!"
for %%c in (a b c d e f g h i j k l m n o p q r s t u v w x y z 0 1 2 3 4 5 6 7 8 9 _ -) do if defined TOKEN_RESIDUE set "TOKEN_RESIDUE=!TOKEN_RESIDUE:%%c=!"
if defined TOKEN_RESIDUE set "GH_TOKEN_VAL="
set "TOKEN_RESIDUE="
)
if defined GH_TOKEN_VAL (
set "GH_AUTH_HEADER=-H "Authorization: Bearer !GH_TOKEN_VAL!""
) else (
set "GH_AUTH_HEADER="
)

REM Download release info to a randomized temp file so concurrent
REM invocations don't collide and a same-user pre-placed symlink at
REM a predictable path can't redirect curl's output.
set "RELEASE_JSON=%TEMP%\plannotator-release-%RANDOM%.json"
curl -fsSL "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!"
curl -fsSL !GH_AUTH_HEADER! "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!"
REM A stale/revoked token (expired GITHUB_TOKEN lingering in CI images,
REM dotfiles, direnv) gets a 401 here and would break an install that
REM works fine anonymously today. If the authenticated call failed and
REM we had a token, retry once without the header so a bad token costs
REM one extra request but never blocks an otherwise-working install.
REM Note: install.sh / install.ps1 inspect the HTTP status and retry
REM only on 401; capturing the status portably in batch is not worth the
REM complexity, so cmd retries on any failure when a token was used - an
REM accepted cmd-only compromise. See backnotprop/plannotator#1157.
REM Both ERRORLEVEL reads below sit immediately adjacent to the curl
REM they test (only REM lines and a no-op if in between); the token
REM clears deliberately come AFTER the failure check because `set` can
REM disturb ERRORLEVEL.
if !ERRORLEVEL! neq 0 if defined GH_AUTH_HEADER (
curl -fsSL "https://api.github.com/repos/!REPO!/releases/latest" -o "!RELEASE_JSON!"
)
if !ERRORLEVEL! neq 0 (
echo Failed to get latest version >&2
exit /b 1
)
REM Drop the local token copies; downloads and git clone are anonymous.
REM GITHUB_TOKEN / GH_TOKEN themselves remain in the environment exactly
REM as the user set them.
set "GH_TOKEN_VAL="
set "GH_AUTH_HEADER="

REM Extract tag_name from JSON
for /f "tokens=2 delims=:," %%i in ('findstr /c:"\"tag_name\"" "!RELEASE_JSON!"') do (
Expand Down
48 changes: 47 additions & 1 deletion scripts/install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,53 @@ foreach ($oldPath in $oldLocations) {

if ($Version -eq "latest") {
Write-Host "Fetching latest version..."
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repo/releases/latest"

# api.github.com caps unauthenticated requests at 60/hour per source IP,
# which fails installs behind shared egress IPs (NAT/CGNAT/corporate
# proxies) and during repeated/debug runs within an hour. Attach an
# Authorization header when a token is available (raises the limit to
# 5000/hour); when none is found, fall back to anonymous (unchanged
# behavior). Precedence matches `gh`: GITHUB_TOKEN > GH_TOKEN > gh auth token.
$ghToken = $env:GITHUB_TOKEN
if (-not $ghToken) { $ghToken = $env:GH_TOKEN }
if (-not $ghToken -and (Get-Command gh -ErrorAction SilentlyContinue)) {
# --hostname github.com scopes the fallback to github.com credentials,
# so a gh setup whose default host is a GitHub Enterprise server never
# leaks a GHES token to api.github.com. On an ancient gh without the
# flag, stderr is swallowed and we fall back to anonymous.
try { $ghToken = (gh auth token --hostname github.com 2>$null) } catch { }
}
$ghHeaders = if ($ghToken) { @{ Authorization = "Bearer $ghToken" } } else { @{} }
# A stale/revoked token (expired GITHUB_TOKEN lingering in CI images,
# dotfiles, direnv) gets a 401 here and would break an install that
# works fine anonymously today. Retry anonymously ONLY on HTTP 401:
# requests carrying invalid credentials count against the anonymous
# 60/hour per-IP pool, so a blind retry on any failure would double the
# burn, and network failures gain nothing from a second attempt. The
# [int] cast handles both Windows PowerShell 5.1 (HttpWebResponse enum)
# and PowerShell 7 (HttpResponseMessage); the inner try guards a null
# Response (e.g. DNS failure). See backnotprop/plannotator#1157.
$apiUrl = "https://api.github.com/repos/$repo/releases/latest"
try {
$release = Invoke-RestMethod -Uri $apiUrl -Headers $ghHeaders
} catch {
$status = $null
try { $status = [int]$_.Exception.Response.StatusCode } catch { }
if ($ghHeaders.Count -gt 0 -and $status -eq 401) {
try {
$release = Invoke-RestMethod -Uri $apiUrl
} catch {
Write-Error "Failed to fetch latest version: $($_.Exception.Message)"
exit 1
}
} else {
Write-Error "Failed to fetch latest version: $($_.Exception.Message) (if this is HTTP 403, the GitHub API may be rate-limiting your IP; see https://github.com/backnotprop/plannotator/issues/1156)"
exit 1
}
}
# Drop the local token copies; GITHUB_TOKEN / GH_TOKEN themselves remain
# in the environment exactly as the user set them.
$ghToken = $null; $ghHeaders = $null; $apiUrl = $null
$latestTag = $release.tag_name

if (-not $latestTag) {
Expand Down
43 changes: 42 additions & 1 deletion scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,48 @@ fi

if [ "$VERSION" = "latest" ]; then
echo "Fetching latest version..."
latest_tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | cut -d'"' -f4)

# api.github.com caps unauthenticated requests at 60/hour per source IP,
# which fails installs behind shared egress IPs (NAT/CGNAT/corporate
# proxies) and during repeated/debug runs within an hour. Attach an
# Authorization header when a token is available (raises the limit to
# 5000/hour); when none is found, fall back to anonymous (unchanged
# behavior). Precedence matches `gh`: GITHUB_TOKEN > GH_TOKEN > gh auth token.
GH_AUTH_HEADER=()
if [ -n "${GITHUB_TOKEN:-${GH_TOKEN:-}}" ]; then
GH_AUTH_HEADER=(-H "Authorization: Bearer ${GITHUB_TOKEN:-${GH_TOKEN}}")
elif command -v gh >/dev/null 2>&1; then
# --hostname github.com scopes the fallback to github.com credentials,
# so a gh setup whose default host is a GitHub Enterprise server never
# leaks a GHES token to api.github.com. On an ancient gh without the
# flag, stderr is swallowed and we fall back to anonymous.
if _gh_token="$(gh auth token --hostname github.com 2>/dev/null)" && [ -n "$_gh_token" ]; then
GH_AUTH_HEADER=(-H "Authorization: Bearer ${_gh_token}")
fi
fi
# A stale/revoked token (expired GITHUB_TOKEN lingering in CI images,
# dotfiles, direnv) gets a 401 here and would break an install that
# works fine anonymously today. Retry anonymously ONLY on HTTP 401:
# requests carrying invalid credentials count against the anonymous
# 60/hour per-IP pool, so a blind retry on any failure would double the
# burn, and network failures gain nothing from a second attempt.
# Note: no -f here, so a 401 body doesn't abort curl before -w prints
# the status code. See backnotprop/plannotator#1157.
_api_url="https://api.github.com/repos/${REPO}/releases/latest"
_api_body=$(curl -sSL -w '\n%{http_code}' "${GH_AUTH_HEADER[@]}" "$_api_url" 2>/dev/null) || true
_api_code="${_api_body##*$'\n'}"
if [ "$_api_code" = "401" ] && [ ${#GH_AUTH_HEADER[@]} -gt 0 ]; then
_api_body=$(curl -sSL -w '\n%{http_code}' "$_api_url" 2>/dev/null) || true
_api_code="${_api_body##*$'\n'}"
fi
if [ "$_api_code" = "200" ]; then
latest_tag=$(printf '%s' "$_api_body" | grep '"tag_name"' | cut -d'"' -f4)
else
latest_tag=""
fi
# Drop the local token copies; GITHUB_TOKEN / GH_TOKEN themselves remain
# in the environment exactly as the user set them.
unset _gh_token GH_AUTH_HEADER _api_url _api_body _api_code

if [ -z "$latest_tag" ]; then
echo "Failed to fetch latest version" >&2
Expand Down
Loading