diff --git a/cli/README.md b/cli/README.md index 8a8521e8d1b..bde55e4bfe9 100644 --- a/cli/README.md +++ b/cli/README.md @@ -28,7 +28,7 @@ curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/in Windows: `$env:DIFYCTL_R2_BASE=''; irm https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-r2.ps1 | iex` (same env vars, e.g. `$env:DIFYCTL_COMMIT='ce4af86'`). -Re-run to upgrade. For tagged `rc`/`stable` builds, use the GitHub installer (`install-cli.sh`). +Re-run to upgrade. For tagged `rc`/`stable` builds, use the GitHub installer (`install-cli.sh` / `install.ps1`), which resolves releases via the GitHub API. That API caps unauthenticated requests at 60/hour per IP; behind a shared NAT or in CI, set `GITHUB_TOKEN` (or `GH_TOKEN`) to raise it to 5000/hour — the installer sends it as a bearer token. ## Quickstart diff --git a/cli/scripts/install-cli.sh b/cli/scripts/install-cli.sh index 5bc2d3e93a0..7bd87886c35 100755 --- a/cli/scripts/install-cli.sh +++ b/cli/scripts/install-cli.sh @@ -10,6 +10,7 @@ # DIFYCTL_VERSION difyctl version pin (used only when DIFY_VERSION is unset). # DIFYCTL_PREFIX install dir (default $HOME/.local); binary -> $PREFIX/bin/difyctl # DIFYCTL_REPO release source repo (default langgenius/dify) +# GITHUB_TOKEN GitHub token (or GH_TOKEN) to raise the API rate limit to 5000/hour. # requires: curl, uname, sort -V, and sha256sum or shasum. set -eu @@ -17,11 +18,14 @@ REPO="${DIFYCTL_REPO:-langgenius/dify}" PREFIX="${DIFYCTL_PREFIX:-${HOME}/.local}" DIFY_VERSION="${DIFY_VERSION:-}" DIFYCTL_VERSION="${DIFYCTL_VERSION:-}" +GH_AUTH="${GITHUB_TOKEN:-${GH_TOKEN:-}}" +# fetch_json runs in a subshell, so it reports failures via this file, not a variable. +FETCH_ERR_FILE="${TMPDIR:-/tmp}/difyctl-fetcherr.$$" API="https://api.github.com/repos/${REPO}" DL="https://github.com/${REPO}/releases/download" err() { printf '%s\n' "install-cli: $*" >&2; } -die() { err "$*"; exit 1; } +die() { err "$*"; rm -f "$FETCH_ERR_FILE"; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } re_escape() { printf '%s' "$1" | sed 's/[][\\.^$*+?(){}|/]/\\&/g'; } @@ -66,8 +70,75 @@ list_release_tags() { | sed -E 's#.*:[[:space:]]*"([^"]*)".*#\1#' } +# fetch_json URL -> body on stdout, 0 on success. On failure writes the cause to +# FETCH_ERR_FILE (ratelimit: | http: | network) and returns nonzero. +# Inspects the status and rate-limit headers, so it must not use curl -f. fetch_json() { - curl -fsSL -H "Accept: application/vnd.github+json" "$1" + rm -f "$FETCH_ERR_FILE" + _hdr=$(mktemp) || return 1 + _body=$(mktemp) || { rm -f "$_hdr"; return 1; } + if [ -n "$GH_AUTH" ]; then + _code=$(curl -sSL -D "$_hdr" -o "$_body" -w '%{http_code}' \ + -H "Accept: application/vnd.github+json" \ + -H "Authorization: Bearer ${GH_AUTH}" \ + "$1" 2>/dev/null) || _code=000 + else + _code=$(curl -sSL -D "$_hdr" -o "$_body" -w '%{http_code}' \ + -H "Accept: application/vnd.github+json" \ + "$1" 2>/dev/null) || _code=000 + fi + case "$_code" in + 2*) cat "$_body"; rm -f "$_hdr" "$_body"; return 0 ;; + 403|429) + _rem=$(awk 'tolower($1)=="x-ratelimit-remaining:"{gsub(/\r/,"",$2);v=$2} END{print v}' "$_hdr") + if [ "$_code" = "429" ] || [ "${_rem:-}" = "0" ]; then + _rst=$(awk 'tolower($1)=="x-ratelimit-reset:"{gsub(/\r/,"",$2);v=$2} END{print v}' "$_hdr") + printf 'ratelimit:%s' "$_rst" > "$FETCH_ERR_FILE" + else + printf 'http:%s' "$_code" > "$FETCH_ERR_FILE" + fi ;; + 000) printf 'network' > "$FETCH_ERR_FILE" ;; + *) printf 'http:%s' "$_code" > "$FETCH_ERR_FILE" ;; + esac + rm -f "$_hdr" "$_body" + return 1 +} + +# rate_limit_hint [RESET_EPOCH] -> explain the GitHub API limit and how to proceed. +rate_limit_hint() { + err "GitHub API rate limit exceeded (unauthenticated requests are capped at 60/hour per IP)." + case "${1:-}" in + '' | *[!0-9]*) ;; + *) + _now=$(date +%s 2>/dev/null) || _now="" + if [ -n "$_now" ]; then + _mins=$(( ($1 - _now + 59) / 60 )) + if [ "$_mins" -gt 0 ]; then + err "The limit resets in ~${_mins} min." + fi + fi ;; + esac + err "To proceed now, authenticate to raise the limit to 5000/hour:" + err " curl -fsSL | GITHUB_TOKEN= sh" + err "Tip: pinning DIFY_VERSION makes a single API call (DIFYCTL_VERSION scans many)." +} + +# True if the last fetch_json hit a rate limit — lets a subshell bail without printing. +fetch_hit_ratelimit() { + [ -f "$FETCH_ERR_FILE" ] || return 1 + case "$(cat "$FETCH_ERR_FILE" 2>/dev/null || true)" in + ratelimit:*) return 0 ;; + esac + return 1 +} + +# Explain and exit if the last fetch_json hit a rate limit; else return. Main shell only. +maybe_ratelimit_exit() { + fetch_hit_ratelimit || return 0 + _reset=$(cat "$FETCH_ERR_FILE" 2>/dev/null || true) + rm -f "$FETCH_ERR_FILE" + rate_limit_hint "${_reset#ratelimit:}" + exit 1 } # find_release_for_difyctl WANT TARGET -> newest Dify tag whose assets host that difyctl build @@ -75,11 +146,11 @@ find_release_for_difyctl() { _want="$1" _target="$2" _raw=$(fetch_json "${API}/releases?per_page=100") \ - || die "failed to query ${REPO} releases (network error or GitHub API rate limit)" + || { fetch_hit_ratelimit && return 1; die "failed to query ${REPO} releases (network error or GitHub API rate limit)"; } _tags=$(printf '%s' "$_raw" | list_release_tags) for _t in $_tags; do _rel=$(fetch_json "${API}/releases/tags/${_t}") \ - || { err "fetch failed for ${_t}, skipping"; continue; } + || { fetch_hit_ratelimit && return 1; err "fetch failed for ${_t}, skipping"; continue; } _name=$(printf '%s' "$_rel" | pick_asset "$_target") [ -n "$_name" ] || continue if [ "$(asset_version "$_name" "$_target")" = "$_want" ]; then @@ -94,22 +165,23 @@ resolve_release() { _target="$1" if [ -n "$DIFY_VERSION" ]; then REL=$(fetch_json "${API}/releases/tags/${DIFY_VERSION}") \ - || die "Dify release ${DIFY_VERSION} not found" + || { maybe_ratelimit_exit; die "Dify release ${DIFY_VERSION} not found"; } DIFY_TAG="$DIFY_VERSION" elif [ -n "$DIFYCTL_VERSION" ]; then DIFY_TAG=$(find_release_for_difyctl "$DIFYCTL_VERSION" "$_target") \ - || die "difyctl ${DIFYCTL_VERSION} not found on any Dify release" + || { maybe_ratelimit_exit; die "difyctl ${DIFYCTL_VERSION} not found on any Dify release"; } REL=$(fetch_json "${API}/releases/tags/${DIFY_TAG}") \ - || die "failed to fetch Dify release ${DIFY_TAG}" + || { maybe_ratelimit_exit; die "failed to fetch Dify release ${DIFY_TAG}"; } else REL=$(fetch_json "${API}/releases/latest") \ - || die "failed to query latest Dify release (set DIFY_VERSION to pin one)" + || { maybe_ratelimit_exit; die "failed to query latest Dify release (set DIFY_VERSION to pin one)"; } DIFY_TAG=$(printf '%s' "$REL" | list_release_tags | head -1) [ -n "$DIFY_TAG" ] || die "could not parse a tag from the latest Dify release" fi } main() { + trap 'rm -f "$FETCH_ERR_FILE"' EXIT INT TERM need curl need uname sort -V /dev/null >/dev/null 2>&1 || die "sort with -V support is required (install coreutils)" @@ -131,7 +203,7 @@ main() { base="${DL}/${DIFY_TAG}" tmp=$(mktemp -d 2>/dev/null || mktemp -d -t difyctl-install) - trap 'rm -rf "$tmp"' EXIT INT TERM + trap 'rm -rf "$tmp"; rm -f "$FETCH_ERR_FILE"' EXIT INT TERM printf 'downloading %s (Dify %s)...\n' "$asset" "$DIFY_TAG" curl -fsSL "${base}/${asset}" -o "${tmp}/${asset}" \ diff --git a/cli/scripts/install-cli.test.ts b/cli/scripts/install-cli.test.ts index d72b940df37..5f30114ccdd 100644 --- a/cli/scripts/install-cli.test.ts +++ b/cli/scripts/install-cli.test.ts @@ -1,4 +1,7 @@ import { execFileSync, spawnSync } from 'node:child_process' +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' @@ -50,6 +53,24 @@ function runLib(program: string, env: Record = {}): { code: numb return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' } } +// Like runLib but with a caller-supplied fetch_json stub, so we can drive the +// real rate_limit_hint / maybe_ratelimit_exit / fetch_hit_ratelimit (which the +// script defines) by writing a classified reason to FETCH_ERR_FILE. +function runLibStub(stub: string, program: string, env: Record = {}): { code: number, stderr: string } { + const full = `. "${SCRIPT}"\n${stub}\n${program}` + const r = spawnSync('sh', ['-c', full], { + encoding: 'utf8', + env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', ...env }, + }) + return { code: r.status ?? 1, stderr: r.stderr ?? '' } +} + +// A fetch_json that always fails with the given classification, mimicking the +// real one writing to FETCH_ERR_FILE from inside a command-substitution subshell. +function failStub(reason: string): string { + return `fetch_json() { printf '%s' '${reason}' > "$FETCH_ERR_FILE"; return 1; }` +} + const REL_1142 = JSON.stringify({ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }] }) const REL_1150 = JSON.stringify({ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-linux-x64' }] }) const LIST_NEWEST_FIRST = JSON.stringify({ releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }] }) @@ -190,3 +211,132 @@ describe('install-cli find_release_for_difyctl', () => { expect(r.stderr).toContain('fetch failed for 1.15.0') }) }) + +describe('install-cli rate limit', () => { + const futureReset = String(Math.floor(Date.now() / 1000) + 1800) + + it('latest: reports the rate limit with reset ETA and remediation, not a generic error', () => { + const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64') + expect(r.code).not.toBe(0) + expect(r.stderr).toContain('rate limit exceeded') + expect(r.stderr).toContain('resets in ~') + expect(r.stderr).toContain('GITHUB_TOKEN') + expect(r.stderr).not.toContain('failed to query latest') + }) + + it('DIFY_VERSION: rate limit wins over the misleading "not found" message', () => { + const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', { DIFY_VERSION: '1.15.0' }) + expect(r.code).not.toBe(0) + expect(r.stderr).toContain('rate limit exceeded') + expect(r.stderr).not.toContain('not found') + }) + + it('DIFYCTL_VERSION: rate limit surfaces from the nested subshell, not "not found"', () => { + const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', { DIFYCTL_VERSION: '0.2.0' }) + expect(r.code).not.toBe(0) + expect(r.stderr).toContain('rate limit exceeded') + expect(r.stderr).not.toContain('not found') + }) + + it('omits the ETA line when the reset epoch is missing', () => { + const r = runLibStub(failStub('ratelimit:'), 'resolve_release linux-x64') + expect(r.code).not.toBe(0) + expect(r.stderr).toContain('rate limit exceeded') + expect(r.stderr).not.toContain('resets in ~') + }) + + it('a non-rate-limit HTTP error falls back to the generic message (no false hint)', () => { + const r = runLibStub(failStub('http:500'), 'resolve_release linux-x64') + expect(r.code).not.toBe(0) + expect(r.stderr).toContain('failed to query latest') + expect(r.stderr).not.toContain('rate limit exceeded') + }) +}) + +// A stand-in for curl that honours the flags fetch_json passes (-D/-o/-w/-H) and +// fabricates a response per FAKE_MODE, so the tests exercise the REAL fetch_json +// (its curl invocation, header parsing, classification and token handling) rather +// than a stub. Header names it emits are lowercase, as HTTP/2 delivers them. +const FAKE_CURL = `#!/bin/sh +hdr=""; body="" +while [ $# -gt 0 ]; do + case "$1" in + -D) hdr="$2"; shift 2 ;; + -o) body="$2"; shift 2 ;; + -H) printf '%s\\n' "$2" >> "\${FAKE_HDR_LOG:-/dev/null}"; shift 2 ;; + -w) shift 2 ;; + *) shift ;; + esac +done +case "\${FAKE_MODE:-ok}" in + ok) + [ -n "$hdr" ] && printf 'HTTP/2 200\\r\\n\\r\\n' > "$hdr" + [ -n "$body" ] && printf '%s' "\${FAKE_BODY:-}" > "$body" + printf '200' ;; + ratelimit) + [ -n "$hdr" ] && printf 'HTTP/2 403\\r\\nx-ratelimit-remaining: 0\\r\\nx-ratelimit-reset: %s\\r\\n\\r\\n' "\${FAKE_RESET:-9999999999}" > "$hdr" + printf '403' ;; + perm403) + [ -n "$hdr" ] && printf 'HTTP/2 403\\r\\nx-ratelimit-remaining: 59\\r\\n\\r\\n' > "$hdr" + printf '403' ;; + notfound) + [ -n "$hdr" ] && printf 'HTTP/2 404\\r\\n\\r\\n' > "$hdr" + printf '404' ;; + net) exit 6 ;; +esac +` + +// Drive the real fetch_json with FAKE_CURL first on PATH. Returns "OK|" or +// "FAIL|", plus any -H lines the fake curl received. +function runRealFetch(mode: string, env: Record = {}): { result: string, headers: string } { + const dir = mkdtempSync(join(tmpdir(), 'difyctl-fakecurl-')) + const hdrLog = join(dir, 'hdrlog') + writeFileSync(join(dir, 'curl'), FAKE_CURL) + chmodSync(join(dir, 'curl'), 0o755) + const program = 'if body=$(fetch_json "https://api.github.com/repos/x/releases/latest"); then printf \'OK|%s\' "$body"; else printf \'FAIL|%s\' "$(cat "$FETCH_ERR_FILE" 2>/dev/null)"; fi' + const r = spawnSync('sh', ['-c', `. "${SCRIPT}"\n${program}`], { + encoding: 'utf8', + env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}`, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', FAKE_MODE: mode, FAKE_HDR_LOG: hdrLog, ...env }, + }) + let headers = '' + try { + headers = readFileSync(hdrLog, 'utf8') + } + catch { /* no headers logged */ } + rmSync(dir, { recursive: true, force: true }) + return { result: (r.stdout ?? '').trim(), headers } +} + +describe('install-cli fetch_json (real, fake curl on PATH)', () => { + it('returns the response body on 200', () => { + expect(runRealFetch('ok', { FAKE_BODY: '{"tag_name":"1.15.0"}' }).result).toBe('OK|{"tag_name":"1.15.0"}') + }) + + it('classifies a 403 with x-ratelimit-remaining:0 as a rate limit and captures the reset', () => { + expect(runRealFetch('ratelimit', { FAKE_RESET: '1893456000' }).result).toBe('FAIL|ratelimit:1893456000') + }) + + it('classifies a 403 with tokens left as a plain http error, not a rate limit', () => { + expect(runRealFetch('perm403').result).toBe('FAIL|http:403') + }) + + it('classifies a 404 as an http error', () => { + expect(runRealFetch('notfound').result).toBe('FAIL|http:404') + }) + + it('classifies a curl transport failure as a network error', () => { + expect(runRealFetch('net').result).toBe('FAIL|network') + }) + + it('sends an Authorization bearer header when GITHUB_TOKEN is set', () => { + expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: 'ghp_secret' }).headers).toContain('Authorization: Bearer ghp_secret') + }) + + it('falls back to GH_TOKEN when GITHUB_TOKEN is unset', () => { + expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: 'gho_fallback' }).headers).toContain('Authorization: Bearer gho_fallback') + }) + + it('sends no Authorization header when neither token is set', () => { + expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: '' }).headers).not.toContain('Authorization') + }) +}) diff --git a/cli/scripts/install.ps1 b/cli/scripts/install.ps1 index 2f2f2f43204..19a114eff10 100644 --- a/cli/scripts/install.ps1 +++ b/cli/scripts/install.ps1 @@ -9,6 +9,8 @@ # DIFYCTL_VERSION difyctl version pin (used only when DIFY_VERSION is unset). # DIFYCTL_PREFIX install dir (default $env:LOCALAPPDATA\difyctl) # DIFYCTL_REPO release source repo (default langgenius/dify) +# GITHUB_TOKEN GitHub token (or GH_TOKEN) sent as a bearer to raise the API +# rate limit from 60 to 5000 requests/hour (useful in CI). $ErrorActionPreference = 'Stop' @@ -21,6 +23,55 @@ $apiBase = "https://api.github.com/repos/$repo" $dlBase = "https://github.com/$repo/releases/download" $headers = @{ Accept = 'application/vnd.github+json' } +$ghToken = if ($env:GITHUB_TOKEN) { $env:GITHUB_TOKEN } elseif ($env:GH_TOKEN) { $env:GH_TOKEN } else { $null } +if ($ghToken) { $headers.Authorization = "Bearer $ghToken" } + +# Read a response header, coping with both PS7 (TryGetValues) and PS5.1 (indexer). +function Get-HeaderValue($Response, [string]$Name) { + if (-not $Response -or -not $Response.Headers) { return $null } + try { + $vals = $null + if ($Response.Headers.TryGetValues($Name, [ref]$vals)) { return ($vals | Select-Object -First 1) } + return $null + } + catch { + try { return $Response.Headers[$Name] } catch { return $null } + } +} + +# $null unless the error is a GitHub rate limit; else an object with the reset epoch. +function Get-RateLimitInfo($ErrorRecord) { + $resp = $null + try { $resp = $ErrorRecord.Exception.Response } catch { return $null } + if (-not $resp) { return $null } + $status = 0; try { $status = [int]$resp.StatusCode } catch {} + if ($status -ne 403 -and $status -ne 429) { return $null } + $remaining = Get-HeaderValue $resp 'x-ratelimit-remaining' + if ($status -eq 429 -or $remaining -eq '0') { + return [pscustomobject]@{ Reset = (Get-HeaderValue $resp 'x-ratelimit-reset') } + } + return $null +} + +function Write-RateLimitHint($ResetEpoch) { + [Console]::Error.WriteLine('GitHub API rate limit exceeded (unauthenticated requests are capped at 60/hour per IP).') + if ("$ResetEpoch" -match '^\d+$') { + $mins = [int][math]::Ceiling(([long]$ResetEpoch - [DateTimeOffset]::UtcNow.ToUnixTimeSeconds()) / 60) + if ($mins -gt 0) { [Console]::Error.WriteLine("The limit resets in ~$mins min.") } + } + [Console]::Error.WriteLine('To proceed now, authenticate to raise the limit to 5000/hour:') + [Console]::Error.WriteLine(' $env:GITHUB_TOKEN = ""; irm | iex') + [Console]::Error.WriteLine('Tip: pinning DIFY_VERSION makes a single API call (DIFYCTL_VERSION scans many).') +} + +# Print the hint and exit if rate-limited; else return so the caller can throw. +function Stop-IfRateLimited($ErrorRecord) { + $info = Get-RateLimitInfo $ErrorRecord + if (-not $info) { return } + Write-RateLimitHint $info.Reset + exit 1 +} + function Get-AssetSemver([string]$Name) { if ($Name -notmatch '^difyctl-v(.+?)-windows-x64\.exe$') { return $null } $v = $Matches[1] @@ -39,7 +90,8 @@ function Select-Asset([object]$Release) { } function Find-ReleaseForDifyctl([string]$Want) { - $releases = Invoke-RestMethod -Uri "$apiBase/releases?per_page=100" -Headers $headers + try { $releases = Invoke-RestMethod -Uri "$apiBase/releases?per_page=100" -Headers $headers } + catch { Stop-IfRateLimited $_; throw } foreach ($rel in $releases) { $asset = Select-Asset $rel if ($asset -and $asset.Version -eq $Want) { return $rel } @@ -50,7 +102,7 @@ function Find-ReleaseForDifyctl([string]$Want) { function Resolve-Release { if ($difyVersion) { try { return Invoke-RestMethod -Uri "$apiBase/releases/tags/$difyVersion" -Headers $headers } - catch { throw "Dify release $difyVersion not found: $_" } + catch { Stop-IfRateLimited $_; throw "Dify release $difyVersion not found: $_" } } elseif ($difyctlVersion) { $release = Find-ReleaseForDifyctl $difyctlVersion @@ -59,7 +111,7 @@ function Resolve-Release { } else { try { return Invoke-RestMethod -Uri "$apiBase/releases/latest" -Headers $headers } - catch { throw "failed to query latest Dify release (set DIFY_VERSION to pin one): $_" } + catch { Stop-IfRateLimited $_; throw "failed to query latest Dify release (set DIFY_VERSION to pin one): $_" } } } diff --git a/cli/scripts/install.ps1.test.ts b/cli/scripts/install.ps1.test.ts index 65ff13d8586..3e5f39a3825 100644 --- a/cli/scripts/install.ps1.test.ts +++ b/cli/scripts/install.ps1.test.ts @@ -180,3 +180,79 @@ describe.skipIf(!PWSH)('install.ps1 Find-ReleaseForDifyctl', () => { expect(r.stdout).toBe('NULL') }) }) + +// Build a fake ErrorRecord whose Exception.Response is a real HttpResponseMessage +// carrying the given status + headers, matching what Get-RateLimitInfo inspects. +function fakeErr(status: number, headers: Record): string { + const adds = Object.entries(headers) + .map(([k, v]) => `$resp.Headers.TryAddWithoutValidation('${k}','${v}') | Out-Null`) + .join('\n') + return [ + `$resp = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]${status})`, + adds, + '$err = [pscustomobject]@{ Exception = [pscustomobject]@{ Response = $resp } }', + ].join('\n') +} + +const futureReset = String(Math.floor(Date.now() / 1000) + 1800) + +describe.skipIf(!PWSH)('install.ps1 rate limit', () => { + it('classifies a 403 with x-ratelimit-remaining:0 as rate-limited, returning the reset', () => { + const r = runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': futureReset })} + $i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { $i.Reset }`) + expect(r.code).toBe(0) + expect(r.stdout).toBe(futureReset) + }) + + it('does not classify a 403 with remaining tokens as rate-limited', () => { + const r = runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '59' })} + $i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { 'LIMITED' }`) + expect(r.code).toBe(0) + expect(r.stdout).toBe('NULL') + }) + + it('always treats a 429 as rate-limited', () => { + const r = runPwsh(`${fakeErr(429, {})} + $i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { 'LIMITED' }`) + expect(r.code).toBe(0) + expect(r.stdout).toBe('LIMITED') + }) + + it('returns null for an error without a response (e.g. a plain string throw)', () => { + const r = runPwsh('$err = [pscustomobject]@{ Exception = [pscustomobject]@{} }; if ($null -eq (Get-RateLimitInfo $err)) { \'NULL\' } else { \'OBJ\' }') + expect(r.code).toBe(0) + expect(r.stdout).toBe('NULL') + }) + + it('Write-RateLimitHint prints cause, ETA, and remediation to stderr', () => { + const r = runPwsh(`Write-RateLimitHint '${futureReset}'`) + expect(r.code).toBe(0) + expect(r.stderr).toContain('rate limit exceeded') + expect(r.stderr).toContain('resets in ~') + expect(r.stderr).toContain('GITHUB_TOKEN') + }) + + it('Write-RateLimitHint omits the ETA line when the reset epoch is missing', () => { + const r = runPwsh('Write-RateLimitHint \'\'') + expect(r.code).toBe(0) + expect(r.stderr).toContain('rate limit exceeded') + expect(r.stderr).not.toContain('resets in ~') + }) + + it('sends an Authorization header when GITHUB_TOKEN is set', () => { + const r = runPwsh('$headers.Authorization', { GITHUB_TOKEN: 'ghp_secret123' }) + expect(r.code).toBe(0) + expect(r.stdout).toBe('Bearer ghp_secret123') + }) + + it('Resolve-Release surfaces the rate-limit hint and exits, not "not found"', () => { + const stub = `function Invoke-RestMethod { throw [Microsoft.PowerShell.Commands.HttpResponseException]::new('rate limited', $script:rlResp) }` + const setup = `$script:rlResp = [System.Net.Http.HttpResponseMessage]::new([System.Net.HttpStatusCode]403) + $script:rlResp.Headers.TryAddWithoutValidation('x-ratelimit-remaining','0') | Out-Null + $script:rlResp.Headers.TryAddWithoutValidation('x-ratelimit-reset','${futureReset}') | Out-Null` + const r = runPwsh(`${setup}\n${stub}\nResolve-Release`, { DIFY_VERSION: '1.15.0' }) + expect(r.code).not.toBe(0) + expect(r.stderr).toContain('rate limit exceeded') + expect(r.stderr).not.toContain('not found') + }) +})