mirror of
https://github.com/langgenius/dify.git
synced 2026-07-20 09:38:32 +08:00
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:
parent
fde08d24fe
commit
a84c2d36a3
21
.github/workflows/autofix.yml
vendored
21
.github/workflows/autofix.yml
vendored
@ -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
|
||||
|
||||
7
.github/workflows/style.yml
vendored
7
.github/workflows/style.yml
vendored
@ -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'
|
||||
|
||||
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
|
||||
@ -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 ~')
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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`,
|
||||
)
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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 ?? '' }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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}`)
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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 } })
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
)
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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}`,
|
||||
|
||||
@ -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' } })
|
||||
})
|
||||
|
||||
@ -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,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
13
cli/src/cache/app-info.test.ts
vendored
13
cli/src/cache/app-info.test.ts
vendored
@ -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')
|
||||
|
||||
|
||||
21
cli/src/cache/app-info.ts
vendored
21
cli/src/cache/app-info.ts
vendored
@ -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 {
|
||||
|
||||
9
cli/src/cache/compat-store.test.ts
vendored
9
cli/src/cache/compat-store.test.ts
vendored
@ -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)
|
||||
|
||||
12
cli/src/cache/compat-store.ts
vendored
12
cli/src/cache/compat-store.ts
vendored
@ -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)
|
||||
}
|
||||
|
||||
6
cli/src/cache/nudge-store.test.ts
vendored
6
cli/src/cache/nudge-store.test.ts
vendored
@ -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 })
|
||||
})
|
||||
|
||||
|
||||
12
cli/src/cache/nudge-store.ts
vendored
12
cli/src/cache/nudge-store.ts
vendored
@ -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)
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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')
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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`
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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()) }
|
||||
}
|
||||
}
|
||||
|
||||
@ -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[]) {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
|
||||
@ -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/)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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 */ }
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 }),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 }),
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)),
|
||||
|
||||
@ -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[]) {
|
||||
|
||||
@ -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',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
}),
|
||||
|
||||
@ -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[]) {
|
||||
|
||||
@ -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 })
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
5
cli/src/commands/env/list/index.ts
vendored
5
cli/src/commands/env/list/index.ts
vendored
@ -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 }),
|
||||
|
||||
30
cli/src/commands/env/list/run-list.test.ts
vendored
30
cli/src/commands/env/list/run-list.test.ts
vendored
@ -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>')
|
||||
})
|
||||
|
||||
13
cli/src/commands/env/list/run-list.ts
vendored
13
cli/src/commands/env/list/run-list.ts
vendored
@ -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 = ''
|
||||
|
||||
@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -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/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
Loading…
Reference in New Issue
Block a user