feat: adding dify cli (#36348)

Co-authored-by: GareArc <garethcxy@dify.ai>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: L1nSn0w <l1nsn0w@qq.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: gigglewang <gigglewang@dify.ai>
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
Co-authored-by: Xiyuan Chen <52963600+GareArc@users.noreply.github.com>
This commit is contained in:
Yunlu Wen
2026-05-26 09:12:36 +08:00
committed by fatelei
parent 31a454abf7
commit 6c0bbef7a7
386 changed files with 33278 additions and 148 deletions

View File

@ -0,0 +1,13 @@
# Cross-arch keyring prebuilds for difyctl release builds.
#
# Pre-populates node_modules with @napi-rs/keyring native bindings for every
# release target so `bun build --compile` can embed them. Use via:
#
# NPM_CONFIG_USERCONFIG=cli/scripts/cross-arch.npmrc pnpm install --force
#
# Do not set as a workspace default — it would bloat dev installs.
supported-architectures-os[]=linux
supported-architectures-os[]=darwin
supported-architectures-os[]=win32
supported-architectures-cpu[]=x64
supported-architectures-cpu[]=arm64

View File

@ -0,0 +1,214 @@
import type { CommandEntry } from './generate-command-tree.js'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
buildTree,
discoverCommands,
formatModule,
generate,
pathToTokens,
tokensToIdentifier,
} from './generate-command-tree.js'
describe('pathToTokens', () => {
it('extracts tokens for nested command', () => {
expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands'))
.toEqual(['auth', 'devices', 'list'])
})
it('extracts tokens for top-level command', () => {
expect(pathToTokens('src/commands/version/index.ts', 'src/commands'))
.toEqual(['version'])
})
it('normalizes backslashes (windows-style paths)', () => {
expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands'))
.toEqual(['auth', 'login'])
})
})
describe('tokensToIdentifier', () => {
it('pascal-cases joined tokens', () => {
expect(tokensToIdentifier(['auth', 'devices', 'list'])).toBe('AuthDevicesList')
expect(tokensToIdentifier(['version'])).toBe('Version')
expect(tokensToIdentifier(['run', 'app', 'resume'])).toBe('RunAppResume')
})
it('splits hyphenated tokens', () => {
expect(tokensToIdentifier(['agent-chat'])).toBe('AgentChat')
expect(tokensToIdentifier(['my-cmd', 'sub-thing'])).toBe('MyCmdSubThing')
})
it('prefixes underscore on reserved words', () => {
expect(tokensToIdentifier(['delete'])).toBe('_Delete')
})
})
describe('buildTree', () => {
it('assembles a nested tree from entries', () => {
const entries = [
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: './auth/login/index.js' },
{ tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: './auth/devices/list/index.js' },
{ tokens: ['version'], identifier: 'Version', importPath: './version/index.js' },
]
const tree = buildTree(entries)
expect(tree.subcommands.get('auth')?.command).toBeUndefined()
expect(tree.subcommands.get('auth')?.subcommands.get('login')?.command).toBe('AuthLogin')
expect(tree.subcommands.get('auth')?.subcommands.get('devices')?.subcommands.get('list')?.command)
.toBe('AuthDevicesList')
expect(tree.subcommands.get('version')?.command).toBe('Version')
})
it('supports a parent command with its own children', () => {
const entries = [
{ tokens: ['run', 'app'], identifier: 'RunApp', importPath: './run/app/index.js' },
{ tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: './run/app/resume/index.js' },
]
const tree = buildTree(entries)
const runApp = tree.subcommands.get('run')?.subcommands.get('app')
expect(runApp?.command).toBe('RunApp')
expect(runApp?.subcommands.get('resume')?.command).toBe('RunAppResume')
})
})
describe('formatModule', () => {
it('produces a deterministic ESM file with imports + tree literal', () => {
const entries: CommandEntry[] = [
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: './auth/login/index.js' },
{ tokens: ['version'], identifier: 'Version', importPath: './version/index.js' },
{ tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: './auth/devices/list/index.js' },
]
const tree = buildTree(entries)
const out = formatModule(entries, tree)
expect(out).toBe(
`// @generated by scripts/generate-command-tree.ts — DO NOT EDIT.
// Regenerate via \`pnpm tree:gen\`. Drift gated by \`pnpm tree:check\` in CI.
import type { CommandTree } from '../framework/registry.js'
import AuthDevicesList from './auth/devices/list/index.js'
import AuthLogin from './auth/login/index.js'
import Version from './version/index.js'
export const commandTree: CommandTree = {
auth: {
subcommands: {
devices: {
subcommands: {
list: { command: AuthDevicesList, subcommands: {} },
},
},
login: { command: AuthLogin, subcommands: {} },
},
},
version: { command: Version, subcommands: {} },
}
`,
)
})
it('emits parent-with-own-command shape', () => {
const entries: CommandEntry[] = [
{ tokens: ['run', 'app'], identifier: 'RunApp', importPath: './run/app/index.js' },
{ tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: './run/app/resume/index.js' },
]
const tree = buildTree(entries)
const out = formatModule(entries, tree)
expect(out).toContain(`run: {
subcommands: {
app: {
command: RunApp,
subcommands: {
resume: { command: RunAppResume, subcommands: {} },
},
},
},
},`)
})
it('imports sorted alphabetically by import path', () => {
const entries: CommandEntry[] = [
{ tokens: ['version'], identifier: 'Version', importPath: './version/index.js' },
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: './auth/login/index.js' },
]
const out = formatModule(entries, buildTree(entries))
const authIdx = out.indexOf('AuthLogin')
const verIdx = out.indexOf('Version')
expect(authIdx).toBeLessThan(verIdx)
})
})
function makeFixture(): string {
const root = mkdtempSync(join(tmpdir(), 'difyctl-codegen-'))
const commands = join(root, 'src', 'commands')
mkdirSync(join(commands, 'auth', 'login'), { recursive: true })
writeFileSync(join(commands, 'auth', 'login', 'index.ts'), 'export default class Login {}\n')
mkdirSync(join(commands, 'auth', 'devices', 'list'), { recursive: true })
writeFileSync(join(commands, 'auth', 'devices', 'list', 'index.ts'), 'export default class DevicesList {}\n')
mkdirSync(join(commands, '_shared'), { recursive: true })
writeFileSync(join(commands, '_shared', 'index.ts'), 'export default class Shared {}\n')
mkdirSync(join(commands, 'version'), { recursive: true })
writeFileSync(join(commands, 'version', 'index.ts'), 'export default class Version {}\n')
return root
}
describe('discoverCommands', () => {
it('returns sorted entries, skipping _-prefixed segments', async () => {
const root = makeFixture()
const entries = await discoverCommands(join(root, 'src', 'commands'))
expect(entries.map(e => e.tokens.join('/'))).toEqual([
'auth/devices/list',
'auth/login',
'version',
])
expect(entries.find(e => e.tokens[0] === '_shared')).toBeUndefined()
})
it('errors on a loose .ts file under commands/', async () => {
const root = mkdtempSync(join(tmpdir(), 'difyctl-codegen-loose-'))
const commands = join(root, 'src', 'commands')
mkdirSync(commands, { recursive: true })
writeFileSync(join(commands, 'foo.ts'), 'export default class Foo {}\n')
await expect(discoverCommands(commands)).rejects.toThrow(/must live under their own folder/)
})
})
describe('generate', () => {
it('writes tree.generated.ts on default mode', async () => {
const root = makeFixture()
const commandsDir = join(root, 'src', 'commands')
const result = await generate({ commandsDir, mode: 'write' })
expect(result.mode).toBe('write')
const target = join(commandsDir, 'tree.generated.ts')
expect(existsSync(target)).toBe(true)
const content = readFileSync(target, 'utf8')
expect(content).toContain('@generated')
expect(content).toContain('import AuthLogin')
expect(content).toContain('import AuthDevicesList')
expect(content).toContain('import Version')
})
it('returns ok: true on --check when file matches', async () => {
const root = makeFixture()
const commandsDir = join(root, 'src', 'commands')
await generate({ commandsDir, mode: 'write' })
const result = await generate({ commandsDir, mode: 'check' })
if (result.mode !== 'check')
throw new Error('expected check mode')
expect(result.ok).toBe(true)
})
it('returns ok: false on --check when file is stale', async () => {
const root = makeFixture()
const commandsDir = join(root, 'src', 'commands')
await generate({ commandsDir, mode: 'write' })
writeFileSync(join(commandsDir, 'tree.generated.ts'), '// stale\n')
const result = await generate({ commandsDir, mode: 'check' })
if (result.mode !== 'check')
throw new Error('expected check mode')
expect(result.ok).toBe(false)
if (!result.ok)
expect(result.diff).toBeDefined()
})
})

