Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/controllers/openapi/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
PermittedExternalAppsListQuery,
PermittedExternalAppsListResponse,
RevokeResponse,
ServerVersionResponse,
SessionListResponse,
SessionRow,
TagItem,
Expand Down Expand Up @@ -87,9 +88,11 @@
DeviceCodeResponse,
DeviceLookupResponse,
DeviceMutateResponse,
ServerVersionResponse,
)

from . import (
_meta,
account,
app_run,
apps,
Expand All @@ -105,6 +108,7 @@
# Request models are imported from _models.py and registered above.

__all__ = [
"_meta",
"account",
"app_run",
"apps",
Expand Down
23 changes: 23 additions & 0 deletions api/controllers/openapi/_meta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""Meta endpoint: `GET /openapi/v1/_version` — no auth.

Returns the server's project version and edition so the difyctl CLI can probe
compatibility without needing to be logged in. Mirrors the `_health` endpoint
in `index.py`.
"""

from flask_restx import Resource

from configs import dify_config
from controllers.openapi import openapi_ns
from controllers.openapi._models import ServerVersionResponse


@openapi_ns.route("/_version")
class VersionApi(Resource):
@openapi_ns.response(200, "Server version", openapi_ns.models[ServerVersionResponse.__name__])
def get(self):
edition = dify_config.EDITION if dify_config.EDITION in ("SELF_HOSTED", "CLOUD") else "SELF_HOSTED"
return ServerVersionResponse(
version=dify_config.project.version,
edition=edition,
).model_dump(mode="json")
7 changes: 7 additions & 0 deletions api/controllers/openapi/_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,13 @@ class DeviceMutateResponse(BaseModel):
status: str


class ServerVersionResponse(BaseModel):
"""Meta endpoint payload for `GET /openapi/v1/_version` — no auth required."""

version: str
edition: Literal["SELF_HOSTED", "CLOUD"]


class AppDescribeQuery(BaseModel):
"""`?fields=` allow-list for GET /apps/<id>/describe.

Expand Down
54 changes: 54 additions & 0 deletions api/tests/unit_tests/controllers/openapi/test_meta_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"""Meta endpoint /openapi/v1/_version — no auth, returns version + edition."""

from __future__ import annotations


def test_version_endpoint_returns_200_without_auth(openapi_app):
client = openapi_app.test_client()
response = client.get("/openapi/v1/_version")

assert response.status_code == 200
payload = response.get_json()
assert isinstance(payload, dict)
assert "version" in payload
assert "edition" in payload
assert isinstance(payload["version"], str)
assert payload["edition"] in ("SELF_HOSTED", "CLOUD")


def test_version_endpoint_ignores_bearer_header(openapi_app):
"""Endpoint is auth-free — a bogus bearer should not break it."""
client = openapi_app.test_client()
response = client.get(
"/openapi/v1/_version",
headers={"Authorization": "Bearer total-nonsense"},
)

assert response.status_code == 200
payload = response.get_json()
assert "version" in payload
assert "edition" in payload


def test_version_endpoint_reflects_edition_config(openapi_app, monkeypatch):
from configs import dify_config

monkeypatch.setattr(dify_config, "EDITION", "CLOUD")

client = openapi_app.test_client()
response = client.get("/openapi/v1/_version")

assert response.status_code == 200
assert response.get_json()["edition"] == "CLOUD"


def test_version_endpoint_falls_back_to_self_hosted_on_unexpected_edition(openapi_app, monkeypatch):
from configs import dify_config

monkeypatch.setattr(dify_config, "EDITION", "EXPERIMENTAL")

client = openapi_app.test_client()
response = client.get("/openapi/v1/_version")

assert response.status_code == 200
assert response.get_json()["edition"] == "SELF_HOSTED"
15 changes: 9 additions & 6 deletions cli/bin/dev.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
#!/usr/bin/env -S node --import tsx

globalThis.__DIFYCTL_VERSION__ = process.env.DIFYCTL_VERSION ?? '0.0.0-dev'
globalThis.__DIFYCTL_COMMIT__ = process.env.DIFYCTL_COMMIT ?? 'HEAD'
globalThis.__DIFYCTL_BUILD_DATE__ = process.env.DIFYCTL_BUILD_DATE ?? new Date().toISOString()
globalThis.__DIFYCTL_CHANNEL__ = process.env.DIFYCTL_CHANNEL ?? 'dev'
globalThis.__DIFYCTL_MIN_DIFY__ = process.env.DIFYCTL_MIN_DIFY ?? '0.0.0'
globalThis.__DIFYCTL_MAX_DIFY__ = process.env.DIFYCTL_MAX_DIFY ?? '0.0.0'
import { resolveBuildInfo } from '../scripts/lib/resolve-buildinfo.ts'

const info = resolveBuildInfo()
globalThis.__DIFYCTL_VERSION__ = info.version
globalThis.__DIFYCTL_COMMIT__ = info.commit
globalThis.__DIFYCTL_BUILD_DATE__ = info.buildDate
globalThis.__DIFYCTL_CHANNEL__ = info.channel
globalThis.__DIFYCTL_MIN_DIFY__ = info.minDify
globalThis.__DIFYCTL_MAX_DIFY__ = info.maxDify

const { commandTree } = await import('../src/commands/tree.ts')
const { run } = await import('../src/framework/run.ts')
Expand Down
32 changes: 29 additions & 3 deletions cli/scripts/lib/resolve-buildinfo.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { ExecSyncOptions } from 'node:child_process'
import { execSync } from 'node:child_process'
import { readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

export const BUILD_CHANNELS = ['dev', 'rc', 'stable'] as const
export type BuildChannel = (typeof BUILD_CHANNELS)[number]
Expand Down Expand Up @@ -30,18 +33,41 @@ export const defaultGitProbe: GitProbe = (cmd) => {
}
}

type PackageManifest = {
difyctl?: {
channel?: string
compat?: { minDify?: string, maxDify?: string }
}
}

export type PackageReader = () => PackageManifest

// Default reader resolves cli/package.json relative to this file so the same
// helper works whether invoked from vite.config.ts, bin/dev.js, or release.sh.
const defaultPackageReader: PackageReader = () => {
try {
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json')
return JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageManifest
}
catch {
return {}
}
}

export type ResolveOptions = {
env?: Env
git?: GitProbe
now?: () => Date
pkg?: PackageReader
}

export function resolveBuildInfo(opts: ResolveOptions = {}): BuildInfo {
const env = opts.env ?? process.env
const git = opts.git ?? defaultGitProbe
const now = opts.now ?? (() => new Date())
const pkg = (opts.pkg ?? defaultPackageReader)()

const channel = env.DIFYCTL_CHANNEL ?? 'dev'
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(' | ')})`,
Expand All @@ -59,8 +85,8 @@ export function resolveBuildInfo(opts: ResolveOptions = {}): BuildInfo {
?? 'none'

const buildDate = env.DIFYCTL_BUILD_DATE ?? now().toISOString()
const minDify = env.DIFYCTL_MIN_DIFY ?? '0.0.0'
const maxDify = env.DIFYCTL_MAX_DIFY ?? '0.0.0'
const minDify = env.DIFYCTL_MIN_DIFY ?? pkg.difyctl?.compat?.minDify ?? '0.0.0'
const maxDify = env.DIFYCTL_MAX_DIFY ?? pkg.difyctl?.compat?.maxDify ?? '0.0.0'

return { version, commit, buildDate, channel: channel as BuildChannel, minDify, maxDify }
}
49 changes: 49 additions & 0 deletions cli/src/api/meta.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { DifyMock } from '../../test/fixtures/dify-mock/server.js'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { startMock } from '../../test/fixtures/dify-mock/server.js'
import { createClient } from '../http/client.js'
import { MetaClient } from './meta.js'

describe('MetaClient', () => {
let mock: DifyMock

beforeEach(async () => {
mock = await startMock()
})
afterEach(async () => {
await mock.stop()
})

it('fetches /openapi/v1/_version without a bearer token', async () => {
const client = new MetaClient(createClient({ host: mock.url }))
const info = await client.serverVersion()

expect(info.version).toBe('1.6.4')
expect(info.edition).toBe('CLOUD')
})

it('honors the auth-expired scenario by allowing the unauthed endpoint anyway', async () => {
mock.setScenario('auth-expired')
const client = new MetaClient(createClient({ host: mock.url }))
const info = await client.serverVersion()

// The meta endpoint is exempt from auth middleware, so an auth-expired
// session does not stop the version probe.
expect(info.version).toBe('1.6.4')
})

it('returns an empty version string when the server scenario forces it', async () => {
mock.setScenario('server-version-empty')
const client = new MetaClient(createClient({ host: mock.url }))
const info = await client.serverVersion()

expect(info.version).toBe('')
expect(info.edition).toBe('SELF_HOSTED')
})

it('throws when the host has no Dify on it', async () => {
// Closed port — connection refused.
const client = new MetaClient(createClient({ host: 'http://127.0.0.1:1' }))
await expect(client.serverVersion()).rejects.toBeDefined()
})
})
21 changes: 21 additions & 0 deletions cli/src/api/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { KyInstance } from 'ky'
import type { ServerVersionResponse } from '../types/data-contracts.js'

export const META_PROBE_TIMEOUT_MS = 2000

export class MetaClient {
private readonly http: KyInstance

constructor(http: KyInstance) {
this.http = http
}

async serverVersion(): Promise<ServerVersionResponse> {
return this.http
.get('_version', {
timeout: META_PROBE_TIMEOUT_MS,
retry: { limit: 0 },
})
.json<ServerVersionResponse>()
}
}
77 changes: 39 additions & 38 deletions cli/src/commands/version/index.ts
Original file line number Diff line number Diff line change
@@ -1,52 +1,53 @@
import pc from 'picocolors'
import { Flags } from '../../framework/flags.js'
import { raw } from '../../framework/output.js'
import { compatString, difyCompat } from '../../version/compat.js'
import { formatted, raw } from '../../framework/output.js'
import { versionInfo } from '../../version/info.js'
import { runVersionProbe } from '../../version/probe.js'
import { renderVersionText } from '../../version/render.js'
import { DifyCommand } from '../_shared/dify-command.js'

const RC_WARNING_LINES = [
'WARNING: This build is a release candidate. It is in beta test, not stable,',
' and may have bugs. For production use, install the stable channel.',
] as const
export const COMPAT_FAIL_EXIT_CODE = 64

export default class Version extends DifyCommand {
static override description = 'Show difyctl version, channel, and supported dify range'
static override examples = ['<%= config.bin %> version', '<%= config.bin %> version --json']
static override description = 'Show difyctl version, probe server, and report compatibility'
static override examples = [
'<%= config.bin %> version',
'<%= config.bin %> version --short',
'<%= config.bin %> version --client',
'<%= config.bin %> version -o json',
'<%= config.bin %> version --check-compat',
]

static override flags = {
json: Flags.boolean({ description: 'emit JSON' }),
'output': Flags.string({
char: 'o',
description: 'output format (text|json|yaml)',
default: '',
}),
'client': Flags.boolean({ description: 'skip server probe' }),
'short': Flags.boolean({ description: 'print only the client semver' }),
'check-compat': Flags.boolean({
description: `exit ${COMPAT_FAIL_EXIT_CODE} if server is not 'compatible'`,
}),
}

async run(argv: string[]) {
const { flags } = this.parse(Version, argv)
const { version, commit, buildDate, channel } = versionInfo

if (flags.json) {
const payload = JSON.stringify({
version,
commit,
buildDate,
channel,
compat: { minDify: difyCompat.minDify, maxDify: difyCompat.maxDify },
})
return raw(`${payload}\n`)
}

const lines = [
`difyctl ${version}`,
` channel: ${channel}`,
` built: ${buildDate} (commit ${commit.slice(0, 7)})`,
` compat: ${compatString()}`,
]

if (channel === 'rc') {
lines.push('')
const colour = process.stdout.isTTY ? pc.yellow : (s: string) => s
for (const line of RC_WARNING_LINES)
lines.push(colour(line))
}

return raw(`${lines.join('\n')}\n`)

if (flags.short)
return raw(`${versionInfo.version}\n`)

const report = await runVersionProbe({ skipServer: flags.client })

if (flags['check-compat'] && report.compat.status !== 'compatible')
this.error(report.compat.detail, { exit: COMPAT_FAIL_EXIT_CODE })

const useColor = process.stdout.isTTY === true
return formatted({
format: flags.output,
data: {
text: () => renderVersionText(report, { color: useColor }),
json: () => report,
},
})
}
}
Loading
Loading