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')
+ })
+})