View File

@ -0,0 +1,318 @@
import { readdir, readFile, rename, writeFile } from 'node:fs/promises'
import { join, relative, sep } from 'node:path'
import { fileURLToPath } from 'node:url'
import { isExcludedCommandPath } from '../src/framework/command-fs.js'
const RESERVED_JS_KEYWORDS = new Set([
'break',
'case',
'catch',
'class',
'const',
'continue',
'debugger',
'default',
'delete',
'do',
'else',
'enum',
'export',
'extends',
'false',
'finally',
'for',
'function',
'if',
'import',
'in',
'instanceof',
'new',
'null',
'return',
'super',
'switch',
'this',
'throw',
'true',
'try',
'typeof',
'var',
'void',
'while',
'with',
'yield',
])
export type CommandEntry = {
readonly tokens: readonly string[]
readonly identifier: string
readonly importPath: string
}
export type TreeNode = {
command?: string
subcommands: Map<string, TreeNode>
}
export function pathToTokens(filePath: string, commandsRoot: string): string[] {
const normalized = filePath.replace(/\\/g, '/')
const root = commandsRoot.replace(/\\/g, '/').replace(/\/$/, '')
const trimmed = normalized.startsWith(`${root}/`)
? normalized.slice(root.length + 1)
: normalized
const withoutIndex = trimmed.replace(/\/index\.ts$/, '')
return withoutIndex.split('/').filter(s => s.length > 0)
}
function capitalize(part: string): string {
if (part.length === 0)
return ''
return part[0]!.toUpperCase() + part.slice(1)
}
export function tokensToIdentifier(tokens: readonly string[]): string {
const id = tokens
.flatMap(t => t.split(/[-_]/))
.map(capitalize)
.join('')
if (RESERVED_JS_KEYWORDS.has(id.toLowerCase()))
return `_${id}`
return id
}
export function buildTree(entries: readonly CommandEntry[]): TreeNode {
const root: TreeNode = { subcommands: new Map() }
for (const entry of entries) {
let node = root
for (let i = 0; i < entry.tokens.length; i++) {
const tok = entry.tokens[i]!
let next = node.subcommands.get(tok)
if (!next) {
next = { subcommands: new Map() }
node.subcommands.set(tok, next)
}
node = next
}
node.command = entry.identifier
}
return root
}
const HEADER = `// @generated by scripts/generate-command-tree.ts — DO NOT EDIT.
// Regenerate via \`pnpm tree:gen\`. Drift gated by \`pnpm tree:check\` in CI.
`
function compareStrings(a: string, b: string): number {
if (a < b)
return -1
if (a > b)
return 1
return 0
}
function emitImports(entries: readonly CommandEntry[]): string {
const sorted = [...entries].sort((a, b) => compareStrings(a.importPath, b.importPath))
const lines = [`import type { CommandTree } from '../framework/registry.js'`]
for (const e of sorted)
lines.push(`import ${e.identifier} from '${e.importPath}'`)
return lines.join('\n')
}
function emitNode(node: TreeNode, indent: string): string {
const inner = `${indent} `
const keys = [...node.subcommands.keys()].sort()
const parts: string[] = []
if (node.command !== undefined)
parts.push(`${inner}command: ${node.command},`)
if (keys.length === 0) {
parts.push(`${inner}subcommands: {},`)
}
else {
parts.push(`${inner}subcommands: {`)
for (const key of keys) {
const child = node.subcommands.get(key)!
parts.push(emitEntry(key, child, `${inner} `))
}
parts.push(`${inner}},`)
}
return parts.join('\n')
}
function emitEntry(key: string, node: TreeNode, indent: string): string {
const isLeaf = node.subcommands.size === 0 && node.command !== undefined
if (isLeaf)
return `${indent}${key}: { command: ${node.command}, subcommands: {} },`
return [
`${indent}${key}: {`,
emitNode(node, indent),
`${indent}},`,
].join('\n')
}
export function formatModule(entries: readonly CommandEntry[], tree: TreeNode): string {
const importsBlock = emitImports(entries)
const topKeys = [...tree.subcommands.keys()].sort()
const literalParts = ['export const commandTree: CommandTree = {']
for (const key of topKeys) {
const child = tree.subcommands.get(key)!
literalParts.push(emitEntry(key, child, ' '))
}
literalParts.push('}')
return `${HEADER}\n${importsBlock}\n\n${literalParts.join('\n')}\n`
}
async function walk(dir: string): Promise<string[]> {
const out: string[] = []
const entries = await readdir(dir, { withFileTypes: true })
for (const e of entries) {
const full = join(dir, e.name)
if (e.isDirectory())
out.push(...await walk(full))
else if (e.isFile())
out.push(full)
}
return out
}
function toPosix(p: string): string {
return p.split(sep).join('/')
}
export async function discoverCommands(commandsDir: string): Promise<CommandEntry[]> {
const all = await walk(commandsDir)
const tsFiles = all.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts'))
const loose: string[] = []
for (const abs of tsFiles) {
const rel = toPosix(relative(commandsDir, abs))
if (isExcludedCommandPath(rel))
continue
if (rel === 'tree.ts' || rel === 'tree.generated.ts')
continue
// Only flag files directly under commands/ (no path separator — no parent folder)
if (!rel.includes('/'))
loose.push(rel)
}
if (loose.length > 0) {
const list = loose.map(p => ` - src/commands/${p}`).join('\n')
throw new Error(
`commands must live under their own folder (see CLAUDE memory: feedback_cli_command_structure). Found:\n${list}`,
)
}
const entries: CommandEntry[] = []
for (const abs of tsFiles) {
const rel = toPosix(relative(commandsDir, abs))
if (isExcludedCommandPath(rel))
continue
if (!rel.endsWith('/index.ts'))
continue
const tokens = pathToTokens(rel, '')
if (tokens.length === 0)
continue
if (tokens[0]!.startsWith('-'))
throw new Error(`command token cannot start with '-': ${rel}`)
entries.push({
tokens,
identifier: tokensToIdentifier(tokens),
importPath: `./${tokens.join('/')}/index.js`,
})
}
entries.sort((a, b) => compareStrings(a.importPath, b.importPath))
if (entries.length === 0)
throw new Error(`no commands found under ${commandsDir}`)
assertUniqueIdentifiers(entries)
return entries
}
function assertUniqueIdentifiers(entries: readonly CommandEntry[]): void {
const seen = new Map<string, string>()
for (const e of entries) {
const prev = seen.get(e.identifier)
if (prev !== undefined)
throw new Error(`identifier collision: ${e.identifier} from ${prev} and ${e.importPath}`)
seen.set(e.identifier, e.importPath)
}
}
export type GenerateOptions = {
readonly commandsDir: string
readonly mode: 'write' | 'check'
}
export type GenerateResult
= | { mode: 'write', wrote: boolean, path: string }
| { mode: 'check', ok: true, path: string }
| { mode: 'check', ok: false, path: string, diff: string }
export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
const entries = await discoverCommands(opts.commandsDir)
const tree = buildTree(entries)
const content = formatModule(entries, tree)
const target = join(opts.commandsDir, 'tree.generated.ts')
if (opts.mode === 'check') {
let onDisk = ''
try {
onDisk = await readFile(target, 'utf8')
}
catch {
onDisk = ''
}
if (onDisk === content)
return { mode: 'check', ok: true, path: target }
return { mode: 'check', ok: false, path: target, diff: shortDiff(onDisk, content) }
}
const tmp = `${target}.tmp-${process.pid}-${Date.now()}`
await writeFile(tmp, content, 'utf8')
await rename(tmp, target)
return { mode: 'write', wrote: true, path: target }
}
function shortDiff(a: string, b: string): string {
const aLines = a.split('\n')
const bLines = b.split('\n')
const lines: string[] = []
const max = Math.max(aLines.length, bLines.length)
for (let i = 0; i < max; i++) {
if (aLines[i] !== bLines[i]) {
if (aLines[i] !== undefined)
lines.push(`- ${aLines[i]}`)
if (bLines[i] !== undefined)
lines.push(`+ ${bLines[i]}`)
}
}
return lines.slice(0, 40).join('\n')
}
async function main(): Promise<void> {
const here = fileURLToPath(import.meta.url)
const commandsDir = join(here, '..', '..', 'src', 'commands')
const checkMode = process.argv.includes('--check')
const result = await generate({ commandsDir, mode: checkMode ? 'check' : 'write' })
if (result.mode === 'write') {
process.stderr.write(`tree:gen wrote ${result.path}\n`)
return
}
if (result.ok) {
process.stderr.write(`tree:check ok\n`)
return
}
process.stderr.write(`tree:check FAILED — tree.generated.ts is stale.\nDiff (first 40 lines):\n${result.diff}\n\nRun \`pnpm tree:gen\` and commit.\n`)
process.exit(1)
}
const invokedDirectly = process.argv[1] !== undefined
&& fileURLToPath(import.meta.url) === process.argv[1]
if (invokedDirectly)
await main()

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

@ -0,0 +1,118 @@
#!/bin/sh
# install-cli.sh — one-line difyctl installer from the latest GitHub Actions build.
#
# usage:
# GH_TOKEN=<pat> 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.
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:-}}"
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
[ -n "$TOKEN" ] || die "GH_TOKEN (or GITHUB_TOKEN) is required — workflow artifact downloads need auth"
gh_curl() { curl -fsSL -H "Authorization: Bearer ${TOKEN}" -H "Accept: application/vnd.github.v3+json" "$@"; }
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) (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 <prefix>/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

43
cli/scripts/install-local.sh Executable file
View File

@ -0,0 +1,43 @@
#!/bin/sh
# install-local.sh — install difyctl from a locally built tarball.
# Run via: pnpm install:local
set -eu
PREFIX="${DIFYCTL_PREFIX:-${HOME}/.local}"
SHARE_DIR="${PREFIX}/share/difyctl"
BIN_DIR="${PREFIX}/bin"
case "$(uname -s)" in
Linux*) os=linux ;;
Darwin*) os=darwin ;;
*) echo "unsupported OS: $(uname -s)" >&2; exit 1 ;;
esac
case "$(uname -m)" in
x86_64|amd64) arch=x64 ;;
arm64|aarch64) arch=arm64 ;;
*) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;;
esac
# Accept an optional directory path as the first argument.
# Default to the cli/dist directory if not provided.
ARTIFACT_DIR="${1:-$(cd "$(dirname "$0")/../dist" && pwd)}"
TARBALL="$(ls "${ARTIFACT_DIR}"/difyctl-*-${os}-${arch}.tar.xz 2>/dev/null | head -1)"
if [ -z "$TARBALL" ]; then
echo "no tarball found for ${os}-${arch} in ${ARTIFACT_DIR}" >&2
echo "run: pnpm pack:tarballs" >&2
exit 1
fi
echo "installing from $(basename "$TARBALL") ..."
rm -rf "$SHARE_DIR"
mkdir -p "$SHARE_DIR" "$BIN_DIR"
tar -xJf "$TARBALL" -C "$SHARE_DIR" --strip-components=1
ln -sf "${SHARE_DIR}/bin/difyctl" "${BIN_DIR}/difyctl"
echo "installed: ${BIN_DIR}/difyctl"
case ":${PATH}:" in
*":${BIN_DIR}:"*) ;;
*) echo "note: add ${BIN_DIR} to your PATH" ;;
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,92 @@
import type { ExecSyncOptions } from 'node:child_process'
import { execSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
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
}
}
type PackageManifest = {
difyctl?: {
channel?: string
compat?: { minDify?: string, maxDify?: string }
}
}
export type PackageReader = () => PackageManifest
// Default reader resolves cli/package.json relative to this file so the same
// helper works whether invoked from vite.config.ts, bin/dev.js, or release.sh.
const defaultPackageReader: PackageReader = () => {
try {
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json')
return JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageManifest
}
catch {
return {}
}
}
export type ResolveOptions = {
env?: Env
git?: GitProbe
now?: () => Date
pkg?: PackageReader
}
export function resolveBuildInfo(opts: ResolveOptions = {}): BuildInfo {
const env = opts.env ?? process.env
const git = opts.git ?? defaultGitProbe
const now = opts.now ?? (() => new Date())
const pkg = (opts.pkg ?? defaultPackageReader)()
const channel = env.DIFYCTL_CHANNEL ?? pkg.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 ?? pkg.difyctl?.compat?.minDify ?? '0.0.0'
const maxDify = env.DIFYCTL_MAX_DIFY ?? pkg.difyctl?.compat?.maxDify ?? '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`,
)

91
cli/scripts/release-build.sh Executable file
View File

@ -0,0 +1,91 @@
#!/usr/bin/env bash
# scripts/release-build.sh — cross-compile difyctl to standalone binaries
# for every release target via `bun build --compile`.
#
# Bun consumes bin/run.ts (which imports from src/) directly — no `pnpm build`
# / dist/ step needed.
#
# Prereqs:
# - All @napi-rs/keyring native variants present in node_modules. Use
# `NPM_CONFIG_USERCONFIG=cli/scripts/cross-arch.npmrc pnpm install --force`
# to populate them.
#
# Env (all optional; defaults derived from cli/package.json + git):
# CLI_VERSION — package.json `version`
# DIFYCTL_CHANNEL — package.json `difyctl.channel`
# DIFYCTL_MIN_DIFY — package.json `difyctl.compat.minDify`
# DIFYCTL_MAX_DIFY — package.json `difyctl.compat.maxDify`
# DIFYCTL_COMMIT — `git rev-parse HEAD` (or "unknown")
# DIFYCTL_BUILD_DATE — current UTC time
#
# Output: dist/bin/difyctl-v<CLI_VERSION>-<target>[.exe]
set -euo pipefail
_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=lib/common.sh
source "${_dir}/lib/common.sh"
require bun
cli_root="$(cli::root)"
entry="${cli_root}/bin/run.ts"
out_dir="${cli_root}/dist/bin"
read_pkg() { node -p "require('${cli_root}/package.json').$1" 2>/dev/null; }
CLI_VERSION="${CLI_VERSION:-$(read_pkg version)}"
DIFYCTL_CHANNEL="${DIFYCTL_CHANNEL:-$(read_pkg difyctl.channel)}"
DIFYCTL_MIN_DIFY="${DIFYCTL_MIN_DIFY:-$(read_pkg difyctl.compat.minDify)}"
DIFYCTL_MAX_DIFY="${DIFYCTL_MAX_DIFY:-$(read_pkg difyctl.compat.maxDify)}"
DIFYCTL_COMMIT="${DIFYCTL_COMMIT:-$(git -C "$cli_root" rev-parse HEAD 2>/dev/null || echo unknown)}"
DIFYCTL_BUILD_DATE="${DIFYCTL_BUILD_DATE:-$(date -u +%Y-%m-%dT%H:%M:%SZ)}"
[[ "$CLI_VERSION" != "undefined" ]] || die "CLI_VERSION could not be derived from package.json"
[[ -f "$entry" ]] || die "entry not found: $entry"
rm -rf "$out_dir"
mkdir -p "$out_dir"
# Build-info globals (referenced as bare identifiers in src/version/info.ts).
# Each value must be a JS expression — wrap strings as JSON-quoted strings.
defines=(
"--define" "__DIFYCTL_VERSION__=\"${CLI_VERSION}\""
"--define" "__DIFYCTL_CHANNEL__=\"${DIFYCTL_CHANNEL}\""
"--define" "__DIFYCTL_MIN_DIFY__=\"${DIFYCTL_MIN_DIFY}\""
"--define" "__DIFYCTL_MAX_DIFY__=\"${DIFYCTL_MAX_DIFY}\""
"--define" "__DIFYCTL_COMMIT__=\"${DIFYCTL_COMMIT}\""
"--define" "__DIFYCTL_BUILD_DATE__=\"${DIFYCTL_BUILD_DATE}\""
)
# Bun --target -> release asset suffix (asset name omits the bun- prefix
# and uses Node-style platform names; .exe is appended for Windows).
targets=(
"bun-linux-x64:linux-x64"
"bun-linux-arm64:linux-arm64"
"bun-darwin-x64:darwin-x64"
"bun-darwin-arm64:darwin-arm64"
"bun-windows-x64:windows-x64"
)
for spec in "${targets[@]}"; do
bun_target="${spec%%:*}"
asset_target="${spec##*:}"
suffix=""
case "$bun_target" in
bun-windows-*) suffix=".exe" ;;
esac
out="${out_dir}/difyctl-v${CLI_VERSION}-${asset_target}${suffix}"
log::info "compiling ${asset_target} -> $(basename "$out")..."
bun build "$entry" \
--target="$bun_target" \
--compile \
--minify \
"${defines[@]}" \
--outfile="$out" >/dev/null
done
log::info "built $(find "$out_dir" -type f | wc -l | tr -d ' ') binaries:"
ls -lh "$out_dir" >&2

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,38 @@
#!/usr/bin/env bash
# scripts/release-write-checksums.sh — write sha256 manifest for release binaries.
#
# Required env: CLI_VERSION (e.g. 0.1.0-rc.1). Output:
# cli/dist/bin/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/bin"
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 bin in difyctl-v"${CLI_VERSION}"-*; do
[[ -f "$bin" ]] || continue
[[ "$bin" == "$manifest" ]] && continue
$hash_cmd "$bin" >> "$manifest"
found=$((found + 1))
done
[[ "$found" -gt 0 ]] || die "no binaries matching difyctl-v${CLI_VERSION}-* in dist/bin/"
log::info "wrote ${manifest} (${found} entries)"

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

@ -0,0 +1,44 @@
#!/usr/bin/env -S bun
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(`bun 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)

12
cli/scripts/uninstall-local.sh Executable file
View File

@ -0,0 +1,12 @@
#!/bin/sh
# uninstall-local.sh — remove a locally installed difyctl.
# Run via: pnpm uninstall:local
set -eu
PREFIX="${DIFYCTL_PREFIX:-${HOME}/.local}"
SHARE_DIR="${PREFIX}/share/difyctl"
BIN_LINK="${PREFIX}/bin/difyctl"
rm -rf "$SHARE_DIR"
rm -f "$BIN_LINK"
echo "removed ${SHARE_DIR} and ${BIN_LINK}"