Skip to content
Merged
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 @@ -89,9 +90,11 @@
DeviceLookupResponse,
DeviceMutateResponse,
FileResponse,
ServerVersionResponse,
)

from . import (
_meta,
account,
app_run,
apps,
Expand All @@ -108,6 +111,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 bun

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
1 change: 0 additions & 1 deletion cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@
"clean": "rm -rf dist node_modules/.cache",
"version:info": "bun scripts/print-buildinfo.ts",
"docker:build-dev": "scripts/docker-build-dev.sh",
"sync-models": "scripts/sync-models.sh",
"build:bin": "scripts/release-build.sh"
},
"dependencies": {
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()
})
})
19 changes: 19 additions & 0 deletions cli/src/api/meta.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen'
import type { KyInstance } from 'ky'

// Used by every /_version probe call site (the version command and the
// per-command auto-nudge). Both must construct their ky client with this
// timeout + retry=0, otherwise the default 30s/3-retry budget kicks in.
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').json<ServerVersionResponse>()
}
}
94 changes: 94 additions & 0 deletions cli/src/cache/nudge-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { loadNudgeStore, nudgeStorePath, WARN_INTERVAL_MS } from './nudge-store.js'

const HOST = 'https://cloud.dify.ai'

describe('NudgeStore', () => {
let dir: string
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-'))
})
afterEach(async () => {
await rm(dir, { recursive: true, force: true })
})

it('canWarn=true when no prior record exists', async () => {
const store = await loadNudgeStore({ configDir: dir })
expect(store.canWarn(HOST)).toBe(true)
})

it('canWarn=false within the silence window, true past it', async () => {
const t0 = new Date('2026-05-19T12:00:00.000Z')
const store = await loadNudgeStore({ configDir: dir, now: () => t0 })
await store.markWarned(HOST)
expect(store.canWarn(HOST, new Date('2026-05-19T18:00:00.000Z'))).toBe(false)
expect(store.canWarn(HOST, new Date('2026-05-20T12:00:00.000Z'))).toBe(true)
})

it('canWarn clamps negative elapsed under clock skew (treats as still in window)', async () => {
const t0 = new Date('2026-05-19T12:00:00.000Z')
const store = await loadNudgeStore({ configDir: dir, now: () => t0 })
await store.markWarned(HOST)
const pastClock = new Date('2026-05-19T11:00:00.000Z') // clock moved backwards 1h
expect(store.canWarn(HOST, pastClock)).toBe(false)
})

it('markWarned persists across store reloads', async () => {
const t0 = new Date('2026-05-19T12:00:00.000Z')
const s1 = await loadNudgeStore({ configDir: dir, now: () => t0 })
await s1.markWarned(HOST)
const s2 = await loadNudgeStore({ configDir: dir, now: () => t0 })
expect(s2.canWarn(HOST)).toBe(false)
})

it('treats a corrupt cache file as empty', async () => {
const path = nudgeStorePath(dir)
await writeCacheFile(path, '{ not valid json')
const store = await loadNudgeStore({ configDir: dir })
expect(store.canWarn(HOST)).toBe(true)
})

it('ignores file with mismatched schema', async () => {
const path = nudgeStorePath(dir)
await writeCacheFile(path, JSON.stringify({ schema: 99, warned: { [HOST]: '2026-05-19T12:00:00.000Z' } }))
const store = await loadNudgeStore({ configDir: dir })
expect(store.canWarn(HOST)).toBe(true)
})

it('writes ISO timestamps under schema:1/warned on disk', async () => {
const t = new Date('2026-05-19T12:00:00.000Z')
const store = await loadNudgeStore({ configDir: dir, now: () => t })
await store.markWarned(HOST)
const raw = await readFile(nudgeStorePath(dir), 'utf8')
const parsed = JSON.parse(raw) as Record<string, unknown>
expect(parsed.schema).toBe(1)
expect((parsed.warned as Record<string, string>)[HOST]).toBe(t.toISOString())
})

it('concurrent writers across different hosts: both stamps survive (merge-on-write)', async () => {
// Two stores independently loaded (simulating two CLI processes), each
// warns about a different host. Without merge-on-write the second writer
// would clobber the first.
const t = new Date('2026-05-19T12:00:00.000Z')
const a = await loadNudgeStore({ configDir: dir, now: () => t })
const b = await loadNudgeStore({ configDir: dir, now: () => t })
await a.markWarned('https://a.example')
await b.markWarned('https://b.example')
const reread = await loadNudgeStore({ configDir: dir, now: () => t })
expect(reread.canWarn('https://a.example')).toBe(false)
expect(reread.canWarn('https://b.example')).toBe(false)
})

it('exposes WARN_INTERVAL_MS as 24h', () => {
expect(WARN_INTERVAL_MS).toBe(24 * 60 * 60 * 1000)
})
})

async function writeCacheFile(path: string, body: string): Promise<void> {
const { mkdir } = await import('node:fs/promises')
await mkdir(dirname(path), { recursive: true })
await writeFile(path, body)
}
Loading