style: format with vp fmt (#38803)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Stephen Zhou 2026-07-12 23:57:46 +08:00 committed by GitHub
parent fde08d24fe
commit a84c2d36a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6213 changed files with 227966 additions and 187190 deletions

View File

@ -162,19 +162,18 @@ jobs:
cd api
uv run dev/generate_swagger_markdown_docs.py --swagger-dir ../packages/contracts/openapi --markdown-dir openapi/markdown --keep-swagger-json
# TODO: Enable after the repository-wide vp fmt baseline lands.
# - name: Generate frontend contracts
# if: github.event_name != 'merge_group' && steps.frontend-contract-changes.outputs.any_changed == 'true'
# run: pnpm --dir packages/contracts gen-api-contract
- name: Generate frontend contracts
if: github.event_name != 'merge_group' && steps.frontend-contract-changes.outputs.any_changed == 'true'
run: pnpm --dir packages/contracts gen-api-contract
# - name: ESLint autofix
# if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
# run: |
# vp exec eslint --fix --concurrency=2 --prune-suppressions --quiet || true
- name: ESLint autofix
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
run: |
vp exec eslint --fix --concurrency=2 --prune-suppressions --quiet || true
# - name: Format frontend files
# if: github.event_name != 'merge_group' && (steps.web-changes.outputs.any_changed == 'true' || steps.frontend-contract-changes.outputs.any_changed == 'true')
# run: vp fmt
- name: Format frontend files
if: github.event_name != 'merge_group' && (steps.web-changes.outputs.any_changed == 'true' || steps.frontend-contract-changes.outputs.any_changed == 'true')
run: vp fmt
- if: github.event_name != 'merge_group'
uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4

View File

@ -173,10 +173,9 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
uses: ./.github/actions/setup-web
# TODO: Enable after the repository-wide vp fmt baseline lands.
# - name: Format check
# if: steps.changed-files.outputs.any_changed == 'true'
# run: vp fmt --check
- name: Format check
if: steps.changed-files.outputs.any_changed == 'true'
run: vp fmt --check
- name: Restore ESLint cache
if: steps.changed-files.outputs.any_changed == 'true'

View File

@ -1,61 +1,19 @@
{
"name": "@langgenius/difyctl",
"type": "module",
"version": "0.2.0-alpha",
"description": "Dify command-line interface",
"difyctl": {
"channel": "alpha",
"compat": {
"minDify": "1.16.0",
"maxDify": "1.16.0"
},
"release": {
"tagPrefix": "difyctl-v",
"binName": "difyctl",
"checksumsSuffix": "-checksums.txt",
"targets": [
{
"id": "linux-x64",
"bunTarget": "bun-linux-x64",
"exe": false
},
{
"id": "linux-arm64",
"bunTarget": "bun-linux-arm64",
"exe": false
},
{
"id": "darwin-x64",
"bunTarget": "bun-darwin-x64",
"exe": false
},
{
"id": "darwin-arm64",
"bunTarget": "bun-darwin-arm64",
"exe": false
},
{
"id": "windows-x64",
"bunTarget": "bun-windows-x64",
"exe": true
}
]
}
},
"license": "Apache-2.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"README.md",
"bin",
"dist"
],
"engines": {
"node": "^22.22.1"
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "vp pack",
@ -108,5 +66,47 @@
"vite": "catalog:",
"vite-plus": "catalog:",
"vitest": "catalog:"
},
"engines": {
"node": "^22.22.1"
},
"difyctl": {
"channel": "alpha",
"compat": {
"minDify": "1.16.0",
"maxDify": "1.16.0"
},
"release": {
"tagPrefix": "difyctl-v",
"binName": "difyctl",
"checksumsSuffix": "-checksums.txt",
"targets": [
{
"id": "linux-x64",
"bunTarget": "bun-linux-x64",
"exe": false
},
{
"id": "linux-arm64",
"bunTarget": "bun-linux-arm64",
"exe": false
},
{
"id": "darwin-x64",
"bunTarget": "bun-darwin-x64",
"exe": false
},
{
"id": "darwin-arm64",
"bunTarget": "bun-darwin-arm64",
"exe": false
},
{
"id": "windows-x64",
"bunTarget": "bun-windows-x64",
"exe": true
}
]
}
}
}

View File

@ -26,7 +26,6 @@ import { Buffer } from 'node:buffer'
* Output file:
* .provision-output.json (also written to GITHUB_OUTPUT if set)
*/
import { appendFile, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@ -36,7 +35,7 @@ import { fileURLToPath } from 'node:url'
const host = process.env.DIFY_E2E_HOST ?? ''
const email = process.env.DIFY_E2E_EMAIL ?? ''
const password = process.env.DIFY_E2E_PASSWORD ?? ''
const edition = ((process.env.DIFY_E2E_EDITION ?? 'ee').toLowerCase()) as 'ee' | 'ce'
const edition = (process.env.DIFY_E2E_EDITION ?? 'ee').toLowerCase() as 'ee' | 'ce'
const preToken = process.env.DIFY_E2E_TOKEN ?? ''
if (!host || !email || !password) {
@ -49,10 +48,10 @@ const base = host.replace(/\/$/, '')
// ── Helpers ───────────────────────────────────────────────────────────────────
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms))
return new Promise((r) => setTimeout(r, ms))
}
async function consoleLogin(): Promise<{ cookieString: string, csrfToken: string }> {
async function consoleLogin(): Promise<{ cookieString: string; csrfToken: string }> {
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
const res = await fetch(`${base}/console/api/login`, {
method: 'POST',
@ -60,14 +59,15 @@ async function consoleLogin(): Promise<{ cookieString: string, csrfToken: string
body: JSON.stringify({ email, password: passwordB64, remember_me: false }),
signal: AbortSignal.timeout(15_000),
})
if (!res.ok)
throw new Error(`console/api/login failed: HTTP ${res.status}`)
if (!res.ok) throw new Error(`console/api/login failed: HTTP ${res.status}`)
const setCookies = res.headers.getSetCookie?.() ?? []
const cookieString = setCookies.map(c => c.split(';')[0]).join('; ')
const cookieString = setCookies.map((c) => c.split(';')[0]).join('; ')
// cookie names may have __Host- prefix on HTTPS deployments
const csrfPair = setCookies.map(c => c.split(';')[0]).find(p => p.includes('csrf_token='))
const csrfToken = csrfPair ? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length) : ''
const csrfPair = setCookies.map((c) => c.split(';')[0]).find((p) => p.includes('csrf_token='))
const csrfToken = csrfPair
? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length)
: ''
return { cookieString, csrfToken }
}
@ -78,8 +78,9 @@ async function validateToken(token: string): Promise<boolean> {
signal: AbortSignal.timeout(10_000),
})
return res.ok
} catch {
return false
}
catch { return false }
}
async function mintToken(cookieStr: string, csrf: string, label: string): Promise<string> {
@ -90,28 +91,27 @@ async function mintToken(cookieStr: string, csrf: string, label: string): Promis
body: JSON.stringify({ client_id: 'difyctl', device_label: label }),
signal: AbortSignal.timeout(15_000),
})
if (!codeRes.ok)
throw new Error(`device/code failed: HTTP ${codeRes.status}`)
const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string }
if (!codeRes.ok) throw new Error(`device/code failed: HTTP ${codeRes.status}`)
const { device_code, user_code } = (await codeRes.json()) as {
device_code: string
user_code: string
}
// Step 2: approve (with retry)
let approveRes: Response | undefined
for (let i = 1; i <= 5; i++) {
approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': cookieStr, 'X-CSRFToken': csrf },
headers: { 'Content-Type': 'application/json', Cookie: cookieStr, 'X-CSRFToken': csrf },
body: JSON.stringify({ user_code }),
signal: AbortSignal.timeout(10_000),
})
if (approveRes.ok)
break
if (approveRes.status !== 429 && approveRes.status < 500)
break
if (approveRes.ok) break
if (approveRes.status !== 429 && approveRes.status < 500) break
console.warn(`[provision] device/approve HTTP ${approveRes.status}; retry ${i}/5 in ${i * 2}s`)
await sleep(i * 2_000)
}
if (!approveRes?.ok)
throw new Error(`device/approve failed: HTTP ${approveRes?.status}`)
if (!approveRes?.ok) throw new Error(`device/approve failed: HTTP ${approveRes?.status}`)
// Step 3: exchange token
const tokenRes = await fetch(`${base}/openapi/v1/oauth/device/token`, {
@ -120,39 +120,42 @@ async function mintToken(cookieStr: string, csrf: string, label: string): Promis
body: JSON.stringify({ device_code, client_id: 'difyctl' }),
signal: AbortSignal.timeout(10_000),
})
if (!tokenRes.ok)
throw new Error(`device/token failed: HTTP ${tokenRes.status}`)
const body = await tokenRes.json() as { token?: string }
if (!body.token)
throw new Error(`device/token missing token: ${JSON.stringify(body)}`)
if (!tokenRes.ok) throw new Error(`device/token failed: HTTP ${tokenRes.status}`)
const body = (await tokenRes.json()) as { token?: string }
if (!body.token) throw new Error(`device/token missing token: ${JSON.stringify(body)}`)
return body.token
}
async function discoverWorkspaces(cookieStr: string, csrf: string) {
const res = await fetch(`${base}/console/api/workspaces`, {
headers: { 'Cookie': cookieStr, 'X-CSRF-Token': csrf },
headers: { Cookie: cookieStr, 'X-CSRF-Token': csrf },
signal: AbortSignal.timeout(10_000),
})
if (!res.ok)
throw new Error(`list workspaces failed: HTTP ${res.status}`)
const data = await res.json() as { workspaces?: Array<{ id: string, name: string }> }
if (!res.ok) throw new Error(`list workspaces failed: HTTP ${res.status}`)
const data = (await res.json()) as { workspaces?: Array<{ id: string; name: string }> }
const all = data.workspaces ?? []
if (edition === 'ee') {
const ws0 = all.find(w => w.name === 'auto_test0')
const ws1 = all.find(w => w.name === 'auto_test1')
if (!ws0)
throw new Error('[provision] EE: workspace "auto_test0" not found')
const ws0 = all.find((w) => w.name === 'auto_test0')
const ws1 = all.find((w) => w.name === 'auto_test1')
if (!ws0) throw new Error('[provision] EE: workspace "auto_test0" not found')
console.warn(`[provision] EE primary: ${ws0.name} (${ws0.id})`)
console.warn(`[provision] EE secondary: ${ws1?.name ?? 'reuses primary'} (${ws1?.id ?? ws0.id})`)
console.warn(
`[provision] EE secondary: ${ws1?.name ?? 'reuses primary'} (${ws1?.id ?? ws0.id})`,
)
return { primaryWsId: ws0.id, primaryWsName: ws0.name, secondaryWsId: ws1?.id ?? ws0.id }
}
const auto = all.filter(w => w.name.toLowerCase().includes('auto')).sort((a, b) => a.name.localeCompare(b.name))
const auto = all
.filter((w) => w.name.toLowerCase().includes('auto'))
.sort((a, b) => a.name.localeCompare(b.name))
const primary = auto[0] ?? all[0]
if (!primary)
throw new Error('[provision] No workspaces found')
return { primaryWsId: primary.id, primaryWsName: primary.name, secondaryWsId: auto[1]?.id ?? primary.id }
if (!primary) throw new Error('[provision] No workspaces found')
return {
primaryWsId: primary.id,
primaryWsName: primary.name,
secondaryWsId: auto[1]?.id ?? primary.id,
}
}
async function provisionApps(
@ -162,7 +165,7 @@ async function provisionApps(
secondaryWsId: string,
): Promise<Record<string, string>> {
const mkH = (extra: Record<string, string> = {}) => ({
'Cookie': cookieStr,
Cookie: cookieStr,
'X-CSRF-Token': csrf,
...extra,
})
@ -202,9 +205,10 @@ async function provisionApps(
}
const dsl = await readFile(join(fixturesDir, dslFile), 'utf8')
const appName = (dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1]
?.trim()
.replace(/^['"]|['"]$/g, '') ?? dslFile
const appName =
(dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1]
?.trim()
.replace(/^['"]|['"]$/g, '') ?? dslFile
const appMode = (dsl.match(/^[ \t]+mode:[ \t]*(\S+)/m) ?? [])[1] ?? ''
// Find existing or import
@ -212,34 +216,34 @@ async function provisionApps(
`${base}/console/api/apps?name=${encodeURIComponent(appName)}&limit=50&page=1`,
{ headers: mkH(), signal: AbortSignal.timeout(10_000) },
)
const searchData = await searchRes.json() as { data?: Array<{ id: string, name: string }> }
let appId = searchData.data?.find(a => a.name === appName)?.id
const searchData = (await searchRes.json()) as { data?: Array<{ id: string; name: string }> }
let appId = searchData.data?.find((a) => a.name === appName)?.id
if (appId) {
console.warn(`[provision] ${dslFile}: exists id=${appId}`)
}
else {
} else {
const importRes = await fetch(`${base}/console/api/apps/imports`, {
method: 'POST',
headers: { ...mkH(), 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'yaml-content', yaml_content: dsl }),
signal: AbortSignal.timeout(30_000),
})
const importData = await importRes.json() as { app_id?: string, import_id?: string }
const importData = (await importRes.json()) as { app_id?: string; import_id?: string }
if (importRes.status === 202 && importData.import_id) {
const confirmRes = await fetch(`${base}/console/api/apps/imports/${importData.import_id}/confirm`, {
method: 'POST',
headers: mkH(),
signal: AbortSignal.timeout(15_000),
})
const confirmData = await confirmRes.json() as { app_id?: string }
const confirmRes = await fetch(
`${base}/console/api/apps/imports/${importData.import_id}/confirm`,
{
method: 'POST',
headers: mkH(),
signal: AbortSignal.timeout(15_000),
},
)
const confirmData = (await confirmRes.json()) as { app_id?: string }
appId = confirmData.app_id
}
else {
} else {
appId = importData.app_id
}
if (!appId)
throw new Error(`import failed: ${JSON.stringify(importData)}`)
if (!appId) throw new Error(`import failed: ${JSON.stringify(importData)}`)
console.warn(`[provision] ${dslFile}: imported id=${appId}`)
}
@ -270,8 +274,7 @@ async function provisionApps(
}
results[envVar] = appId
}
catch (err) {
} catch (err) {
console.warn(`[provision] ${dslFile} skipped: ${err}`)
}
}
@ -281,7 +284,9 @@ async function provisionApps(
async function writeOutputs(outputs: Record<string, string>) {
const ghOutput = process.env.GITHUB_OUTPUT
const lines = `${Object.entries(outputs).map(([k, v]) => `${k}=${v}`).join('\n')}\n`
const lines = `${Object.entries(outputs)
.map(([k, v]) => `${k}=${v}`)
.join('\n')}\n`
// Always write local JSON for debugging
const { writeFile } = await import('node:fs/promises')
@ -310,18 +315,19 @@ async function main() {
// 2. Token
let primaryToken = preToken
if (primaryToken && await validateToken(primaryToken)) {
if (primaryToken && (await validateToken(primaryToken))) {
console.warn(`[provision] Using pre-set token: ${primaryToken.slice(0, 20)}`)
}
else {
if (primaryToken)
console.warn('[provision] Pre-set token invalid, minting fresh…')
} else {
if (primaryToken) console.warn('[provision] Pre-set token invalid, minting fresh…')
primaryToken = await mintToken(cookieString, csrfToken, 'e2e-provision')
console.warn(`[provision] Minted token: ${primaryToken.slice(0, 20)}`)
}
// 3. Discover workspaces
const { primaryWsId, primaryWsName, secondaryWsId } = await discoverWorkspaces(cookieString, csrfToken)
const { primaryWsId, primaryWsName, secondaryWsId } = await discoverWorkspaces(
cookieString,
csrfToken,
)
// 4. Provision apps
const appIds = await provisionApps(cookieString, csrfToken, primaryWsId, secondaryWsId)
@ -333,10 +339,16 @@ async function main() {
// their describe calls rejected with "workspace_id does not match app's workspace".
await fetch(`${base}/console/api/workspaces/switch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRF-Token': csrfToken },
headers: {
'Content-Type': 'application/json',
Cookie: cookieString,
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ tenant_id: primaryWsId }),
signal: AbortSignal.timeout(10_000),
}).catch((err: unknown) => console.warn(`[provision] switch-back to primary failed (non-fatal): ${err}`))
}).catch((err: unknown) =>
console.warn(`[provision] switch-back to primary failed (non-fatal): ${err}`),
)
console.warn(`[provision] Session workspace reset to primary: ${primaryWsId}`)
// 5. Write outputs

View File

@ -14,18 +14,22 @@ import {
describe('pathToTokens', () => {
it('extracts tokens for nested command', () => {
expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands'))
.toEqual(['auth', 'devices', 'list'])
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'])
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'])
expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands')).toEqual([
'auth',
'login',
])
})
})
@ -49,22 +53,35 @@ describe('tokensToIdentifier', () => {
describe('buildTree', () => {
it('assembles a nested tree from entries', () => {
const entries = [
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' },
{ tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: '@/commands/auth/devices/list/index' },
{
tokens: ['auth', 'login'],
identifier: 'AuthLogin',
importPath: '@/commands/auth/login/index',
},
{
tokens: ['auth', 'devices', 'list'],
identifier: 'AuthDevicesList',
importPath: '@/commands/auth/devices/list/index',
},
{ tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' },
]
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('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: '@/commands/run/app/index' },
{ tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: '@/commands/run/app/resume/index' },
{
tokens: ['run', 'app', 'resume'],
identifier: 'RunAppResume',
importPath: '@/commands/run/app/resume/index',
},
]
const tree = buildTree(entries)
const runApp = tree.subcommands.get('run')?.subcommands.get('app')
@ -76,9 +93,17 @@ describe('buildTree', () => {
describe('formatModule', () => {
it('produces a deterministic ESM file with imports + tree literal', () => {
const entries: CommandEntry[] = [
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' },
{
tokens: ['auth', 'login'],
identifier: 'AuthLogin',
importPath: '@/commands/auth/login/index',
},
{ tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' },
{ tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: '@/commands/auth/devices/list/index' },
{
tokens: ['auth', 'devices', 'list'],
identifier: 'AuthDevicesList',
importPath: '@/commands/auth/devices/list/index',
},
]
const tree = buildTree(entries)
const out = formatModule(entries, tree)
@ -111,7 +136,11 @@ export const commandTree: CommandTree = {
it('emits parent-with-own-command shape', () => {
const entries: CommandEntry[] = [
{ tokens: ['run', 'app'], identifier: 'RunApp', importPath: '@/commands/run/app/index' },
{ tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: '@/commands/run/app/resume/index' },
{
tokens: ['run', 'app', 'resume'],
identifier: 'RunAppResume',
importPath: '@/commands/run/app/resume/index',
},
]
const tree = buildTree(entries)
const out = formatModule(entries, tree)
@ -130,7 +159,11 @@ export const commandTree: CommandTree = {
it('imports sorted alphabetically by import path', () => {
const entries: CommandEntry[] = [
{ tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' },
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' },
{
tokens: ['auth', 'login'],
identifier: 'AuthLogin',
importPath: '@/commands/auth/login/index',
},
]
const out = formatModule(entries, buildTree(entries))
const authIdx = out.indexOf('AuthLogin')
@ -140,8 +173,16 @@ export const commandTree: CommandTree = {
it('quotes hyphenated keys and leaves plain identifier keys unquoted', () => {
const entries: CommandEntry[] = [
{ tokens: ['export', 'app'], identifier: 'ExportApp', importPath: '@/commands/export/app/index' },
{ tokens: ['export', 'studio-app'], identifier: 'ExportStudioApp', importPath: '@/commands/export/studio-app/index' },
{
tokens: ['export', 'app'],
identifier: 'ExportApp',
importPath: '@/commands/export/app/index',
},
{
tokens: ['export', 'studio-app'],
identifier: 'ExportStudioApp',
importPath: '@/commands/export/studio-app/index',
},
]
const out = formatModule(entries, buildTree(entries))
expect(out).toContain(`'studio-app': { command: ExportStudioApp, subcommands: {} },`)
@ -156,7 +197,10 @@ function makeFixture(): string {
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')
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 })
@ -168,12 +212,12 @@ 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([
expect(entries.map((e) => e.tokens.join('/'))).toEqual([
'auth/devices/list',
'auth/login',
'version',
])
expect(entries.find(e => e.tokens[0] === '_shared')).toBeUndefined()
expect(entries.find((e) => e.tokens[0] === '_shared')).toBeUndefined()
})
it('errors on a loose .ts file under commands/', async () => {
@ -205,8 +249,7 @@ describe('generate', () => {
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')
if (result.mode !== 'check') throw new Error('expected check mode')
expect(result.ok).toBe(true)
})
@ -216,10 +259,8 @@ describe('generate', () => {
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')
if (result.mode !== 'check') throw new Error('expected check mode')
expect(result.ok).toBe(false)
if (!result.ok)
expect(result.diff).toBeDefined()
if (!result.ok) expect(result.diff).toBeDefined()
})
})

View File

@ -57,26 +57,22 @@ export type 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 trimmed = normalized.startsWith(`${root}/`) ? normalized.slice(root.length + 1) : normalized
const withoutIndex = trimmed.replace(/\/index\.ts$/, '')
return withoutIndex.split('/').filter(s => s.length > 0)
return withoutIndex.split('/').filter((s) => s.length > 0)
}
function capitalize(part: string): string {
if (part.length === 0)
return ''
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(/[-_]/))
.flatMap((t) => t.split(/[-_]/))
.map(capitalize)
.join('')
if (RESERVED_JS_KEYWORDS.has(id.toLowerCase()))
return `_${id}`
if (RESERVED_JS_KEYWORDS.has(id.toLowerCase())) return `_${id}`
return id
}
@ -103,18 +99,15 @@ const HEADER = `// @generated by scripts/generate-command-tree.ts — DO NOT EDI
`
function compareStrings(a: string, b: string): number {
if (a < b)
return -1
if (a > b)
return 1
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'`]
for (const e of sorted)
lines.push(`import ${e.identifier} from '${e.importPath}'`)
for (const e of sorted) lines.push(`import ${e.identifier} from '${e.importPath}'`)
return lines.join('\n')
}
@ -123,13 +116,11 @@ function emitNode(node: TreeNode, indent: string): string {
const keys = [...node.subcommands.keys()].sort()
const parts: string[] = []
if (node.command !== undefined)
parts.push(`${inner}command: ${node.command},`)
if (node.command !== undefined) parts.push(`${inner}command: ${node.command},`)
if (keys.length === 0) {
parts.push(`${inner}subcommands: {},`)
}
else {
} else {
parts.push(`${inner}subcommands: {`)
for (const key of keys) {
const child = node.subcommands.get(key)!
@ -154,14 +145,9 @@ function emitKey(key: string): string {
function emitEntry(key: string, node: TreeNode, indent: string): string {
const k = emitKey(key)
const isLeaf = node.subcommands.size === 0 && node.command !== undefined
if (isLeaf)
return `${indent}${k}: { command: ${node.command}, subcommands: {} },`
if (isLeaf) return `${indent}${k}: { command: ${node.command}, subcommands: {} },`
return [
`${indent}${k}: {`,
emitNode(node, indent),
`${indent}},`,
].join('\n')
return [`${indent}${k}: {`, emitNode(node, indent), `${indent}},`].join('\n')
}
export function formatModule(entries: readonly CommandEntry[], tree: TreeNode): string {
@ -181,10 +167,8 @@ async function walk(dir: string): Promise<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)
if (e.isDirectory()) out.push(...(await walk(full)))
else if (e.isFile()) out.push(full)
}
return out
}
@ -195,21 +179,20 @@ function toPosix(p: string): string {
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 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
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 (!rel.includes('/')) loose.push(rel)
}
if (loose.length > 0) {
const list = loose.map(p => ` - src/commands/${p}`).join('\n')
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}`,
)
@ -218,15 +201,11 @@ export async function discoverCommands(commandsDir: string): Promise<CommandEntr
const entries: CommandEntry[] = []
for (const abs of tsFiles) {
const rel = toPosix(relative(commandsDir, abs))
if (isExcludedCommandPath(rel))
continue
if (!rel.endsWith('/index.ts'))
continue
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}`)
if (tokens.length === 0) continue
if (tokens[0]!.startsWith('-')) throw new Error(`command token cannot start with '-': ${rel}`)
entries.push({
tokens,
identifier: tokensToIdentifier(tokens),
@ -236,8 +215,7 @@ export async function discoverCommands(commandsDir: string): Promise<CommandEntr
entries.sort((a, b) => compareStrings(a.importPath, b.importPath))
if (entries.length === 0)
throw new Error(`no commands found under ${commandsDir}`)
if (entries.length === 0) throw new Error(`no commands found under ${commandsDir}`)
assertUniqueIdentifiers(entries)
return entries
@ -258,10 +236,10 @@ export type GenerateOptions = {
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 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)
@ -273,12 +251,10 @@ export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
let onDisk = ''
try {
onDisk = await readFile(target, 'utf8')
}
catch {
} catch {
onDisk = ''
}
if (onDisk === content)
return { mode: 'check', ok: true, path: target }
if (onDisk === content) return { mode: 'check', ok: true, path: target }
return { mode: 'check', ok: false, path: target, diff: shortDiff(onDisk, content) }
}
@ -295,10 +271,8 @@ function shortDiff(a: string, b: string): 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]}`)
if (aLines[i] !== undefined) lines.push(`- ${aLines[i]}`)
if (bLines[i] !== undefined) lines.push(`+ ${bLines[i]}`)
}
}
return lines.slice(0, 40).join('\n')
@ -318,12 +292,13 @@ async function main(): Promise<void> {
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.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]
const invokedDirectly =
process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]
if (invokedDirectly)
await main()
if (invokedDirectly) await main()

View File

@ -44,11 +44,20 @@ const FETCH_STUB = [
].join('\n')
/* eslint-enable no-template-curly-in-string */
function runLib(program: string, env: Record<string, string> = {}): { code: number, stdout: string, stderr: string } {
function runLib(
program: string,
env: Record<string, string> = {},
): { code: number; stdout: string; stderr: string } {
const full = `. "${SCRIPT}"\n${FETCH_STUB}\n${program}`
const r = spawnSync('sh', ['-c', full], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', ...env },
env: {
...process.env,
DIFYCTL_INSTALL_LIB: '1',
DIFY_VERSION: '',
DIFYCTL_VERSION: '',
...env,
},
})
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
}
@ -56,11 +65,21 @@ function runLib(program: string, env: Record<string, string> = {}): { code: numb
// Like runLib but with a caller-supplied fetch_json stub, so we can drive the
// real rate_limit_hint / maybe_ratelimit_exit / fetch_hit_ratelimit (which the
// script defines) by writing a classified reason to FETCH_ERR_FILE.
function runLibStub(stub: string, program: string, env: Record<string, string> = {}): { code: number, stderr: string } {
function runLibStub(
stub: string,
program: string,
env: Record<string, string> = {},
): { code: number; stderr: string } {
const full = `. "${SCRIPT}"\n${stub}\n${program}`
const r = spawnSync('sh', ['-c', full], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', ...env },
env: {
...process.env,
DIFYCTL_INSTALL_LIB: '1',
DIFY_VERSION: '',
DIFYCTL_VERSION: '',
...env,
},
})
return { code: r.status ?? 1, stderr: r.stderr ?? '' }
}
@ -71,9 +90,17 @@ function failStub(reason: string): string {
return `fetch_json() { printf '%s' '${reason}' > "$FETCH_ERR_FILE"; return 1; }`
}
const REL_1142 = JSON.stringify({ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }] })
const REL_1150 = JSON.stringify({ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-linux-x64' }] })
const LIST_NEWEST_FIRST = JSON.stringify({ releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }] })
const REL_1142 = JSON.stringify({
tag_name: '1.14.2',
assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }],
})
const REL_1150 = JSON.stringify({
tag_name: '1.15.0',
assets: [{ name: 'difyctl-v0.3.0-linux-x64' }],
})
const LIST_NEWEST_FIRST = JSON.stringify({
releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }],
})
const RELEASE = JSON.stringify({
tag_name: '1.14.2',
@ -138,7 +165,10 @@ describe('install-cli asset_version', () => {
describe('install-cli resolve_release', () => {
it('DIFY_VERSION pins the release directly', () => {
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', { DIFY_VERSION: '1.14.2', TAG_1_14_2: REL_1142 })
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
DIFY_VERSION: '1.14.2',
TAG_1_14_2: REL_1142,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
@ -150,7 +180,9 @@ describe('install-cli resolve_release', () => {
})
it('blank resolves to the latest stable release', () => {
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', { LATEST_JSON: REL_1150 })
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
LATEST_JSON: REL_1150,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.15.0')
})
@ -225,14 +257,18 @@ describe('install-cli rate limit', () => {
})
it('DIFY_VERSION: rate limit wins over the misleading "not found" message', () => {
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', { DIFY_VERSION: '1.15.0' })
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', {
DIFY_VERSION: '1.15.0',
})
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('not found')
})
it('DIFYCTL_VERSION: rate limit surfaces from the nested subshell, not "not found"', () => {
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', { DIFYCTL_VERSION: '0.2.0' })
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', {
DIFYCTL_VERSION: '0.2.0',
})
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('not found')
@ -288,32 +324,50 @@ esac
// Drive the real fetch_json with FAKE_CURL first on PATH. Returns "OK|<body>" or
// "FAIL|<FETCH_ERR_FILE contents>", plus any -H lines the fake curl received.
function runRealFetch(mode: string, env: Record<string, string> = {}): { result: string, headers: string } {
function runRealFetch(
mode: string,
env: Record<string, string> = {},
): { result: string; headers: string } {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-fakecurl-'))
const hdrLog = join(dir, 'hdrlog')
writeFileSync(join(dir, 'curl'), FAKE_CURL)
chmodSync(join(dir, 'curl'), 0o755)
const program = 'if body=$(fetch_json "https://api.github.com/repos/x/releases/latest"); then printf \'OK|%s\' "$body"; else printf \'FAIL|%s\' "$(cat "$FETCH_ERR_FILE" 2>/dev/null)"; fi'
const program =
'if body=$(fetch_json "https://api.github.com/repos/x/releases/latest"); then printf \'OK|%s\' "$body"; else printf \'FAIL|%s\' "$(cat "$FETCH_ERR_FILE" 2>/dev/null)"; fi'
const r = spawnSync('sh', ['-c', `. "${SCRIPT}"\n${program}`], {
encoding: 'utf8',
env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}`, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', FAKE_MODE: mode, FAKE_HDR_LOG: hdrLog, ...env },
env: {
...process.env,
PATH: `${dir}:${process.env.PATH ?? ''}`,
DIFYCTL_INSTALL_LIB: '1',
DIFY_VERSION: '',
DIFYCTL_VERSION: '',
FAKE_MODE: mode,
FAKE_HDR_LOG: hdrLog,
...env,
},
})
let headers = ''
try {
headers = readFileSync(hdrLog, 'utf8')
} catch {
/* no headers logged */
}
catch { /* no headers logged */ }
rmSync(dir, { recursive: true, force: true })
return { result: (r.stdout ?? '').trim(), headers }
}
describe('install-cli fetch_json (real, fake curl on PATH)', () => {
it('returns the response body on 200', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{"tag_name":"1.15.0"}' }).result).toBe('OK|{"tag_name":"1.15.0"}')
expect(runRealFetch('ok', { FAKE_BODY: '{"tag_name":"1.15.0"}' }).result).toBe(
'OK|{"tag_name":"1.15.0"}',
)
})
it('classifies a 403 with x-ratelimit-remaining:0 as a rate limit and captures the reset', () => {
expect(runRealFetch('ratelimit', { FAKE_RESET: '1893456000' }).result).toBe('FAIL|ratelimit:1893456000')
expect(runRealFetch('ratelimit', { FAKE_RESET: '1893456000' }).result).toBe(
'FAIL|ratelimit:1893456000',
)
})
it('classifies a 403 with tokens left as a plain http error, not a rate limit', () => {
@ -329,14 +383,20 @@ describe('install-cli fetch_json (real, fake curl on PATH)', () => {
})
it('sends an Authorization bearer header when GITHUB_TOKEN is set', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: 'ghp_secret' }).headers).toContain('Authorization: Bearer ghp_secret')
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: 'ghp_secret' }).headers).toContain(
'Authorization: Bearer ghp_secret',
)
})
it('falls back to GH_TOKEN when GITHUB_TOKEN is unset', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: 'gho_fallback' }).headers).toContain('Authorization: Bearer gho_fallback')
expect(
runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: 'gho_fallback' }).headers,
).toContain('Authorization: Bearer gho_fallback')
})
it('sends no Authorization header when neither token is set', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: '' }).headers).not.toContain('Authorization')
expect(
runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: '' }).headers,
).not.toContain('Authorization')
})
})

