From d84fad76edc35398d3138549581d10bcdef5bbbc Mon Sep 17 00:00:00 2001 From: GareArc Date: Wed, 3 Jun 2026 19:14:19 -0700 Subject: [PATCH] feat(cli): trigger-agnostic difyctl release pipeline + tokenless installers - cli-release.yml: workflow_dispatch/workflow_call/repository_dispatch/tag triggers; version sourced from cli/package.json (not the trigger). Split into validate + release jobs with an idempotency guard (gh release view) and a tag-vs-manifest match guard so a difyctl-v* tag push must equal the manifest version (prevents a dangling/mismatched tag). Releases may be cut from any branch. Concurrency serialized (fixed group, cancel-in-progress: false) so releases never double-publish. - install-cli.sh: tokenless resolution via git/matching-refs, channel-aware (stable/rc) selection, fail-closed sha256 verification; distinguishes a fetch failure (network / API rate limit) from "no release found". - install.ps1: Windows installer at parity with install-cli.sh. - install-cli.test.ts: resolver unit tests (rc ordering, channel filter). - .bun-version: pin Bun 1.3.11 for reproducible --compile builds. --- .github/workflows/cli-release.yml | 117 +++++++++++++---- cli/.bun-version | 1 + cli/scripts/install-cli.sh | 211 ++++++++++++++++-------------- cli/scripts/install-cli.test.ts | 50 +++++++ cli/scripts/install.ps1 | 89 +++++++++++++ 5 files changed, 343 insertions(+), 125 deletions(-) create mode 100644 cli/.bun-version create mode 100644 cli/scripts/install-cli.test.ts create mode 100644 cli/scripts/install.ps1 diff --git a/.github/workflows/cli-release.yml b/.github/workflows/cli-release.yml index 2aea3c9e0ed..1861c9477ec 100644 --- a/.github/workflows/cli-release.yml +++ b/.github/workflows/cli-release.yml @@ -2,40 +2,41 @@ name: CLI Release on: workflow_dispatch: + workflow_call: + repository_dispatch: + types: [difyctl-release] push: tags: - 'difyctl-v*' concurrency: - group: cli-release-${{ github.ref }} - cancel-in-progress: true + group: difyctl-release + cancel-in-progress: false jobs: - release: - name: build standalone binaries (all targets) + validate: + name: validate manifest + idempotency guard runs-on: depot-ubuntu-24.04 + # Releases may be cut from any branch (tag pushes and dispatches alike); the + # tag-vs-manifest guard below still prevents a dangling/mismatched tag. if: github.repository == 'langgenius/dify' permissions: - contents: write + contents: read defaults: run: shell: bash working-directory: ./cli - + outputs: + version: ${{ steps.manifest.outputs.version }} + channel: ${{ steps.manifest.outputs.channel }} + minDify: ${{ steps.manifest.outputs.minDify }} + maxDify: ${{ steps.manifest.outputs.maxDify }} + already_exists: ${{ steps.guard.outputs.already_exists }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: persist-credentials: false - fetch-depth: 0 - - - name: Setup web environment - uses: ./.github/actions/setup-web - - - name: Setup Bun - uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2 - with: - bun-version: latest - name: Read cli/package.json id: manifest @@ -54,6 +55,56 @@ jobs: - name: Validate manifest run: scripts/release-validate-manifest.sh + - name: Tag matches manifest version + # On a tag push the version is still sourced from cli/package.json; the + # pushed tag must match it, else the tag would dangle with no release. + if: github.ref_type == 'tag' + run: | + expected="difyctl-v${{ steps.manifest.outputs.version }}" + if [ "$GITHUB_REF_NAME" != "$expected" ]; then + echo "::error::pushed tag ${GITHUB_REF_NAME} does not match manifest version (${expected}); bump cli/package.json or push the matching tag" + exit 1 + fi + + - name: Idempotency guard + id: guard + env: + GH_TOKEN: ${{ github.token }} + run: | + tag="difyctl-v${{ steps.manifest.outputs.version }}" + if gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "already_exists=true" >> "$GITHUB_OUTPUT" + echo "::notice::release ${tag} already exists; release job will skip" + else + echo "already_exists=false" >> "$GITHUB_OUTPUT" + fi + + release: + name: build standalone binaries (all targets) + needs: validate + if: needs.validate.outputs.already_exists == 'false' + runs-on: depot-ubuntu-24.04 + permissions: + contents: write + defaults: + run: + shell: bash + working-directory: ./cli + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + fetch-depth: 1 + + - name: Setup web environment + uses: ./.github/actions/setup-web + + - name: Setup Bun + uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2 + with: + bun-version-file: cli/.bun-version + - name: Install cross-arch native prebuilds # Re-installs node_modules with every @napi-rs/keyring platform variant # so `bun build --compile` can embed the right .node into each target. @@ -62,10 +113,10 @@ jobs: - name: Compile standalone binaries (all targets) env: - CLI_VERSION: ${{ steps.manifest.outputs.version }} - DIFYCTL_CHANNEL: ${{ steps.manifest.outputs.channel }} - DIFYCTL_MIN_DIFY: ${{ steps.manifest.outputs.minDify }} - DIFYCTL_MAX_DIFY: ${{ steps.manifest.outputs.maxDify }} + CLI_VERSION: ${{ needs.validate.outputs.version }} + DIFYCTL_CHANNEL: ${{ needs.validate.outputs.channel }} + DIFYCTL_MIN_DIFY: ${{ needs.validate.outputs.minDify }} + DIFYCTL_MAX_DIFY: ${{ needs.validate.outputs.maxDify }} run: | DIFYCTL_COMMIT="$(git rev-parse HEAD)" \ DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \ @@ -73,16 +124,34 @@ jobs: - name: Generate sha256 checksum file env: - CLI_VERSION: ${{ steps.manifest.outputs.version }} + CLI_VERSION: ${{ needs.validate.outputs.version }} run: scripts/release-write-checksums.sh - name: Publish GitHub Release uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 with: - tag_name: difyctl-v${{ steps.manifest.outputs.version }} - name: difyctl ${{ steps.manifest.outputs.version }} - prerelease: ${{ steps.manifest.outputs.channel != 'stable' }} - generate_release_notes: true + tag_name: difyctl-v${{ needs.validate.outputs.version }} + name: difyctl ${{ needs.validate.outputs.version }} + prerelease: ${{ needs.validate.outputs.channel != 'stable' }} + generate_release_notes: false fail_on_unmatched_files: true + body: | + difyctl ${{ needs.validate.outputs.version }} (${{ needs.validate.outputs.channel }}) + + **Compatible with Dify** ${{ needs.validate.outputs.minDify }} – ${{ needs.validate.outputs.maxDify }} + + ### Install + + macOS / Linux: + ```sh + curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-cli.sh | sh + ``` + + Windows (PowerShell): + ```powershell + irm https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install.ps1 | iex + ``` + + [Changes in `cli/`](https://github.com/langgenius/dify/commits/difyctl-v${{ needs.validate.outputs.version }}/cli) files: | cli/dist/bin/difyctl-v* diff --git a/cli/.bun-version b/cli/.bun-version new file mode 100644 index 00000000000..17e63e7affd --- /dev/null +++ b/cli/.bun-version @@ -0,0 +1 @@ +1.3.11 diff --git a/cli/scripts/install-cli.sh b/cli/scripts/install-cli.sh index 955dcd5a9e9..5952d7da4db 100755 --- a/cli/scripts/install-cli.sh +++ b/cli/scripts/install-cli.sh @@ -1,118 +1,127 @@ #!/bin/sh -# install-cli.sh — one-line difyctl installer from the latest GitHub Actions build. +# install-cli.sh — one-line difyctl installer from public GitHub Releases. # # usage: -# GH_TOKEN= curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-cli.sh | sh +# curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-cli.sh | sh # -# env: DIFYCTL_PREFIX (default $HOME/.local), DIFYCTL_REPO (default langgenius/dify), -# DIFYCTL_BRANCH (default main), -# GH_TOKEN/GITHUB_TOKEN (required — workflow artifact zip downloads need -# auth even on public repos; minimum scope: actions:read). -# requires: curl, uname, jq, unzip, sha256sum or shasum. - +# env: +# DIFYCTL_CHANNEL track to install: stable (default) | rc +# DIFYCTL_VERSION exact version pin (e.g. 0.2.0); overrides DIFYCTL_CHANNEL +# DIFYCTL_PREFIX install dir (default $HOME/.local); binary -> $PREFIX/bin/difyctl +# DIFYCTL_REPO release source repo (default langgenius/dify) +# requires: curl, uname, sort -V, and sha256sum or shasum. set -eu REPO="${DIFYCTL_REPO:-langgenius/dify}" -BRANCH="${DIFYCTL_BRANCH:-main}" PREFIX="${DIFYCTL_PREFIX:-${HOME}/.local}" -WORKFLOW_FILE="cli-release.yml" -TOKEN="${GH_TOKEN:-${GITHUB_TOKEN:-}}" +CHANNEL="${DIFYCTL_CHANNEL:-stable}" +VERSION="${DIFYCTL_VERSION:-}" +TAG_PREFIX="difyctl-v" err() { printf '%s\n' "install-cli: $*" >&2; } die() { err "$*"; exit 1; } need() { command -v "$1" >/dev/null 2>&1 || die "$1 is required"; } -need curl -need uname -need jq -need unzip +# select_version CHANNEL (reads git/matching-refs JSON on stdin) -> highest matching version +select_version() { + _channel="$1" + _versions=$(grep -oE '"ref"[[:space:]]*:[[:space:]]*"refs/tags/difyctl-v[^"]*"' \ + | sed -E 's#.*"refs/tags/difyctl-v([^"]*)".*#\1#') + case "$_channel" in + rc) _versions=$(printf '%s\n' "$_versions" | grep -E -- '-rc\.[0-9]+$' || true) ;; + stable) _versions=$(printf '%s\n' "$_versions" | grep -vE -- '-' || true) ;; + *) die "invalid DIFYCTL_CHANNEL: ${_channel} (expected stable | rc)" ;; + esac + printf '%s\n' "$_versions" | sed '/^$/d' | sort -V | tail -1 +} -[ -n "$TOKEN" ] || die "GH_TOKEN (or GITHUB_TOKEN) is required — workflow artifact downloads need auth" +detect_target() { + case "$(uname -s)" in + Linux*) _os=linux ;; + Darwin*) _os=darwin ;; + *) die "unsupported OS: $(uname -s) (use install.ps1 on Windows)" ;; + esac + case "$(uname -m)" in + x86_64|amd64) _arch=x64 ;; + arm64|aarch64) _arch=arm64 ;; + *) die "unsupported arch: $(uname -m)" ;; + esac + printf '%s-%s' "$_os" "$_arch" +} -gh_curl() { curl -fsSL -H "Authorization: Bearer ${TOKEN}" -H "Accept: application/vnd.github.v3+json" "$@"; } +resolve_version() { + if [ -n "$VERSION" ]; then + printf '%s' "$VERSION" + return 0 + fi + _api="https://api.github.com/repos/${REPO}/git/matching-refs/tags/difyctl-v" + if ! _refs=$(curl -fsSL -H "Accept: application/vnd.github+json" "$_api"); then + die "failed to query ${REPO} releases (network error or GitHub API rate limit); set DIFYCTL_VERSION to pin a version" + fi + _resolved=$(printf '%s' "$_refs" | select_version "$CHANNEL") + [ -n "$_resolved" ] || die "no ${CHANNEL} difyctl release found in ${REPO}" + printf '%s' "$_resolved" +} -if command -v sha256sum >/dev/null 2>&1; then - HASH="sha256sum" -elif command -v shasum >/dev/null 2>&1; then - HASH="shasum -a 256" -else - die "need sha256sum or shasum" +main() { + need curl + need uname + need sort + if command -v sha256sum >/dev/null 2>&1; then + HASH="sha256sum" + elif command -v shasum >/dev/null 2>&1; then + HASH="shasum -a 256" + else + die "need sha256sum or shasum" + fi + + target=$(detect_target) + version=$(resolve_version) + tag="${TAG_PREFIX}${version}" + asset="difyctl-v${version}-${target}" + checksums="difyctl-v${version}-checksums.txt" + base="https://github.com/${REPO}/releases/download/${tag}" + + tmp=$(mktemp -d 2>/dev/null || mktemp -d -t difyctl-install) + trap 'rm -rf "$tmp"' EXIT INT TERM + + printf 'downloading %s (%s)...\n' "$asset" "$tag" + curl -fsSL "${base}/${asset}" -o "${tmp}/${asset}" \ + || die "download failed: ${base}/${asset}" + curl -fsSL "${base}/${checksums}" -o "${tmp}/${checksums}" \ + || die "checksum manifest download failed: ${base}/${checksums}" + + # exact, end-anchored, regex-safe match of the asset's checksum line; fail-closed on no match + _pattern=$(printf '%s' "$asset" | sed 's/[][\\.^$*+?(){}|/]/\\&/g') + _sumline=$(grep -E -- "[[:space:]]${_pattern}\$" "${tmp}/${checksums}") || true + [ -n "$_sumline" ] || die "no checksum entry for ${asset}" + ( + cd "$tmp" + printf '%s\n' "$_sumline" | $HASH -c - + ) || die "checksum mismatch for ${asset}" + + bin_dir="${PREFIX}/bin" + mkdir -p "$bin_dir" + target_bin="${bin_dir}/difyctl" + cp "${tmp}/${asset}" "$target_bin" + chmod +x "$target_bin" + + printf '\ndifyctl v%s installed: %s\n' "$version" "$target_bin" + + case ":${PATH}:" in + *":${bin_dir}:"*) + "$target_bin" version >/dev/null 2>&1 \ + && printf 'verify: run "difyctl version"\n' \ + || err "binary present but failed to execute; check ${target_bin}" + ;; + *) + printf '\n%s is not on your PATH. Add this to your shell profile:\n' "$bin_dir" + printf ' export PATH="%s:$PATH"\n' "$bin_dir" + ;; + esac +} + +# Run main unless sourced as a library (tests set DIFYCTL_INSTALL_LIB=1). +if [ "${DIFYCTL_INSTALL_LIB:-0}" != "1" ]; then + main "$@" fi - -case "$(uname -s)" in - Linux*) os=linux ;; - Darwin*) os=darwin ;; - *) die "unsupported OS: $(uname -s) (use the Windows .exe directly)" ;; -esac - -case "$(uname -m)" in - x86_64|amd64) arch=x64 ;; - arm64|aarch64) arch=arm64 ;; - *) die "unsupported arch: $(uname -m)" ;; -esac - -target="${os}-${arch}" - -# 1. Find the latest successful workflow run on the branch -api_url="https://api.github.com/repos/${REPO}/actions/workflows/${WORKFLOW_FILE}/runs?branch=${BRANCH}&status=success&per_page=1" -run_id=$(gh_curl "$api_url" | jq -r '.workflow_runs[0].id') - -if [ -z "$run_id" ] || [ "$run_id" = "null" ]; then - die "could not find a successful workflow run for ${WORKFLOW_FILE} on branch ${BRANCH}" -fi - -# 2. Find the artifact from that run -artifacts_url="https://api.github.com/repos/${REPO}/actions/runs/${run_id}/artifacts" -artifact_info=$(gh_curl "$artifacts_url" | jq '.artifacts[0]') -artifact_id=$(printf '%s' "$artifact_info" | jq -r '.id') -artifact_name=$(printf '%s' "$artifact_info" | jq -r '.name') - -if [ -z "$artifact_id" ] || [ "$artifact_id" = "null" ]; then - die "could not find any artifacts for workflow run ${run_id}" -fi - -# 3. Download and unzip the artifact (one zip with all platform binaries + checksums) -tmp=$(mktemp -d 2>/dev/null || mktemp -d -t difyctl-install) -trap 'rm -rf "$tmp"' EXIT INT TERM - -download_url="https://api.github.com/repos/${REPO}/actions/artifacts/${artifact_id}/zip" -printf 'downloading artifact %s (run %s)...\n' "$artifact_name" "$run_id" -gh_curl -L "$download_url" -o "${tmp}/artifact.zip" -unzip -q "${tmp}/artifact.zip" -d "${tmp}/artifact" - -# 4. Locate the binary for this host + the checksum manifest -asset_path=$(ls "${tmp}/artifact"/difyctl-v*-"${target}" 2>/dev/null | head -1) -[ -n "$asset_path" ] || die "no binary matching target ${target} in artifact" -asset=$(basename "$asset_path") -cli_version=${asset#difyctl-v} -cli_version=${cli_version%-${target}} -checksums="difyctl-v${cli_version}-checksums.txt" - -[ -f "${tmp}/artifact/${checksums}" ] || die "checksum file ${checksums} not found in artifact" - -# 5. Verify checksum -( - cd "${tmp}/artifact" - grep " ${asset}\$" "$checksums" | $HASH -c - -) || die "checksum mismatch for ${asset}" - -# 6. Install: copy binary to /bin/difyctl and chmod +x -bin_dir="${PREFIX}/bin" -mkdir -p "$bin_dir" -target_bin="${bin_dir}/difyctl" -cp "${tmp}/artifact/${asset}" "$target_bin" -chmod +x "$target_bin" - -printf '\ndifyctl v%s installed: %s\n' "$cli_version" "$target_bin" - -case ":${PATH}:" in - *":${bin_dir}:"*) - "$target_bin" version >/dev/null 2>&1 \ - && printf 'verify: run "difyctl version"\n' \ - || err "binary present but failed to execute; check ${target_bin}" - ;; - *) - printf '\n%s is not on your PATH. Add this to your shell profile:\n' "$bin_dir" - printf ' export PATH="%s:$PATH"\n' "$bin_dir" - ;; -esac diff --git a/cli/scripts/install-cli.test.ts b/cli/scripts/install-cli.test.ts new file mode 100644 index 00000000000..381c64415ee --- /dev/null +++ b/cli/scripts/install-cli.test.ts @@ -0,0 +1,50 @@ +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const SCRIPT = fileURLToPath(new URL('./install-cli.sh', import.meta.url)) + +// Drive the pure resolver: `. install-cli.sh; select_version ` with +// the matching-refs JSON piped to stdin. DIFYCTL_INSTALL_LIB=1 stops main running. +function selectVersion(channel: string, refsJson: string): string { + return execFileSync('sh', ['-c', `. "${SCRIPT}"; select_version "$1"`, 'sh', channel], { + input: refsJson, + encoding: 'utf8', + env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' }, + }).trim() +} + +// A git/matching-refs/tags/difyctl-v response in arbitrary (non-sorted) order, +// with a stray non-difyctl ref to prove the filter is strict. +const REFS = JSON.stringify([ + { ref: 'refs/tags/difyctl-v0.1.0-rc.1' }, + { ref: 'refs/tags/difyctl-v0.2.0' }, + { ref: 'refs/tags/difyctl-v0.1.0' }, + { ref: 'refs/tags/some-other-tag' }, + { ref: 'refs/tags/difyctl-v0.2.0-rc.2' }, + { ref: 'refs/tags/difyctl-v0.10.0' }, + { ref: 'refs/tags/difyctl-v0.2.0-rc.10' }, +]) + +describe('install-cli select_version', () => { + it('stable picks the highest non-prerelease difyctl version', () => { + expect(selectVersion('stable', REFS)).toBe('0.10.0') + }) + + it('rc picks the highest rc (semver-aware: rc.10 > rc.2)', () => { + expect(selectVersion('rc', REFS)).toBe('0.2.0-rc.10') + }) + + it('ignores non-difyctl refs entirely', () => { + const noisy = JSON.stringify([{ ref: 'refs/tags/v1.15.0' }, { ref: 'refs/tags/difyctl-v0.3.0' }]) + expect(selectVersion('stable', noisy)).toBe('0.3.0') + }) + + it('yields empty when there are no difyctl tags', () => { + expect(selectVersion('stable', '[]')).toBe('') + }) + + it('rejects an invalid channel', () => { + expect(() => selectVersion('nightly', REFS)).toThrow() + }) +}) diff --git a/cli/scripts/install.ps1 b/cli/scripts/install.ps1 new file mode 100644 index 00000000000..c092a837667 --- /dev/null +++ b/cli/scripts/install.ps1 @@ -0,0 +1,89 @@ +# install.ps1 — one-line difyctl installer for Windows from public GitHub Releases. +# +# usage: +# irm https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install.ps1 | iex +# +# env: +# DIFYCTL_CHANNEL stable (default) | rc +# DIFYCTL_VERSION exact version pin (e.g. 0.2.0); overrides DIFYCTL_CHANNEL +# DIFYCTL_PREFIX install dir (default $env:LOCALAPPDATA\difyctl) +# DIFYCTL_REPO release source repo (default langgenius/dify) + +$ErrorActionPreference = 'Stop' + +$repo = if ($env:DIFYCTL_REPO) { $env:DIFYCTL_REPO } else { 'langgenius/dify' } +$channel = if ($env:DIFYCTL_CHANNEL) { $env:DIFYCTL_CHANNEL } else { 'stable' } +$version = $env:DIFYCTL_VERSION +$prefix = if ($env:DIFYCTL_PREFIX) { $env:DIFYCTL_PREFIX } else { Join-Path $env:LOCALAPPDATA 'difyctl' } +$target = 'windows-x64' + +function Select-Version([string]$Channel, [object[]]$Refs) { + $versions = $Refs.ref | + Where-Object { $_ -like 'refs/tags/difyctl-v*' } | + ForEach-Object { $_ -replace '^refs/tags/difyctl-v', '' } + switch ($Channel) { + 'rc' { $versions = $versions | Where-Object { $_ -match '-rc\.\d+$' } } + 'stable' { $versions = $versions | Where-Object { $_ -notmatch '-' } } + default { throw "invalid DIFYCTL_CHANNEL: $Channel (expected stable | rc)" } + } + $versions | + Sort-Object ` + @{ Expression = { [version](($_ -split '-')[0]) } }, ` + @{ Expression = { if ($_ -match '-rc\.(\d+)$') { [int]$Matches[1] } else { [int]::MaxValue } } } | + Select-Object -Last 1 +} + +function Resolve-Version { + if ($version) { return $version } + $api = "https://api.github.com/repos/$repo/git/matching-refs/tags/difyctl-v" + try { + $refs = Invoke-RestMethod -Uri $api -Headers @{ Accept = 'application/vnd.github+json' } + } catch { + throw "failed to query $repo releases (network error or GitHub API rate limit); set DIFYCTL_VERSION to pin a version" + } + $resolved = Select-Version -Channel $channel -Refs $refs + if (-not $resolved) { throw "no $channel difyctl release found in $repo" } + return $resolved +} + +$ver = Resolve-Version +$tag = "difyctl-v$ver" +$asset = "difyctl-v$ver-$target.exe" +$checksums = "difyctl-v$ver-checksums.txt" +$base = "https://github.com/$repo/releases/download/$tag" + +$tmp = Join-Path $env:TEMP ("difyctl-" + [guid]::NewGuid().ToString('N')) +New-Item -ItemType Directory -Path $tmp -Force | Out-Null +try { + Write-Host "downloading $asset ($tag)..." + $assetPath = Join-Path $tmp $asset + $sumsPath = Join-Path $tmp $checksums + Invoke-WebRequest -Uri "$base/$asset" -OutFile $assetPath + Invoke-WebRequest -Uri "$base/$checksums" -OutFile $sumsPath + + $expected = (Get-Content $sumsPath | + Where-Object { $_ -match '\s' + [regex]::Escape($asset) + '$' } | + ForEach-Object { ($_ -split '\s+')[0] } | + Select-Object -First 1) + if (-not $expected) { throw "no checksum entry for $asset" } + $actual = (Get-FileHash -Path $assetPath -Algorithm SHA256).Hash.ToLower() + if ($actual -ne $expected.ToLower()) { throw "checksum mismatch for $asset" } + + $binDir = Join-Path $prefix 'bin' + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + $targetBin = Join-Path $binDir 'difyctl.exe' + Copy-Item -Path $assetPath -Destination $targetBin -Force + + Write-Host "" + Write-Host "difyctl v$ver installed: $targetBin" + if (($env:PATH -split ';') -notcontains $binDir) { + Write-Host "" + Write-Host "$binDir is not on your PATH. Add it with:" + Write-Host " [Environment]::SetEnvironmentVariable('PATH', `"$binDir;`$env:PATH`", 'User')" + } else { + Write-Host 'verify: run "difyctl version"' + } +} +finally { + Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue +}