mirror of
https://github.com/langgenius/dify.git
synced 2026-06-08 09:27:39 +08:00
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:
13
cli/scripts/cross-arch.npmrc
Normal file
13
cli/scripts/cross-arch.npmrc
Normal 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
|
||||
214
cli/scripts/generate-command-tree.test.ts
Normal file
214
cli/scripts/generate-command-tree.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
318
cli/scripts/generate-command-tree.ts
Normal file
318
cli/scripts/generate-command-tree.ts
Normal 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
118
cli/scripts/install-cli.sh
Executable 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
43
cli/scripts/install-local.sh
Executable 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
22
cli/scripts/lib/common.sh
Executable 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}"
|
||||
}
|
||||
92
cli/scripts/lib/resolve-buildinfo.ts
Normal file
92
cli/scripts/lib/resolve-buildinfo.ts
Normal 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 }
|
||||
}
|
||||
9
cli/scripts/print-buildinfo.ts
Normal file
9
cli/scripts/print-buildinfo.ts
Normal 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
91
cli/scripts/release-build.sh
Executable 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
|
||||
43
cli/scripts/release-validate-manifest.sh
Executable file
43
cli/scripts/release-validate-manifest.sh
Executable 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}"
|
||||
38
cli/scripts/release-write-checksums.sh
Executable file
38
cli/scripts/release-write-checksums.sh
Executable 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
44
cli/scripts/run-smoke.ts
Normal 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
12
cli/scripts/uninstall-local.sh
Executable 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}"
|
||||
Reference in New Issue
Block a user