View File

@ -12,23 +12,30 @@ const MANIFEST = JSON.stringify({
channel: 'edge',
version: '0.1.0-edge.2fd7b82',
baseUrl: 'https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82',
targets: { 'windows-x64': { asset: 'difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe', sha256: 'deadbeef' } },
targets: {
'windows-x64': { asset: 'difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe', sha256: 'deadbeef' },
},
})
function pwsh(program: string): { code: number, stdout: string, stderr: string } {
function pwsh(program: string): { code: number; stdout: string; stderr: string } {
const full = `. '${SCRIPT}'\n${program}`
const r = spawnSync('pwsh', ['-NoProfile', '-Command', full], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' },
})
return { code: r.status ?? 1, stdout: (r.stdout ?? '').replace(/\r\n/g, '\n').trim(), stderr: r.stderr ?? '' }
return {
code: r.status ?? 1,
stdout: (r.stdout ?? '').replace(/\r\n/g, '\n').trim(),
stderr: r.stderr ?? '',
}
}
d('install-r2.ps1', () => {
it('parses a target asset + sha from the manifest', () => {
const prog = `$m = ConvertFrom-Json @'\n${MANIFEST}\n'@\n`
+ `Write-Output (Get-TargetField $m 'windows-x64' 'asset')\n`
+ `Write-Output (Get-TargetField $m 'windows-x64' 'sha256')`
const prog =
`$m = ConvertFrom-Json @'\n${MANIFEST}\n'@\n` +
`Write-Output (Get-TargetField $m 'windows-x64' 'asset')\n` +
`Write-Output (Get-TargetField $m 'windows-x64' 'sha256')`
const { stdout } = pwsh(prog)
expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe\ndeadbeef')
})

View File

@ -49,7 +49,10 @@ const CHECKSUMS = [
'beadc0de difyctl-v0.1.0-edge.ce4af868-windows-x64.exe',
].join('\n')
function lib(program: string, env: Record<string, string> = {}): { code: number, stdout: string, stderr: string } {
function lib(
program: string,
env: Record<string, string> = {},
): { code: number; stdout: string; stderr: string } {
const full = `. "${SCRIPT}"\n${program}`
const r = spawnSync('sh', ['-c', full], {
encoding: 'utf8',
@ -63,24 +66,33 @@ describe('install-r2 manifest parsing', () => {
// so detect_target intentionally dies (Windows installs go through install-r2.ps1).
it.skipIf(process.platform === 'win32')('detect_target maps to one of the 5 ids', () => {
const { stdout } = lib('detect_target')
expect(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']).toContain(stdout)
expect(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']).toContain(
stdout,
)
})
it('manifest_str reads a top-level string field', () => {
const { stdout } = lib(`printf '%s' '${MANIFEST}' > "$tmp_m"; manifest_str "$tmp_m" channel`, {})
const { stdout } = lib(
`printf '%s' '${MANIFEST}' > "$tmp_m"; manifest_str "$tmp_m" channel`,
{},
)
expect(stdout).toBe('edge')
})
it('manifest_target_field extracts per-target values from a single line', () => {
const prog = `printf '%s' '${MANIFEST}' > "$tmp_m"\n`
+ 'manifest_target_field "$tmp_m" darwin-arm64 asset\n'
+ 'manifest_target_field "$tmp_m" darwin-arm64 sha256'
const prog =
`printf '%s' '${MANIFEST}' > "$tmp_m"\n` +
'manifest_target_field "$tmp_m" darwin-arm64 asset\n' +
'manifest_target_field "$tmp_m" darwin-arm64 sha256'
const { stdout } = lib(prog)
expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-darwin-arm64\ncafef00d')
})
it('requires DIFYCTL_R2_BASE when run as the installer (not lib)', () => {
const r = spawnSync('sh', [SCRIPT], { encoding: 'utf8', env: { ...process.env, DIFYCTL_R2_BASE: '' } })
const r = spawnSync('sh', [SCRIPT], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_R2_BASE: '' },
})
expect(r.status).not.toBe(0)
expect(r.stderr).toMatch(/DIFYCTL_R2_BASE/)
})
@ -93,14 +105,18 @@ describe('install-r2 manifest parsing', () => {
it('sha256_check passes on the correct hash', () => {
// sha256('hello') = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
const r = lib('f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 && echo OK')
const r = lib(
'f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 && echo OK',
)
expect(r.stdout).toBe('OK')
})
})
describe('install-r2 version/commit pin', () => {
it('index_resolve matches a build by exact version', () => {
const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" version 0.1.0-edge.aaaa111`)
const r = lib(
`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" version 0.1.0-edge.aaaa111`,
)
expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111')
})
@ -110,7 +126,9 @@ describe('install-r2 version/commit pin', () => {
})
it('index_resolve matches the full 40-char commit too', () => {
const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit aaaa111bbbbcccc000011112222333344445555`)
const r = lib(
`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit aaaa111bbbbcccc000011112222333344445555`,
)
expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111')
})

View File

@ -5,9 +5,13 @@ import { describe, expect, it } from 'vitest'
const SCRIPT = fileURLToPath(new URL('./install.ps1', import.meta.url))
function hasPwsh(): boolean {
const r = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'], {
encoding: 'utf8',
})
const r = spawnSync(
'pwsh',
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'],
{
encoding: 'utf8',
},
)
return r.status === 0
}
@ -16,26 +20,26 @@ const PWSH = hasPwsh()
const STUB = [
'function Invoke-RestMethod {',
' param([string]$Uri, $Headers)',
' if ($Uri -like \'*/releases/latest\') {',
' if (-not $env:HX_LATEST) { throw \'mock 404\' }',
" if ($Uri -like '*/releases/latest') {",
" if (-not $env:HX_LATEST) { throw 'mock 404' }",
' return ($env:HX_LATEST | ConvertFrom-Json)',
' }',
' elseif ($Uri -like \'*/releases?per_page=100\') {',
' if (-not $env:HX_LIST) { throw \'mock 404\' }',
" elseif ($Uri -like '*/releases?per_page=100') {",
" if (-not $env:HX_LIST) { throw 'mock 404' }",
' return ($env:HX_LIST | ConvertFrom-Json)',
' }',
' elseif ($Uri -like \'*/releases/tags/*\') {',
' $t = $Uri -replace \'.*/releases/tags/\', \'\'',
' $k = \'HX_TAG_\' + ($t -replace \'[.\\-]\', \'_\')',
" elseif ($Uri -like '*/releases/tags/*') {",
" $t = $Uri -replace '.*/releases/tags/', ''",
" $k = 'HX_TAG_' + ($t -replace '[.\\-]', '_')",
' $v = [Environment]::GetEnvironmentVariable($k)',
' if (-not $v) { throw \'mock 404\' }',
" if (-not $v) { throw 'mock 404' }",
' return ($v | ConvertFrom-Json)',
' }',
' throw "unexpected uri $Uri"',
'}',
].join('\n')
type Run = { code: number, stdout: string, stderr: string }
type Run = { code: number; stdout: string; stderr: string }
function runPwsh(body: string, env: Record<string, string> = {}): Run {
const script = `$ErrorActionPreference='Stop'\n${STUB}\n. '${SCRIPT}'\n${body}`
@ -54,8 +58,14 @@ function runPwsh(body: string, env: Record<string, string> = {}): Run {
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
}
const REL_1142 = JSON.stringify({ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] })
const REL_1150 = JSON.stringify({ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] })
const REL_1142 = JSON.stringify({
tag_name: '1.14.2',
assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }],
})
const REL_1150 = JSON.stringify({
tag_name: '1.15.0',
assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }],
})
const LIST_NEWEST_FIRST = JSON.stringify([
{ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] },
{ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] },
@ -63,25 +73,31 @@ const LIST_NEWEST_FIRST = JSON.stringify([
describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => {
it('extracts the version from a windows .exe asset name', () => {
const r = runPwsh('(Get-AssetSemver \'difyctl-v0.2.0-windows-x64.exe\').Version')
const r = runPwsh("(Get-AssetSemver 'difyctl-v0.2.0-windows-x64.exe').Version")
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.2.0')
})
it('extracts a prerelease version and its rc number', () => {
const r = runPwsh('$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"')
const r = runPwsh(
'$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"',
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.1.0-rc.1 1')
})
it('rejects a non-windows asset (returns null)', () => {
const r = runPwsh('if ($null -eq (Get-AssetSemver \'difyctl-v0.2.0-linux-x64\')) { \'NULL\' } else { \'OBJ\' }')
const r = runPwsh(
"if ($null -eq (Get-AssetSemver 'difyctl-v0.2.0-linux-x64')) { 'NULL' } else { 'OBJ' }",
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
it('rejects a malformed core version (returns null)', () => {
const r = runPwsh('if ($null -eq (Get-AssetSemver \'difyctl-vx.y.z-windows-x64.exe\')) { \'NULL\' } else { \'OBJ\' }')
const r = runPwsh(
"if ($null -eq (Get-AssetSemver 'difyctl-vx.y.z-windows-x64.exe')) { 'NULL' } else { 'OBJ' }",
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
@ -89,33 +105,39 @@ describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => {
describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
it('picks the highest semver among several windows builds', () => {
const rel = JSON.stringify({ assets: [
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'difyctl-v0.10.0-windows-x64.exe' },
{ name: 'difyctl-v0.9.0-windows-x64.exe' },
] })
const rel = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'difyctl-v0.10.0-windows-x64.exe' },
{ name: 'difyctl-v0.9.0-windows-x64.exe' },
],
})
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.10.0')
})
it('prefers the stable release over an rc of the same core', () => {
const rel = JSON.stringify({ assets: [
{ name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
] })
const rel = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
],
})
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.2.0')
})
it('ignores checksums and non-windows assets', () => {
const rel = JSON.stringify({ assets: [
{ name: 'difyctl-v0.2.0-linux-x64' },
{ name: 'difyctl-v0.2.0-checksums.txt' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'some-other-asset.zip' },
] })
const rel = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-linux-x64' },
{ name: 'difyctl-v0.2.0-checksums.txt' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'some-other-asset.zip' },
],
})
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Name`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('difyctl-v0.2.0-windows-x64.exe')
@ -123,7 +145,9 @@ describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
it('yields null when no windows asset is present', () => {
const rel = JSON.stringify({ assets: [{ name: 'difyctl-v0.2.0-linux-x64' }] })
const r = runPwsh(`if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`)
const r = runPwsh(
`if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`,
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
@ -131,7 +155,10 @@ describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
it('DIFY_VERSION pins the release directly', () => {
const r = runPwsh('(Resolve-Release).tag_name', { DIFY_VERSION: '1.14.2', HX_TAG_1_14_2: REL_1142 })
const r = runPwsh('(Resolve-Release).tag_name', {
DIFY_VERSION: '1.14.2',
HX_TAG_1_14_2: REL_1142,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
@ -155,13 +182,19 @@ describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
})
it('DIFYCTL_VERSION resolves to the release hosting that build', () => {
const r = runPwsh('(Resolve-Release).tag_name', { DIFYCTL_VERSION: '0.2.0', HX_LIST: LIST_NEWEST_FIRST })
const r = runPwsh('(Resolve-Release).tag_name', {
DIFYCTL_VERSION: '0.2.0',
HX_LIST: LIST_NEWEST_FIRST,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
it('DIFYCTL_VERSION not hosted anywhere throws', () => {
const r = runPwsh('(Resolve-Release).tag_name', { DIFYCTL_VERSION: '9.9.9', HX_LIST: LIST_NEWEST_FIRST })
const r = runPwsh('(Resolve-Release).tag_name', {
DIFYCTL_VERSION: '9.9.9',
HX_LIST: LIST_NEWEST_FIRST,
})
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('difyctl 9.9.9 not found on any Dify release')
})
@ -169,13 +202,16 @@ describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
describe.skipIf(!PWSH)('install.ps1 Find-ReleaseForDifyctl', () => {
it('returns the newest release whose assets host the wanted build', () => {
const r = runPwsh('(Find-ReleaseForDifyctl \'0.2.0\').tag_name', { HX_LIST: LIST_NEWEST_FIRST })
const r = runPwsh("(Find-ReleaseForDifyctl '0.2.0').tag_name", { HX_LIST: LIST_NEWEST_FIRST })
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
it('returns nothing when no release hosts the wanted build', () => {
const r = runPwsh('$x = Find-ReleaseForDifyctl \'9.9.9\'; if ($null -eq $x) { \'NULL\' } else { $x.tag_name }', { HX_LIST: LIST_NEWEST_FIRST })
const r = runPwsh(
"$x = Find-ReleaseForDifyctl '9.9.9'; if ($null -eq $x) { 'NULL' } else { $x.tag_name }",
{ HX_LIST: LIST_NEWEST_FIRST },
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
@ -198,7 +234,8 @@ const futureReset = String(Math.floor(Date.now() / 1000) + 1800)
describe.skipIf(!PWSH)('install.ps1 rate limit', () => {
it('classifies a 403 with x-ratelimit-remaining:0 as rate-limited, returning the reset', () => {
const r = runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': futureReset })}
const r =
runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': futureReset })}
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { $i.Reset }`)
expect(r.code).toBe(0)
expect(r.stdout).toBe(futureReset)
@ -219,7 +256,9 @@ describe.skipIf(!PWSH)('install.ps1 rate limit', () => {
})
it('returns null for an error without a response (e.g. a plain string throw)', () => {
const r = runPwsh('$err = [pscustomobject]@{ Exception = [pscustomobject]@{} }; if ($null -eq (Get-RateLimitInfo $err)) { \'NULL\' } else { \'OBJ\' }')
const r = runPwsh(
"$err = [pscustomobject]@{ Exception = [pscustomobject]@{} }; if ($null -eq (Get-RateLimitInfo $err)) { 'NULL' } else { 'OBJ' }",
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
@ -233,7 +272,7 @@ describe.skipIf(!PWSH)('install.ps1 rate limit', () => {
})
it('Write-RateLimitHint omits the ETA line when the reset epoch is missing', () => {
const r = runPwsh('Write-RateLimitHint \'\'')
const r = runPwsh("Write-RateLimitHint ''")
expect(r.code).toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('resets in ~')

View File

@ -27,8 +27,7 @@ const GIT_PROBE_OPTS: ExecSyncOptions = {
export const defaultGitProbe: GitProbe = (cmd) => {
try {
return execSync(cmd, GIT_PROBE_OPTS).toString().trim() || null
}
catch {
} catch {
return null
}
}
@ -36,7 +35,7 @@ export const defaultGitProbe: GitProbe = (cmd) => {
type PackageManifest = {
difyctl?: {
channel?: string
compat?: { minDify?: string, maxDify?: string }
compat?: { minDify?: string; maxDify?: string }
}
}
@ -48,8 +47,7 @@ const defaultPackageReader: PackageReader = () => {
try {
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json')
return JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageManifest
}
catch {
} catch {
return {}
}
}
@ -69,20 +67,12 @@ export function resolveBuildInfo(opts: ResolveOptions = {}): BuildInfo {
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(' | ')})`,
)
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 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 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'

View File

@ -2,8 +2,8 @@ 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`,
`version: ${info.version}\n` +
`commit: ${info.commit}\n` +
`built: ${info.buildDate}\n` +
`channel: ${info.channel}\n`,
)

View File

@ -31,8 +31,8 @@ const CHANNELS = [
{ name: 'edge', prerelease: true, versionForm: /^\d+\.\d+\.\d+-edge\.[0-9a-f]{7,40}$/ },
]
const channelByName = name => CHANNELS.find(c => c.name === name)
const channelNames = () => CHANNELS.map(c => c.name).join(', ')
const channelByName = (name) => CHANNELS.find((c) => c.name === name)
const channelNames = () => CHANNELS.map((c) => c.name).join(', ')
function parsePrecedence(v) {
const s = String(v).replace(/^v/, '').replace(/\+.*$/, '')
@ -51,8 +51,7 @@ function edgeVersion(sha) {
die('edge-version requires a git short sha (7-40 hex chars)')
const { version } = loadPkg()
const core = versionCore(version)
if (!/^\d+\.\d+\.\d+$/.test(core))
die(`cannot derive edge base from version: ${version}`)
if (!/^\d+\.\d+\.\d+$/.test(core)) die(`cannot derive edge base from version: ${version}`)
return `${core}-edge.${sha}`
}
@ -63,8 +62,7 @@ function channelVersionProblem(version, channel) {
if (typeof version !== 'string' || version.length === 0)
return 'version must be a non-empty string'
const ch = channelByName(channel)
if (!ch)
return `unknown channel: ${channel} (expected one of: ${channelNames()})`
if (!ch) return `unknown channel: ${channel} (expected one of: ${channelNames()})`
if (!ch.versionForm.test(version))
return `version ${version} does not match the ${channel} channel form`
return null
@ -72,8 +70,7 @@ function channelVersionProblem(version, channel) {
function validateVersionForChannel(version, channelName) {
const problem = channelVersionProblem(version, channelName)
if (problem)
die(problem)
if (problem) die(problem)
return `valid: ${version} is a ${channelName} version`
}
@ -82,21 +79,16 @@ function comparePre(a, b) {
const bparts = b.split('.')
const len = Math.max(aparts.length, bparts.length)
for (let i = 0; i < len; i++) {
if (aparts[i] === undefined)
return -1
if (bparts[i] === undefined)
return 1
if (aparts[i] === undefined) return -1
if (bparts[i] === undefined) return 1
const an = /^\d+$/.test(aparts[i])
const bn = /^\d+$/.test(bparts[i])
if (an && bn) {
const d = Number(aparts[i]) - Number(bparts[i])
if (d !== 0)
return d < 0 ? -1 : 1
}
else if (an !== bn) {
if (d !== 0) return d < 0 ? -1 : 1
} else if (an !== bn) {
return an ? -1 : 1
}
else if (aparts[i] !== bparts[i]) {
} else if (aparts[i] !== bparts[i]) {
return aparts[i] < bparts[i] ? -1 : 1
}
}
@ -109,15 +101,11 @@ function comparePrecedence(a, b) {
for (let i = 0; i < SEMVER_CORE_LEN; i++) {
const x = A.nums[i] ?? 0
const y = B.nums[i] ?? 0
if (x !== y)
return x < y ? -1 : 1
if (x !== y) return x < y ? -1 : 1
}
if (A.pre === B.pre)
return 0
if (A.pre === '')
return 1
if (B.pre === '')
return -1
if (A.pre === B.pre) return 0
if (A.pre === '') return 1
if (B.pre === '') return -1
return comparePre(A.pre, B.pre)
}
@ -129,8 +117,7 @@ function die(msg) {
function loadPkg() {
const pkgUrl = new URL('../package.json', import.meta.url)
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8'))
if (!pkg.difyctl?.release)
die('cli/package.json missing difyctl.release')
if (!pkg.difyctl?.release) die('cli/package.json missing difyctl.release')
return {
version: pkg.version,
channel: pkg.difyctl.channel,
@ -151,32 +138,29 @@ function githubEnv() {
tagPrefix: release.tagPrefix,
difyctlTag: `${release.tagPrefix}${version}`,
}
return Object.entries(fields).map(([k, v]) => `${k}=${v}`).join('\n')
return Object.entries(fields)
.map(([k, v]) => `${k}=${v}`)
.join('\n')
}
function requireVersion(version) {
if (!version)
die('version argument is required')
if (!version) die('version argument is required')
return version
}
function assetName(release, version, id) {
const target = release.targets.find(t => t.id === id)
if (!target)
die(`unknown target id: ${id}`)
const target = release.targets.find((t) => t.id === id)
if (!target) die(`unknown target id: ${id}`)
const suffix = target.exe ? '.exe' : ''
return `${release.tagPrefix}${version}-${id}${suffix}`
}
function validateRelease(release) {
const problems = []
const str = v => typeof v === 'string' && v.length > 0
if (!str(release.tagPrefix))
problems.push('tagPrefix must be a non-empty string')
if (!str(release.binName))
problems.push('binName must be a non-empty string')
if (!str(release.checksumsSuffix))
problems.push('checksumsSuffix must be a non-empty string')
const str = (v) => typeof v === 'string' && v.length > 0
if (!str(release.tagPrefix)) problems.push('tagPrefix must be a non-empty string')
if (!str(release.binName)) problems.push('binName must be a non-empty string')
if (!str(release.checksumsSuffix)) problems.push('checksumsSuffix must be a non-empty string')
if (!Array.isArray(release.targets) || release.targets.length === 0) {
problems.push('targets must be a non-empty array')
return problems
@ -184,15 +168,12 @@ function validateRelease(release) {
const seen = new Set()
for (const t of release.targets) {
const label = t?.id ?? JSON.stringify(t)
if (!str(t?.id))
problems.push(`target ${label}: id must be a non-empty string`)
else if (seen.has(t.id))
problems.push(`duplicate target id: ${t.id}`)
if (!str(t?.id)) problems.push(`target ${label}: id must be a non-empty string`)
else if (seen.has(t.id)) problems.push(`duplicate target id: ${t.id}`)
else seen.add(t.id)
if (!str(t?.bunTarget) || !BUN_TARGET_RE.test(t.bunTarget))
problems.push(`target ${label}: bunTarget must match ${BUN_TARGET_RE}`)
if (typeof t?.exe !== 'boolean')
problems.push(`target ${label}: exe must be a boolean`)
if (typeof t?.exe !== 'boolean') problems.push(`target ${label}: exe must be a boolean`)
else if (str(t?.bunTarget) && t.exe !== t.bunTarget.startsWith('bun-windows-'))
problems.push(`target ${label}: exe must be true iff bunTarget is bun-windows-*`)
}
@ -210,7 +191,11 @@ function main(argv) {
case 'tag':
return `${loadPkg().release.tagPrefix}${requireVersion(rest[0])}`
case 'asset':
return assetName(loadPkg().release, requireVersion(rest[0]), rest[1] ?? die('target id is required'))
return assetName(
loadPkg().release,
requireVersion(rest[0]),
rest[1] ?? die('target id is required'),
)
case 'checksums': {
const { release } = loadPkg()
return `${release.tagPrefix}${requireVersion(rest[0])}${release.checksumsSuffix}`
@ -218,9 +203,11 @@ function main(argv) {
case 'tag-prefix':
return loadPkg().release.tagPrefix
case 'targets':
return loadPkg().release.targets.map(t => `${t.bunTarget}\t${t.id}\t${t.exe ? 1 : 0}`).join('\n')
return loadPkg()
.release.targets.map((t) => `${t.bunTarget}\t${t.id}\t${t.exe ? 1 : 0}`)
.join('\n')
case 'channels':
return CHANNELS.map(c => c.name).join('\n')
return CHANNELS.map((c) => c.name).join('\n')
case 'github-env':
return githubEnv()
case 'compat-check': {
@ -228,14 +215,18 @@ function main(argv) {
const difyVersion = requireVersion(rest[0])
if (!compat.minDify || !compat.maxDify)
die('cli/package.json missing difyctl.compat.minDify/maxDify')
if (comparePrecedence(difyVersion, compat.minDify) < 0 || comparePrecedence(difyVersion, compat.maxDify) > 0)
die(`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`)
if (
comparePrecedence(difyVersion, compat.minDify) < 0 ||
comparePrecedence(difyVersion, compat.maxDify) > 0
)
die(
`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`,
)
return `compatible: Dify ${difyVersion} within ${compat.minDify}..${compat.maxDify}`
}
case 'prerelease': {
const ch = channelByName(rest[0] ?? die('channel argument is required'))
if (!ch)
die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`)
if (!ch) die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`)
return String(ch.prerelease)
}
case 'validate': {
@ -257,9 +248,16 @@ function main(argv) {
}
}
const invokedDirectly = process.argv[1]
&& realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
if (invokedDirectly)
process.stdout.write(`${main(process.argv.slice(2))}\n`)
const invokedDirectly =
process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
if (invokedDirectly) process.stdout.write(`${main(process.argv.slice(2))}\n`)
export { assetName, channelByName, CHANNELS, edgeVersion, loadPkg, validateVersionForChannel, versionCore }
export {
assetName,
channelByName,
CHANNELS,
edgeVersion,
loadPkg,
validateVersionForChannel,
versionCore,
}

View File

@ -4,13 +4,12 @@ import { describe, expect, it } from 'vitest'
const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url))
function run(args: string[]): { code: number, stdout: string, stderr: string } {
function run(args: string[]): { code: number; stdout: string; stderr: string } {
try {
const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' })
return { code: 0, stdout, stderr: '' }
}
catch (e) {
const err = e as { status?: number, stdout?: string, stderr?: string }
} catch (e) {
const err = e as { status?: number; stdout?: string; stderr?: string }
return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
}
}

View File

@ -24,8 +24,7 @@ function parseArgs(argv) {
function requireArgs(args, keys) {
for (const k of keys) {
if (!args[k])
die(`missing --${k}`)
if (!args[k]) die(`missing --${k}`)
}
}
@ -34,8 +33,7 @@ function shaMap(checksumsPath) {
const map = new Map()
for (const line of readFileSync(checksumsPath, 'utf8').split('\n')) {
const m = line.match(/^([0-9a-f]{64})\s+(\S+)$/i)
if (m)
map.set(m[2], m[1])
if (m) map.set(m[2], m[1])
}
return map
}
@ -46,14 +44,15 @@ function emitManifest(args) {
const { release, compat } = loadPkg()
const shas = shaMap(args.checksums)
const targetLines = release.targets.map((t) => {
const asset = assetName(release, args.version, t.id)
const sha = shas.get(asset)
if (!sha)
die(`no sha256 for ${asset} in ${args.checksums}`)
// one target per line: install-r2.sh grep/sed depends on this layout
return ` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(asset)}, "sha256": ${JSON.stringify(sha)} }`
}).join(',\n')
const targetLines = release.targets
.map((t) => {
const asset = assetName(release, args.version, t.id)
const sha = shas.get(asset)
if (!sha) die(`no sha256 for ${asset} in ${args.checksums}`)
// one target per line: install-r2.sh grep/sed depends on this layout
return ` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(asset)}, "sha256": ${JSON.stringify(sha)} }`
})
.join(',\n')
const head = {
schema: 1,
@ -65,20 +64,20 @@ function emitManifest(args) {
compat: { minDify: compat.minDify, maxDify: compat.maxDify },
baseUrl: args['base-url'],
}
const headLines = Object.entries(head).map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(',\n')
const headLines = Object.entries(head)
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)
.join(',\n')
process.stdout.write(`{\n${headLines},\n "targets": {\n${targetLines}\n }\n}\n`)
}
// Newline-delimited dir names of binaries that still exist in R2. Absent file =
// no reconciliation (caller could not list); empty file = no survivors.
function loadExistingDirs(path) {
if (!path || !existsSync(path))
return null
if (!path || !existsSync(path)) return null
const set = new Set()
for (const line of readFileSync(path, 'utf8').split('\n')) {
const d = line.trim()
if (d)
set.add(d)
if (d) set.add(d)
}
return set
}
@ -93,23 +92,26 @@ function emitIndex(args) {
if (raw && raw !== '-') {
try {
current = JSON.parse(raw)
}
catch {
} catch {
die(`current index at ${args.current} is not valid JSON`)
}
}
}
const entry = { version: args.version, commit: args.commit, buildDate: args['build-date'], dir: args.version }
const kept = (current.builds ?? []).filter(b => b.version !== entry.version)
const entry = {
version: args.version,
commit: args.commit,
buildDate: args['build-date'],
dir: args.version,
}
const kept = (current.builds ?? []).filter((b) => b.version !== entry.version)
let builds = [entry, ...kept]
// Reconcile to binaries that still exist in R2: lifecycle/TTL on the bin prefix
// is the only deletion mechanism, so the ledger never advertises a build whose
// binary is gone. The new build is always kept (just uploaded). No count cap.
const existing = loadExistingDirs(args['existing-dirs'])
if (existing)
builds = builds.filter(b => b.dir === entry.dir || existing.has(b.dir))
if (existing) builds = builds.filter((b) => b.dir === entry.dir || existing.has(b.dir))
const index = { schema: 1, channel: args.channel, updated: args['build-date'], builds }
process.stdout.write(`${JSON.stringify(index, null, 2)}\n`)

View File

@ -7,12 +7,15 @@ import { describe, expect, it } from 'vitest'
const SCRIPT = fileURLToPath(new URL('./release-r2-edge.mjs', import.meta.url))
function run(args: string[]): { code: number, stdout: string, stderr: string } {
function run(args: string[]): { code: number; stdout: string; stderr: string } {
try {
return { code: 0, stdout: execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }), stderr: '' }
}
catch (e) {
const err = e as { status?: number, stdout?: string, stderr?: string }
return {
code: 0,
stdout: execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }),
stderr: '',
}
} catch (e) {
const err = e as { status?: number; stdout?: string; stderr?: string }
return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
}
}
@ -42,9 +45,9 @@ type ManifestJson = {
version: string
commit: string
buildDate: string
compat: { minDify: string, maxDify: string }
compat: { minDify: string; maxDify: string }
baseUrl: string
targets: Record<string, { asset: string, sha256: string }>
targets: Record<string, { asset: string; sha256: string }>
}
type IndexBuild = {
@ -61,10 +64,34 @@ type IndexJson = {
builds: IndexBuild[]
}
function buildManifest(version = VERSION): { code: number, json: ManifestJson, stdout: string, stderr: string } {
function buildManifest(version = VERSION): {
code: number
json: ManifestJson
stdout: string
stderr: string
} {
const checksums = writeChecksums(version)
const r = run(['manifest', '--channel', 'edge', '--version', version, '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', BASE_URL, '--checksums', checksums])
return { code: r.code, json: (r.code === 0 ? JSON.parse(r.stdout) : null) as ManifestJson, stdout: r.stdout, stderr: r.stderr }
const r = run([
'manifest',
'--channel',
'edge',
'--version',
version,
'--commit',
'abc1234',
'--build-date',
'2026-06-14T12:00:00Z',
'--base-url',
BASE_URL,
'--checksums',
checksums,
])
return {
code: r.code,
json: (r.code === 0 ? JSON.parse(r.stdout) : null) as ManifestJson,
stdout: r.stdout,
stderr: r.stderr,
}
}
describe('release-r2-edge manifest', () => {
@ -86,9 +113,13 @@ describe('release-r2-edge manifest', () => {
it('lists all 5 targets with asset name + sha256 from the checksums file', () => {
const { json } = buildManifest()
expect(Object.keys(json.targets).sort()).toEqual(
['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'windows-x64'],
)
expect(Object.keys(json.targets).sort()).toEqual([
'darwin-arm64',
'darwin-x64',
'linux-arm64',
'linux-x64',
'windows-x64',
])
expect(json.targets['linux-x64'].asset).toBe(`difyctl-v${VERSION}-linux-x64`)
expect(json.targets['windows-x64'].asset).toBe(`difyctl-v${VERSION}-windows-x64.exe`)
expect(json.targets['linux-x64'].sha256).toMatch(/^\d{64}$/)
@ -108,20 +139,51 @@ describe('release-r2-edge manifest', () => {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-'))
const file = join(dir, `difyctl-v${VERSION}-checksums.txt`)
writeFileSync(file, `${'0'.repeat(64)} difyctl-v${VERSION}-linux-x64\n`) // only 1 of 5
const r = run(['manifest', '--channel', 'edge', '--version', VERSION, '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', BASE_URL, '--checksums', file])
const r = run([
'manifest',
'--channel',
'edge',
'--version',
VERSION,
'--commit',
'abc1234',
'--build-date',
'2026-06-14T12:00:00Z',
'--base-url',
BASE_URL,
'--checksums',
file,
])
expect(r.code).not.toBe(0)
})
it('rejects a malformed dropped-value argument (no silent misparse)', () => {
// --version has no value; --commit must NOT be swallowed as the version
const r = run(['manifest', '--channel', 'edge', '--version', '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', 'https://x', '--checksums', '/nonexistent'])
const r = run([
'manifest',
'--channel',
'edge',
'--version',
'--commit',
'abc1234',
'--build-date',
'2026-06-14T12:00:00Z',
'--base-url',
'https://x',
'--checksums',
'/nonexistent',
])
expect(r.code).not.toBe(0)
})
})
// ---- index ----
function runIndex(currentContent: string | null, build: Record<string, string>, existingDirs?: string[]) {
function runIndex(
currentContent: string | null,
build: Record<string, string>,
existingDirs?: string[],
) {
let currentArg = '-'
if (currentContent !== null) {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-index-'))
@ -135,7 +197,25 @@ function runIndex(currentContent: string | null, build: Record<string, string>,
writeFileSync(f, `${existingDirs.join('\n')}\n`)
extra.push('--existing-dirs', f)
}
const r = spawnSync('node', [SCRIPT, 'index', '--current', currentArg, '--channel', 'edge', '--version', build.version, '--commit', build.commit, '--build-date', build.buildDate, ...extra], { encoding: 'utf8' })
const r = spawnSync(
'node',
[
SCRIPT,
'index',
'--current',
currentArg,
'--channel',
'edge',
'--version',
build.version,
'--commit',
build.commit,
'--build-date',
build.buildDate,
...extra,
],
{ encoding: 'utf8' },
)
return {
code: r.status ?? 1,
index: (r.status === 0 ? JSON.parse(r.stdout) : null) as IndexJson,
@ -151,7 +231,11 @@ describe('release-r2-edge index', () => {
expect(index.schema).toBe(1)
expect(index.channel).toBe('edge')
expect(index.builds).toHaveLength(1)
expect(index.builds[0]).toMatchObject({ version: B1.version, commit: B1.commit, dir: B1.version })
expect(index.builds[0]).toMatchObject({
version: B1.version,
commit: B1.commit,
dir: B1.version,
})
})
it('treats an empty current file as fresh (first publish, curl wrote nothing)', () => {
@ -167,40 +251,58 @@ describe('release-r2-edge index', () => {
})
it('prepends the new build (publish order; newest at [0])', () => {
const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }] })
const current = JSON.stringify({
schema: 1,
channel: 'edge',
builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
],
})
const { index } = runIndex(current, B2)
expect(index.builds.map(b => b.version)).toEqual([B2.version, B1.version])
expect(index.builds.map((b) => b.version)).toEqual([B2.version, B1.version])
})
it('dedups a re-cut of the same version (no duplicate, moves to top)', () => {
const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [
{ version: B2.version, commit: B2.commit, buildDate: B2.buildDate, dir: B2.version },
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
] })
const current = JSON.stringify({
schema: 1,
channel: 'edge',
builds: [
{ version: B2.version, commit: B2.commit, buildDate: B2.buildDate, dir: B2.version },
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
],
})
const { index } = runIndex(current, B1) // re-cut B1
expect(index.builds.map(b => b.version)).toEqual([B1.version, B2.version])
expect(index.builds.map((b) => b.version)).toEqual([B1.version, B2.version])
})
it('reconciles to surviving binary dirs (drops a build whose binary expired)', () => {
const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
] })
const current = JSON.stringify({
schema: 1,
channel: 'edge',
builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
],
})
// B1's binary is gone (not in existing); the new B2 is always kept.
const { index } = runIndex(current, B2, [B2.version])
expect(index.builds.map(b => b.version)).toEqual([B2.version])
expect(index.builds.map((b) => b.version)).toEqual([B2.version])
})
it('keeps the new build even when it is absent from the existing-dirs list', () => {
const { index } = runIndex(null, B1, []) // empty survivors, fresh ledger
expect(index.builds.map(b => b.version)).toEqual([B1.version])
expect(index.builds.map((b) => b.version)).toEqual([B1.version])
})
it('does not reconcile when no --existing-dirs is given (list unavailable)', () => {
const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
] })
const current = JSON.stringify({
schema: 1,
channel: 'edge',
builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
],
})
const { index } = runIndex(current, B2) // no existing-dirs → keep all
expect(index.builds.map(b => b.version)).toEqual([B2.version, B1.version])
expect(index.builds.map((b) => b.version)).toEqual([B2.version, B1.version])
})
it('dies on a non-empty current file that is not valid JSON', () => {

View File

@ -6,7 +6,7 @@ const SCRIPT = fileURLToPath(new URL('./release-r2-publish.sh', import.meta.url)
// Stub `aws` + `curl` + `node` as shell functions that just log action verbs to
// $ORDER_LOG, then run the publish `main` and assert the order of operations.
function runPublish(): { code: number, order: string[], stderr: string } {
function runPublish(): { code: number; order: string[]; stderr: string } {
const stub = [
'ORDER_LOG="$(mktemp)"',
'aws() {',
@ -23,10 +23,10 @@ function runPublish(): { code: number, order: string[], stderr: string } {
'node() {',
' case "$*" in',
' *release-naming.mjs*targets*)',
' printf \'bun-linux-x64\\tlinux-x64\\t0\\nbun-linux-arm64\\tlinux-arm64\\t0\\nbun-darwin-x64\\tdarwin-x64\\t0\\nbun-darwin-arm64\\tdarwin-arm64\\t0\\nbun-windows-x64\\twindows-x64\\t1\\n\' ;;',
' *release-naming.mjs*\' asset \'*) printf \'difyctl-vX\\n\' ;;',
' *release-r2-edge.mjs*\' index \'*) echo \'{}\' ;;',
' *release-r2-edge.mjs*\' manifest \'*) echo \'{}\' ;;',
" printf 'bun-linux-x64\\tlinux-x64\\t0\\nbun-linux-arm64\\tlinux-arm64\\t0\\nbun-darwin-x64\\tdarwin-x64\\t0\\nbun-darwin-arm64\\tdarwin-arm64\\t0\\nbun-windows-x64\\twindows-x64\\t1\\n' ;;",
" *release-naming.mjs*' asset '*) printf 'difyctl-vX\\n' ;;",
" *release-r2-edge.mjs*' index '*) echo '{}' ;;",
" *release-r2-edge.mjs*' manifest '*) echo '{}' ;;",
' *) : ;;',
' esac',
'}',
@ -49,7 +49,11 @@ function runPublish(): { code: number, order: string[], stderr: string } {
DIST_DIR: '/tmp',
},
})
return { code: r.status ?? 1, order: (r.stdout ?? '').trim().split('\n').filter(Boolean), stderr: r.stderr ?? '' }
return {
code: r.status ?? 1,
order: (r.stdout ?? '').trim().split('\n').filter(Boolean),
stderr: r.stderr ?? '',
}
}
describe('release-r2-publish order', () => {

View File

@ -1,7 +1,7 @@
#!/usr/bin/env -S bun
import { execSync } from 'node:child_process'
type Check = { name: string, run: () => void }
type Check = { name: string; run: () => void }
const baseUrlIdx = process.argv.indexOf('--base-url')
const baseUrl = baseUrlIdx > -1 ? process.argv[baseUrlIdx + 1] : 'http://localhost:5001'
@ -17,16 +17,30 @@ function cli(args: string): string {
}
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')
} },
{
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
@ -34,8 +48,7 @@ for (const c of checks) {
try {
c.run()
console.log(`[x] ${c.name}`)
}
catch (err) {
} catch (err) {
failed++
console.log(`[ ] ${c.name}${(err as Error).message}`)
}

View File

@ -19,7 +19,7 @@ describe('AccountSessionsClient.list', () => {
})
it('GETs account/sessions with no query when paging is unset', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list()
@ -29,7 +29,7 @@ describe('AccountSessionsClient.list', () => {
})
it('forwards page/limit when supplied', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list({ page: 2, limit: 25 })
@ -50,7 +50,7 @@ describe('AccountSessionsClient.revoke', () => {
// The server replies 200 + {status:"revoked"}; revoke() returns void but the
// typed client still parses the body — this guards against a regression where
// a non-empty 200 body trips JSON handling.
stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
await expect(makeClient(stub.url).revoke('sess-1')).resolves.toBeUndefined()
expect(stub.captured.method).toBe('DELETE')
@ -58,7 +58,7 @@ describe('AccountSessionsClient.revoke', () => {
})
it('URL-encodes the session id', async () => {
stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
await makeClient(stub.url).revoke('sess/1 2')
@ -66,16 +66,17 @@ describe('AccountSessionsClient.revoke', () => {
})
it('propagates 404 as a classified BaseError', async () => {
stub = await startStubServer(cap =>
jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap))
stub = await startStubServer((cap) =>
jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap),
)
await expect(makeClient(stub.url).revoke('missing')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 404,
(err) => isHttpClientError(err) && err.httpStatus === 404,
)
})
it('revokeSelf DELETEs the self subresource', async () => {
stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
await expect(makeClient(stub.url).revokeSelf()).resolves.toBeUndefined()
expect(stub.captured.method).toBe('DELETE')

View File

@ -10,7 +10,7 @@ export class AccountSessionsClient {
this.orpc = createOpenApiClient(http)
}
async list(q?: { page?: number, limit?: number }): Promise<SessionListResponse> {
async list(q?: { page?: number; limit?: number }): Promise<SessionListResponse> {
return this.orpc.account.sessions.get({ query: { page: q?.page, limit: q?.limit } })
}

View File

@ -17,11 +17,16 @@ describe('AccountClient.get', () => {
})
it('GETs account, sends the bearer, and returns the parsed payload', async () => {
stub = await startStubServer(cap =>
jsonResponder(200, {
subject_type: 'account',
account: { id: 'acct-1', email: 'a@e.com', name: 'A' },
}, cap))
stub = await startStubServer((cap) =>
jsonResponder(
200,
{
subject_type: 'account',
account: { id: 'acct-1', email: 'a@e.com', name: 'A' },
},
cap,
),
)
const res = await makeClient(stub.url).get()
@ -32,10 +37,10 @@ describe('AccountClient.get', () => {
})
it('maps 401 to a classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
stub = await startStubServer((cap) => jsonResponder(401, { error: 'expired' }, cap))
await expect(makeClient(stub.url).get()).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 401,
(err) => isHttpClientError(err) && err.httpStatus === 401,
)
})
})

View File

@ -21,7 +21,7 @@ describe('AppDslClient.exportDsl', () => {
})
it('returns the data string from the response', async () => {
stub = await startStubServer(cap => jsonResponder(200, { data: DSL_YAML }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { data: DSL_YAML }, cap))
const yaml = await makeClient(stub.url).exportDsl('app-1')
@ -31,16 +31,18 @@ describe('AppDslClient.exportDsl', () => {
})
it('throws when response has no data field', async () => {
stub = await startStubServer(cap => jsonResponder(200, { wrong: 1 }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { wrong: 1 }, cap))
await expect(makeClient(stub.url).exportDsl('app-1')).rejects.toThrow('export response missing data field')
await expect(makeClient(stub.url).exportDsl('app-1')).rejects.toThrow(
'export response missing data field',
)
})
it('propagates 404 as a classified HttpClientError', async () => {
stub = await startStubServer(cap => jsonResponder(404, { error: 'not_found' }, cap))
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not_found' }, cap))
await expect(makeClient(stub.url).exportDsl('missing')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 404,
(err) => isHttpClientError(err) && err.httpStatus === 404,
)
})
})
@ -53,7 +55,7 @@ describe('AppDslClient.importApp', () => {
})
it('POST to /workspaces/:id/apps/imports with body and returns Import', async () => {
stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap))
stub = await startStubServer((cap) => jsonResponder(200, COMPLETED_IMPORT, cap))
const result = await makeClient(stub.url).importApp('ws-1', {
mode: 'yaml-content',
@ -67,10 +69,18 @@ describe('AppDslClient.importApp', () => {
})
it('returns pending import on 202', async () => {
const pending = { id: 'imp-1', status: 'pending', current_dsl_version: '0.1.4', imported_dsl_version: '0.0.9' }
stub = await startStubServer(cap => jsonResponder(202, pending, cap))
const pending = {
id: 'imp-1',
status: 'pending',
current_dsl_version: '0.1.4',
imported_dsl_version: '0.0.9',
}
stub = await startStubServer((cap) => jsonResponder(202, pending, cap))
const result = await makeClient(stub.url).importApp('ws-1', { mode: 'yaml-content', yaml_content: DSL_YAML })
const result = await makeClient(stub.url).importApp('ws-1', {
mode: 'yaml-content',
yaml_content: DSL_YAML,
})
expect(result.status).toBe('pending')
expect(result.id).toBe('imp-1')
@ -85,7 +95,7 @@ describe('AppDslClient.confirmImport', () => {
})
it('POST to confirm URL and returns completed Import', async () => {
stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap))
stub = await startStubServer((cap) => jsonResponder(200, COMPLETED_IMPORT, cap))
const result = await makeClient(stub.url).confirmImport('ws-1', 'imp-1')
@ -103,7 +113,7 @@ describe('AppDslClient.checkDependencies', () => {
})
it('returns empty leaked_dependencies on healthy app', async () => {
stub = await startStubServer(cap => jsonResponder(200, { leaked_dependencies: [] }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { leaked_dependencies: [] }, cap))
const result = await makeClient(stub.url).checkDependencies('app-1')

View File

@ -35,19 +35,19 @@ export class AppDslClient {
async exportDsl(appId: string, query?: ExportQuery): Promise<string> {
const resp = await this.orpc.apps.byAppId.dsl.get({
params: { app_id: appId },
query: query !== undefined
? {
include_secret: query.includeSecret,
workflow_id: query.workflowId,
}
: undefined,
query:
query !== undefined
? {
include_secret: query.includeSecret,
workflow_id: query.workflowId,
}
: undefined,
})
// The response schema is an open object {"data": "<yaml string>"}; the
// contract generator marks it as loose because the backend annotation
// does not narrow the shape. Extract `data` directly.
const data = (resp as Record<string, unknown>).data
if (typeof data !== 'string')
throw new Error('export response missing data field')
if (typeof data !== 'string') throw new Error('export response missing data field')
return data
}

View File

@ -24,10 +24,8 @@ describe('AppMetaClient', () => {
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prevCacheDir
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
else process.env[ENV_CACHE_DIR] = prevCacheDir
await mock.stop()
await rm(dir, { recursive: true, force: true })
})
@ -62,15 +60,29 @@ describe('AppMetaClient', () => {
})
it('expired cache entry refetches', async () => {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO), ttlMs: 100, now: () => new Date('2026-05-09T00:00:00Z') })
const cache = await loadAppInfoCache({
store: getCache(CACHE_APP_INFO),
ttlMs: 100,
now: () => new Date('2026-05-09T00:00:00Z'),
})
const apps = new AppsClient(testHttpClient(mock.url, 'dfoa_test'))
const spy = vi.spyOn(apps, 'describe')
const client = new AppMetaClient({ apps, host: mock.url, cache, now: () => new Date('2026-05-09T00:00:00Z') })
const client = new AppMetaClient({
apps,
host: mock.url,
cache,
now: () => new Date('2026-05-09T00:00:00Z'),
})
await client.get('app-1', [FieldInfo])
expect(spy).toHaveBeenCalledTimes(1)
const client2 = new AppMetaClient({ apps, host: mock.url, cache, now: () => new Date('2026-05-09T00:00:01Z') })
const client2 = new AppMetaClient({
apps,
host: mock.url,
cache,
now: () => new Date('2026-05-09T00:00:01Z'),
})
await client2.get('app-1', [FieldInfo])
expect(spy).toHaveBeenCalledTimes(2)
})
@ -113,10 +125,12 @@ describe('AppMetaClient', () => {
const validEntry = file.entries[`${mock.url}::app-1`]
await writeFile(
path,
dump({ entries: {
[`${mock.url}::app-1`]: 'corrupted-string',
[`${mock.url}::sibling`]: validEntry,
} }),
dump({
entries: {
[`${mock.url}::app-1`]: 'corrupted-string',
[`${mock.url}::sibling`]: validEntry,
},
}),
'utf8',
)

View File

@ -25,21 +25,24 @@ export class AppMetaClient {
async get(appId: string, fields: readonly AppMetaFieldKey[] = []): Promise<AppMeta> {
const cached = this.cache?.get(this.host, appId)
if (cached !== undefined && this.cache?.isFresh(cached, this.now()) === true && covers(cached.meta, fields))
if (
cached !== undefined &&
this.cache?.isFresh(cached, this.now()) === true &&
covers(cached.meta, fields)
)
return cached.meta
const resp = await this.apps.describe(appId, fields.length === 0 ? undefined : fields)
const fresh = fromDescribe(resp, fields)
const merged = cached !== undefined && this.cache?.isFresh(cached, this.now()) === true
? mergeMeta(cached.meta, fresh)
: fresh
if (this.cache !== undefined)
await this.cache.set(this.host, appId, merged)
const merged =
cached !== undefined && this.cache?.isFresh(cached, this.now()) === true
? mergeMeta(cached.meta, fresh)
: fresh
if (this.cache !== undefined) await this.cache.set(this.host, appId, merged)
return merged
}
async invalidate(appId: string): Promise<void> {
if (this.cache !== undefined)
await this.cache.delete(this.host, appId)
if (this.cache !== undefined) await this.cache.delete(this.host, appId)
}
}

View File

@ -26,8 +26,8 @@ type AppReaderFactory = (http: HttpClient) => AppReader
// Maps each auth subject to the app reader for its surface.
const APP_READER_BY_SUBJECT: Readonly<Record<SubjectKindValue, AppReaderFactory>> = {
[SubjectKind.Account]: http => new AppsClient(http),
[SubjectKind.External]: http => new PermittedExternalAppsClient(http),
[SubjectKind.Account]: (http) => new AppsClient(http),
[SubjectKind.External]: (http) => new PermittedExternalAppsClient(http),
}
export function selectAppReader(active: ActiveContext, http: HttpClient): AppReader {

View File

@ -75,21 +75,24 @@ describe('AppRunClient.runStream', () => {
it('throws typed BaseError on non-2xx open', async () => {
mock.setScenario('server-5xx')
const c = new AppRunClient(testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }))
await expect(
c.runStream('app-1', buildRunBody({ message: 'hi' })),
).rejects.toMatchObject({ code: 'server_5xx' })
await expect(c.runStream('app-1', buildRunBody({ message: 'hi' }))).rejects.toMatchObject({
code: 'server_5xx',
})
})
it('aborts when signal fires', async () => {
expect.assertions(1)
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
const ctrl = new AbortController()
const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }), { signal: ctrl.signal })
const iter = await c.runStream('app-1', buildRunBody({ message: 'hi' }), {
signal: ctrl.signal,
})
ctrl.abort()
try {
for await (const _ of iter) { /* drain */ }
}
catch (e) {
for await (const _ of iter) {
/* drain */
}
} catch (e) {
expect((e as Error).name).toBe('AbortError')
}
})
@ -98,9 +101,13 @@ describe('AppRunClient.runStream', () => {
const c = new AppRunClient(testHttpClient(mock.url, 'dfoa_test'))
const iter = await c.runStream('app-2', buildRunBody({ inputs: { x: '1' } }))
const names: string[] = []
for await (const ev of iter)
names.push(ev.name)
expect(names).toEqual(['workflow_started', 'node_started', 'node_finished', 'workflow_finished'])
for await (const ev of iter) names.push(ev.name)
expect(names).toEqual([
'workflow_started',
'node_started',
'node_finished',
'workflow_finished',
])
})
})

View File

@ -18,16 +18,13 @@ export function buildRunBody(args: RunBodyArgs): Record<string, unknown> {
const body: Record<string, unknown> = {
inputs: args.inputs ?? {},
}
if (args.message !== undefined && args.message !== '')
body.query = args.message
if (args.message !== undefined && args.message !== '') body.query = args.message
if (args.conversationId !== undefined && args.conversationId !== '')
body.conversation_id = args.conversationId
if (args.workspaceId !== undefined && args.workspaceId !== '')
body.workspace_id = args.workspaceId
if (args.workflowId !== undefined && args.workflowId !== '')
body.workflow_id = args.workflowId
if (args.files !== undefined && args.files.length > 0)
body.files = args.files
if (args.workflowId !== undefined && args.workflowId !== '') body.workflow_id = args.workflowId
if (args.files !== undefined && args.files.length > 0) body.files = args.files
return body
}
@ -62,8 +59,7 @@ export class AppRunClient {
throwOnError: true,
retryOnRateLimit: opts.retryOnRateLimit,
})
if (res.body === null)
throw new Error('streaming response body missing')
if (res.body === null) throw new Error('streaming response body missing')
return normalizeDifyStream(parseSSE(res.body, opts.signal))
}
@ -100,8 +96,7 @@ export class AppRunClient {
signal: opts.signal,
throwOnError: true,
})
if (res.body === null)
throw new Error('reconnect stream body missing')
if (res.body === null) throw new Error('reconnect stream body missing')
return normalizeDifyStream(parseSSE(res.body, opts.signal))
}
}

View File

@ -6,7 +6,9 @@ import { isHttpClientError } from '@/errors/base'
import { AppsClient } from './apps.js'
const LIST_BODY = { page: 1, limit: 20, total: 0, has_more: false, data: [] }
const DESCRIBE_BODY = { info: { id: 'app-1', name: 'Demo', mode: 'chat', service_api_enabled: true } }
const DESCRIBE_BODY = {
info: { id: 'app-1', name: 'Demo', mode: 'chat', service_api_enabled: true },
}
function makeClient(host: string): AppsClient {
return new AppsClient(testHttpClient(host, 'dfoa_test'))
@ -24,7 +26,7 @@ describe('AppsClient.list', () => {
})
it('defaults page=1 & limit=20 and always sends workspace_id', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list({ workspaceId: 'ws-1' })
@ -39,7 +41,7 @@ describe('AppsClient.list', () => {
})
it('forwards explicit pagination and filters', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list({
workspaceId: 'ws-1',
@ -57,7 +59,7 @@ describe('AppsClient.list', () => {
})
it('treats empty-string filters as absent (not blank query params)', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '' })
@ -67,10 +69,10 @@ describe('AppsClient.list', () => {
})
it('propagates server 403 as a classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap))
stub = await startStubServer((cap) => jsonResponder(403, { error: 'forbidden' }, cap))
await expect(makeClient(stub.url).list({ workspaceId: 'ws-1' })).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 403,
(err) => isHttpClientError(err) && err.httpStatus === 403,
)
})
})
@ -83,7 +85,7 @@ describe('AppsClient.describe', () => {
})
it('hits /apps/<id>, omits workspace_id and fields when not given', async () => {
stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
const res = await makeClient(stub.url).describe('app-1')
@ -95,7 +97,7 @@ describe('AppsClient.describe', () => {
})
it('joins fields with commas', async () => {
stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
await makeClient(stub.url).describe('app-1', ['parameters', 'input_schema'])
@ -103,7 +105,7 @@ describe('AppsClient.describe', () => {
})
it('URL-encodes the app id', async () => {
stub = await startStubServer(cap => jsonResponder(200, DESCRIBE_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, DESCRIBE_BODY, cap))
await makeClient(stub.url).describe('app/with space')

View File

@ -1,4 +1,8 @@
import type { AppDescribeResponse, AppListResponse, SupportedAppType } from '@dify/contracts/api/openapi/types.gen'
import type {
AppDescribeResponse,
AppListResponse,
SupportedAppType,
} from '@dify/contracts/api/openapi/types.gen'
import type { AppReader } from './app-reader'
import type { OpenApiClient } from '@/http/orpc'
import type { HttpClient } from '@/http/types'
@ -13,7 +17,9 @@ export type ListQuery = {
}
// An absent or empty mode filter means "any mode" — collapse both to undefined for the query.
export function normalizeMode(mode: SupportedAppType | '' | undefined): SupportedAppType | undefined {
export function normalizeMode(
mode: SupportedAppType | '' | undefined,
): SupportedAppType | undefined {
return mode !== undefined && mode !== '' ? mode : undefined
}

View File

@ -15,24 +15,33 @@ type StubServer = {
stop: () => Promise<void>
}
function startStub(handler: (req: http.IncomingMessage, res: http.ServerResponse) => void): Promise<StubServer> {
function startStub(
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
): Promise<StubServer> {
return new Promise((resolve, reject) => {
const server = http.createServer(handler)
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo
resolve({
url: `http://127.0.0.1:${addr.port}`,
stop: () => new Promise<void>((res, rej) => server.close(err => err ? rej(err) : res())),
stop: () =>
new Promise<void>((res, rej) => server.close((err) => (err ? rej(err) : res()))),
})
})
server.on('error', reject)
})
}
function jsonStub(status: number, body: unknown): (req: http.IncomingMessage, res: http.ServerResponse) => void {
function jsonStub(
status: number,
body: unknown,
): (req: http.IncomingMessage, res: http.ServerResponse) => void {
return (_req, res) => {
const payload = JSON.stringify(body)
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) })
res.writeHead(status, {
'content-type': 'application/json',
'content-length': Buffer.byteLength(payload),
})
res.end(payload)
}
}
@ -74,15 +83,12 @@ describe('DeviceFlowApi.requestCode', () => {
let caught: unknown
try {
await api.requestCode({ device_label: 'l' })
}
catch (e) {
} catch (e) {
caught = e
}
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught))
expect(caught.code).toBe(ErrorCode.UnsupportedEndpoint)
}
finally {
if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.UnsupportedEndpoint)
} finally {
await stub?.stop()
}
})
@ -108,8 +114,7 @@ describe('DeviceFlowApi.pollOnce', () => {
const api = makeApi(mock)
const r = await api.pollOnce({ device_code: 'devcode-1' })
expect(r.status).toBe('approved')
if (r.status === 'approved')
expect(r.success.token).toBe('dfoa_test')
if (r.status === 'approved') expect(r.success.token).toBe('dfoa_test')
})
it('maps authorization_pending to pending', async () => {
@ -119,8 +124,7 @@ describe('DeviceFlowApi.pollOnce', () => {
const api = new DeviceFlowApi(testHttpClient(stub.url))
const r = await api.pollOnce({ device_code: 'dc' })
expect(r.status).toBe('pending')
}
finally {
} finally {
await stub?.stop()
}
})
@ -152,8 +156,7 @@ describe('DeviceFlowApi.pollOnce', () => {
stub = await startStub(jsonStub(404, {}))
const api = new DeviceFlowApi(testHttpClient(stub.url))
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/device flow/i)
}
finally {
} finally {
await stub?.stop()
}
})
@ -171,8 +174,7 @@ describe('DeviceFlowApi.pollOnce', () => {
stub = await startStub(jsonStub(200, {}))
const api = new DeviceFlowApi(testHttpClient(stub.url))
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/no OAuth envelope|token/i)
}
finally {
} finally {
await stub?.stop()
}
})
@ -183,8 +185,7 @@ describe('DeviceFlowApi.pollOnce', () => {
stub = await startStub(jsonStub(400, { error: 'something_else' }))
const api = new DeviceFlowApi(testHttpClient(stub.url))
await expect(api.pollOnce({ device_code: 'dc' })).rejects.toThrow(/unknown poll error/)
}
finally {
} finally {
await stub?.stop()
}
})

View File

@ -36,7 +36,7 @@ describe('FileUploadClient.upload', () => {
it('POSTs multipart/form-data (boundary intact, no JSON content-type) and returns the parsed file', async () => {
const filePath = join(dir, 'hello.png')
await writeFile(filePath, 'hello')
stub = await startStubServer(cap => jsonResponder(200, UPLOADED, cap))
stub = await startStubServer((cap) => jsonResponder(200, UPLOADED, cap))
const result = await makeClient(stub.url).upload('app-1', filePath)
@ -57,7 +57,7 @@ describe('FileUploadClient.upload', () => {
it('encodes the app id in the path', async () => {
const filePath = join(dir, 'a.txt')
await writeFile(filePath, 'x')
stub = await startStubServer(cap => jsonResponder(200, UPLOADED, cap))
stub = await startStubServer((cap) => jsonResponder(200, UPLOADED, cap))
await makeClient(stub.url).upload('app/with space', filePath)
@ -67,10 +67,10 @@ describe('FileUploadClient.upload', () => {
it('propagates a server 413 as a classified BaseError', async () => {
const filePath = join(dir, 'big.bin')
await writeFile(filePath, 'data')
stub = await startStubServer(cap => jsonResponder(413, { error: 'file too large' }, cap))
stub = await startStubServer((cap) => jsonResponder(413, { error: 'file too large' }, cap))
await expect(makeClient(stub.url).upload('app-1', filePath)).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 413,
(err) => isHttpClientError(err) && err.httpStatus === 413,
)
})
})

View File

@ -64,9 +64,9 @@ export class FileUploadClient {
const form = new FormData()
form.append('file', blob, filename)
return this.http.post<UploadedFile>(
`apps/${encodeURIComponent(appId)}/files`,
{ body: form, timeoutMs: 60_000 },
)
return this.http.post<UploadedFile>(`apps/${encodeURIComponent(appId)}/files`, {
body: form,
timeoutMs: 60_000,
})
}
}

View File

@ -18,7 +18,7 @@ describe('MembersClient.list', () => {
})
it('GETs /workspaces/<id>/members and returns parsed envelope', async () => {
stub = await startStubServer(cap =>
stub = await startStubServer((cap) =>
jsonResponder(
200,
{
@ -26,12 +26,11 @@ describe('MembersClient.list', () => {
limit: 20,
total: 1,
has_more: false,
data: [
{ id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' },
],
data: [{ id: 'm-1', name: 'Mia', email: 'mia@e.com', role: 'admin', status: 'active' }],
},
cap,
))
),
)
const result = await makeClient(stub.url).list('ws-1')
@ -41,8 +40,9 @@ describe('MembersClient.list', () => {
})
it('URL-encodes workspace id', async () => {
stub = await startStubServer(cap =>
jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, cap))
stub = await startStubServer((cap) =>
jsonResponder(200, { page: 1, limit: 20, total: 0, has_more: false, data: [] }, cap),
)
await makeClient(stub.url).list('ws with space')
@ -50,8 +50,9 @@ describe('MembersClient.list', () => {
})
it('forwards page/limit as query params', async () => {
stub = await startStubServer(cap =>
jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, cap))
stub = await startStubServer((cap) =>
jsonResponder(200, { page: 2, limit: 50, total: 0, has_more: false, data: [] }, cap),
)
await makeClient(stub.url).list('ws-1', { page: 2, limit: 50 })
@ -59,18 +60,18 @@ describe('MembersClient.list', () => {
})
it('propagates server 403 as classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(403, { error: 'forbidden' }, cap))
stub = await startStubServer((cap) => jsonResponder(403, { error: 'forbidden' }, cap))
await expect(makeClient(stub.url).list('ws-1')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 403,
(err) => isHttpClientError(err) && err.httpStatus === 403,
)
})
it('propagates 404 as classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap))
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not found' }, cap))
await expect(makeClient(stub.url).list('ws-missing')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 404,
(err) => isHttpClientError(err) && err.httpStatus === 404,
)
})
})
@ -83,7 +84,7 @@ describe('MembersClient.invite', () => {
})
it('POSTs JSON body and returns parsed invite response', async () => {
stub = await startStubServer(cap =>
stub = await startStubServer((cap) =>
jsonResponder(
201,
{
@ -95,7 +96,8 @@ describe('MembersClient.invite', () => {
tenant_id: 'ws-1',
},
cap,
))
),
)
const result = await makeClient(stub.url).invite('ws-1', {
email: 'new@e.com',
@ -113,11 +115,11 @@ describe('MembersClient.invite', () => {
})
it('propagates 400 (already in tenant) as classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(400, { error: 'already in tenant' }, cap))
stub = await startStubServer((cap) => jsonResponder(400, { error: 'already in tenant' }, cap))
await expect(
makeClient(stub.url).invite('ws-1', { email: 'u@e.com', role: 'normal' }),
).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400)
).rejects.toSatisfy((err) => isHttpClientError(err) && err.httpStatus === 400)
})
})
@ -129,7 +131,7 @@ describe('MembersClient.remove', () => {
})
it('DELETEs member by id and returns success', async () => {
stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { result: 'success' }, cap))
const result = await makeClient(stub.url).remove('ws-1', 'm-1')
@ -139,10 +141,10 @@ describe('MembersClient.remove', () => {
})
it('propagates 400 (cannot operate self / cannot remove owner)', async () => {
stub = await startStubServer(cap => jsonResponder(400, { error: 'cannot operate self' }, cap))
stub = await startStubServer((cap) => jsonResponder(400, { error: 'cannot operate self' }, cap))
await expect(makeClient(stub.url).remove('ws-1', 'm-1')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 400,
(err) => isHttpClientError(err) && err.httpStatus === 400,
)
})
})
@ -155,7 +157,7 @@ describe('MembersClient.updateRole', () => {
})
it('PATCHes role payload to the member resource', async () => {
stub = await startStubServer(cap => jsonResponder(200, { result: 'success' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { result: 'success' }, cap))
const result = await makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' })
@ -166,11 +168,11 @@ describe('MembersClient.updateRole', () => {
})
it('propagates 400 (admin cannot demote owner)', async () => {
stub = await startStubServer(cap => jsonResponder(400, { error: 'no permission' }, cap))
stub = await startStubServer((cap) => jsonResponder(400, { error: 'no permission' }, cap))
await expect(
makeClient(stub.url).updateRole('ws-1', 'm-1', { role: 'admin' }),
).rejects.toSatisfy(err => isHttpClientError(err) && err.httpStatus === 400)
).rejects.toSatisfy((err) => isHttpClientError(err) && err.httpStatus === 400)
})
})
@ -182,7 +184,7 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
})
it('POSTs /workspaces/<id>:switch and returns workspace detail', async () => {
stub = await startStubServer(cap =>
stub = await startStubServer((cap) =>
jsonResponder(
200,
{
@ -194,7 +196,8 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
created_at: '2026-05-18T00:00:00Z',
},
cap,
))
),
)
const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test'))
const result = await client.switch('ws-1')
@ -205,11 +208,11 @@ describe('WorkspacesClient.switch (integration with stub)', () => {
})
it('propagates 404 (non-member)', async () => {
stub = await startStubServer(cap => jsonResponder(404, { error: 'not found' }, cap))
stub = await startStubServer((cap) => jsonResponder(404, { error: 'not found' }, cap))
const client = new WorkspacesClient(testHttpClient(stub.url, 'dfoa_test'))
await expect(client.switch('ws-x')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 404,
(err) => isHttpClientError(err) && err.httpStatus === 404,
)
})
})

View File

@ -22,7 +22,10 @@ export class MembersClient {
this.orpc = createOpenApiClient(http)
}
async list(workspaceId: string, q?: { page?: number, limit?: number }): Promise<MemberListResponse> {
async list(
workspaceId: string,
q?: { page?: number; limit?: number },
): Promise<MemberListResponse> {
return this.orpc.workspaces.byWorkspaceId.members.get({
params: { workspace_id: workspaceId },
query: { page: q?.page, limit: q?.limit },

View File

@ -46,13 +46,13 @@ export type PollSuccess = {
token_id?: string
}
export type PollResult
= | { status: 'pending' }
| { status: 'slow_down' }
| { status: 'expired' }
| { status: 'denied' }
| { status: 'retry_5xx' }
| { status: 'approved', success: PollSuccess }
export type PollResult =
| { status: 'pending' }
| { status: 'slow_down' }
| { status: 'expired' }
| { status: 'denied' }
| { status: 'retry_5xx' }
| { status: 'approved'; success: PollSuccess }
const POLL_ERROR_TO_STATUS: Record<string, PollResult['status']> = {
authorization_pending: 'pending',
@ -77,8 +77,7 @@ export class DeviceFlowApi {
}
const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_label: req.device_label }
const res = await this.http.fetch('oauth/device/code', { method: 'POST', json: body })
if (res.status === 404)
throw versionSkew()
if (res.status === 404) throw versionSkew()
if (!res.ok) {
throw new HttpClientError({
code: ErrorCode.Server4xxOther,
@ -86,7 +85,7 @@ export class DeviceFlowApi {
httpStatus: res.status,
})
}
return await res.json() as CodeResponse
return (await res.json()) as CodeResponse
}
async pollOnce(req: PollRequest): Promise<PollResult> {
@ -98,16 +97,13 @@ export class DeviceFlowApi {
}
const body = { client_id: req.client_id ?? DEFAULT_CLIENT_ID, device_code: req.device_code }
const res = await this.http.fetch('oauth/device/token', { method: 'POST', json: body })
if (res.status === 404)
throw versionSkew()
if (res.status >= 500)
return { status: 'retry_5xx' }
if (res.status === 404) throw versionSkew()
if (res.status >= 500) return { status: 'retry_5xx' }
let payload: { error?: string } & Partial<PollSuccess> = {}
try {
const text = await res.text()
payload = text === '' ? {} : JSON.parse(text) as typeof payload
}
catch (err) {
payload = text === '' ? {} : (JSON.parse(text) as typeof payload)
} catch (err) {
throw new BaseError({
code: ErrorCode.Unknown,
message: `decode poll response: ${(err as Error).message}`,

View File

@ -11,7 +11,9 @@ type WithOrpc = { orpc: unknown }
describe('PermittedExternalAppsClient', () => {
it('list calls permittedExternalApps.get with paging/filter query', async () => {
const c = new PermittedExternalAppsClient(fakeHttp())
const get = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] })
const get = vi
.fn()
.mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] })
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { get: vi.fn() } } }
await c.list({ workspaceId: '', page: 2, limit: 5, mode: undefined, name: 'a' })
expect(get).toHaveBeenCalledWith({ query: { page: 2, limit: 5, mode: undefined, name: 'a' } })
@ -20,7 +22,9 @@ describe('PermittedExternalAppsClient', () => {
it('describe calls permittedExternalApps.byAppId.get with app_id + fields', async () => {
const c = new PermittedExternalAppsClient(fakeHttp())
const dget = vi.fn().mockResolvedValue({ info: null, parameters: null, input_schema: null })
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get: vi.fn(), byAppId: { get: dget } } }
;(c as unknown as WithOrpc).orpc = {
permittedExternalApps: { get: vi.fn(), byAppId: { get: dget } },
}
await c.describe('app-1', ['info'])
expect(dget).toHaveBeenCalledWith({ params: { app_id: 'app-1' }, query: { fields: 'info' } })
})

View File

@ -22,12 +22,17 @@ describe('WorkspacesClient.list', () => {
})
it('GETs /workspaces and returns the parsed list', async () => {
stub = await startStubServer(cap =>
jsonResponder(200, {
workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true },
],
}, cap))
stub = await startStubServer((cap) =>
jsonResponder(
200,
{
workspaces: [
{ id: 'ws-1', name: 'Default', role: 'owner', status: 'normal', current: true },
],
},
cap,
),
)
const res = await makeClient(stub.url).list()
@ -37,10 +42,10 @@ describe('WorkspacesClient.list', () => {
})
it('maps 401 to a classified BaseError', async () => {
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
stub = await startStubServer((cap) => jsonResponder(401, { error: 'expired' }, cap))
await expect(makeClient(stub.url).list()).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 401,
(err) => isHttpClientError(err) && err.httpStatus === 401,
)
})
})

View File

@ -1,4 +1,7 @@
import type { WorkspaceDetailResponse, WorkspaceListResponse } from '@dify/contracts/api/openapi/types.gen'
import type {
WorkspaceDetailResponse,
WorkspaceListResponse,
} from '@dify/contracts/api/openapi/types.gen'
import type { OpenApiClient } from '@/http/orpc'
import type { HttpClient } from '@/http/types'
import { createOpenApiClient } from '@/http/orpc'

View File

@ -71,13 +71,15 @@ describe('notLoggedInError', () => {
expect(notLoggedInError().toString()).toMatch(/auth login/)
})
it('accepts a custom hint', () => {
expect(notLoggedInError('run \'difyctl use host\'').toString()).toMatch(/use host/)
expect(notLoggedInError("run 'difyctl use host'").toString()).toMatch(/use host/)
})
})
describe('Registry (pure)', () => {
const baseReg = (): Registry => Registry.empty('file')
const ctx = (email: string): AccountContext => ({ account: { id: `id-${email}`, email, name: email } })
const ctx = (email: string): AccountContext => ({
account: { id: `id-${email}`, email, name: email },
})
it('upsert creates host + account; remove drops them', () => {
const reg = baseReg()

View File

@ -62,7 +62,7 @@ export type ActiveContext = {
readonly insecureTls?: boolean
}
export function notLoggedInError(hint = 'run \'difyctl auth login\''): BaseError {
export function notLoggedInError(hint = "run 'difyctl auth login'"): BaseError {
return new BaseError({ code: ErrorCode.NotLoggedIn, message: 'not logged in', hint })
}
@ -75,8 +75,7 @@ export class Registry {
static async load(): Promise<Registry> {
const raw = await getHostStore().getTyped<Record<string, unknown>>()
if (raw === null)
return Registry.empty()
if (raw === null) return Registry.empty()
return new Registry(RegistrySchema.parse(raw))
}
@ -88,31 +87,34 @@ export class Registry {
return new Registry(data)
}
get hosts(): RegistryData['hosts'] { return this.data.hosts }
get current_host(): string | undefined { return this.data.current_host }
get token_storage(): StorageMode { return this.data.token_storage }
set token_storage(mode: StorageMode) { this.data.token_storage = mode }
get hosts(): RegistryData['hosts'] {
return this.data.hosts
}
get current_host(): string | undefined {
return this.data.current_host
}
get token_storage(): StorageMode {
return this.data.token_storage
}
set token_storage(mode: StorageMode) {
this.data.token_storage = mode
}
resolveActive(): ActiveContext | undefined {
const host = this.data.current_host
if (host === undefined || host === '')
return undefined
if (host === undefined || host === '') return undefined
const entry = this.data.hosts[host]
if (entry === undefined)
return undefined
if (entry === undefined) return undefined
const email = entry?.current_account
if (!email)
return undefined
if (!email) return undefined
const ctx = entry.accounts[email]
if (ctx === undefined)
return undefined
if (ctx === undefined) return undefined
return { host, email, ctx, scheme: entry.scheme, insecureTls: entry.insecure_tls }
}
requireActive(hint?: string): ActiveContext {
const active = this.resolveActive()
if (active === undefined)
throw notLoggedInError(hint)
if (active === undefined) throw notLoggedInError(hint)
return active
}
@ -124,18 +126,14 @@ export class Registry {
remove(host: string, email: string): void {
const entry = this.data.hosts[host]
if (entry === undefined)
return
if (entry === undefined) return
const wasActive = entry.current_account === email
delete entry.accounts[email]
if (wasActive)
entry.current_account = undefined
if (wasActive) entry.current_account = undefined
if (Object.keys(entry.accounts).length === 0) {
delete this.data.hosts[host]
if (this.data.current_host === host)
this.data.current_host = undefined
}
else if (wasActive && this.data.current_host === host) {
if (this.data.current_host === host) this.data.current_host = undefined
} else if (wasActive && this.data.current_host === host) {
this.data.current_host = undefined
}
}
@ -146,23 +144,19 @@ export class Registry {
setAccount(email: string): void {
const host = this.data.current_host
if (host === undefined)
return
if (host === undefined) return
const entry = this.data.hosts[host]
if (entry !== undefined)
entry.current_account = email
if (entry !== undefined) entry.current_account = email
}
setScheme(host: string, scheme: string): void {
const entry = this.data.hosts[host]
if (entry !== undefined)
entry.scheme = scheme
if (entry !== undefined) entry.scheme = scheme
}
setInsecureTls(host: string, insecure: boolean): void {
const entry = this.data.hosts[host]
if (entry !== undefined)
entry.insecure_tls = insecure
if (entry !== undefined) entry.insecure_tls = insecure
}
activate(host: string, email: string, ctx: AccountContext): void {
@ -176,8 +170,9 @@ export class Registry {
async forget(active: ActiveContext, store: TokenStore): Promise<void> {
try {
await store.remove(active.host, active.email)
} catch {
/* best-effort */
}
catch { /* best-effort */ }
this.remove(active.host, active.email)
await this.save()
}

View File

@ -40,10 +40,8 @@ describe('app-info disk cache', () => {
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prevCacheDir
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
else process.env[ENV_CACHE_DIR] = prevCacheDir
await rm(dir, { recursive: true, force: true })
})
@ -91,8 +89,7 @@ describe('app-info disk cache', () => {
await c.set('h', 'app-1', metaInfoOnly())
const { stat } = await import('node:fs/promises')
const s = await stat(appInfoPath(dir))
if (platform() !== 'win32')
expect(s.mode & 0o777).toBe(0o600)
if (platform() !== 'win32') expect(s.mode & 0o777).toBe(0o600)
})
it('missing cache file is not an error', async () => {
@ -112,7 +109,9 @@ describe('app-info disk cache', () => {
await seed.set('h', 'app-2', metaInfoOnly('app-2'))
// Inject a corrupt sibling alongside the real one.
const file = load(await readFile(appInfoPath(dir), 'utf8')) as { entries: Record<string, unknown> }
const file = load(await readFile(appInfoPath(dir), 'utf8')) as {
entries: Record<string, unknown>
}
file.entries['h::app-1'] = 'corrupted-string-not-object'
await writeFile(appInfoPath(dir), dump(file), 'utf8')

View File

@ -46,7 +46,10 @@ export async function loadAppInfoCache(opts: AppInfoCacheOptions = {}): Promise<
return {
get: (host, appId) => state.entries.get(key(host, appId)),
set: async (host, appId, meta) => {
const record: AppMetaCacheRecord = { meta, fetchedAt: (opts.now ?? (() => new Date()))().toISOString() }
const record: AppMetaCacheRecord = {
meta,
fetchedAt: (opts.now ?? (() => new Date()))().toISOString(),
}
state.entries.set(key(host, appId), record)
await writeEntries(store, state.entries)
},
@ -70,19 +73,16 @@ async function readEntries(store: Store): Promise<Map<string, AppMetaCacheRecord
let raw: Record<string, DiskEntry>
try {
raw = await store.get(ENTRIES_KEY)
}
catch {
} catch {
return out
}
// A scalar/array survives Object.entries as garbage rather than throwing.
if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
return out
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) return out
for (const [k, e] of Object.entries(raw)) {
try {
out.set(k, deserialize(e))
}
catch {
} catch {
// Drop unreadable entry → becomes a cache miss → consumer refetches.
}
}
@ -103,10 +103,11 @@ function deserialize(e: DiskEntry): AppMetaCacheRecord {
}
function filterFields(input: unknown): AppMetaFieldKey[] {
if (!Array.isArray(input))
return []
if (!Array.isArray(input)) return []
const valid = new Set<AppMetaFieldKey>([FieldInfo, FieldParameters, FieldInputSchema])
return input.filter((s): s is AppMetaFieldKey => typeof s === 'string' && valid.has(s as AppMetaFieldKey))
return input.filter(
(s): s is AppMetaFieldKey => typeof s === 'string' && valid.has(s as AppMetaFieldKey),
)
}
function serialize(record: AppMetaCacheRecord): DiskEntry {

View File

@ -19,14 +19,13 @@ describe('compat-store', () => {
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prev === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prev
if (prev === undefined) delete process.env[ENV_CACHE_DIR]
else process.env[ENV_CACHE_DIR] = prev
await rm(dir, { recursive: true, force: true })
})
const store = (now: Date = NOW) => loadCompatStore({ store: getCache(CACHE_COMPAT), now: () => now })
const store = (now: Date = NOW) =>
loadCompatStore({ store: getCache(CACHE_COMPAT), now: () => now })
it('is not fresh before anything is marked', async () => {
expect((await store()).isFreshCompatible(HOST)).toBe(false)

View File

@ -29,8 +29,7 @@ export async function loadCompatStore(opts: CompatStoreOptions = {}): Promise<Co
return {
isFreshCompatible: (host, now) => {
const last = memory.get(host)
if (last === undefined)
return false
if (last === undefined) return false
const elapsed = Math.max(0, (now ?? clock()).getTime() - last)
return elapsed < ttlMs
},
@ -51,21 +50,18 @@ async function readCompatible(store: Store): Promise<Map<string, number>> {
let raw: Record<string, string>
try {
raw = await store.get(COMPATIBLE_KEY)
}
catch {
} catch {
return out
}
for (const [host, iso] of Object.entries(raw)) {
const t = Date.parse(iso)
if (!Number.isNaN(t))
out.set(host, t)
if (!Number.isNaN(t)) out.set(host, t)
}
return out
}
async function writeCompatible(store: Store, state: Map<string, number>): Promise<void> {
const compatible: Record<string, string> = {}
for (const [host, t] of state)
compatible[host] = new Date(t).toISOString()
for (const [host, t] of state) compatible[host] = new Date(t).toISOString()
await store.set(COMPATIBLE_KEY, compatible)
}

View File

@ -22,10 +22,8 @@ describe('NudgeStore', () => {
process.env[ENV_CACHE_DIR] = dir
})
afterEach(async () => {
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prevCacheDir
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
else process.env[ENV_CACHE_DIR] = prevCacheDir
await rm(dir, { recursive: true, force: true })
})

View File

@ -28,8 +28,7 @@ export async function loadNudgeStore(opts: NudgeStoreOptions = {}): Promise<Nudg
return {
canWarn: (host, now) => {
const last = memory.get(host)
if (last === undefined)
return true
if (last === undefined) return true
const elapsed = Math.max(0, (now ?? clock()).getTime() - last)
return elapsed >= intervalMs
},
@ -51,21 +50,18 @@ async function readWarned(store: Store): Promise<Map<string, number>> {
let raw: Record<string, string>
try {
raw = await store.get(WARNED_KEY)
}
catch {
} catch {
return out
}
for (const [host, iso] of Object.entries(raw)) {
const t = Date.parse(iso)
if (!Number.isNaN(t))
out.set(host, t)
if (!Number.isNaN(t)) out.set(host, t)
}
return out
}
async function writeWarned(store: Store, state: Map<string, number>): Promise<void> {
const warned: Record<string, string> = {}
for (const [host, t] of state)
warned[host] = new Date(t).toISOString()
for (const [host, t] of state) warned[host] = new Date(t).toISOString()
await store.set(WARNED_KEY, warned)
}

View File

@ -42,13 +42,11 @@ export async function buildAuthedContext(
const io = realStreams(opts.format ?? '')
const reg = await Registry.load()
const active = reg.resolveActive()
if (active === undefined)
fail(cmd, opts, io)
if (active === undefined) fail(cmd, opts, io)
const store = getTokenStore(reg.token_storage)
const bearer = await store.read(active.host, active.email)
if (bearer === '')
fail(cmd, opts, io)
if (bearer === '') fail(cmd, opts, io)
const { host, insecure } = activeHostInfo(active)
const retryAttempts = resolveRetryAttempts({ flag: opts.retryFlag, env: getEnv })
@ -67,7 +65,9 @@ export async function buildAuthedContext(
function fail(cmd: Pick<Command, 'error'>, opts: AuthedContextOptions, io: IOStreams): never {
const err = notLoggedInError()
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), { exit: err.exit() })
cmd.error(formatErrorForCli(err, { format: opts.format, isErrTTY: io.isErrTTY }), {
exit: err.exit(),
})
}
// Best-effort nudge: never throws, never blocks. Lives here so every authed
@ -82,17 +82,21 @@ async function runCompatNudge(opts: {
await maybeNudgeCompat(opts.host, {
store,
probe: async (host) => {
const http = createHttpClient({ baseURL: openAPIBase(host), timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0, insecure: opts.insecure })
const http = createHttpClient({
baseURL: openAPIBase(host),
timeoutMs: META_PROBE_TIMEOUT_MS,
retryAttempts: 0,
insecure: opts.insecure,
})
return new MetaClient(http).serverVersion()
},
emit: line => opts.io.err.write(line),
emit: (line) => opts.io.err.write(line),
isTty: opts.io.isOutTTY,
format: opts.io.outputFormat,
clientVersion: versionInfo.version,
color: opts.io.isErrTTY,
})
}
catch {
} catch {
// already swallowed inside maybeNudgeCompat; this is belt-and-braces
}
}

View File

@ -22,8 +22,7 @@ describe('resolveRetryAttempts', () => {
let caught: unknown
try {
resolveRetryAttempts({ flag: undefined, env: () => 'foo' })
}
catch (e) {
} catch (e) {
caught = e
}
expect((caught as { code: string }).code).toBe('usage_invalid_flag')
@ -34,8 +33,7 @@ describe('resolveRetryAttempts', () => {
let caught: unknown
try {
resolveRetryAttempts({ flag: undefined, env: () => '-1' })
}
catch (e) {
} catch (e) {
caught = e
}
expect((caught as { code: string }).code).toBe('usage_invalid_flag')

View File

@ -5,7 +5,8 @@ import { Flags } from '@/framework/flags'
export const HTTP_RETRY_DEFAULT = 3
export const httpRetryFlag = Flags.integer({
description: 'HTTP retry attempts for GET/PUT/DELETE on transient errors. 0 disables. Overrides DIFYCTL_HTTP_RETRY.',
description:
'HTTP retry attempts for GET/PUT/DELETE on transient errors. 0 disables. Overrides DIFYCTL_HTTP_RETRY.',
helpGroup: 'GLOBAL',
})
@ -15,15 +16,15 @@ export type ResolveRetryAttemptsOpts = {
}
export function resolveRetryAttempts(opts: ResolveRetryAttemptsOpts): number {
if (opts.flag !== undefined)
return opts.flag
if (opts.flag !== undefined) return opts.flag
const raw = opts.env('DIFYCTL_HTTP_RETRY')
if (raw === undefined || raw === '')
return HTTP_RETRY_DEFAULT
if (raw === undefined || raw === '') return HTTP_RETRY_DEFAULT
if (!/^-?\d+$/.test(raw))
throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${JSON.stringify(raw)} is not a non-negative integer`)
throw newError(
ErrorCode.UsageInvalidFlag,
`DIFYCTL_HTTP_RETRY: ${JSON.stringify(raw)} is not a non-negative integer`,
)
const n = Number(raw)
if (n < 0)
throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${n} is negative`)
if (n < 0) throw newError(ErrorCode.UsageInvalidFlag, `DIFYCTL_HTTP_RETRY: ${n} is negative`)
return n
}

View File

@ -11,7 +11,11 @@ import { Registry } from '@/auth/hosts'
import { bufferStreams } from '@/sys/io/streams'
import { listAllSessions, runDevicesList, runDevicesRevoke } from './devices.js'
function buildRegistry(host: string, email: string, tokenId: string): { reg: Registry, active: ActiveContext } {
function buildRegistry(
host: string,
email: string,
tokenId: string,
): { reg: Registry; active: ActiveContext } {
const reg = Registry.empty('file')
reg.upsert(host, email, {
account: { id: 'acct-1', email, name: 'Test Tester' },
@ -42,7 +46,7 @@ describe('runDevicesList', () => {
expect(out).toContain('difyctl on laptop')
expect(out).toContain('difyctl on desktop')
const lines = out.trim().split('\n')
const laptopLine = lines.find(l => l.includes('difyctl on laptop'))!
const laptopLine = lines.find((l) => l.includes('difyctl on laptop'))!
expect(laptopLine).toMatch(/\*\s*$/)
})
@ -75,7 +79,15 @@ describe('runDevicesRevoke', () => {
await reg.save()
const http = testHttpClient(mock.url, 'dfoa_test')
await runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl on desktop', all: false })
await runDevicesRevoke({
io,
reg,
active,
store,
http,
target: 'difyctl on desktop',
all: false,
})
expect(io.outBuf()).toContain('Revoked 1 session(s)')
expect(store.entries.size).toBe(1)
})
@ -106,9 +118,9 @@ describe('runDevicesRevoke', () => {
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = testHttpClient(mock.url, 'dfoa_test')
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }))
.rejects
.toThrow(/matches multiple/)
await expect(
runDevicesRevoke({ io, reg, active, store, http, target: 'difyctl', all: false }),
).rejects.toThrow(/matches multiple/)
})
it('no match throws', async () => {
@ -117,9 +129,9 @@ describe('runDevicesRevoke', () => {
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = testHttpClient(mock.url, 'dfoa_test')
await expect(runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }))
.rejects
.toThrow(/no session matches/)
await expect(
runDevicesRevoke({ io, reg, active, store, http, target: 'nonexistent', all: false }),
).rejects.toThrow(/no session matches/)
})
it('--all: revokes everything except current', async () => {
@ -153,9 +165,9 @@ describe('runDevicesRevoke', () => {
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = testHttpClient(mock.url, 'dfoa_test')
await expect(runDevicesRevoke({ io, reg, active, store, http, all: true }))
.rejects
.toThrow(/aborted by user/)
await expect(runDevicesRevoke({ io, reg, active, store, http, all: true })).rejects.toThrow(
/aborted by user/,
)
expect(base.errBuf()).toContain('Revoke 2 session(s)? [y/N]')
expect(base.outBuf()).not.toContain('Revoked')
})
@ -188,9 +200,9 @@ describe('runDevicesRevoke', () => {
const store = new MemStore()
const { reg, active } = buildRegistry(mock.url, 'tester@dify.ai', 'tok-1')
const http = testHttpClient(mock.url, 'dfoa_test')
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false }))
.rejects
.toThrow(/specify a device label/)
await expect(runDevicesRevoke({ io, reg, active, store, http, all: false })).rejects.toThrow(
/specify a device label/,
)
})
})
@ -205,12 +217,14 @@ describe('listAllSessions', () => {
expires_at: null,
})
function stubClient(pages: readonly SessionListResponse[]): { client: AccountSessionsClient, list: ReturnType<typeof vi.fn> } {
const list = vi.fn(async (q?: { page?: number, limit?: number }) => {
function stubClient(pages: readonly SessionListResponse[]): {
client: AccountSessionsClient
list: ReturnType<typeof vi.fn>
} {
const list = vi.fn(async (q?: { page?: number; limit?: number }) => {
const page = q?.page ?? 1
const env = pages[page - 1]
if (env === undefined)
throw new Error(`stub: no page ${page}`)
if (env === undefined) throw new Error(`stub: no page ${page}`)
return env
})
return { client: { list } as unknown as AccountSessionsClient, list }
@ -218,8 +232,20 @@ describe('listAllSessions', () => {
it('exhausts pages until has_more=false', async () => {
const { client, list } = stubClient([
{ page: 1, limit: 200, total: 250, has_more: true, data: Array.from({ length: 200 }, (_, i) => row(`s-${i}`)) },
{ page: 2, limit: 200, total: 250, has_more: false, data: Array.from({ length: 50 }, (_, i) => row(`s-${200 + i}`)) },
{
page: 1,
limit: 200,
total: 250,
has_more: true,
data: Array.from({ length: 200 }, (_, i) => row(`s-${i}`)),
},
{
page: 2,
limit: 200,
total: 250,
has_more: false,
data: Array.from({ length: 50 }, (_, i) => row(`s-${200 + i}`)),
},
])
const all = await listAllSessions(client)
expect(all.length).toBe(250)

View File

@ -26,9 +26,8 @@ export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
const env = opts.envLookup ?? ((k: string) => process.env[k])
const limit = resolveLimit(opts.limitRaw, env)
const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page
const envelope = await runWithSpinner(
{ io: opts.io, label: 'Fetching devices' },
() => sessions.list({ page, limit }),
const envelope = await runWithSpinner({ io: opts.io, label: 'Fetching devices' }, () =>
sessions.list({ page, limit }),
)
if (opts.json === true) {
@ -40,11 +39,9 @@ export async function runDevicesList(opts: DevicesListOptions): Promise<void> {
}
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
if (raw !== undefined && raw !== '')
return parseLimit(raw, '--limit')
if (raw !== undefined && raw !== '') return parseLimit(raw, '--limit')
const envValue = env('DIFY_LIMIT')
if (envValue !== undefined && envValue !== '')
return parseLimit(envValue, 'DIFY_LIMIT')
if (envValue !== undefined && envValue !== '') return parseLimit(envValue, 'DIFY_LIMIT')
return LIMIT_DEFAULT
}
@ -53,7 +50,9 @@ function resolveLimit(raw: string | undefined, env: (k: string) => string | unde
* session sitting on page 2+ is still findable / revocable. Uses the max
* page size (LIMIT_MAX) to minimize round-trips.
*/
export async function listAllSessions(client: AccountSessionsClient): Promise<readonly SessionRow[]> {
export async function listAllSessions(
client: AccountSessionsClient,
): Promise<readonly SessionRow[]> {
const out: SessionRow[] = []
let page = 1
// Hard guard against a misbehaving server that lies about has_more.
@ -61,8 +60,7 @@ export async function listAllSessions(client: AccountSessionsClient): Promise<re
while (page <= MAX_PAGES) {
const env = await client.list({ page, limit: LIMIT_MAX })
out.push(...env.data)
if (!env.has_more)
return out
if (!env.has_more) return out
page++
}
return out
@ -85,7 +83,7 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: 'specify a device label / id, or pass --all',
hint: 'see \'difyctl auth devices list\'',
hint: "see 'difyctl auth devices list'",
})
}
@ -108,11 +106,9 @@ export async function runDevicesRevoke(opts: DevicesRevokeOptions): Promise<void
}
}
for (const id of ids)
await sessions.revoke(id)
for (const id of ids) await sessions.revoke(id)
if (selfHit)
await opts.reg.forget(opts.active, opts.store)
if (selfHit) await opts.reg.forget(opts.active, opts.store)
opts.io.out.write(`${cs.successIcon()} Revoked ${ids.length} session(s)\n`)
}
@ -122,30 +118,29 @@ export type PickResult = {
selfHit: boolean
}
export function pickTargets(rows: readonly SessionRow[], opts: { target?: string, all: boolean }, currentId: string): PickResult {
export function pickTargets(
rows: readonly SessionRow[],
opts: { target?: string; all: boolean },
currentId: string,
): PickResult {
if (opts.all) {
const ids = rows.filter(r => r.id !== currentId).map(r => r.id)
const ids = rows.filter((r) => r.id !== currentId).map((r) => r.id)
return { ids, selfHit: false }
}
const target = opts.target ?? ''
const byLabel = rows.filter(r => r.device_label === target)
if (byLabel.length > 1)
throw ambiguous(target, byLabel)
const byLabel = rows.filter((r) => r.device_label === target)
if (byLabel.length > 1) throw ambiguous(target, byLabel)
const onlyLabel = byLabel[0]
if (onlyLabel !== undefined)
return { ids: [onlyLabel.id], selfHit: onlyLabel.id === currentId }
if (onlyLabel !== undefined) return { ids: [onlyLabel.id], selfHit: onlyLabel.id === currentId }
const byId = rows.find(r => r.id === target)
if (byId !== undefined)
return { ids: [byId.id], selfHit: byId.id === currentId }
const byId = rows.find((r) => r.id === target)
if (byId !== undefined) return { ids: [byId.id], selfHit: byId.id === currentId }
const needle = target.toLowerCase()
const bySub = rows.filter(r => r.device_label.toLowerCase().includes(needle))
if (bySub.length > 1)
throw ambiguous(target, bySub)
const bySub = rows.filter((r) => r.device_label.toLowerCase().includes(needle))
if (bySub.length > 1) throw ambiguous(target, bySub)
const onlySub = bySub[0]
if (onlySub !== undefined)
return { ids: [onlySub.id], selfHit: onlySub.id === currentId }
if (onlySub !== undefined) return { ids: [onlySub.id], selfHit: onlySub.id === currentId }
throw new BaseError({
code: ErrorCode.UsageMissingArg,
@ -154,7 +149,7 @@ export function pickTargets(rows: readonly SessionRow[], opts: { target?: string
}
function ambiguous(target: string, rows: readonly SessionRow[]): BaseError {
const labels = rows.map(r => `${r.device_label} (${r.id})`).join(', ')
const labels = rows.map((r) => `${r.device_label} (${r.id})`).join(', ')
return new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `"${target}" matches multiple sessions: ${labels}; pass an exact id to disambiguate`,
@ -163,14 +158,19 @@ function ambiguous(target: string, rows: readonly SessionRow[]): BaseError {
function renderTable(rows: readonly SessionRow[], currentId: string): string {
const header = ['DEVICE', 'CREATED', 'LAST USED', 'CURRENT']
const body = rows.map(r => [
const body = rows.map((r) => [
r.device_label !== '' ? r.device_label : r.id,
r.created_at ?? '',
r.last_used_at ?? '',
r.id === currentId ? '*' : '',
])
const widths = header.map((h, i) => Math.max(h.length, ...body.map(row => (row[i] ?? '').length)))
const widths = header.map((h, i) =>
Math.max(h.length, ...body.map((row) => (row[i] ?? '').length)),
)
const fmt = (cells: readonly string[]): string =>
cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ').trimEnd()
cells
.map((c, i) => c.padEnd(widths[i] ?? 0))
.join(' ')
.trimEnd()
return body.length === 0 ? `${fmt(header)}\n` : `${[fmt(header), ...body.map(fmt)].join('\n')}\n`
}

View File

@ -14,9 +14,9 @@ export default class DevicesList extends DifyCommand {
static override flags = {
'http-retry': httpRetryFlag,
'json': Flags.boolean({ description: 'emit JSON', default: false }),
'page': Flags.integer({ description: 'page number', default: 1 }),
'limit': Flags.string({ description: 'page size [1..200]' }),
json: Flags.boolean({ description: 'emit JSON', default: false }),
page: Flags.integer({ description: 'page number', default: 1 }),
limit: Flags.string({ description: 'page size [1..200]' }),
}
async run(argv: string[]): Promise<void> {

View File

@ -19,9 +19,12 @@ export default class DevicesRevoke extends DifyCommand {
}
static override flags = {
'all': Flags.boolean({ description: 'revoke every session except the current one', default: false }),
all: Flags.boolean({
description: 'revoke every session except the current one',
default: false,
}),
'http-retry': httpRetryFlag,
'yes': Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }),
yes: Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }),
}
async run(argv: string[]): Promise<void> {

View File

@ -52,14 +52,14 @@ export class ContextListOutput {
}
tableRows(): readonly (readonly TableCell[])[] {
return this.rows.map(r => r.tableRow())
return this.rows.map((r) => r.tableRow())
}
name(): string {
return this.rows.map(r => r.name()).join('\n')
return this.rows.map((r) => r.name()).join('\n')
}
json() {
return { contexts: this.rows.map(r => r.json()) }
return { contexts: this.rows.map((r) => r.json()) }
}
}

View File

@ -14,7 +14,10 @@ export default class AuthList extends DifyCommand {
]
static override flags = {
output: Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME], default: '' }),
output: Flags.outputFormat({
options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME],
default: '',
}),
}
async run(argv: string[]) {

View File

@ -39,7 +39,7 @@ describe('runAuthList', () => {
it('marks only the active context', () => {
const result = runAuthList(twoHostReg())
const active = result.rows.filter(r => r.active)
const active = result.rows.filter((r) => r.active)
expect(active).toHaveLength(1)
expect(active[0]!.host).toBe('cloud.dify.ai')
expect(active[0]!.account).toBe('alice@corp.com')
@ -56,17 +56,19 @@ describe('runAuthList', () => {
it('table: marks active row with *', () => {
const out = stringifyOutput(table({ format: '', data: runAuthList(twoHostReg()) }))
const lines = out.trim().split('\n')
const activeLine = lines.find(l => l.includes('alice@corp.com'))!
const activeLine = lines.find((l) => l.includes('alice@corp.com'))!
expect(activeLine).toContain('*')
const inactiveLine = lines.find(l => l.includes('bob@corp.com'))!
const inactiveLine = lines.find((l) => l.includes('bob@corp.com'))!
expect(inactiveLine).not.toContain('*')
})
it('json: emits { contexts: [...] }', () => {
const out = stringifyOutput(table({ format: 'json', data: runAuthList(twoHostReg()) }))
const parsed = JSON.parse(out) as { contexts: Array<{ host: string, account: string, active: boolean }> }
const parsed = JSON.parse(out) as {
contexts: Array<{ host: string; account: string; active: boolean }>
}
expect(parsed.contexts).toHaveLength(3)
const activeCtx = parsed.contexts.find(c => c.active)!
const activeCtx = parsed.contexts.find((c) => c.active)!
expect(activeCtx.host).toBe('cloud.dify.ai')
expect(activeCtx.account).toBe('alice@corp.com')
})

View File

@ -27,8 +27,7 @@ class FakeClock implements Clock {
async sleepMs(ms: number): Promise<void> {
this.sleeps.push(ms)
if (this.cancelAt !== undefined && this.sleeps.length >= this.cancelAt)
this.cancelled = true
if (this.cancelAt !== undefined && this.sleeps.length >= this.cancelAt) this.cancelled = true
}
isCancelled(): boolean {
@ -41,8 +40,7 @@ function fakeApi(scripted: PollResult[]): { pollOnce: (req: PollRequest) => Prom
return {
pollOnce: async () => {
const r = scripted[i++]
if (r === undefined)
throw new Error('scripted-api: out of responses')
if (r === undefined) throw new Error('scripted-api: out of responses')
return r
},
}
@ -119,10 +117,7 @@ describe('awaitAuthorization', () => {
})
it('uses default interval when CodeResponse.interval is 0', async () => {
const api = fakeApi([
{ status: 'pending' },
{ status: 'approved', success: successPayload },
])
const api = fakeApi([{ status: 'pending' }, { status: 'approved', success: successPayload }])
const clock = new FakeClock()
await awaitAuthorization(api, { ...code, interval: 0 }, { clock })
expect(clock.sleeps[0]).toBe(DEFAULT_INTERVAL_MS)
@ -165,7 +160,11 @@ describe('awaitAuthorization', () => {
it('propagates BaseError thrown by api.pollOnce', async () => {
const api = {
pollOnce: vi.fn().mockRejectedValue(new BaseError({ code: ErrorCode.UnsupportedEndpoint, message: 'old server' })),
pollOnce: vi
.fn()
.mockRejectedValue(
new BaseError({ code: ErrorCode.UnsupportedEndpoint, message: 'old server' }),
),
}
const clock = new FakeClock()
await expect(awaitAuthorization(api, code, { clock })).rejects.toThrow(/old server/)

View File

@ -28,8 +28,7 @@ export async function awaitAuthorization(
code: CodeResponse,
opts: AwaitOptions,
): Promise<PollSuccess> {
if (code.device_code === '')
throw expired()
if (code.device_code === '') throw expired()
const baseInterval = code.interval > 0 ? code.interval * 1000 : DEFAULT_INTERVAL_MS
let interval = baseInterval
@ -39,8 +38,7 @@ export async function awaitAuthorization(
}
while (true) {
if (opts.clock.isCancelled())
throw expired()
if (opts.clock.isCancelled()) throw expired()
const result = await pollWithRetry(api, req, opts.clock)
switch (result.status) {
case 'approved':
@ -64,8 +62,7 @@ export async function awaitAuthorization(
})
}
await opts.clock.sleepMs(interval)
if (opts.clock.isCancelled())
throw expired()
if (opts.clock.isCancelled()) throw expired()
}
}
@ -77,10 +74,8 @@ async function pollWithRetry(
let backoff = POLL_RETRY_INITIAL_MS
for (let attempt = 1; attempt <= POLL_RETRY_ATTEMPTS; attempt++) {
const result = await api.pollOnce(req)
if (result.status !== 'retry_5xx')
return result
if (attempt === POLL_RETRY_ATTEMPTS)
break
if (result.status !== 'retry_5xx') return result
if (attempt === POLL_RETRY_ATTEMPTS) break
await clock.sleepMs(backoff)
backoff = Math.min(backoff * 2, POLL_RETRY_CAP_MS)
}
@ -98,7 +93,7 @@ export function realClock(): Clock {
const cancelled = false
return {
async sleepMs(ms) {
await new Promise<void>(r => setTimeout(r, ms))
await new Promise<void>((r) => setTimeout(r, ms))
},
isCancelled() {
return cancelled

View File

@ -18,7 +18,7 @@ export default class Login extends DifyCommand {
]
static override flags = {
'host': Flags.string({
host: Flags.string({
description: 'Dify host URL',
default: '',
}),
@ -26,7 +26,7 @@ export default class Login extends DifyCommand {
description: 'do not auto-open the browser',
default: false,
}),
'insecure': Flags.boolean({
insecure: Flags.boolean({
description: 'allow http:// hosts and skip TLS certificate verification (local-dev only)',
default: false,
}),

View File

@ -14,11 +14,15 @@ import { openAPIBase } from '@/util/host'
import { runLogin } from './login.js'
const noopClock: Clock = {
sleepMs: async () => { /* immediate */ },
sleepMs: async () => {
/* immediate */
},
isCancelled: () => false,
}
const noopBrowser = async (): Promise<void> => { /* skip OS open */ }
const noopBrowser = async (): Promise<void> => {
/* skip OS open */
}
describe('runLogin', () => {
let mock: DifyMock
@ -91,17 +95,19 @@ describe('runLogin', () => {
mock.setScenario('denied')
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})).rejects.toThrow(/denied/)
await expect(
runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
}),
).rejects.toThrow(/denied/)
expect(store.entries.size).toBe(0)
await expect(readFile(join(configDir(), 'hosts.yml'), 'utf8')).rejects.toThrow(/ENOENT/)
})
@ -110,51 +116,57 @@ describe('runLogin', () => {
mock.setScenario('expired')
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})).rejects.toThrow(/expired/)
await expect(
runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
}),
).rejects.toThrow(/expired/)
})
it('rejects login when the account has no email', async () => {
mock.setScenario('no-email')
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(mock.url) })),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})).rejects.toThrow(/no email/i)
await expect(
runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: true,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(mock.url) })),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
}),
).rejects.toThrow(/no email/i)
expect(store.entries.size).toBe(0)
})
it('rejects http:// host without --insecure', async () => {
const io = bufferStreams()
const store = new MemStore()
await expect(runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: false,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
})).rejects.toThrow(/https:\/\//)
await expect(
runLogin({
io,
host: mock.url,
noBrowser: true,
insecure: false,
deviceLabel: 'difyctl on test',
api: new DeviceFlowApi(testHttpClient(mock.url)),
store: { store, mode: 'file' },
clock: noopClock,
browserOpener: noopBrowser,
}),
).rejects.toThrow(/https:\/\//)
})
it('emits skip-reason to stderr when --no-browser', async () => {

View File

@ -17,7 +17,13 @@ import { colorEnabled, colorScheme } from '@/sys/io/color'
import { promptText } from '@/sys/io/prompt'
import { startSpinner } from '@/sys/io/spinner'
import { decideOpen, OpenDecision, openUrl, realEnv } from '@/util/browser'
import { bareHost, DEFAULT_HOST, openAPIBase, resolveHost, validateVerificationURI } from '@/util/host'
import {
bareHost,
DEFAULT_HOST,
openAPIBase,
resolveHost,
validateVerificationURI,
} from '@/util/host'
import { awaitAuthorization, realClock } from './device-flow.js'
export type LoginOptions = {
@ -26,7 +32,7 @@ export type LoginOptions = {
readonly noBrowser?: boolean
readonly insecure?: boolean
readonly deviceLabel?: string
readonly store?: { readonly store: TokenStore, readonly mode: StorageMode }
readonly store?: { readonly store: TokenStore; readonly mode: StorageMode }
readonly api?: DeviceFlowApi
readonly browserEnv?: BrowserEnv
readonly browserOpener?: BrowserOpener
@ -44,7 +50,8 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
const host = await resolveLoginHost(opts, insecure)
const label = opts.deviceLabel ?? defaultDeviceLabel()
const api = opts.api ?? new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(host), insecure }))
const api =
opts.api ?? new DeviceFlowApi(createHttpClient({ baseURL: openAPIBase(host), insecure }))
const code = await api.requestCode({ device_label: label })
renderCodePrompt(opts.io.err, cs, code)
@ -56,12 +63,12 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
const opener = opts.browserOpener ?? openUrl
try {
await opener(code.verification_uri)
} catch (err) {
opts.io.err.write(
`${cs.warningIcon()} couldn't open browser (${(err as Error).message}); open the URL above manually\n`,
)
}
catch (err) {
opts.io.err.write(`${cs.warningIcon()} couldn't open browser (${(err as Error).message}); open the URL above manually\n`)
}
}
else {
} else {
opts.io.err.write(`${cs.warningIcon()} ${decision} — open the URL above manually\n`)
}
@ -69,15 +76,14 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
let success: PollSuccess
try {
success = await awaitAuthorization(api, code, { clock: opts.clock ?? realClock() })
}
finally {
} finally {
spinner.stop()
}
// Refuse to persist a session to a server too old for this difyctl.
await (opts.verifyServer ?? (async () => {}))(host)
const storeBundle = opts.store ?? await detectTokenStore()
const storeBundle = opts.store ?? (await detectTokenStore())
const display = bareHost(host)
const email = accountEmail(success)
const ctx = contextFromSuccess(success)
@ -97,13 +103,12 @@ export async function runLogin(opts: LoginOptions): Promise<Registry> {
async function resolveLoginHost(opts: LoginOptions, insecure: boolean): Promise<string> {
const raw = opts.host?.trim() ?? ''
if (raw !== '')
return resolveHost({ raw, insecure })
if (raw !== '') return resolveHost({ raw, insecure })
if (!opts.io.isErrTTY) {
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: '--host is required (no TTY)',
hint: 'pass the host explicitly, e.g. \'difyctl auth login --host cloud.dify.ai\'',
hint: "pass the host explicitly, e.g. 'difyctl auth login --host cloud.dify.ai'",
})
}
return promptHost(opts.io, insecure)
@ -113,8 +118,7 @@ function makeHostParser(insecure: boolean): (raw: string) => ParseResult<string>
return (raw: string) => {
try {
return { ok: true, value: resolveHost({ raw, insecure }) }
}
catch (err) {
} catch (err) {
if (isBaseError(err)) {
const msg = err.hint !== undefined ? `${err.message}${err.hint}` : err.message
return { ok: false, error: msg }
@ -128,9 +132,11 @@ async function promptHost(io: IOStreams, insecure: boolean): Promise<string> {
return promptText<string>({
io,
label: 'Enter Dify host URL',
hint: insecure ? 'e.g. https://cloud.dify.ai or http://localhost' : 'e.g. https://your-dify.com',
hint: insecure
? 'e.g. https://cloud.dify.ai or http://localhost'
: 'e.g. https://your-dify.com',
default: DEFAULT_HOST,
acceptAsDefault: raw => /^y(?:es)?$/i.test(raw.trim()),
acceptAsDefault: (raw) => /^y(?:es)?$/i.test(raw.trim()),
parse: makeHostParser(insecure),
})
}
@ -140,34 +146,49 @@ function defaultDeviceLabel(): string {
return `difyctl on ${host !== '' ? host : 'unknown-host'}`
}
function renderCodePrompt(w: NodeJS.WritableStream, cs: ReturnType<typeof colorScheme>, code: CodeResponse): void {
function renderCodePrompt(
w: NodeJS.WritableStream,
cs: ReturnType<typeof colorScheme>,
code: CodeResponse,
): void {
w.write(`${cs.warningIcon()} Copy this one-time code: ${cs.bold(code.user_code)}\n`)
w.write(` Open: ${code.verification_uri}\n`)
}
function renderLoggedIn(w: NodeJS.WritableStream, cs: ReturnType<typeof colorScheme>, host: string, s: PollSuccess): void {
function renderLoggedIn(
w: NodeJS.WritableStream,
cs: ReturnType<typeof colorScheme>,
host: string,
s: PollSuccess,
): void {
const display = bareHost(host)
if (s.account && s.account.email !== '') {
w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.account.email)} (${s.account.name})\n`)
w.write(
`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.account.email)} (${s.account.name})\n`,
)
const ws = findDefaultWorkspace(s)
if (ws !== undefined)
w.write(` Workspace: ${ws.name}\n`)
if (ws !== undefined) w.write(` Workspace: ${ws.name}\n`)
return
}
if (s.subject_email !== undefined && s.subject_email !== '') {
if (s.subject_issuer !== undefined && s.subject_issuer !== '')
w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.subject_email)} (external SSO, issuer: ${s.subject_issuer})\n`)
w.write(
`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.subject_email)} (external SSO, issuer: ${s.subject_issuer})\n`,
)
else
w.write(`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.subject_email)} (external SSO)\n`)
w.write(
`${cs.successIcon()} Logged in to ${display} as ${cs.bold(s.subject_email)} (external SSO)\n`,
)
return
}
w.write(`${cs.successIcon()} Logged in to ${display}\n`)
}
function findDefaultWorkspace(s: PollSuccess): { id: string, name: string, role: string } | undefined {
if (s.default_workspace_id === undefined || s.default_workspace_id === '')
return undefined
return s.workspaces?.find(w => w.id === s.default_workspace_id)
function findDefaultWorkspace(
s: PollSuccess,
): { id: string; name: string; role: string } | undefined {
if (s.default_workspace_id === undefined || s.default_workspace_id === '') return undefined
return s.workspaces?.find((w) => w.id === s.default_workspace_id)
}
function accountEmail(s: PollSuccess): string {
@ -189,21 +210,23 @@ function contextFromSuccess(s: PollSuccess): AccountContext {
: { id: '', email: '', name: '' },
token_id: s.token_id,
}
if (s.subject_email !== undefined && s.subject_email !== ''
&& (!s.account || s.account.id === '')) {
if (
s.subject_email !== undefined &&
s.subject_email !== '' &&
(!s.account || s.account.id === '')
) {
ctx.external_subject = { email: s.subject_email, issuer: s.subject_issuer ?? '' }
}
const def = findDefaultWorkspace(s)
if (def !== undefined)
ctx.workspace = def
if (def !== undefined) ctx.workspace = def
return ctx
}
function applyScheme(reg: Registry, display: string, host: string): void {
try {
const u = new URL(host)
if (u.protocol !== 'https:')
reg.setScheme(display, u.protocol.replace(':', ''))
if (u.protocol !== 'https:') reg.setScheme(display, u.protocol.replace(':', ''))
} catch {
/* keep scheme unset */
}
catch { /* keep scheme unset */ }
}

View File

@ -14,9 +14,7 @@ export default class Logout extends DifyCommand {
static override effect: CommandEffect = 'write'
static override examples = [
'<%= config.bin %> auth logout',
]
static override examples = ['<%= config.bin %> auth logout']
async run(argv: string[]): Promise<void> {
this.parse(Logout, argv)
@ -29,17 +27,17 @@ export default class Logout extends DifyCommand {
let bearer = ''
try {
bearer = await getTokenStore(reg.token_storage).read(active.host, active.email)
} catch {
/* keyring locked — skip remote revocation, local cleanup still runs */
}
catch { /* keyring locked — skip remote revocation, local cleanup still runs */ }
if (bearer !== '') {
const { host, insecure } = activeHostInfo(active)
http = createHttpClient({ baseURL: openAPIBase(host), bearer, retryAttempts: 0, insecure })
}
}
await runWithSpinner(
{ io, label: 'Signing out', enabled: true, style: 'dify-dim' },
() => runLogout({ io, reg, http }),
await runWithSpinner({ io, label: 'Signing out', enabled: true, style: 'dify-dim' }, () =>
runLogout({ io, reg, http }),
)
}
}

View File

@ -48,8 +48,8 @@ describe('runLogout', () => {
it('throws NotLoggedIn when no active context', async () => {
await Registry.empty('file').save()
await expect(runLogout({ io: bufferStreams(), reg: await Registry.load(), store: new MemStore() }))
.rejects
.toThrow(/not logged in/i)
await expect(
runLogout({ io: bufferStreams(), reg: await Registry.load(), store: new MemStore() }),
).rejects.toThrow(/not logged in/i)
})
})

View File

@ -25,26 +25,25 @@ export async function runLogout(opts: LogoutOptions): Promise<void> {
let bearer = ''
try {
bearer = await store.read(active.host, active.email)
} catch {
/* keyring locked — skip remote revocation, local cleanup still runs */
}
catch { /* keyring locked — skip remote revocation, local cleanup still runs */ }
let revokeWarning = ''
if (bearer !== '' && revokeAllowed(bearer) && opts.http !== undefined) {
try {
await new AccountSessionsClient(opts.http).revokeSelf()
}
catch (err) {
} catch (err) {
revokeWarning = `${cs.warningIcon()} server revoke failed (${(err as Error).message}); local credentials cleared anyway\n`
}
}
await reg.forget(active, store)
if (revokeWarning !== '')
opts.io.err.write(revokeWarning)
if (revokeWarning !== '') opts.io.err.write(revokeWarning)
opts.io.out.write(`${cs.successIcon()} Logged out of ${active.host}\n`)
}
function revokeAllowed(bearer: string): boolean {
return REVOCABLE_PREFIXES.some(p => bearer.startsWith(p))
return REVOCABLE_PREFIXES.some((p) => bearer.startsWith(p))
}

View File

@ -5,7 +5,7 @@ import { realStreams } from '@/sys/io/streams'
import { runWhoami } from './whoami'
export default class Whoami extends DifyCommand {
static override description = 'Print the active subject\'s identity'
static override description = "Print the active subject's identity"
static override examples = [
'<%= config.bin %> auth whoami',

View File

@ -7,9 +7,14 @@ function accountReg(): Registry {
return Registry.from({
token_storage: 'file',
current_host: 'cloud.dify.ai',
hosts: { 'cloud.dify.ai': { current_account: 'a@b.c', accounts: {
'a@b.c': { account: { id: 'acct-1', email: 'a@b.c', name: 'Ann' } },
} } },
hosts: {
'cloud.dify.ai': {
current_account: 'a@b.c',
accounts: {
'a@b.c': { account: { id: 'acct-1', email: 'a@b.c', name: 'Ann' } },
},
},
},
})
}
@ -17,18 +22,25 @@ function ssoReg(): Registry {
return Registry.from({
token_storage: 'file',
current_host: 'cloud.dify.ai',
hosts: { 'cloud.dify.ai': { current_account: 'sso@dify.ai', accounts: {
'sso@dify.ai': {
account: { email: 'sso@dify.ai', name: '' },
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
hosts: {
'cloud.dify.ai': {
current_account: 'sso@dify.ai',
accounts: {
'sso@dify.ai': {
account: { email: 'sso@dify.ai', name: '' },
external_subject: { email: 'sso@dify.ai', issuer: 'https://issuer.example' },
},
},
},
} } },
},
})
}
describe('runWhoami', () => {
it('throws NotLoggedIn when no active context', async () => {
await expect(runWhoami({ io: bufferStreams(), reg: Registry.empty() })).rejects.toThrow(/not logged in/i)
await expect(runWhoami({ io: bufferStreams(), reg: Registry.empty() })).rejects.toThrow(
/not logged in/i,
)
})
it('prints email + name for an account', async () => {

View File

@ -13,12 +13,16 @@ export async function runWhoami(opts: WhoamiOptions): Promise<void> {
const sub = active.ctx.external_subject
if (sub !== undefined) {
if (opts.json === true) {
opts.io.out.write(`${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.issuer })}\n`)
opts.io.out.write(
`${JSON.stringify({ subject_type: 'external_sso', email: sub.email, issuer: sub.issuer })}\n`,
)
return
}
opts.io.out.write(sub.issuer !== ''
? `${sub.email} (external SSO, issuer: ${sub.issuer})\n`
: `${sub.email} (external SSO)\n`)
opts.io.out.write(
sub.issuer !== ''
? `${sub.email} (external SSO, issuer: ${sub.issuer})\n`
: `${sub.email} (external SSO)\n`,
)
return
}

View File

@ -5,11 +5,9 @@ import { getConfigurationStore } from '@/store/manager'
import { runConfigGet } from './run'
export default class ConfigGet extends DifyCommand {
static override description = 'Print one config key\'s value'
static override description = "Print one config key's value"
static override examples = [
'<%= config.bin %> config get defaults.format',
]
static override examples = ['<%= config.bin %> config get defaults.format']
static override args = {
key: Args.string({ description: 'config key', required: true }),

View File

@ -26,11 +26,11 @@ describe('runConfigGet', () => {
let caught: unknown
try {
await runConfigGet({ store: getConfigurationStore(), key: 'bogus.key' })
} catch (err) {
caught = err
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught))
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
it('returns numeric limit as string', async () => {

View File

@ -7,14 +7,10 @@ import { CONFIG_FILE_NAME } from '@/store/manager'
export default class ConfigPath extends DifyCommand {
static override description = 'Print the resolved config.yml path'
static override examples = [
'<%= config.bin %> config path',
]
static override examples = ['<%= config.bin %> config path']
async run(argv: string[]) {
this.parse(ConfigPath, argv)
return raw(
join(resolveConfigDir(), CONFIG_FILE_NAME),
)
return raw(join(resolveConfigDir(), CONFIG_FILE_NAME))
}
}

View File

@ -22,6 +22,8 @@ export default class ConfigSet extends DifyCommand {
async run(argv: string[]) {
const { args } = this.parse(ConfigSet, argv)
return raw(await runConfigSet({ store: getConfigurationStore(), key: args.key, value: args.value }))
return raw(
await runConfigSet({ store: getConfigurationStore(), key: args.key, value: args.value }),
)
}
}

View File

@ -10,35 +10,38 @@ describe('runConfigSet', () => {
useTempConfigDir('difyctl-set-')
it('persists the value and returns "set k = v\\n"', async () => {
const out = await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'json' })
const out = await runConfigSet({
store: getConfigurationStore(),
key: 'defaults.format',
value: 'json',
})
expect(out).toBe('set defaults.format = json\n')
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.defaults.format).toBe('json')
if (r.found) expect(r.config.defaults.format).toBe('json')
})
it('rejects invalid format value with config_invalid_value', async () => {
let caught: unknown
try {
await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
} catch (err) {
caught = err
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught))
expect(caught.code).toBe(ErrorCode.ConfigInvalidValue)
if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidValue)
})
it('rejects unknown key with config_invalid_key', async () => {
let caught: unknown
try {
await runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
} catch (err) {
caught = err
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught))
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
it('preserves prior keys when setting a new one', async () => {
@ -57,30 +60,31 @@ describe('runConfigSet', () => {
let caught: unknown
try {
await runConfigSet({ store: getConfigurationStore(), key: 'defaults.format', value: 'csv' })
} catch (err) {
caught = err
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught))
expect(caught.exit()).toBe(ExitCode.Usage)
if (isBaseError(caught)) expect(caught.exit()).toBe(ExitCode.Usage)
})
it('exit code for unknown key is Usage (2)', async () => {
let caught: unknown
try {
await runConfigSet({ store: getConfigurationStore(), key: 'bogus', value: 'x' })
} catch (err) {
caught = err
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught))
expect(caught.exit()).toBe(ExitCode.Usage)
if (isBaseError(caught)) expect(caught.exit()).toBe(ExitCode.Usage)
})
it('typed wrap chain: invalid defaults.limit surfaces ConfigInvalidValue (not UsageInvalidFlag)', async () => {
let caught: unknown
try {
await runConfigSet({ store: getConfigurationStore(), key: 'defaults.limit', value: 'abc' })
} catch (err) {
caught = err
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught)) {
expect(caught.code).toBe(ErrorCode.ConfigInvalidValue)

View File

@ -10,9 +10,7 @@ export default class ConfigUnset extends DifyCommand {
static override effect: CommandEffect = 'write'
static override examples = [
'<%= config.bin %> config unset defaults.format',
]
static override examples = ['<%= config.bin %> config unset defaults.format']
static override args = {
key: Args.string({ description: 'config key', required: true }),

View File

@ -30,18 +30,17 @@ describe('runConfigUnset', () => {
expect(out).toBe('unset defaults.format\n')
const r = await loadConfig(getConfigurationStore())
expect(r.found).toBe(true)
if (r.found)
expect(r.config.schema_version).toBe(1)
if (r.found) expect(r.config.schema_version).toBe(1)
})
it('rejects unknown key', async () => {
let caught: unknown
try {
await runConfigUnset({ store: getConfigurationStore(), key: 'bogus' })
} catch (err) {
caught = err
}
catch (err) { caught = err }
expect(isBaseError(caught)).toBe(true)
if (isBaseError(caught))
expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
if (isBaseError(caught)) expect(caught.code).toBe(ErrorCode.ConfigInvalidKey)
})
})

View File

@ -18,9 +18,7 @@ describe('runConfigView', () => {
state: { current_app: 'app-1' },
})
const out = await runConfigView({ store: getConfigurationStore() })
expect(out).toBe(
'defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n',
)
expect(out).toBe('defaults.format = json\ndefaults.limit = 50\nstate.current_app = app-1\n')
})
it('text format: skips unset keys', async () => {

View File

@ -15,12 +15,10 @@ export async function runConfigView(opts: RunConfigViewOptions): Promise<string>
const loaded = await loadConfig(opts.store)
const config: ConfigFile = loaded.found ? loaded.config : emptyConfig()
const out = collect(config)
if (opts.json)
return `${JSON.stringify(out, null, 2)}\n`
if (opts.json) return `${JSON.stringify(out, null, 2)}\n`
let text = ''
for (const k of knownKeyNames()) {
if (!(k in out))
continue
if (!(k in out)) continue
text += `${k} = ${out[k]}\n`
}
return text
@ -30,15 +28,12 @@ function collect(config: ConfigFile): ViewOut {
const out: ViewOut = {}
for (const k of knownKeyNames()) {
const spec = lookupKey(k)
if (spec === undefined)
continue
if (spec === undefined) continue
const v = spec.get(config)
if (v === '')
continue
if (v === '') continue
if (k === 'defaults.limit') {
const n = Number.parseInt(v, 10)
if (Number.isFinite(n))
out[k] = n
if (Number.isFinite(n)) out[k] = n
continue
}
out[k] = v

View File

@ -2,10 +2,7 @@
import { describe, expect, it } from 'vitest'
import { isExcludedCommandPath } from '@/framework/command-fs'
const INDEX_MODULES = import.meta.glob<{ default?: unknown }>(
'./**/index.ts',
{ eager: true },
)
const INDEX_MODULES = import.meta.glob<{ default?: unknown }>('./**/index.ts', { eager: true })
const COMMAND_MODULES = Object.fromEntries(
Object.entries(INDEX_MODULES).filter(([path]) => !isExcludedCommandPath(path)),

View File

@ -17,17 +17,20 @@ export default class CreateMember extends DifyCommand {
]
static override flags = {
'email': Flags.string({ description: 'invitee email address', required: true }),
'role': Flags.string({
email: Flags.string({ description: 'invitee email address', required: true }),
role: Flags.string({
description: 'role to assign (normal|admin); owner is not assignable here',
required: true,
}),
'workspace': Flags.string({
workspace: Flags.string({
char: 'w',
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }),
output: Flags.outputFormat({
options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT],
default: '',
}),
}
async run(argv: string[]) {

View File

@ -17,7 +17,7 @@ function active(): ActiveContext {
function fakeClient() {
return {
invite: vi.fn((_ws: string, body: { email: string, role: string }) =>
invite: vi.fn((_ws: string, body: { email: string; role: string }) =>
Promise.resolve({
result: 'success' as const,
email: body.email.toLowerCase(),
@ -25,7 +25,8 @@ function fakeClient() {
member_id: 'acct-new',
invite_url: 'https://console.example.com/activate?email=x&token=tok',
tenant_id: '550e8400-e29b-41d4-a716-446655440000',
})),
}),
),
}
}
@ -41,7 +42,10 @@ describe('runCreateMember', () => {
membersFactory: () => client as never,
},
)
expect(client.invite).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440000', { email: 'new@example.com', role: 'normal' })
expect(client.invite).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440000', {
email: 'new@example.com',
role: 'normal',
})
expect(result.data.text()).toMatch(/Invited new@example\.com as normal/)
expect(result.data.name()).toBe('acct-new')
expect(result.data.json()).toMatchObject({
@ -89,7 +93,11 @@ describe('runCreateMember', () => {
it('-w flag overrides resolved workspace', async () => {
const client = fakeClient()
await runCreateMember(
{ email: 'new@example.com', role: 'admin', workspace: '550e8400-e29b-41d4-a716-446655440008' },
{
email: 'new@example.com',
role: 'admin',
workspace: '550e8400-e29b-41d4-a716-446655440008',
},
{
active: active(),
http: {} as HttpClient,
@ -97,6 +105,9 @@ describe('runCreateMember', () => {
membersFactory: () => client as never,
},
)
expect(client.invite).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440008', { email: 'new@example.com', role: 'admin' })
expect(client.invite).toHaveBeenCalledWith('550e8400-e29b-41d4-a716-446655440008', {
email: 'new@example.com',
role: 'admin',
})
})
})

View File

@ -62,9 +62,8 @@ export async function runCreateMember(
active: deps.active,
})
const response = await runWithSpinner(
{ io, label: `Inviting ${opts.email}` },
() => factory(deps.http).invite(wsId, {
const response = await runWithSpinner({ io, label: `Inviting ${opts.email}` }, () =>
factory(deps.http).invite(wsId, {
email: opts.email,
role: opts.role as 'normal' | 'admin',
}),

View File

@ -21,13 +21,16 @@ export default class DeleteMember extends DifyCommand {
}
static override flags = {
'workspace': Flags.string({
workspace: Flags.string({
char: 'w',
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT], default: '' }),
'yes': Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }),
output: Flags.outputFormat({
options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.TEXT],
default: '',
}),
yes: Flags.boolean({ char: 'y', description: 'skip confirmation prompt', default: false }),
}
async run(argv: string[]) {

View File

@ -33,7 +33,10 @@ describe('runDeleteMember', () => {
membersFactory: () => client as never,
},
)
expect(client.remove).toHaveBeenCalledExactlyOnceWith('550e8400-e29b-41d4-a716-446655440000', 'acct-2')
expect(client.remove).toHaveBeenCalledExactlyOnceWith(
'550e8400-e29b-41d4-a716-446655440000',
'acct-2',
)
expect(result.data.text()).toMatch(/Removed acct-2/)
expect(result.data.name()).toBe('acct-2')
expect(result.data.json()).toEqual({ id: 'acct-2', deleted: true })

View File

@ -65,9 +65,8 @@ export async function runDeleteMember(
}
}
await runWithSpinner(
{ io, label: `Removing ${opts.memberId}` },
() => factory(deps.http).remove(wsId, opts.memberId),
await runWithSpinner({ io, label: `Removing ${opts.memberId}` }, () =>
factory(deps.http).remove(wsId, opts.memberId),
)
const textLine = `${cs.successIcon()} Removed ${opts.memberId}\n`

View File

@ -33,15 +33,14 @@ export class AppDescribeOutput {
]
if (info.description !== '' && info.description !== undefined)
rows.push(['Description', info.description ?? ''])
if (info.is_agent)
rows.push(['Agent', 'true'])
if (info.is_agent) rows.push(['Agent', 'true'])
lines.push(...alignedRows(rows))
}
if (this.payload.parameters !== null && this.payload.parameters !== undefined) {
lines.push('Parameters:')
const indented = JSON.stringify(this.payload.parameters, null, 2)
.split('\n')
.map(l => ` ${l}`)
.map((l) => ` ${l}`)
.join('\n')
lines.push(indented)
}

View File

@ -22,16 +22,27 @@ export default class DescribeApp extends DifyCommand {
}
static override flags = {
'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
workspace: Flags.string({
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT], default: '' }),
'refresh': Flags.boolean({ description: 'bypass app-info cache and fetch fresh', default: false }),
output: Flags.outputFormat({
options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.TEXT],
default: '',
}),
refresh: Flags.boolean({
description: 'bypass app-info cache and fetch fresh',
default: false,
}),
}
async run(argv: string[]) {
const { args, flags } = this.parse(DescribeApp, argv)
if (!isValidUuid(args.id))
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: `${JSON.stringify(args.id)} is not a valid app UUID` })
throw new BaseError({
code: ErrorCode.UsageInvalidFlag,
message: `${JSON.stringify(args.id)} is not a valid app UUID`,
})
const format = flags.output
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], withCache: true, format })
return formatted({

View File

@ -35,20 +35,20 @@ describe('runDescribeApp', () => {
})
afterEach(async () => {
vi.restoreAllMocks()
if (prevCacheDir === undefined)
delete process.env[ENV_CACHE_DIR]
else
process.env[ENV_CACHE_DIR] = prevCacheDir
if (prevCacheDir === undefined) delete process.env[ENV_CACHE_DIR]
else process.env[ENV_CACHE_DIR] = prevCacheDir
await mock.stop()
await rm(dir, { recursive: true, force: true })
})
async function render(opts: Parameters<typeof runDescribeApp>[0]): Promise<string> {
const cache = await loadAppInfoCache({ store: getCache(CACHE_APP_INFO) })
const data = await runDescribeApp(
opts,
{ active: active(), http: testHttpClient(mock.url, 'dfoa_test'), host: mock.url, cache },
)
const data = await runDescribeApp(opts, {
active: active(),
http: testHttpClient(mock.url, 'dfoa_test'),
host: mock.url,
cache,
})
return stringifyOutput(formatted({ format: opts.format ?? '', data }))
}
@ -73,7 +73,7 @@ describe('runDescribeApp', () => {
it('json: passes through DescribeResponse-shaped meta', async () => {
const out = await render({ appId: 'app-1', format: 'json' })
const parsed = JSON.parse(out) as { info: { id: string }, parameters: unknown }
const parsed = JSON.parse(out) as { info: { id: string }; parameters: unknown }
expect(parsed.info.id).toBe('app-1')
expect(parsed.parameters).toBeDefined()
})
@ -105,18 +105,27 @@ describe('runDescribeApp', () => {
})
it('unknown app id surfaces as error', async () => {
await expect(runDescribeApp(
{ appId: 'nope' },
{
active: active(),
http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }),
host: mock.url,
},
)).rejects.toThrow()
await expect(
runDescribeApp(
{ appId: 'nope' },
{
active: active(),
http: testHttpClient(mock.url, { bearer: 'dfoa_test', retryAttempts: 0 }),
host: mock.url,
},
),
).rejects.toThrow()
})
it('external login resolves describe via the permitted-external route', async () => {
const activeExt: ActiveContext = { host: mock.url, email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
const activeExt: ActiveContext = {
host: mock.url,
email: 'e',
ctx: {
account: { id: 'a', email: 'e', name: 'n' },
external_subject: { email: 'e', issuer: 'i' },
},
}
const out = await runDescribeApp(
{ appId: 'app-1' },
{ active: activeExt, http: testHttpClient(mock.url, 'dfoe_test'), host: mock.url },

View File

@ -25,17 +25,16 @@ export type DescribeAppDeps = {
readonly envLookup?: (k: string) => string | undefined
}
export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise<AppDescribeOutput> {
export async function runDescribeApp(
opts: DescribeAppOptions,
deps: DescribeAppDeps,
): Promise<AppDescribeOutput> {
const apps = selectAppReader(deps.active, deps.http)
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
const io = deps.io ?? nullStreams()
const result = await runWithSpinner(
{ io, label: 'Fetching app details' },
async () => {
if (opts.refresh === true)
await meta.invalidate(opts.appId)
return meta.get(opts.appId, [FieldInfo, FieldParameters, FieldInputSchema])
},
)
const result = await runWithSpinner({ io, label: 'Fetching app details' }, async () => {
if (opts.refresh === true) await meta.invalidate(opts.appId)
return meta.get(opts.appId, [FieldInfo, FieldParameters, FieldInputSchema])
})
return new AppDescribeOutput(result)
}

View File

@ -6,10 +6,7 @@ import { runEnvList } from './run-list'
export default class EnvList extends DifyCommand {
static override description = 'Show every DIFY_* env var difyctl reads'
static override examples = [
'<%= config.bin %> env list',
'<%= config.bin %> env list --json',
]
static override examples = ['<%= config.bin %> env list', '<%= config.bin %> env list --json']
static override flags = {
json: Flags.boolean({ description: 'emit JSON', default: false }),

View File

@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest'
import { runEnvList } from './run-list'
const stub = (overrides: Record<string, string> = {}) => (name: string) => overrides[name]
const stub =
(overrides: Record<string, string> = {}) =>
(name: string) =>
overrides[name]
describe('runEnvList', () => {
it('text: header is NAME VALUE DESCRIPTION', () => {
@ -11,44 +14,51 @@ describe('runEnvList', () => {
it('text: <unset> for unset non-sensitive var', () => {
const out = runEnvList({ lookup: stub() })
const hostLine = out.split('\n').find(l => l.startsWith('DIFY_HOST'))!
const hostLine = out.split('\n').find((l) => l.startsWith('DIFY_HOST'))!
expect(hostLine).toContain('<unset>')
})
it('text: prints actual value for set non-sensitive var', () => {
const out = runEnvList({ lookup: stub({ DIFY_HOST: 'https://acme' }) })
const hostLine = out.split('\n').find(l => l.startsWith('DIFY_HOST'))!
const hostLine = out.split('\n').find((l) => l.startsWith('DIFY_HOST'))!
expect(hostLine).toContain('https://acme')
})
it('text: <set> for set sensitive var (token never echoed)', () => {
const out = runEnvList({ lookup: stub({ DIFY_TOKEN: 'dfoa_secret' }) })
const tokLine = out.split('\n').find(l => l.startsWith('DIFY_TOKEN'))!
const tokLine = out.split('\n').find((l) => l.startsWith('DIFY_TOKEN'))!
expect(tokLine).toContain('<set>')
expect(tokLine).not.toContain('dfoa_secret')
})
it('text: <unset> for unset sensitive var', () => {
const out = runEnvList({ lookup: stub() })
const tokLine = out.split('\n').find(l => l.startsWith('DIFY_TOKEN'))!
const tokLine = out.split('\n').find((l) => l.startsWith('DIFY_TOKEN'))!
expect(tokLine).toContain('<unset>')
})
it('text: rows are sorted alphabetically by name', () => {
const out = runEnvList({ lookup: stub() })
const lines = out.trim().split('\n').slice(1).map(l => l.split(/\s+/)[0])
const lines = out
.trim()
.split('\n')
.slice(1)
.map((l) => l.split(/\s+/)[0])
const sorted = [...lines].sort()
expect(lines).toEqual(sorted)
})
it('json: emits array with name/description/sensitive/value fields', () => {
const out = runEnvList({ json: true, lookup: stub({ DIFY_HOST: 'https://acme', DIFY_TOKEN: 'dfoa_x' }) })
const parsed = JSON.parse(out) as Array<{ name: string, sensitive: boolean, value: string }>
const out = runEnvList({
json: true,
lookup: stub({ DIFY_HOST: 'https://acme', DIFY_TOKEN: 'dfoa_x' }),
})
const parsed = JSON.parse(out) as Array<{ name: string; sensitive: boolean; value: string }>
expect(parsed.length).toBeGreaterThan(0)
const host = parsed.find(r => r.name === 'DIFY_HOST')!
const host = parsed.find((r) => r.name === 'DIFY_HOST')!
expect(host.sensitive).toBe(false)
expect(host.value).toBe('https://acme')
const tok = parsed.find(r => r.name === 'DIFY_TOKEN')!
const tok = parsed.find((r) => r.name === 'DIFY_TOKEN')!
expect(tok.sensitive).toBe(true)
expect(tok.value).toBe('<set>')
})

View File

@ -20,7 +20,7 @@ const COLUMN_PADDING = 2
export function runEnvList(opts: RunEnvListOptions = {}): string {
const lookup = opts.lookup ?? getEnv
if (opts.json) {
const rows: EnvListJsonRow[] = ENV_REGISTRY.map(v => ({
const rows: EnvListJsonRow[] = ENV_REGISTRY.map((v) => ({
name: v.name,
description: v.description,
sensitive: v.sensitive ?? false,
@ -29,7 +29,7 @@ export function runEnvList(opts: RunEnvListOptions = {}): string {
return `${JSON.stringify(rows, null, 2)}\n`
}
const header: readonly string[] = ['NAME', 'VALUE', 'DESCRIPTION']
const dataRows = ENV_REGISTRY.map(v => [
const dataRows = ENV_REGISTRY.map((v) => [
v.name,
displayValue(v.name, v.sensitive ?? false, lookup),
v.description,
@ -39,21 +39,18 @@ export function runEnvList(opts: RunEnvListOptions = {}): string {
function displayValue(name: string, sensitive: boolean, lookup: EnvLookup): string {
const raw = lookup(name) ?? ''
if (sensitive)
return raw === '' ? '<unset>' : '<set>'
if (sensitive) return raw === '' ? '<unset>' : '<set>'
return raw === '' ? '<unset>' : raw
}
function renderTable(rows: readonly (readonly string[])[]): string {
if (rows.length === 0)
return ''
if (rows.length === 0) return ''
const cols = rows[0]?.length ?? 0
const widths: number[] = Array.from({ length: cols }, () => 0)
for (const row of rows) {
for (let i = 0; i < cols; i++) {
const cell = row[i] ?? ''
if (cell.length > (widths[i] ?? 0))
widths[i] = cell.length
if (cell.length > (widths[i] ?? 0)) widths[i] = cell.length
}
}
let out = ''

View File

@ -5,7 +5,7 @@ import { agentGuide } from './guide'
import { runExportApp } from './run'
export default class ExportStudioApp extends DifyCommand {
static override description = 'Export a studio app\'s DSL configuration as YAML'
static override description = "Export a studio app's DSL configuration as YAML"
static override examples = [
'<%= config.bin %> export studio-app <app-id>',
@ -19,28 +19,40 @@ export default class ExportStudioApp extends DifyCommand {
}
static override flags = {
'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
'output': Flags.string({ description: 'write DSL YAML to this file path (prints to stdout if omitted)', char: 'o' }),
'include-secret': Flags.boolean({ description: 'include encrypted secret values in the exported DSL', default: false }),
'workflow-id': Flags.string({ description: 'export a specific workflow by ID (workflow apps only)' }),
workspace: Flags.string({
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
output: Flags.string({
description: 'write DSL YAML to this file path (prints to stdout if omitted)',
char: 'o',
}),
'include-secret': Flags.boolean({
description: 'include encrypted secret values in the exported DSL',
default: false,
}),
'workflow-id': Flags.string({
description: 'export a specific workflow by ID (workflow apps only)',
}),
'http-retry': httpRetryFlag,
}
async run(argv: string[]) {
const { args, flags } = this.parse(ExportStudioApp, argv)
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
const result = await runExportApp({
appId: args.id,
workspace: flags.workspace,
output: flags.output,
includeSecret: flags['include-secret'],
workflowId: flags['workflow-id'],
}, { active: ctx.active, http: ctx.http, io: ctx.io })
const result = await runExportApp(
{
appId: args.id,
workspace: flags.workspace,
output: flags.output,
includeSecret: flags['include-secret'],
workflowId: flags['workflow-id'],
},
{ active: ctx.active, http: ctx.http, io: ctx.io },
)
if (result.writtenTo === undefined) {
ctx.io.out.write(result.yaml)
if (!result.yaml.endsWith('\n'))
ctx.io.out.write('\n')
if (!result.yaml.endsWith('\n')) ctx.io.out.write('\n')
}
}

View File

@ -30,7 +30,10 @@ export type ExportAppResult = {
readonly writtenTo: string | undefined
}
export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps): Promise<ExportAppResult> {
export async function runExportApp(
opts: ExportAppOptions,
deps: ExportAppDeps,
): Promise<ExportAppResult> {
const env = deps.envLookup ?? getEnv
const io = deps.io ?? nullStreams()
const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h))
@ -41,9 +44,8 @@ export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps):
const client = dslFactory(deps.http)
const yaml = await runWithSpinner(
{ io, label: `Exporting DSL for app ${opts.appId}` },
() => client.exportDsl(opts.appId, {
const yaml = await runWithSpinner({ io, label: `Exporting DSL for app ${opts.appId}` }, () =>
client.exportDsl(opts.appId, {
includeSecret: opts.includeSecret,
workflowId: opts.workflowId,
}),

View File

@ -55,11 +55,11 @@ export class AppListOutput {
}
tableRows(): readonly (readonly TableCell[])[] {
return this.rows.map(row => row.tableRow())
return this.rows.map((row) => row.tableRow())
}
name(): string {
return this.rows.map(row => row.name()).join('\n')
return this.rows.map((row) => row.name()).join('\n')
}
json(): AppListResponse {

View File

@ -12,7 +12,7 @@ import { runGetApp } from './run'
const APP_MODE_VALUES: readonly SupportedAppType[] = zSupportedAppType.options
export default class GetApp extends DifyCommand {
static override description = 'List apps or describe one app\'s basic info'
static override description = "List apps or describe one app's basic info"
static override examples = [
'<%= config.bin %> get app',
@ -26,34 +26,42 @@ export default class GetApp extends DifyCommand {
}
static override flags = {
'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
workspace: Flags.string({
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'all-workspaces': Flags.boolean({
char: 'A',
description: 'list apps across every workspace the bearer can see',
default: false,
}),
'page': Flags.integer({ description: 'page number', default: 1 }),
'limit': Flags.string({ description: 'page size [1..200]' }),
'mode': Flags.string({ description: 'filter by app mode', options: APP_MODE_VALUES }),
'name': Flags.string({ description: 'filter by app name (server-side substring)' }),
page: Flags.integer({ description: 'page number', default: 1 }),
limit: Flags.string({ description: 'page size [1..200]' }),
mode: Flags.string({ description: 'filter by app mode', options: APP_MODE_VALUES }),
name: Flags.string({ description: 'filter by app name (server-side substring)' }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
output: Flags.outputFormat({
options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE],
default: '',
}),
}
async run(argv: string[]) {
const { args, flags } = this.parse(GetApp, argv)
const format = flags.output
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format })
const result = await runGetApp({
appId: args.id,
workspace: flags.workspace,
allWorkspaces: flags['all-workspaces'],
page: flags.page,
limitRaw: flags.limit,
mode: flags.mode as SupportedAppType | undefined,
name: flags.name,
format,
}, { active: ctx.active, http: ctx.http, io: ctx.io })
const result = await runGetApp(
{
appId: args.id,
workspace: flags.workspace,
allWorkspaces: flags['all-workspaces'],
page: flags.page,
limitRaw: flags.limit,
mode: flags.mode as SupportedAppType | undefined,
name: flags.name,
format,
},
{ active: ctx.active, http: ctx.http, io: ctx.io },
)
return table({
format,
data: result.data,

View File

@ -6,8 +6,12 @@ import { describe, expect, it } from 'vitest'
// rejects (rag-pipeline, channel) or modes that aren't listable here (agent).
describe('get app --mode whitelist', () => {
it('is exactly the listable app types', () => {
expect([...zSupportedAppType.options].sort()).toEqual(
['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow'],
)
expect([...zSupportedAppType.options].sort()).toEqual([
'advanced-chat',
'agent-chat',
'chat',
'completion',
'workflow',
])
})
})

View File

@ -36,10 +36,12 @@ describe('runGetApp', () => {
async function render(opts: Parameters<typeof runGetApp>[0] = {}): Promise<string> {
const result = await runGetApp(opts, { active: baseActive, http: http() })
return stringifyOutput(table({
format: opts.format ?? '',
data: result.data,
}))
return stringifyOutput(
table({
format: opts.format ?? '',
data: result.data,
}),
)
}
it('list (no id, default format) renders table with NAME ID MODE UPDATED', async () => {
@ -53,7 +55,7 @@ describe('runGetApp', () => {
})
it('defines table headers on the output class', () => {
expect(AppListOutput.tableColumns().map(column => column.name)).toEqual([
expect(AppListOutput.tableColumns().map((column) => column.name)).toEqual([
'NAME',
'ID',
'MODE',
@ -87,9 +89,9 @@ describe('runGetApp', () => {
it('-o json emits parseable JSON envelope', async () => {
const out = await render({ format: 'json' })
const parsed = JSON.parse(out) as { data: Array<{ id: string }>, total: number }
const parsed = JSON.parse(out) as { data: Array<{ id: string }>; total: number }
expect(parsed.data).toHaveLength(2)
expect(parsed.data.map(r => r.id).sort()).toEqual(['app-1', 'app-2'])
expect(parsed.data.map((r) => r.id).sort()).toEqual(['app-1', 'app-2'])
})
it('-o yaml emits YAML envelope', async () => {
@ -110,9 +112,7 @@ describe('runGetApp', () => {
})
it('rejects unknown format', async () => {
await expect(render({ format: 'bogus' }))
.rejects
.toThrow(/not supported/)
await expect(render({ format: 'bogus' })).rejects.toThrow(/not supported/)
})
it('--workspace flag overrides bundle default', async () => {
@ -132,10 +132,33 @@ describe('runGetApp', () => {
})
it('external login lists via permitted-external client without workspace', async () => {
const list = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 1, has_more: false, data: [{ id: 'x', name: 'X', description: null, mode: 'chat', updated_at: null, workspace_id: 'w', workspace_name: 'W' }] })
const list = vi.fn().mockResolvedValue({
page: 1,
limit: 20,
total: 1,
has_more: false,
data: [
{
id: 'x',
name: 'X',
description: null,
mode: 'chat',
updated_at: null,
workspace_id: 'w',
workspace_name: 'W',
},
],
})
const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps')
vi.spyOn(PermittedExternalAppsClient.prototype, 'list').mockImplementation(list)
const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
const active: ActiveContext = {
host: 'h',
email: 'e',
ctx: {
account: { id: 'a', email: 'e', name: 'n' },
external_subject: { email: 'e', issuer: 'i' },
},
}
const http = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
const res = await runGetApp({}, { active, http })
expect(list).toHaveBeenCalled()
@ -145,10 +168,17 @@ describe('runGetApp', () => {
})
it('--all-workspaces throws UsageInvalidFlag for external logins', async () => {
const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
const active: ActiveContext = {
host: 'h',
email: 'e',
ctx: {
account: { id: 'a', email: 'e', name: 'n' },
external_subject: { email: 'e', issuer: 'i' },
},
}
const httpClient = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
await expect(runGetApp({ allWorkspaces: true }, { active, http: httpClient }))
.rejects
.toThrow(/--all-workspaces is not available for external logins/)
await expect(runGetApp({ allWorkspaces: true }, { active, http: httpClient })).rejects.toThrow(
/--all-workspaces is not available for external logins/,
)
})
})

View File

@ -1,4 +1,9 @@
import type { AppDescribeResponse, AppListResponse, AppMode, SupportedAppType } from '@dify/contracts/api/openapi/types.gen'
import type {
AppDescribeResponse,
AppListResponse,
AppMode,
SupportedAppType,
} from '@dify/contracts/api/openapi/types.gen'
import type { AppReader } from '@/api/app-reader'
import type { ActiveContext } from '@/auth/hosts'
import type { HttpClient } from '@/http/types'
@ -50,50 +55,65 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
const label = opts.appId !== undefined && opts.appId !== '' ? 'Fetching app' : 'Fetching apps'
const io = deps.io ?? nullStreams()
const envelope = await runWithSpinner(
{ io, label },
async (): Promise<AppListResponse> => {
if (opts.allWorkspaces === true) {
if (external)
throw newError(ErrorCode.UsageInvalidFlag, '--all-workspaces is not available for external logins')
const ws = wsFactory(deps.http)
return runAllWorkspaces(apps, ws, opts, page, pageSize)
}
if (opts.appId !== undefined && opts.appId !== '') {
const wsId = external ? '' : resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
const wsName = external ? '' : workspaceNameForId(deps.active, wsId)
const desc = await apps.describe(opts.appId, ['info'])
return describeToEnvelope(desc, wsId, wsName)
}
if (external) {
return apps.list({ workspaceId: '', page, limit: pageSize, mode: opts.mode, name: opts.name })
}
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
return apps.list({
workspaceId: wsId,
page,
limit: pageSize,
mode: opts.mode,
name: opts.name,
})
},
)
const envelope = await runWithSpinner({ io, label }, async (): Promise<AppListResponse> => {
if (opts.allWorkspaces === true) {
if (external)
throw newError(
ErrorCode.UsageInvalidFlag,
'--all-workspaces is not available for external logins',
)
const ws = wsFactory(deps.http)
return runAllWorkspaces(apps, ws, opts, page, pageSize)
}
if (opts.appId !== undefined && opts.appId !== '') {
const wsId = external
? ''
: resolveWorkspaceId({
flag: opts.workspace,
env: env('DIFY_WORKSPACE_ID'),
active: deps.active,
})
const wsName = external ? '' : workspaceNameForId(deps.active, wsId)
const desc = await apps.describe(opts.appId, ['info'])
return describeToEnvelope(desc, wsId, wsName)
}
if (external) {
return apps.list({ workspaceId: '', page, limit: pageSize, mode: opts.mode, name: opts.name })
}
const wsId = resolveWorkspaceId({
flag: opts.workspace,
env: env('DIFY_WORKSPACE_ID'),
active: deps.active,
})
return apps.list({
workspaceId: wsId,
page,
limit: pageSize,
mode: opts.mode,
name: opts.name,
})
})
return {
data: new AppListOutput(envelope.data.map(row => new AppRow(row)), envelope),
data: new AppListOutput(
envelope.data.map((row) => new AppRow(row)),
envelope,
),
}
}
function resolveLimit(raw: string | undefined, env: (k: string) => string | undefined): number {
if (raw !== undefined && raw !== '')
return parseLimit(raw, '--limit')
if (raw !== undefined && raw !== '') return parseLimit(raw, '--limit')
const envValue = env('DIFY_LIMIT')
if (envValue !== undefined && envValue !== '')
return parseLimit(envValue, 'DIFY_LIMIT')
if (envValue !== undefined && envValue !== '') return parseLimit(envValue, 'DIFY_LIMIT')
return LIMIT_DEFAULT
}
function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: string): AppListResponse {
function describeToEnvelope(
desc: AppDescribeResponse,
wsId: string,
wsName: string,
): AppListResponse {
if (desc.info === null || desc.info === undefined) {
return { page: 1, limit: 1, total: 0, has_more: false, data: [] }
}
@ -102,21 +122,22 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
limit: 1,
total: 1,
has_more: false,
data: [{
id: desc.info.id,
name: desc.info.name,
description: desc.info.description,
mode: desc.info.mode as AppMode,
updated_at: desc.info.updated_at,
workspace_id: wsId,
workspace_name: wsName === '' ? undefined : wsName,
}],
data: [
{
id: desc.info.id,
name: desc.info.name,
description: desc.info.description,
mode: desc.info.mode as AppMode,
updated_at: desc.info.updated_at,
workspace_id: wsId,
workspace_name: wsName === '' ? undefined : wsName,
},
],
}
}
function workspaceNameForId(active: ActiveContext, id: string): string {
if (id === '')
return ''
if (id === '') return ''
return active.ctx.workspace?.id === id ? active.ctx.workspace.name : ''
}
@ -128,8 +149,7 @@ async function runAllWorkspaces(
limit: number,
): Promise<AppListResponse> {
const wsResp = await ws.list()
if (wsResp.workspaces.length === 0)
return { page: 1, limit, total: 0, has_more: false, data: [] }
if (wsResp.workspaces.length === 0) return { page: 1, limit, total: 0, has_more: false, data: [] }
const merged: AppListResponse = { page: 1, limit, total: 0, has_more: false, data: [] }
const queue = [...wsResp.workspaces]
@ -150,8 +170,7 @@ async function runAllWorkspaces(
const runner = async (): Promise<void> => {
while (true) {
const next = queue.shift()
if (next === undefined)
return
if (next === undefined) return
await fetchOne(next.id)
}
}

View File

@ -75,11 +75,11 @@ export class MemberListOutput {
}
tableRows(): readonly (readonly TableCell[])[] {
return this.rows.map(row => row.tableRow())
return this.rows.map((row) => row.tableRow())
}
name(): string {
return this.rows.map(row => row.name()).join('\n')
return this.rows.map((row) => row.name()).join('\n')
}
json(): MemberListResponse {

View File

@ -16,14 +16,17 @@ export default class GetMember extends DifyCommand {
]
static override flags = {
'workspace': Flags.string({
workspace: Flags.string({
char: 'w',
description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)',
}),
'page': Flags.integer({ description: 'page number', default: 1 }),
'limit': Flags.string({ description: 'page size [1..200]' }),
page: Flags.integer({ description: 'page number', default: 1 }),
limit: Flags.string({ description: 'page size [1..200]' }),
'http-retry': httpRetryFlag,
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
output: Flags.outputFormat({
options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE],
default: '',
}),
}
async run(argv: string[]) {

Some files were not shown because too many files have changed in this diff Show More