feat(api,web,cli): difyctl v1.0 — OAuth device flow, /openapi/v1 auth pipeline, CLI client

This commit is contained in:
GareArc
2026-05-11 18:40:39 -07:00
parent 8f070f2190
commit 6779366dca
333 changed files with 28550 additions and 101 deletions

149
cli/scripts/install-cli.ps1 Normal file
View File

@ -0,0 +1,149 @@
#Requires -Version 5.1
<#
.SYNOPSIS
One-line difyctl installer for Windows. Verifies sha256 before extract.
.PARAMETER Version
Dify release tag. Defaults to the latest release.
.PARAMETER Prefix
Install root. Defaults to $env:LOCALAPPDATA\difyctl.
.PARAMETER Repo
Release source repo. Defaults to langgenius/dify.
#>
[CmdletBinding()]
param(
[string]$Version = $env:DIFYCTL_VERSION,
[string]$Prefix = $env:DIFYCTL_PREFIX,
[string]$Repo = $env:DIFYCTL_REPO
)
$ErrorActionPreference = 'Stop'
if ([string]::IsNullOrEmpty($Version)) { $Version = 'latest' }
if ([string]::IsNullOrEmpty($Prefix)) { $Prefix = Join-Path $env:LOCALAPPDATA 'difyctl' }
if ([string]::IsNullOrEmpty($Repo)) { $Repo = 'langgenius/dify' }
function Fail($msg) { Write-Error "install-cli: $msg"; exit 1 }
function Need($cmd) {
if (-not (Get-Command $cmd -ErrorAction SilentlyContinue)) { Fail "$cmd is required" }
}
Need tar
switch ($env:PROCESSOR_ARCHITECTURE) {
'AMD64' { $arch = 'x64' }
'ARM64' { $arch = 'arm64' }
default { Fail "unsupported arch: $env:PROCESSOR_ARCHITECTURE" }
}
$target = "win32-$arch"
if ($Version -eq 'latest') {
$api = "https://api.github.com/repos/$Repo/releases/latest"
} else {
$api = "https://api.github.com/repos/$Repo/releases/tags/$Version"
}
try {
$release = Invoke-RestMethod -Uri $api -UseBasicParsing
} catch {
Fail "could not fetch release metadata from $api $($_.Exception.Message)"
}
$tag = $release.tag_name
if ([string]::IsNullOrEmpty($tag)) { Fail "release has no tag_name" }
$assetRegex = "^difyctl-v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?-$target\.tar\.xz$"
$matches = @($release.assets | Where-Object { $_.name -match $assetRegex })
if ($matches.Count -eq 0) { Fail "no difyctl asset for $target on $tag" }
if ($matches.Count -gt 1) {
$names = ($matches | ForEach-Object { $_.name }) -join ', '
Fail "expected exactly 1 difyctl asset for $target on $tag, found $($matches.Count): $names"
}
$asset = $matches[0].name
$suffix = "-$target.tar.xz"
$cliV = $asset.Substring('difyctl-'.Length, $asset.Length - 'difyctl-'.Length - $suffix.Length)
$checksums = "difyctl-$cliV-checksums.txt"
$checksumAsset = $release.assets | Where-Object { $_.name -eq $checksums } | Select-Object -First 1
if ($null -eq $checksumAsset) {
Fail "checksum file $checksums missing on $tag; refusing to install unverified binary"
}
$url = "https://github.com/$Repo/releases/download/$tag/$asset"
$sumsUrl = "https://github.com/$Repo/releases/download/$tag/$checksums"
$tmp = Join-Path ([System.IO.Path]::GetTempPath()) ("difyctl-install-" + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tmp -Force | Out-Null
$tarPath = Join-Path $tmp $asset
$sumPath = Join-Path $tmp $checksums
try {
Write-Host "downloading $asset"
Invoke-WebRequest -Uri $url -OutFile $tarPath -UseBasicParsing
Invoke-WebRequest -Uri $sumsUrl -OutFile $sumPath -UseBasicParsing
$expected = (Get-Content $sumPath | Where-Object { $_ -match " $([Regex]::Escape($asset))$" } | Select-Object -First 1)
if ([string]::IsNullOrEmpty($expected)) { Fail "no checksum entry for $asset in $checksums" }
$expectedHash = ($expected -split '\s+')[0].ToLower()
$actualHash = (Get-FileHash -Algorithm SHA256 -Path $tarPath).Hash.ToLower()
if ($expectedHash -ne $actualHash) {
Fail "checksum mismatch for $asset (expected $expectedHash, got $actualHash)"
}
if (Get-Command cosign -ErrorAction SilentlyContinue) {
$sigUrl = "$url.sig"
$pemUrl = "$url.pem"
$sigPath = Join-Path $tmp "$asset.sig"
$pemPath = Join-Path $tmp "$asset.pem"
try {
Invoke-WebRequest -Uri $sigUrl -OutFile $sigPath -UseBasicParsing
Invoke-WebRequest -Uri $pemUrl -OutFile $pemPath -UseBasicParsing
} catch {
Fail "tarball signature/cert missing on $tag; refusing to install (cosign present): $($_.Exception.Message)"
}
$env:COSIGN_EXPERIMENTAL = '1'
& cosign verify-blob `
--certificate $pemPath `
--signature $sigPath `
--certificate-identity-regexp '^https://github.com/langgenius/dify/' `
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' `
$tarPath
if ($LASTEXITCODE -ne 0) { Fail "cosign verification failed for $asset" }
Write-Host "cosign: verified $asset"
} else {
Write-Host "note: cosign not installed; skipping signature verification (sha256 still enforced)"
}
$shareDir = Join-Path $Prefix 'share'
$binDir = Join-Path $Prefix 'bin'
New-Item -ItemType Directory -Path $shareDir -Force | Out-Null
New-Item -ItemType Directory -Path $binDir -Force | Out-Null
Write-Host "extracting to $shareDir"
& tar.exe -xJf $tarPath -C $shareDir --strip-components=1
if ($LASTEXITCODE -ne 0) { Fail "tar.exe failed with exit $LASTEXITCODE" }
$sourceBin = Join-Path $shareDir 'bin\difyctl.cmd'
if (-not (Test-Path $sourceBin)) { $sourceBin = Join-Path $shareDir 'bin\difyctl.exe' }
if (-not (Test-Path $sourceBin)) { Fail "expected binary at bin\difyctl.{cmd,exe} after extract" }
$shimSrc = Get-Item $sourceBin
Copy-Item -Path $sourceBin -Destination (Join-Path $binDir $shimSrc.Name) -Force
}
finally {
if (Test-Path $tmp) { Remove-Item -Recurse -Force $tmp }
}
Write-Host ""
Write-Host "difyctl $cliV installed: $binDir"
$userPath = [System.Environment]::GetEnvironmentVariable('Path', 'User')
if ($null -eq $userPath) { $userPath = '' }
if (-not ($userPath -split ';' | Where-Object { $_ -ieq $binDir })) {
$newPath = if ($userPath) { "$userPath;$binDir" } else { $binDir }
[System.Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
Write-Host "added $binDir to user PATH (open a new terminal to pick it up)"
}
else {
Write-Host "verify: run 'difyctl version' in a new terminal"
}

132
cli/scripts/install-cli.sh Executable file
View File

@ -0,0 +1,132 @@
#!/bin/sh
# install-cli.sh — one-line difyctl installer for Linux and macOS.
#
# usage:
# curl -fsSL https://raw.githubusercontent.com/langgenius/dify/main/cli/scripts/install-cli.sh | sh
#
# env: DIFYCTL_VERSION (default latest), DIFYCTL_PREFIX (default $HOME/.local),
# DIFYCTL_REPO (default langgenius/dify).
# requires: curl, tar (xz), uname, jq, sha256sum or shasum.
set -eu
REPO="${DIFYCTL_REPO:-langgenius/dify}"
VERSION="${DIFYCTL_VERSION:-latest}"
PREFIX="${DIFYCTL_PREFIX:-${HOME}/.local}"
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 tar
need uname
need jq
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
case "$(uname -s)" in
Linux*) os=linux ;;
Darwin*) os=darwin ;;
*) die "unsupported OS: $(uname -s)" ;;
esac
case "$(uname -m)" in
x86_64|amd64) arch=x64 ;;
arm64|aarch64) arch=arm64 ;;
*) die "unsupported arch: $(uname -m)" ;;
esac
target="${os}-${arch}"
if [ "$VERSION" = "latest" ]; then
api="https://api.github.com/repos/${REPO}/releases/latest"
else
api="https://api.github.com/repos/${REPO}/releases/tags/${VERSION}"
fi
release=$(curl -fsSL "$api") || die "could not fetch release metadata from ${api}"
tag=$(printf '%s' "$release" | jq -r '.tag_name')
[ -n "$tag" ] && [ "$tag" != "null" ] || die "release has no tag_name"
matches=$(printf '%s' "$release" \
| jq -r --arg t "$target" '.assets[].name | select(test("^difyctl-v[0-9]+\\.[0-9]+\\.[0-9]+(-[0-9A-Za-z.-]+)?-\($t)\\.tar\\.xz$"))')
count=$(printf '%s' "$matches" | grep -c . || true)
case "$count" in
0) die "no difyctl asset for ${target} on ${tag}" ;;
1) asset="$matches" ;;
*) die "expected exactly 1 difyctl asset for ${target} on ${tag}, found ${count}: ${matches}" ;;
esac
no_target="${asset%-${target}.tar.xz}"
cli_v="${no_target#difyctl-}"
checksums="difyctl-${cli_v}-checksums.txt"
printf '%s' "$release" | jq -e --arg c "$checksums" '.assets[] | select(.name == $c)' >/dev/null \
|| die "checksum file ${checksums} missing on ${tag}; refusing to install unverified binary"
url="https://github.com/${REPO}/releases/download/${tag}/${asset}"
sums_url="https://github.com/${REPO}/releases/download/${tag}/${checksums}"
tmp=$(mktemp -d 2>/dev/null || mktemp -d -t difyctl-install)
trap 'rm -rf "$tmp"' EXIT INT TERM
printf 'downloading %s\n from %s\n' "$asset" "$url"
curl -fsSL --retry 3 "$url" -o "${tmp}/${asset}"
curl -fsSL --retry 3 "$sums_url" -o "${tmp}/${checksums}"
(
cd "$tmp"
grep " ${asset}\$" "$checksums" | $HASH -c -
) || die "checksum mismatch for ${asset}"
if command -v cosign >/dev/null 2>&1; then
sig_url="${url}.sig"
pem_url="${url}.pem"
curl -fsSL --retry 3 "$sig_url" -o "${tmp}/${asset}.sig" \
|| die "tarball signature missing on ${tag}; refusing to install (cosign present)"
curl -fsSL --retry 3 "$pem_url" -o "${tmp}/${asset}.pem" \
|| die "tarball cert missing on ${tag}; refusing to install (cosign present)"
COSIGN_EXPERIMENTAL=1 cosign verify-blob \
--certificate "${tmp}/${asset}.pem" \
--signature "${tmp}/${asset}.sig" \
--certificate-identity-regexp '^https://github.com/langgenius/dify/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
"${tmp}/${asset}" \
|| die "cosign verification failed for ${asset}"
printf 'cosign: verified %s\n' "$asset"
else
printf 'note: cosign not installed; skipping signature verification (sha256 still enforced)\n' >&2
fi
share_dir="${PREFIX}/share/difyctl"
bin_dir="${PREFIX}/bin"
mkdir -p "$share_dir" "$bin_dir"
printf 'extracting to %s\n' "$share_dir"
tar -xJf "${tmp}/${asset}" -C "$share_dir" --strip-components=1
target_bin="${share_dir}/bin/difyctl"
[ -x "$target_bin" ] || die "expected binary at ${target_bin} after extract"
ln -sf "$target_bin" "${bin_dir}/difyctl"
printf '\ndifyctl %s installed: %s/difyctl\n' "$cli_v" "$bin_dir"
case ":${PATH}:" in
*":${bin_dir}:"*)
"${bin_dir}/difyctl" version >/dev/null 2>&1 \
&& printf 'verify: run "difyctl version"\n' \
|| err "binary present but failed to execute; check ${bin_dir}/difyctl"
;;
*)
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

22
cli/scripts/lib/common.sh Executable file
View File

@ -0,0 +1,22 @@
#!/usr/bin/env bash
# scripts/lib/common.sh — shared shell helpers for cli/ scripts.
[[ -n "${DIFYCTL_LIB_COMMON_SH:-}" ]] && return 0
readonly DIFYCTL_LIB_COMMON_SH=1
log::info() { printf '\033[36m[info]\033[0m %s\n' "$*" >&2; }
log::warn() { printf '\033[33m[warn]\033[0m %s\n' "$*" >&2; }
log::err() { printf '\033[31m[err ]\033[0m %s\n' "$*" >&2; }
die() { log::err "$*"; exit 1; }
# Resolve the cli/ directory (parent of scripts/).
cli::root() {
local dir
dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
printf '%s' "$dir"
}
require() {
command -v "$1" >/dev/null 2>&1 || die "missing dependency: $1${2:+ — $2}"
}

View File

@ -0,0 +1,66 @@
import type { ExecSyncOptions } from 'node:child_process'
import { execSync } from 'node:child_process'
export const BUILD_CHANNELS = ['dev', 'rc', 'stable'] as const
export type BuildChannel = (typeof BUILD_CHANNELS)[number]
export type BuildInfo = {
version: string
commit: string
buildDate: string
channel: BuildChannel
minDify: string
maxDify: string
}
export type Env = Record<string, string | undefined>
export type GitProbe = (cmd: string) => string | null
const GIT_PROBE_OPTS: ExecSyncOptions = {
stdio: ['ignore', 'pipe', 'ignore'],
}
export const defaultGitProbe: GitProbe = (cmd) => {
try {
return execSync(cmd, GIT_PROBE_OPTS).toString().trim() || null
}
catch {
return null
}
}
export type ResolveOptions = {
env?: Env
git?: GitProbe
now?: () => Date
}
export function resolveBuildInfo(opts: ResolveOptions = {}): BuildInfo {
const env = opts.env ?? process.env
const git = opts.git ?? defaultGitProbe
const now = opts.now ?? (() => new Date())
const channel = env.DIFYCTL_CHANNEL ?? 'dev'
if (!(BUILD_CHANNELS as readonly string[]).includes(channel)) {
throw new Error(
`invalid DIFYCTL_CHANNEL: ${channel} (expected ${BUILD_CHANNELS.join(' | ')})`,
)
}
const version
= env.DIFYCTL_VERSION
?? git('git describe --tags --dirty --always')
?? '0.0.0-dev'
const commit
= env.DIFYCTL_COMMIT
?? git('git rev-parse HEAD')
?? 'none'
const buildDate = env.DIFYCTL_BUILD_DATE ?? now().toISOString()
const minDify = env.DIFYCTL_MIN_DIFY ?? '0.0.0'
const maxDify = env.DIFYCTL_MAX_DIFY ?? '0.0.0'
return { version, commit, buildDate, channel: channel as BuildChannel, minDify, maxDify }
}

View File

@ -0,0 +1,9 @@
import { resolveBuildInfo } from './lib/resolve-buildinfo.js'
const info = resolveBuildInfo()
process.stdout.write(
`version: ${info.version}\n`
+ `commit: ${info.commit}\n`
+ `built: ${info.buildDate}\n`
+ `channel: ${info.channel}\n`,
)

View File

@ -0,0 +1,54 @@
#!/usr/bin/env bash
# scripts/release-bump-guard.sh — auto-path only. Refuse if version+compat
# both unchanged vs. the channel-matching npm dist-tag.
#
# Required env: NEW_VERSION, NEW_MIN_DIFY, NEW_MAX_DIFY.
set -euo pipefail
_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${_dir}/lib/common.sh"
require node
require jq
require npm
cd "$(cli::root)"
: "${NEW_VERSION:?NEW_VERSION is required}"
: "${NEW_MIN_DIFY:?NEW_MIN_DIFY is required}"
: "${NEW_MAX_DIFY:?NEW_MAX_DIFY is required}"
channel=$(node -p "require('./package.json').difyctl.channel")
case "$channel" in
stable) dist_tag=latest ;;
rc) dist_tag=next ;;
*) die "unsupported channel for publish: ${channel}" ;;
esac
dist_tags_json=$(npm view @langgenius/difyctl dist-tags --json 2>/dev/null || echo '{}')
prev_version=$(echo "$dist_tags_json" | jq -r --arg t "$dist_tag" '.[$t] // ""')
if [[ -z "$prev_version" ]]; then
log::info "no prior release on dist-tag '${dist_tag}'; skipping bump guard"
exit 0
fi
if [[ "$prev_version" == "$NEW_VERSION" ]]; then
echo "::warning title=cli version not bumped::package.json version ${NEW_VERSION} is already published on dist-tag '${dist_tag}'. If this is a deliberate re-run (dify release re-cut, retry after failure), ignore. If you intended to ship new cli bytes, bump cli/package.json#version and re-run."
log::info "same version as npm dist-tag '${dist_tag}' (${prev_version}); skipping bump guard"
exit 0
fi
prev_meta=$(npm view "@langgenius/difyctl@${prev_version}" --json)
prev_min=$(echo "$prev_meta" | jq -r '.difyctl.compat.minDify')
prev_max=$(echo "$prev_meta" | jq -r '.difyctl.compat.maxDify')
[[ "$NEW_VERSION" != "$prev_version" ]] \
|| die "version unchanged from npm dist-tag '${dist_tag}' (${prev_version}); bump cli/package.json"
[[ "$NEW_MIN_DIFY" != "$prev_min" || "$NEW_MAX_DIFY" != "$prev_max" ]] \
|| die "compat unchanged from npm @${prev_version} on dist-tag '${dist_tag}' (${prev_min}..${prev_max}); bump in cli/package.json"
log::info "bump guard passed: ${prev_version}${NEW_VERSION}, compat ${prev_min}..${prev_max}${NEW_MIN_DIFY}..${NEW_MAX_DIFY}"

View File

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# scripts/release-cosign-sign.sh — keyless cosign sign of tarballs + checksum
# manifest using GitHub Actions OIDC (Sigstore Fulcio cert + Rekor log entry).
#
# Required env: CLI_VERSION. Workflow must export id-token: write and set
# COSIGN_EXPERIMENTAL=1 (cli-release.yml does both).
set -euo pipefail
_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${_dir}/lib/common.sh"
require cosign
: "${CLI_VERSION:?CLI_VERSION is required}"
cd "$(cli::root)/dist"
shopt -s nullglob
targets=(difyctl-v"${CLI_VERSION}"-*.tar.xz "difyctl-v${CLI_VERSION}-checksums.txt")
shopt -u nullglob
[[ ${#targets[@]} -gt 0 ]] || die "no files to sign in dist/ for CLI_VERSION=${CLI_VERSION}"
for f in "${targets[@]}"; do
[[ -f "$f" ]] || continue
cosign sign-blob --yes \
--output-signature "${f}.sig" \
--output-certificate "${f}.pem" \
"$f"
log::info "signed ${f}${f}.sig + ${f}.pem"
done

View File

@ -0,0 +1,48 @@
#!/usr/bin/env bash
# scripts/release-npm-publish.sh — channel-aware npm publish with
# EPUBLISHCONFLICT no-op trap.
#
# Required env: CHANNEL (stable | rc), NEW_VERSION.
set -euo pipefail
_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${_dir}/lib/common.sh"
require npm
require node
cd "$(cli::root)"
: "${CHANNEL:?CHANNEL is required}"
: "${NEW_VERSION:?NEW_VERSION is required}"
case "$CHANNEL" in
stable) dist_tag=latest ;;
rc) dist_tag=next ;;
*) die "unsupported channel for publish: ${CHANNEL}" ;;
esac
pkg_version=$(node -p "require('./package.json').version")
[[ "$pkg_version" == "$NEW_VERSION" ]] \
|| die "package.json version (${pkg_version}) != NEW_VERSION (${NEW_VERSION})"
set +e
output=$(npm publish --access public --provenance --tag "$dist_tag" 2>&1)
status=$?
set -e
if [[ $status -eq 0 ]]; then
log::info "PUBLISHED @langgenius/difyctl@${NEW_VERSION} --tag ${dist_tag}"
printf '%s\n' "$output"
exit 0
fi
if printf '%s' "$output" | grep -qE 'EPUBLISHCONFLICT|cannot publish over the previously published versions'; then
log::warn "NO-OP: @langgenius/difyctl@${NEW_VERSION} already on registry (idempotent re-run)"
exit 0
fi
printf '%s\n' "$output" >&2
die "npm publish failed (exit ${status}); see output above"

View File

@ -0,0 +1,99 @@
#!/usr/bin/env bash
# scripts/release-upload-tarballs.sh — idempotent gh release upload of
# tarballs + checksum file (sha256-strict; skip on match, fail on mismatch)
# and cosign .sig/.pem signatures (overwrite-allowed; bytes vary per run).
#
# Required env: DIFY_TAG, CLI_VERSION, GH_TOKEN.
set -euo pipefail
_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${_dir}/lib/common.sh"
require gh
require jq
: "${DIFY_TAG:?DIFY_TAG is required}"
: "${CLI_VERSION:?CLI_VERSION is required}"
cd "$(cli::root)"
REPO_FLAG=(--repo langgenius/dify)
if command -v sha256sum >/dev/null 2>&1; then
hash_cmd() { sha256sum "$1" | awk '{print $1}'; }
elif command -v shasum >/dev/null 2>&1; then
hash_cmd() { shasum -a 256 "$1" | awk '{print $1}'; }
else
die "no sha256 hasher found (need sha256sum or shasum)"
fi
remote_json=$(gh release view "$DIFY_TAG" "${REPO_FLAG[@]}" --json assets -q '.assets')
upload_one() {
local file="$1"
local mode="${2:-strict}" # strict | clobber
local name
name=$(basename "$file")
local local_sha
local_sha=$(hash_cmd "$file")
local remote_entry
remote_entry=$(printf '%s' "$remote_json" | jq -c --arg n "$name" '.[] | select(.name == $n)')
if [[ -z "$remote_entry" ]]; then
log::info "uploading ${name}"
gh release upload "$DIFY_TAG" "$file" "${REPO_FLAG[@]}"
return
fi
if [[ "$mode" == "clobber" ]]; then
log::info "overwriting ${name} (clobber mode — cosign sig/cert)"
gh release upload "$DIFY_TAG" "$file" "${REPO_FLAG[@]}" --clobber
return
fi
local remote_digest remote_sha=""
remote_digest=$(printf '%s' "$remote_entry" | jq -r '.digest // ""')
if [[ "$remote_digest" == sha256:* ]]; then
remote_sha="${remote_digest#sha256:}"
else
local tmp download_url
tmp=$(mktemp)
download_url=$(printf '%s' "$remote_entry" | jq -r '.url')
gh api -H 'Accept: application/octet-stream' "$download_url" > "$tmp"
remote_sha=$(hash_cmd "$tmp")
rm -f "$tmp"
fi
if [[ "$local_sha" == "$remote_sha" ]]; then
log::info "skip ${name} (sha256 matches)"
return
fi
die "asset ${name} already on ${DIFY_TAG} with different sha256 (local=${local_sha}, remote=${remote_sha}); refusing to overwrite"
}
shopt -s nullglob
tars=(dist/difyctl-v"${CLI_VERSION}"-*.tar.xz)
checksum_file="dist/difyctl-v${CLI_VERSION}-checksums.txt"
sigs=(dist/difyctl-v"${CLI_VERSION}"-*.sig dist/difyctl-v"${CLI_VERSION}"-checksums.txt.sig)
pems=(dist/difyctl-v"${CLI_VERSION}"-*.pem dist/difyctl-v"${CLI_VERSION}"-checksums.txt.pem)
shopt -u nullglob
[[ ${#tars[@]} -gt 0 ]] || die "no tarballs in dist/ matching difyctl-v${CLI_VERSION}-*.tar.xz"
[[ -f "$checksum_file" ]] || die "checksum file missing: ${checksum_file}"
for f in "${tars[@]}" "$checksum_file"; do
upload_one "$f" strict
done
# Cosign signatures + certs are keyless and re-generated per run with fresh
# timestamps; their bytes change each run but verify the same blob. Allow
# overwrite via --clobber so re-runs converge cleanly.
for f in "${sigs[@]}" "${pems[@]}"; do
[[ -f "$f" ]] || continue
upload_one "$f" clobber
done
log::info "uploaded ${#tars[@]} tarballs + checksums.txt + signatures to dify release ${DIFY_TAG}"

View File

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# scripts/release-validate-manifest.sh — validate cli/package.json release fields.
set -euo pipefail
_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${_dir}/lib/common.sh"
cd "$(cli::root)"
SEMVER_RE='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$'
version=$(node -p "require('./package.json').version")
channel=$(node -p "require('./package.json').difyctl.channel")
min_dify=$(node -p "require('./package.json').difyctl.compat.minDify")
max_dify=$(node -p "require('./package.json').difyctl.compat.maxDify")
[[ "$version" =~ $SEMVER_RE ]] || die "invalid version: ${version}"
case "$channel" in
rc|stable) ;;
*) die "invalid difyctl.channel: ${channel} (expected rc | stable)" ;;
esac
[[ "$min_dify" =~ $SEMVER_RE ]] || die "invalid difyctl.compat.minDify: ${min_dify}"
[[ "$max_dify" =~ $SEMVER_RE ]] || die "invalid difyctl.compat.maxDify: ${max_dify}"
case "$min_dify" in *[xX*]*) die "wildcards not allowed in minDify: ${min_dify}" ;; esac
case "$max_dify" in *[xX*]*) die "wildcards not allowed in maxDify: ${max_dify}" ;; esac
cmp=$(node -e "
const a = process.argv[1].split('-')[0].split('.').map(Number)
const b = process.argv[2].split('-')[0].split('.').map(Number)
for (let i = 0; i < 3; i++) {
if (a[i] !== b[i]) { console.log(a[i] < b[i] ? -1 : 1); process.exit(0) }
}
console.log(0)
" "$min_dify" "$max_dify")
[[ "$cmp" -le 0 ]] || die "minDify (${min_dify}) > maxDify (${max_dify})"
log::info "manifest valid: version=${version} channel=${channel} compat=${min_dify}..${max_dify}"

View File

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# scripts/release-write-checksums.sh — write sha256 manifest for tarballs.
#
# Required env: CLI_VERSION (e.g. 0.1.0-rc.1). Output:
# cli/dist/difyctl-v<CLI_VERSION>-checksums.txt
set -euo pipefail
_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${_dir}/lib/common.sh"
: "${CLI_VERSION:?CLI_VERSION is required}"
cd "$(cli::root)/dist"
manifest="difyctl-v${CLI_VERSION}-checksums.txt"
> "$manifest"
if command -v sha256sum >/dev/null 2>&1; then
hash_cmd="sha256sum"
elif command -v shasum >/dev/null 2>&1; then
hash_cmd="shasum -a 256"
else
die "no sha256 hasher found (need sha256sum or shasum)"
fi
found=0
for tar in difyctl-v"${CLI_VERSION}"-*.tar.xz; do
[[ -f "$tar" ]] || continue
$hash_cmd "$tar" >> "$manifest"
found=$((found + 1))
done
[[ "$found" -gt 0 ]] || die "no tarballs matching difyctl-v${CLI_VERSION}-*.tar.xz in dist/"
log::info "wrote ${manifest} (${found} entries)"

38
cli/scripts/release.sh Executable file
View File

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# scripts/release.sh — local-developer release build.
#
# Reads cli/package.json, validates, exports DIFYCTL_* env, runs pnpm build +
# oclif pack tarballs. cli-release.yml does NOT call this; the workflow inlines
# the same env contract.
set -euo pipefail
_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${_dir}/lib/common.sh"
require pnpm 'install with `corepack enable && corepack prepare pnpm@latest --activate`'
require node
cd "$(cli::root)"
scripts/release-validate-manifest.sh
PKG_VERSION=$(node -p "require('./package.json').version")
CHANNEL=$(node -p "require('./package.json').difyctl.channel")
MIN_DIFY=$(node -p "require('./package.json').difyctl.compat.minDify")
MAX_DIFY=$(node -p "require('./package.json').difyctl.compat.maxDify")
export DIFYCTL_VERSION="${PKG_VERSION}"
export DIFYCTL_CHANNEL="${CHANNEL}"
export DIFYCTL_MIN_DIFY="${MIN_DIFY}"
export DIFYCTL_MAX_DIFY="${MAX_DIFY}"
export DIFYCTL_COMMIT="$(git rev-parse HEAD)"
export DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)"
log::info "release ${DIFYCTL_VERSION} (channel=${DIFYCTL_CHANNEL}, compat=${MIN_DIFY}..${MAX_DIFY})"
pnpm build
pnpm pack:tarballs
log::info "artifacts in dist/"
ls -lh dist/ 2>/dev/null | tail -n +2 >&2 || true

44
cli/scripts/run-smoke.ts Normal file
View File

@ -0,0 +1,44 @@
#!/usr/bin/env -S node --import tsx
import { execSync } from 'node:child_process'
type Check = { name: string, run: () => void }
const baseUrlIdx = process.argv.indexOf('--base-url')
const baseUrl = baseUrlIdx > -1 ? process.argv[baseUrlIdx + 1] : 'http://localhost:5001'
if (!baseUrl) {
console.error('usage: run-smoke.ts --base-url <url>')
process.exit(2)
}
const env = { ...process.env, DIFY_BASE_URL: baseUrl }
function cli(args: string): string {
return execSync(`pnpm exec tsx bin/dev.js ${args}`, { env, encoding: 'utf8' })
}
const checks: Check[] = [
{ name: 'config show', run: () => { cli('config show') } },
{ name: 'get workspace', run: () => {
if (!cli('get workspace').includes('id'))
throw new Error('no workspace listed')
} },
{ name: 'get apps', run: () => { cli('get apps') } },
{ name: 'difyctl version prints compat', run: () => {
if (!cli('version').includes('compat:'))
throw new Error('no compat line')
} },
]
let failed = 0
for (const c of checks) {
try {
c.run()
console.log(`[x] ${c.name}`)
}
catch (err) {
failed++
console.log(`[ ] ${c.name}${(err as Error).message}`)
}
}
console.log(`\n${checks.length - failed}/${checks.length} checks passed`)
process.exit(failed > 0 ? 1 : 0)