From 3987e6d34e78300b1b701b537ba347cc8921b03f Mon Sep 17 00:00:00 2001 From: L1nSn0w Date: Mon, 18 May 2026 23:28:49 +0800 Subject: [PATCH 1/4] feat(cli,api): difyctl version probes server and reports compat verdict Adds GET /openapi/v1/_version (no auth, mirrors _health) and reshapes difyctl version into a kubectl-style three-block report (client / server / compat) with -o text|json|yaml, --client to skip the probe, --short for scripting, and --check-compat to exit 64 when status != compatible. resolveBuildInfo now also falls back to package.json#difyctl.compat so pnpm dev/build/test all carry a real compat range without needing the release pipeline env vars. Co-Authored-By: Claude Opus 4.7 (1M context) --- api/controllers/openapi/__init__.py | 4 + api/controllers/openapi/_meta.py | 23 + api/controllers/openapi/_models.py | 7 + .../controllers/openapi/test_meta_version.py | 54 ++ cli/bin/dev.js | 15 +- cli/scripts/lib/resolve-buildinfo.ts | 32 +- cli/src/api/meta.test.ts | 49 ++ cli/src/api/meta.ts | 21 + cli/src/commands/version/index.ts | 77 +-- cli/src/commands/version/version.test.ts | 158 ++++-- cli/src/types/data-contracts.ts | 478 ++++++++++++++++++ cli/src/version/compat.test.ts | 53 +- cli/src/version/compat.ts | 39 ++ cli/src/version/probe.test.ts | 137 +++++ cli/src/version/probe.ts | 133 +++++ cli/src/version/render.test.ts | 120 +++++ cli/src/version/render.ts | 69 +++ cli/test/fixtures/dify-mock/scenarios.ts | 2 + cli/test/fixtures/dify-mock/server.ts | 13 + cli/test/scripts/resolve-buildinfo.test.ts | 46 +- cli/test/setup.ts | 4 +- 21 files changed, 1436 insertions(+), 98 deletions(-) create mode 100644 api/controllers/openapi/_meta.py create mode 100644 api/tests/unit_tests/controllers/openapi/test_meta_version.py create mode 100644 cli/src/api/meta.test.ts create mode 100644 cli/src/api/meta.ts create mode 100644 cli/src/types/data-contracts.ts create mode 100644 cli/src/version/probe.test.ts create mode 100644 cli/src/version/probe.ts create mode 100644 cli/src/version/render.test.ts create mode 100644 cli/src/version/render.ts diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py index 96acb5a1f227cc..2475eef1aafb67 100644 --- a/api/controllers/openapi/__init__.py +++ b/api/controllers/openapi/__init__.py @@ -41,6 +41,7 @@ PermittedExternalAppsListQuery, PermittedExternalAppsListResponse, RevokeResponse, + ServerVersionResponse, SessionListResponse, SessionRow, TagItem, @@ -87,9 +88,11 @@ DeviceCodeResponse, DeviceLookupResponse, DeviceMutateResponse, + ServerVersionResponse, ) from . import ( + _meta, account, app_run, apps, @@ -105,6 +108,7 @@ # Request models are imported from _models.py and registered above. __all__ = [ + "_meta", "account", "app_run", "apps", diff --git a/api/controllers/openapi/_meta.py b/api/controllers/openapi/_meta.py new file mode 100644 index 00000000000000..e1c380bf5563b7 --- /dev/null +++ b/api/controllers/openapi/_meta.py @@ -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") diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py index 509f547fde0d1e..62d643c30f2865 100644 --- a/api/controllers/openapi/_models.py +++ b/api/controllers/openapi/_models.py @@ -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//describe. diff --git a/api/tests/unit_tests/controllers/openapi/test_meta_version.py b/api/tests/unit_tests/controllers/openapi/test_meta_version.py new file mode 100644 index 00000000000000..8f9e1016e8e043 --- /dev/null +++ b/api/tests/unit_tests/controllers/openapi/test_meta_version.py @@ -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" diff --git a/cli/bin/dev.js b/cli/bin/dev.js index bb141c29a95b48..c0a1f3b97752d5 100755 --- a/cli/bin/dev.js +++ b/cli/bin/dev.js @@ -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') diff --git a/cli/scripts/lib/resolve-buildinfo.ts b/cli/scripts/lib/resolve-buildinfo.ts index 247c5ed1c0c4e7..ee0cc7b55069f5 100644 --- a/cli/scripts/lib/resolve-buildinfo.ts +++ b/cli/scripts/lib/resolve-buildinfo.ts @@ -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] @@ -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(' | ')})`, @@ -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 } } diff --git a/cli/src/api/meta.test.ts b/cli/src/api/meta.test.ts new file mode 100644 index 00000000000000..e41189054e493c --- /dev/null +++ b/cli/src/api/meta.test.ts @@ -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() + }) +}) diff --git a/cli/src/api/meta.ts b/cli/src/api/meta.ts new file mode 100644 index 00000000000000..4ee2d2c7eb3e78 --- /dev/null +++ b/cli/src/api/meta.ts @@ -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 { + return this.http + .get('_version', { + timeout: META_PROBE_TIMEOUT_MS, + retry: { limit: 0 }, + }) + .json() + } +} diff --git a/cli/src/commands/version/index.ts b/cli/src/commands/version/index.ts index b642fae96ea932..bbc7644fbb9ee8 100644 --- a/cli/src/commands/version/index.ts +++ b/cli/src/commands/version/index.ts @@ -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, + }, + }) } } diff --git a/cli/src/commands/version/version.test.ts b/cli/src/commands/version/version.test.ts index c6375ee295b1bd..6518b9e59d54d4 100644 --- a/cli/src/commands/version/version.test.ts +++ b/cli/src/commands/version/version.test.ts @@ -1,56 +1,132 @@ -import { describe, expect, it } from 'vitest' -import Version from './index.js' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as info from '../../version/info.js' +import * as probe from '../../version/probe.js' +import Version, { COMPAT_FAIL_EXIT_CODE } from './index.js' + +function fakeReport(overrides: { + channel?: probe.VersionReport['client']['channel'] + reachable?: boolean + status?: probe.VersionReport['compat']['status'] +} = {}): probe.VersionReport { + return { + client: { + version: '0.1.0-rc.1', + commit: '2fd7b82970abcdef', + buildDate: '2026-05-18T00:00:00Z', + channel: overrides.channel ?? 'stable', + platform: 'darwin', + arch: 'arm64', + }, + server: overrides.reachable === false + ? { endpoint: '', reachable: false } + : { endpoint: 'https://cloud.dify.ai', reachable: true, version: '1.6.4', edition: 'CLOUD' }, + compat: { + minDify: '1.6.0', + maxDify: '1.7.0', + status: overrides.status ?? 'compatible', + detail: 'server 1.6.4 in [1.6.0, 1.7.0]', + }, + } +} describe('Version command', () => { - it('prints structured block on stable channel without warning', async () => { - const info = await import('../../version/info.js') - const orig = info.versionInfo.channel - Object.assign(info.versionInfo, { channel: 'stable' }) - try { - const output = await new Version().run([]) - expect(output?.kind).toBe('raw') - if (output?.kind !== 'raw') - throw new Error('expected raw output') - const text = output.data - expect(text).toMatch(/^difyctl /) - expect(text).toContain('channel: stable') - expect(text).toContain('compat:') - expect(text).not.toContain('WARNING:') - } - finally { - Object.assign(info.versionInfo, { channel: orig }) - } + beforeEach(() => { + vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport()) + }) + + afterEach(() => { + vi.restoreAllMocks() }) - it('prints warning on rc channel', async () => { - const info = await import('../../version/info.js') - const orig = info.versionInfo.channel - Object.assign(info.versionInfo, { channel: 'rc' }) + it('emits formatted text output by default with three blocks', async () => { + const output = await new Version().run([]) + expect(output?.kind).toBe('formatted') + if (output?.kind !== 'formatted') + throw new Error('expected formatted output') + + const text = output.data.text() + expect(text).toContain('Client:') + expect(text).toContain('Server:') + expect(text).toContain('Compatibility: ok') + }) + + it('emits the canonical envelope when -o json is passed', async () => { + const output = await new Version().run(['-o', 'json']) + expect(output?.kind).toBe('formatted') + if (output?.kind !== 'formatted') + throw new Error('expected formatted output') + + const payload = output.data.json() as probe.VersionReport + expect(payload).toHaveProperty('client') + expect(payload).toHaveProperty('server') + expect(payload).toHaveProperty('compat') + expect(payload.compat).toHaveProperty('minDify') + expect(payload.compat).toHaveProperty('maxDify') + expect(payload.compat).toHaveProperty('status') + expect(payload.server.reachable).toBe(true) + }) + + it('--short returns a raw single-line semver output', async () => { + const orig = info.versionInfo.version + Object.assign(info.versionInfo, { version: '0.2.0' }) try { - const output = await new Version().run([]) + const output = await new Version().run(['--short']) expect(output?.kind).toBe('raw') if (output?.kind !== 'raw') throw new Error('expected raw output') - const text = output.data - expect(text).toContain('channel: rc') - expect(text).toContain('WARNING: This build is a release candidate') - expect(text).toContain('install the stable channel') + + expect(output.data).toBe('0.2.0\n') } finally { - Object.assign(info.versionInfo, { channel: orig }) + Object.assign(info.versionInfo, { version: orig }) } }) - it('emits JSON when --json flag passed', async () => { - const output = await new Version().run(['--json']) - expect(output?.kind).toBe('raw') - if (output?.kind !== 'raw') - throw new Error('expected raw output') - const payload = JSON.parse(output.data) - expect(payload).toHaveProperty('version') - expect(payload).toHaveProperty('channel') - expect(payload).toHaveProperty('compat') - expect(payload.compat).toHaveProperty('minDify') - expect(payload.compat).toHaveProperty('maxDify') + it('passes skipServer=true to the probe when --client is set', async () => { + const spy = vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ reachable: false, status: 'unknown' })) + await new Version().run(['--client']) + expect(spy).toHaveBeenCalledWith({ skipServer: true }) + }) + + function stubProcessExit(): ReturnType> { + const impl = (() => { + throw new Error('__exit__') + }) as never + return vi.spyOn(process, 'exit').mockImplementation(impl) + } + + it('--check-compat exits with COMPAT_FAIL_EXIT_CODE when compat is unsupported', async () => { + vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ status: 'unsupported' })) + const exitSpy = stubProcessExit() + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + await expect(new Version().run(['--check-compat'])).rejects.toThrow('__exit__') + expect(exitSpy).toHaveBeenCalledWith(COMPAT_FAIL_EXIT_CODE) + expect(stderrSpy).toHaveBeenCalled() + }) + + it('--check-compat exits 64 when compat is unknown (no server)', async () => { + vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ reachable: false, status: 'unknown' })) + const exitSpy = stubProcessExit() + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + await expect(new Version().run(['--check-compat'])).rejects.toThrow('__exit__') + expect(exitSpy).toHaveBeenCalledWith(COMPAT_FAIL_EXIT_CODE) + }) + + it('--check-compat does not exit when compat is compatible', async () => { + const exitSpy = stubProcessExit() + const output = await new Version().run(['--check-compat']) + expect(exitSpy).not.toHaveBeenCalled() + expect(output?.kind).toBe('formatted') + }) + + it('renders RC warning in text output when channel is rc', async () => { + vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ channel: 'rc' })) + const output = await new Version().run([]) + if (output?.kind !== 'formatted') + throw new Error('expected formatted output') + + expect(output.data.text()).toContain('WARNING: This build is a release candidate') }) }) diff --git a/cli/src/types/data-contracts.ts b/cli/src/types/data-contracts.ts new file mode 100644 index 00000000000000..9229552c0f75f6 --- /dev/null +++ b/cli/src/types/data-contracts.ts @@ -0,0 +1,478 @@ +/* eslint-disable erasable-syntax-only/enums, ts/no-explicit-any */ +/* tslint:disable */ +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +/** AppMode */ +export enum AppMode { + AdvancedChat = 'advanced-chat', + AgentChat = 'agent-chat', + Channel = 'channel', + Chat = 'chat', + Completion = 'completion', + RagPipeline = 'rag-pipeline', + Workflow = 'workflow', +} + +/** AccountPayload */ +export type AccountPayload = { + /** Email */ + email: string + /** Id */ + id: string + /** Name */ + name: string +} + +/** AccountResponse */ +export type AccountResponse = { + account?: AccountPayload | null + /** Default Workspace Id */ + default_workspace_id?: string | null + /** Subject Email */ + subject_email?: string | null + /** Subject Issuer */ + subject_issuer?: string | null + /** Subject Type */ + subject_type: string + /** + * Workspaces + * @default [] + */ + workspaces?: WorkspacePayload[] +} + +/** AppDescribeInfo */ +export type AppDescribeInfo = { + /** Author */ + author?: string | null + /** Description */ + description?: string | null + /** Id */ + id: string + /** + * Is Agent + * @default false + */ + is_agent?: boolean + /** Mode */ + mode: string + /** Name */ + name: string + /** Service Api Enabled */ + service_api_enabled: boolean + /** + * Tags + * @default [] + */ + tags?: TagItem[] + /** Updated At */ + updated_at?: string | null +} + +/** + * AppDescribeQuery + * `?fields=` allow-list for GET /apps//describe. + * + * Empty / omitted → all blocks. Unknown member → ValidationError → 422. + */ +export type AppDescribeQuery = { + /** + * Fields + * @uniqueItems true + */ + fields?: string[] | null + /** Workspace Id */ + workspace_id?: string | null +} + +/** AppDescribeResponse */ +export type AppDescribeResponse = { + info?: AppDescribeInfo | null + /** Input Schema */ + input_schema?: Record | null + /** Parameters */ + parameters?: Record | null +} + +/** AppInfoResponse */ +export type AppInfoResponse = { + /** Author */ + author?: string | null + /** Description */ + description?: string | null + /** Id */ + id: string + /** Mode */ + mode: string + /** Name */ + name: string + /** + * Tags + * @default [] + */ + tags?: TagItem[] +} + +/** + * AppListQuery + * mode is a closed enum. + */ +export type AppListQuery = { + /** + * Limit + * @min 1 + * @max 200 + * @default 20 + */ + limit?: number + mode?: AppMode | null + /** + * Name + * @maxLength 200 + */ + name?: string | null + /** + * Page + * @min 1 + * @default 1 + */ + page?: number + /** + * Tag + * @maxLength 100 + */ + tag?: string | null + /** Workspace Id */ + workspace_id: string +} + +/** AppListResponse */ +export type AppListResponse = { + /** Data */ + data: AppListRow[] + /** Has More */ + has_more: boolean + /** Limit */ + limit: number + /** Page */ + page: number + /** Total */ + total: number +} + +/** AppListRow */ +export type AppListRow = { + /** Created By Name */ + created_by_name?: string | null + /** Description */ + description?: string | null + /** Id */ + id: string + mode: AppMode + /** Name */ + name: string + /** + * Tags + * @default [] + */ + tags?: TagItem[] + /** Updated At */ + updated_at?: string | null + /** Workspace Id */ + workspace_id?: string | null + /** Workspace Name */ + workspace_name?: string | null +} + +/** AppRunRequest */ +export type AppRunRequest = { + /** + * Auto Generate Name + * @default true + */ + auto_generate_name?: boolean + /** Conversation Id */ + conversation_id?: string | null + /** Files */ + files?: Record[] | null + /** Inputs */ + inputs: Record + /** Query */ + query?: string | null + /** Workflow Id */ + workflow_id?: string | null + /** Workspace Id */ + workspace_id?: string | null +} + +/** DeviceCodeRequest */ +export type DeviceCodeRequest = { + /** Client Id */ + client_id: string + /** Device Label */ + device_label: string +} + +/** DeviceCodeResponse */ +export type DeviceCodeResponse = { + /** Device Code */ + device_code: string + /** Expires In */ + expires_in: number + /** Interval */ + interval: number + /** User Code */ + user_code: string + /** Verification Uri */ + verification_uri: string +} + +/** DeviceLookupQuery */ +export type DeviceLookupQuery = { + /** User Code */ + user_code: string +} + +/** DeviceLookupResponse */ +export type DeviceLookupResponse = { + /** Client Id */ + client_id?: string | null + /** + * Expires In Remaining + * @default 0 + */ + expires_in_remaining?: number + /** Valid */ + valid: boolean +} + +/** DeviceMutateRequest */ +export type DeviceMutateRequest = { + /** User Code */ + user_code: string +} + +/** DeviceMutateResponse */ +export type DeviceMutateResponse = { + /** Status */ + status: string +} + +/** DevicePollRequest */ +export type DevicePollRequest = { + /** Client Id */ + client_id: string + /** Device Code */ + device_code: string +} + +/** HumanInputFormSubmitPayload */ +export type HumanInputFormSubmitPayload = { + /** Action */ + action: string + /** Inputs */ + inputs: Record +} + +export type JsonValue = any + +/** MessageMetadata */ +export type MessageMetadata = { + /** + * Retriever Resources + * @default [] + */ + retriever_resources?: Record[] + usage?: UsageInfo | null +} + +/** + * PermittedExternalAppsListQuery + * Strict (extra='forbid'). + */ +export type PermittedExternalAppsListQuery = { + /** + * Limit + * @min 1 + * @max 200 + * @default 20 + */ + limit?: number + mode?: AppMode | null + /** + * Name + * @maxLength 200 + */ + name?: string | null + /** + * Page + * @min 1 + * @default 1 + */ + page?: number +} + +/** PermittedExternalAppsListResponse */ +export type PermittedExternalAppsListResponse = { + /** Data */ + data: AppListRow[] + /** Has More */ + has_more: boolean + /** Limit */ + limit: number + /** Page */ + page: number + /** Total */ + total: number +} + +/** RevokeResponse */ +export type RevokeResponse = { + /** Status */ + status: string +} + +/** + * ServerVersionResponse + * Meta endpoint payload for `GET /openapi/v1/_version` — no auth required. + */ +export type ServerVersionResponse = { + /** Edition */ + edition: 'CLOUD' | 'SELF_HOSTED' + /** Version */ + version: string +} + +/** SessionListResponse */ +export type SessionListResponse = { + /** Data */ + data: SessionRow[] + /** Has More */ + has_more: boolean + /** Limit */ + limit: number + /** Page */ + page: number + /** Total */ + total: number +} + +/** SessionRow */ +export type SessionRow = { + /** Client Id */ + client_id: string + /** Created At */ + created_at?: string | null + /** Device Label */ + device_label: string + /** Expires At */ + expires_at?: string | null + /** Id */ + id: string + /** Last Used At */ + last_used_at?: string | null + /** Prefix */ + prefix: string +} + +/** TagItem */ +export type TagItem = { + /** Name */ + name: string +} + +/** UsageInfo */ +export type UsageInfo = { + /** + * Completion Tokens + * @default 0 + */ + completion_tokens?: number + /** + * Prompt Tokens + * @default 0 + */ + prompt_tokens?: number + /** + * Total Tokens + * @default 0 + */ + total_tokens?: number +} + +/** WorkflowRunData */ +export type WorkflowRunData = { + /** Created At */ + created_at?: number | null + /** Elapsed Time */ + elapsed_time?: number | null + /** Error */ + error?: string | null + /** Finished At */ + finished_at?: number | null + /** Id */ + id: string + /** Outputs */ + outputs?: Record + /** Status */ + status: string + /** Total Steps */ + total_steps?: number | null + /** Total Tokens */ + total_tokens?: number | null + /** Workflow Id */ + workflow_id: string +} + +/** WorkspaceDetailResponse */ +export type WorkspaceDetailResponse = { + /** Created At */ + created_at?: string | null + /** Current */ + current: boolean + /** Id */ + id: string + /** Name */ + name: string + /** Role */ + role: string + /** Status */ + status: string +} + +/** WorkspaceListResponse */ +export type WorkspaceListResponse = { + /** Workspaces */ + workspaces: WorkspaceSummaryResponse[] +} + +/** WorkspacePayload */ +export type WorkspacePayload = { + /** Id */ + id: string + /** Name */ + name: string + /** Role */ + role: string +} + +/** WorkspaceSummaryResponse */ +export type WorkspaceSummaryResponse = { + /** Current */ + current: boolean + /** Id */ + id: string + /** Name */ + name: string + /** Role */ + role: string + /** Status */ + status: string +} diff --git a/cli/src/version/compat.test.ts b/cli/src/version/compat.test.ts index ffe89029bb7fde..2ab2d324ecebac 100644 --- a/cli/src/version/compat.test.ts +++ b/cli/src/version/compat.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { compatString, difyCompat } from './compat.js' +import { compatString, difyCompat, evaluateCompat } from './compat.js' describe('difyCompat', () => { it('exposes minDify and maxDify as readonly strings', () => { @@ -13,3 +13,54 @@ describe('compatString', () => { expect(compatString()).toMatch(/^dify >=\d+\.\d+\.\d+(-[\w.]+)?, <=\d+\.\d+\.\d+(-[\w.]+)?$/) }) }) + +describe('evaluateCompat', () => { + const range = { minDify: '1.6.0', maxDify: '1.7.0' } + + it('returns compatible when server version is in range', () => { + expect(evaluateCompat('1.6.4', range)).toEqual({ + status: 'compatible', + detail: 'server 1.6.4 in [1.6.0, 1.7.0]', + }) + }) + + it('returns compatible at the lower bound', () => { + expect(evaluateCompat('1.6.0', range).status).toBe('compatible') + }) + + it('returns compatible at the upper bound (inclusive)', () => { + expect(evaluateCompat('1.7.0', range).status).toBe('compatible') + }) + + it('returns unsupported when server is below minimum', () => { + const v = evaluateCompat('1.5.9', range) + expect(v.status).toBe('unsupported') + expect(v.detail).toContain('1.5.9') + }) + + it('returns unsupported when server is above maximum', () => { + expect(evaluateCompat('2.0.0', range).status).toBe('unsupported') + }) + + it('returns unknown when server version is empty', () => { + expect(evaluateCompat('', range).status).toBe('unknown') + expect(evaluateCompat(undefined, range).status).toBe('unknown') + }) + + it('returns unknown when server version is not valid semver', () => { + const v = evaluateCompat('totally-not-semver', range) + expect(v.status).toBe('unknown') + expect(v.detail).toContain('not valid semver') + }) + + it('returns unknown when compat range itself is not valid semver', () => { + const v = evaluateCompat('1.6.4', { minDify: 'foo', maxDify: 'bar' }) + expect(v.status).toBe('unknown') + }) + + it('uses the bundled difyCompat range by default', () => { + // Build-info range comes from package.json#difyctl.compat (or env at build + // time); a server version equal to the lower bound must be compatible. + expect(evaluateCompat(difyCompat.minDify).status).toBe('compatible') + }) +}) diff --git a/cli/src/version/compat.ts b/cli/src/version/compat.ts index 0c482970d92b6f..b7309f73246f86 100644 --- a/cli/src/version/compat.ts +++ b/cli/src/version/compat.ts @@ -1,3 +1,5 @@ +import { parseRange, satisfies, tryParse } from 'std-semver' + declare const __DIFYCTL_MIN_DIFY__: string declare const __DIFYCTL_MAX_DIFY__: string @@ -14,3 +16,40 @@ export const difyCompat: DifyCompat = { export function compatString(): string { return `dify >=${difyCompat.minDify}, <=${difyCompat.maxDify}` } + +export type CompatStatus = 'compatible' | 'unsupported' | 'unknown' + +export type CompatVerdict = { + readonly status: CompatStatus + readonly detail: string +} + +export function evaluateCompat( + serverVersion: string | undefined, + range: DifyCompat = difyCompat, +): CompatVerdict { + if (serverVersion === undefined || serverVersion === '') + return { status: 'unknown', detail: 'server version unknown' } + + const parsedServer = tryParse(serverVersion) + if (parsedServer === undefined) + return { status: 'unknown', detail: `server version ${JSON.stringify(serverVersion)} is not valid semver` } + + // The compat range is inclusive at both ends, exactly the format compatString prints. + const expr = `>=${range.minDify} <=${range.maxDify}` + const parsedRange = (() => { + try { + return parseRange(expr) + } + catch { + return undefined + } + })() + if (parsedRange === undefined) + return { status: 'unknown', detail: `compat range ${JSON.stringify(expr)} is not valid semver` } + + if (satisfies(parsedServer, parsedRange)) + return { status: 'compatible', detail: `server ${serverVersion} in [${range.minDify}, ${range.maxDify}]` } + + return { status: 'unsupported', detail: `server ${serverVersion} outside [${range.minDify}, ${range.maxDify}]` } +} diff --git a/cli/src/version/probe.test.ts b/cli/src/version/probe.test.ts new file mode 100644 index 00000000000000..221548bff5b38b --- /dev/null +++ b/cli/src/version/probe.test.ts @@ -0,0 +1,137 @@ +import type { HostsBundle } from '../auth/hosts.js' +import type { ServerVersionResponse } from '../types/data-contracts.js' +import { describe, expect, it } from 'vitest' +import { runVersionProbe } from './probe.js' + +function bundle(overrides: Partial = {}): HostsBundle { + return { + current_host: 'cloud.dify.ai', + scheme: 'https', + token_storage: 'file', + tokens: { bearer: 'dfoa_test' }, + ...overrides, + } as HostsBundle +} + +describe('runVersionProbe', () => { + it('returns skipped server + unknown compat when skipServer=true', async () => { + const report = await runVersionProbe({ + skipServer: true, + loadBundle: async () => bundle(), + probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), + }) + + expect(report.server.reachable).toBe(false) + expect(report.server.endpoint).toBe('') + expect(report.compat.status).toBe('unknown') + expect(report.compat.detail).toContain('skipped') + }) + + it('returns no-host + unknown compat when bundle is missing', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => undefined, + probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), + }) + + expect(report.server.reachable).toBe(false) + expect(report.server.endpoint).toBe('') + expect(report.compat.detail).toContain('no host') + }) + + it('returns no-host when bundle has empty current_host', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => bundle({ current_host: '' }), + probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), + }) + + expect(report.server.reachable).toBe(false) + expect(report.compat.status).toBe('unknown') + }) + + it('treats loadBundle throwing as no-host', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => { throw new Error('disk-explode') }, + probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), + }) + + expect(report.server.reachable).toBe(false) + expect(report.server.endpoint).toBe('') + }) + + it('returns compatible report when server is reachable and in range', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => bundle(), + probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), + }) + + expect(report.server.reachable).toBe(true) + expect(report.server.endpoint).toBe('https://cloud.dify.ai') + expect(report.server.version).toBe('1.6.4') + expect(report.server.edition).toBe('CLOUD') + expect(report.compat.status).toBe('compatible') + }) + + it('returns unsupported when server version is out of range', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => bundle(), + probe: async () => ({ version: '99.0.0', edition: 'SELF_HOSTED' }), + }) + + expect(report.server.reachable).toBe(true) + expect(report.compat.status).toBe('unsupported') + }) + + it('returns unknown when server returns an empty version string', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => bundle(), + probe: async (): Promise => ({ version: '', edition: 'SELF_HOSTED' }), + }) + + expect(report.server.reachable).toBe(true) + expect(report.compat.status).toBe('unknown') + }) + + it('treats probe rejection as unreachable + unknown compat', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => bundle(), + probe: async () => { throw new Error('timeout') }, + }) + + expect(report.server.reachable).toBe(false) + expect(report.server.endpoint).toBe('https://cloud.dify.ai') + expect(report.server.version).toBeUndefined() + expect(report.compat.status).toBe('unknown') + expect(report.compat.detail).toContain('unreachable') + }) + + it('builds endpoint using bundle scheme when host has no scheme', async () => { + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => bundle({ current_host: 'localhost:5001', scheme: 'http' }), + probe: async () => ({ version: '1.6.4', edition: 'SELF_HOSTED' }), + }) + + expect(report.server.endpoint).toBe('http://localhost:5001') + }) + + it('always includes client metadata in the report', async () => { + const report = await runVersionProbe({ + skipServer: true, + loadBundle: async () => undefined, + probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), + }) + + expect(report.client.version).toBeTypeOf('string') + expect(report.client.commit).toBeTypeOf('string') + expect(report.client.channel).toMatch(/^(dev|rc|stable)$/) + expect(report.client.platform).toBe(process.platform) + expect(report.client.arch).toBe(process.arch) + }) +}) diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts new file mode 100644 index 00000000000000..ed85a89a4dc00d --- /dev/null +++ b/cli/src/version/probe.ts @@ -0,0 +1,133 @@ +import type { HostsBundle } from '../auth/hosts.js' +import type { ServerVersionResponse } from '../types/data-contracts.js' +import type { CompatVerdict } from './compat.js' +import type { Channel } from './info.js' +import { MetaClient } from '../api/meta.js' +import { loadHosts } from '../auth/hosts.js' +import { resolveConfigDir } from '../config/dir.js' +import { createClient } from '../http/client.js' +import { hostWithScheme } from '../util/host.js' +import { difyCompat, evaluateCompat } from './compat.js' +import { versionInfo } from './info.js' + +export type ClientBlock = { + readonly version: string + readonly commit: string + readonly buildDate: string + readonly channel: Channel + readonly platform: string + readonly arch: string +} + +export type ServerBlock = { + readonly endpoint: string + readonly reachable: boolean + readonly version?: string + readonly edition?: ServerVersionResponse['edition'] +} + +export type CompatBlock = CompatVerdict & { + readonly minDify: string + readonly maxDify: string +} + +export type VersionReport = { + readonly client: ClientBlock + readonly server: ServerBlock + readonly compat: CompatBlock +} + +export type MetaProbe = (endpoint: string, bearer: string | undefined) => Promise + +export type RunVersionProbeOptions = { + readonly skipServer: boolean + readonly loadBundle?: () => Promise + readonly probe?: MetaProbe +} + +const defaultLoadBundle = async (): Promise => loadHosts(resolveConfigDir()) + +const defaultProbe: MetaProbe = async (endpoint, bearer) => { + const http = createClient({ host: endpoint, bearer }) + return new MetaClient(http).serverVersion() +} + +function buildClientBlock(): ClientBlock { + return { + version: versionInfo.version, + commit: versionInfo.commit, + buildDate: versionInfo.buildDate, + channel: versionInfo.channel, + platform: process.platform, + arch: process.arch, + } +} + +function unreachableServer(endpoint: string): ServerBlock { + return { endpoint, reachable: false } +} + +function compatBlock(verdict: CompatVerdict): CompatBlock { + return { + minDify: difyCompat.minDify, + maxDify: difyCompat.maxDify, + status: verdict.status, + detail: verdict.detail, + } +} + +export async function runVersionProbe(opts: RunVersionProbeOptions): Promise { + const client = buildClientBlock() + + if (opts.skipServer) { + return { + client, + server: { endpoint: '', reachable: false }, + compat: compatBlock({ status: 'unknown', detail: 'server probe skipped' }), + } + } + + const loadBundle = opts.loadBundle ?? defaultLoadBundle + const probe = opts.probe ?? defaultProbe + + let bundle: HostsBundle | undefined + try { + bundle = await loadBundle() + } + catch { + bundle = undefined + } + + if (bundle === undefined || bundle.current_host === '') { + return { + client, + server: { endpoint: '', reachable: false }, + compat: compatBlock({ status: 'unknown', detail: 'no host configured' }), + } + } + + const endpoint = hostWithScheme(bundle.current_host, bundle.scheme) + const bearer = bundle.tokens?.bearer + + let serverInfo: ServerVersionResponse | undefined + try { + serverInfo = await probe(endpoint, bearer) + } + catch { + serverInfo = undefined + } + + if (serverInfo === undefined) + return { client, server: unreachableServer(endpoint), compat: compatBlock({ status: 'unknown', detail: 'server unreachable' }) } + + return { + client, + server: { + endpoint, + reachable: true, + version: serverInfo.version, + edition: serverInfo.edition, + }, + compat: compatBlock(evaluateCompat(serverInfo.version)), + } +} diff --git a/cli/src/version/render.test.ts b/cli/src/version/render.test.ts new file mode 100644 index 00000000000000..406b4b90c3a83f --- /dev/null +++ b/cli/src/version/render.test.ts @@ -0,0 +1,120 @@ +import type { VersionReport } from './probe.js' +import { describe, expect, it } from 'vitest' +import { renderVersionText } from './render.js' + +function baseClient(overrides: Partial = {}): VersionReport['client'] { + return { + version: '0.1.0-rc.1', + commit: '2fd7b82970abcdef', + buildDate: '2026-05-18T00:00:00Z', + channel: 'stable', + platform: 'darwin', + arch: 'arm64', + ...overrides, + } +} + +function compatible(): VersionReport['compat'] { + return { + minDify: '1.6.0', + maxDify: '1.7.0', + status: 'compatible', + detail: 'server 1.6.4 in [1.6.0, 1.7.0]', + } +} + +describe('renderVersionText', () => { + it('renders all three blocks for a reachable, compatible server', () => { + const report: VersionReport = { + client: baseClient(), + server: { endpoint: 'https://cloud.dify.ai', reachable: true, version: '1.6.4', edition: 'CLOUD' }, + compat: compatible(), + } + const text = renderVersionText(report) + + expect(text).toContain('Client:') + expect(text).toContain('Version: 0.1.0-rc.1 (channel: stable)') + expect(text).toContain('Commit: 2fd7b82') + expect(text).toContain('Platform: darwin/arm64') + expect(text).toContain('Compat: dify >=1.6.0, <=1.7.0') + + expect(text).toContain('Server:') + expect(text).toContain('Endpoint: https://cloud.dify.ai') + expect(text).toContain('Version: 1.6.4 (cloud)') + + expect(text).toContain('Compatibility: ok') + expect(text).toContain('server 1.6.4 in [1.6.0, 1.7.0]') + + expect(text).not.toContain('WARNING:') + }) + + it('appends RC warning when channel is rc', () => { + const report: VersionReport = { + client: baseClient({ channel: 'rc' }), + server: { endpoint: '', reachable: false }, + compat: { ...compatible(), status: 'unknown', detail: 'server probe skipped' }, + } + const text = renderVersionText(report) + + expect(text).toContain('WARNING: This build is a release candidate') + expect(text).toContain('install the stable channel') + }) + + it('shows "(skipped …)" when server.endpoint is empty', () => { + const report: VersionReport = { + client: baseClient(), + server: { endpoint: '', reachable: false }, + compat: { ...compatible(), status: 'unknown', detail: 'server probe skipped' }, + } + const text = renderVersionText(report) + + expect(text).toContain('(skipped — no host configured or --client passed)') + expect(text).not.toContain('Endpoint: ') + expect(text).toContain('Compatibility: unknown') + }) + + it('shows "(unreachable)" when endpoint is set but reachable=false', () => { + const report: VersionReport = { + client: baseClient(), + server: { endpoint: 'https://cloud.dify.ai', reachable: false }, + compat: { ...compatible(), status: 'unknown', detail: 'server unreachable' }, + } + const text = renderVersionText(report) + + expect(text).toContain('Endpoint: https://cloud.dify.ai') + expect(text).toContain('Version: (unreachable)') + }) + + it('always contains the verdict line on unsupported, regardless of color toggle', () => { + // picocolors no-ops escape sequences when stdout is not a TTY, which is + // the case under vitest, so the colored output may not actually include + // ANSI codes. We only assert that the rendered text is well-formed in + // both modes — the color path running without error is the real test. + const report: VersionReport = { + client: baseClient(), + server: { endpoint: 'https://cloud.dify.ai', reachable: true, version: '99.0.0', edition: 'SELF_HOSTED' }, + compat: { + minDify: '1.6.0', + maxDify: '1.7.0', + status: 'unsupported', + detail: 'server 99.0.0 outside [1.6.0, 1.7.0]', + }, + } + const colored = renderVersionText(report, { color: true }) + const plain = renderVersionText(report, { color: false }) + + for (const out of [colored, plain]) { + expect(out).toContain('Compatibility: incompatible') + expect(out).toContain('outside [1.6.0, 1.7.0]') + } + }) + + it('terminates output with a trailing newline', () => { + const report: VersionReport = { + client: baseClient(), + server: { endpoint: '', reachable: false }, + compat: { ...compatible(), status: 'unknown', detail: 'x' }, + } + expect(renderVersionText(report).endsWith('\n')).toBe(true) + }) +}) diff --git a/cli/src/version/render.ts b/cli/src/version/render.ts new file mode 100644 index 00000000000000..a5f08014940695 --- /dev/null +++ b/cli/src/version/render.ts @@ -0,0 +1,69 @@ +import type { VersionReport } from './probe.js' +import pc from 'picocolors' + +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 type RenderOptions = { + readonly color?: boolean +} + +const COMPAT_GLYPH: Record = { + compatible: 'ok', + unsupported: 'incompatible', + unknown: 'unknown', +} + +function shortCommit(commit: string): string { + return commit.length > 7 ? commit.slice(0, 7) : commit +} + +function colorize(useColor: boolean, fn: (s: string) => string): (s: string) => string { + return useColor ? fn : (s: string) => s +} + +export function renderVersionText(report: VersionReport, opts: RenderOptions = {}): string { + const useColor = opts.color === true + const yellow = colorize(useColor, pc.yellow) + const dim = colorize(useColor, pc.dim) + + const lines: string[] = [] + + const { client, server, compat } = report + lines.push('Client:') + lines.push(` Version: ${client.version} (channel: ${client.channel})`) + lines.push(` Commit: ${shortCommit(client.commit)} (built ${client.buildDate})`) + lines.push(` Platform: ${client.platform}/${client.arch}`) + lines.push(` Compat: dify >=${compat.minDify}, <=${compat.maxDify}`) + lines.push('') + + lines.push('Server:') + if (server.endpoint === '') { + lines.push(` ${dim('(skipped — no host configured or --client passed)')}`) + } + else if (!server.reachable) { + lines.push(` Endpoint: ${server.endpoint}`) + lines.push(` Version: ${dim('(unreachable)')}`) + } + else { + lines.push(` Endpoint: ${server.endpoint}`) + lines.push(` Version: ${server.version ?? ''}${server.edition !== undefined ? ` (${server.edition.toLowerCase()})` : ''}`) + } + lines.push('') + + const verdictText = `Compatibility: ${COMPAT_GLYPH[compat.status]} — ${compat.detail}` + if (compat.status === 'unsupported') + lines.push(yellow(verdictText)) + else + lines.push(verdictText) + + if (client.channel === 'rc') { + lines.push('') + for (const line of RC_WARNING_LINES) + lines.push(yellow(line)) + } + + return `${lines.join('\n')}\n` +} diff --git a/cli/test/fixtures/dify-mock/scenarios.ts b/cli/test/fixtures/dify-mock/scenarios.ts index 09dcc59204904e..39a0564db21440 100644 --- a/cli/test/fixtures/dify-mock/scenarios.ts +++ b/cli/test/fixtures/dify-mock/scenarios.ts @@ -10,6 +10,8 @@ export type Scenario | 'stream-error' | 'hitl-pause' | 'hitl-resume' + | 'server-version-empty' + | 'server-version-unsupported' export type AccountFixture = { id: string diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts index b23aee25ba61d9..c56d792ea0fd25 100644 --- a/cli/test/fixtures/dify-mock/server.ts +++ b/cli/test/fixtures/dify-mock/server.ts @@ -113,6 +113,10 @@ export function buildApp(getScenario: () => Scenario): Hono { await next() return } + if (c.req.path === '/openapi/v1/_version') { + await next() + return + } const auth = c.req.header('Authorization') ?? '' if (!TOKEN_RE.test(auth)) return unauthorized() @@ -122,6 +126,15 @@ export function buildApp(getScenario: () => Scenario): Hono { await next() }) + app.get('/openapi/v1/_version', (c) => { + const scenario = getScenario() + if (scenario === 'server-version-empty') + return c.json({ version: '', edition: 'SELF_HOSTED' }) + if (scenario === 'server-version-unsupported') + return c.json({ version: '99.0.0', edition: 'SELF_HOSTED' }) + return c.json({ version: '1.6.4', edition: 'CLOUD' }) + }) + app.use('*', async (c, next) => { const scenario = getScenario() if (scenario === 'rate-limited') { diff --git a/cli/test/scripts/resolve-buildinfo.test.ts b/cli/test/scripts/resolve-buildinfo.test.ts index 23c3c872b27e35..45649f73cd0a20 100644 --- a/cli/test/scripts/resolve-buildinfo.test.ts +++ b/cli/test/scripts/resolve-buildinfo.test.ts @@ -4,6 +4,9 @@ import { resolveBuildInfo } from '../../scripts/lib/resolve-buildinfo.js' const FIXED_DATE = new Date('2026-05-09T12:00:00.000Z') const fixedNow = () => FIXED_DATE const noGit = () => null +// Stub the package.json reader so tests exercise the "no sources" path +// without coupling to the live cli/package.json#difyctl.compat values. +const noPkg = () => ({}) describe('resolveBuildInfo', () => { it('uses env values when fully populated', () => { @@ -16,6 +19,7 @@ describe('resolveBuildInfo', () => { }, git: () => 'should-not-be-called', now: fixedNow, + pkg: noPkg, }) expect(info).toStrictEqual({ version: '1.2.3', @@ -37,7 +41,7 @@ describe('resolveBuildInfo', () => { return '1234567890abcdef' return null } - const info = resolveBuildInfo({ env: {}, git, now: fixedNow }) + const info = resolveBuildInfo({ env: {}, git, now: fixedNow, pkg: noPkg }) expect(info).toStrictEqual({ version: 'v1.0.0-5-gabc1234-dirty', commit: '1234567890abcdef', @@ -52,8 +56,8 @@ describe('resolveBuildInfo', () => { ]) }) - it('uses string defaults when env unset and git unavailable', () => { - const info = resolveBuildInfo({ env: {}, git: noGit, now: fixedNow }) + it('uses string defaults when env unset, git unavailable, and package.json empty', () => { + const info = resolveBuildInfo({ env: {}, git: noGit, now: fixedNow, pkg: noPkg }) expect(info).toStrictEqual({ version: '0.0.0-dev', commit: 'none', @@ -66,13 +70,13 @@ describe('resolveBuildInfo', () => { it('throws on invalid channel', () => { expect(() => - resolveBuildInfo({ env: { DIFYCTL_CHANNEL: 'beta' }, git: noGit, now: fixedNow }), + resolveBuildInfo({ env: { DIFYCTL_CHANNEL: 'beta' }, git: noGit, now: fixedNow, pkg: noPkg }), ).toThrow(/invalid DIFYCTL_CHANNEL: beta/) }) it('throws on removed nightly channel', () => { expect(() => - resolveBuildInfo({ env: { DIFYCTL_CHANNEL: 'nightly' }, git: noGit, now: fixedNow }), + resolveBuildInfo({ env: { DIFYCTL_CHANNEL: 'nightly' }, git: noGit, now: fixedNow, pkg: noPkg }), ).toThrow(/invalid DIFYCTL_CHANNEL: nightly/) }) @@ -86,6 +90,7 @@ describe('resolveBuildInfo', () => { }, git: noGit, now: fixedNow, + pkg: noPkg, }) expect(info.channel).toBe('rc') }) @@ -96,6 +101,7 @@ describe('resolveBuildInfo', () => { env: { DIFYCTL_COMMIT: 'pinned-sha' }, git, now: fixedNow, + pkg: noPkg, }) expect(info.version).toBe('v9.9.9') expect(info.commit).toBe('pinned-sha') @@ -114,14 +120,40 @@ describe('resolveBuildInfo', () => { }, git: noGit, now: fixedNow, + pkg: noPkg, }) expect(info.minDify).toBe('1.6.0') expect(info.maxDify).toBe('1.7.0') }) - it('defaults minDify and maxDify to 0.0.0 when env unset', () => { - const info = resolveBuildInfo({ env: {}, git: noGit, now: fixedNow }) + it('defaults minDify and maxDify to 0.0.0 when env and package.json are unset', () => { + const info = resolveBuildInfo({ env: {}, git: noGit, now: fixedNow, pkg: noPkg }) expect(info.minDify).toBe('0.0.0') expect(info.maxDify).toBe('0.0.0') }) + + it('falls back to package.json#difyctl.compat when env unset', () => { + const pkg = () => ({ difyctl: { compat: { minDify: '1.6.0', maxDify: '1.7.0' }, channel: 'rc' } }) + const info = resolveBuildInfo({ env: {}, git: noGit, now: fixedNow, pkg }) + expect(info.minDify).toBe('1.6.0') + expect(info.maxDify).toBe('1.7.0') + expect(info.channel).toBe('rc') + }) + + it('env wins over package.json for compat range and channel', () => { + const pkg = () => ({ difyctl: { compat: { minDify: '1.6.0', maxDify: '1.7.0' }, channel: 'rc' } }) + const info = resolveBuildInfo({ + env: { + DIFYCTL_MIN_DIFY: '2.0.0', + DIFYCTL_MAX_DIFY: '2.1.0', + DIFYCTL_CHANNEL: 'stable', + }, + git: noGit, + now: fixedNow, + pkg, + }) + expect(info.minDify).toBe('2.0.0') + expect(info.maxDify).toBe('2.1.0') + expect(info.channel).toBe('stable') + }) }) diff --git a/cli/test/setup.ts b/cli/test/setup.ts index 0c3b72035846e2..292dff0d82f1f0 100644 --- a/cli/test/setup.ts +++ b/cli/test/setup.ts @@ -2,5 +2,5 @@ (globalThis as unknown as Record).__DIFYCTL_COMMIT__ = '0000000'; (globalThis as unknown as Record).__DIFYCTL_BUILD_DATE__ = '1970-01-01T00:00:00.000Z'; (globalThis as unknown as Record).__DIFYCTL_CHANNEL__ = 'dev'; -(globalThis as unknown as Record).__DIFYCTL_MIN_DIFY__ = '0.0.0'; -(globalThis as unknown as Record).__DIFYCTL_MAX_DIFY__ = '0.0.0' +(globalThis as unknown as Record).__DIFYCTL_MIN_DIFY__ = '1.6.0'; +(globalThis as unknown as Record).__DIFYCTL_MAX_DIFY__ = '1.7.0' From 8876356d9e17c7526f06763f330bd5e8936bafd6 Mon Sep 17 00:00:00 2001 From: L1nSn0w Date: Tue, 19 May 2026 15:55:14 +0800 Subject: [PATCH 2/4] feat(cli): auto-nudge compat warning on authed commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every command that goes through buildAuthedContext (get / describe / run / auth-devices / ...) now silently refreshes a per-host compat snapshot at most every 24 h and shows one stderr line if the cached verdict is 'unsupported'. Banner is suppressed in pipeline mode (-o json|yaml|name), when stdout is not a TTY, on first-ever invocation against a host, and for 24 h after the last warning. No new env vars, no new flags, no new config keys — purely behavior driven by what the user is already doing. The existing 'difyctl version' command (explicit probe) is unaffected; it does not go through authedCtx and keeps its own contract intact. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/src/cache/compat-snapshot.test.ts | 122 ++++++++ cli/src/cache/compat-snapshot.ts | 168 +++++++++++ cli/src/commands/_shared/authed-command.ts | 28 ++ cli/src/version/nudge.test.ts | 307 +++++++++++++++++++++ cli/src/version/nudge.ts | 107 +++++++ 5 files changed, 732 insertions(+) create mode 100644 cli/src/cache/compat-snapshot.test.ts create mode 100644 cli/src/cache/compat-snapshot.ts create mode 100644 cli/src/version/nudge.test.ts create mode 100644 cli/src/version/nudge.ts diff --git a/cli/src/cache/compat-snapshot.test.ts b/cli/src/cache/compat-snapshot.test.ts new file mode 100644 index 00000000000000..8c840291f11dd8 --- /dev/null +++ b/cli/src/cache/compat-snapshot.test.ts @@ -0,0 +1,122 @@ +import type { CompatSnapshot } from './compat-snapshot.js' +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 { compatSnapshotPath, loadCompatSnapshotStore } from './compat-snapshot.js' + +const HOST = 'https://cloud.dify.ai' + +function fakeSnapshot(overrides: Partial = {}): CompatSnapshot { + return { + host: HOST, + fetchedAt: '2026-05-19T12:00:00.000Z', + server: { version: '1.6.4', edition: 'CLOUD' }, + compat: { + status: 'compatible', + detail: 'server 1.6.4 in [1.6.0, 1.7.0]', + minDify: '1.6.0', + maxDify: '1.7.0', + }, + ...overrides, + } +} + +describe('CompatSnapshotStore', () => { + let dir: string + beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'difyctl-compat-')) + }) + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('returns undefined when no cache file exists yet', async () => { + const store = await loadCompatSnapshotStore({ configDir: dir }) + expect(store.get(HOST)).toBeUndefined() + }) + + it('treats a corrupt cache file as empty (no throw)', async () => { + const path = compatSnapshotPath(dir) + await writeCacheFile(path, '{ not valid json') + const store = await loadCompatSnapshotStore({ configDir: dir }) + expect(store.get(HOST)).toBeUndefined() + }) + + it('ignores cache file with mismatched schema', async () => { + const path = compatSnapshotPath(dir) + await writeCacheFile(path, JSON.stringify({ schema: 99, by_host: {} })) + const store = await loadCompatSnapshotStore({ configDir: dir }) + expect(store.get(HOST)).toBeUndefined() + }) + + it('persists and reads back a snapshot through a fresh store instance', async () => { + const snap = fakeSnapshot() + const s1 = await loadCompatSnapshotStore({ configDir: dir }) + await s1.set(snap) + const s2 = await loadCompatSnapshotStore({ configDir: dir }) + expect(s2.get(HOST)).toEqual(snap) + }) + + it('writes file with snake_case keys on disk', async () => { + const store = await loadCompatSnapshotStore({ configDir: dir }) + await store.set(fakeSnapshot()) + const raw = await readFile(compatSnapshotPath(dir), 'utf8') + const parsed = JSON.parse(raw) as Record + expect(parsed.schema).toBe(1) + expect(parsed.by_host).toBeDefined() + const entry = (parsed.by_host as Record>)[HOST] + expect(entry).toHaveProperty('fetched_at') + expect(entry).toHaveProperty('compat') + expect((entry.compat as Record).min_dify).toBe('1.6.0') + expect((entry.compat as Record).max_dify).toBe('1.7.0') + }) + + it('isFresh: true when within TTL, false when past TTL', async () => { + const store = await loadCompatSnapshotStore({ configDir: dir }) + const snap = fakeSnapshot({ fetchedAt: '2026-05-19T12:00:00.000Z' }) + expect(store.isFresh(snap, new Date('2026-05-19T13:00:00.000Z'))).toBe(true) + expect(store.isFresh(snap, new Date('2026-05-20T13:00:00.000Z'))).toBe(false) + // boundary: exactly 24h is not fresh + expect(store.isFresh(snap, new Date('2026-05-20T12:00:00.000Z'))).toBe(false) + }) + + it('canWarn: true when no prior warn, false within silence window, true after', async () => { + const store = await loadCompatSnapshotStore({ configDir: dir }) + const cold = fakeSnapshot({ lastWarnedAt: undefined }) + expect(store.canWarn(cold)).toBe(true) + const warmed = fakeSnapshot({ lastWarnedAt: '2026-05-19T12:00:00.000Z' }) + expect(store.canWarn(warmed, new Date('2026-05-19T18:00:00.000Z'))).toBe(false) + expect(store.canWarn(warmed, new Date('2026-05-20T12:00:00.000Z'))).toBe(true) + }) + + it('markWarned only updates lastWarnedAt, leaves other fields intact', async () => { + const store = await loadCompatSnapshotStore({ configDir: dir }) + await store.set(fakeSnapshot({ lastWarnedAt: undefined })) + await store.markWarned(HOST, new Date('2026-05-19T15:30:00.000Z')) + const after = store.get(HOST)! + expect(after.lastWarnedAt).toBe('2026-05-19T15:30:00.000Z') + expect(after.fetchedAt).toBe('2026-05-19T12:00:00.000Z') + expect(after.compat.status).toBe('compatible') + }) + + it('markWarned on absent host is a no-op (no throw)', async () => { + const store = await loadCompatSnapshotStore({ configDir: dir }) + await expect(store.markWarned('https://nowhere')).resolves.toBeUndefined() + }) + + it('multiple hosts cohabit the cache file independently', async () => { + const store = await loadCompatSnapshotStore({ configDir: dir }) + await store.set(fakeSnapshot({ host: 'https://a' })) + await store.set(fakeSnapshot({ host: 'https://b', server: { version: '1.7.0', edition: 'SELF_HOSTED' } })) + const reread = await loadCompatSnapshotStore({ configDir: dir }) + expect(reread.get('https://a')?.server.version).toBe('1.6.4') + expect(reread.get('https://b')?.server.version).toBe('1.7.0') + }) +}) + +async function writeCacheFile(path: string, body: string): Promise { + const { mkdir } = await import('node:fs/promises') + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, body) +} diff --git a/cli/src/cache/compat-snapshot.ts b/cli/src/cache/compat-snapshot.ts new file mode 100644 index 00000000000000..be931b0acec15c --- /dev/null +++ b/cli/src/cache/compat-snapshot.ts @@ -0,0 +1,168 @@ +import type { ServerVersionResponse } from '../types/data-contracts.js' +import type { CompatStatus } from '../version/compat.js' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { DIR_PERM, FILE_PERM } from '../config/dir.js' + +const CACHE_FILE = 'compat-snapshot.json' +const DISK_SCHEMA = 1 +export const COMPAT_TTL_MS = 24 * 60 * 60 * 1000 +export const WARN_SILENCE_MS = 24 * 60 * 60 * 1000 + +export type CompatSnapshot = { + readonly host: string + readonly fetchedAt: string + readonly lastWarnedAt?: string + readonly server: ServerVersionResponse + readonly compat: { + readonly status: CompatStatus + readonly detail: string + readonly minDify: string + readonly maxDify: string + } +} + +export type CompatSnapshotStore = { + readonly get: (host: string) => CompatSnapshot | undefined + readonly set: (snapshot: CompatSnapshot) => Promise + readonly isFresh: (snapshot: CompatSnapshot, now?: Date) => boolean + readonly canWarn: (snapshot: CompatSnapshot, now?: Date) => boolean + readonly markWarned: (host: string, now?: Date) => Promise +} + +export type CompatSnapshotStoreOptions = { + readonly configDir: string + readonly now?: () => Date + readonly ttlMs?: number + readonly silenceMs?: number +} + +type DiskShape = { + schema?: number + by_host?: Record +} + +type DiskSnapshot = { + host: string + fetched_at: string + last_warned_at?: string + server: ServerVersionResponse + compat: { + status: CompatStatus + detail: string + min_dify: string + max_dify: string + } +} + +export function compatSnapshotPath(configDir: string): string { + return join(configDir, 'cache', CACHE_FILE) +} + +export async function loadCompatSnapshotStore( + opts: CompatSnapshotStoreOptions, +): Promise { + const path = compatSnapshotPath(opts.configDir) + const ttlMs = opts.ttlMs ?? COMPAT_TTL_MS + const silenceMs = opts.silenceMs ?? WARN_SILENCE_MS + const clock = opts.now ?? (() => new Date()) + const state = new Map() + await readDisk(path, state) + + return { + get: host => state.get(host), + set: async (snapshot) => { + state.set(snapshot.host, snapshot) + await persist(path, state) + }, + isFresh: (snapshot, now) => { + const elapsed = (now ?? clock()).getTime() - parseIso(snapshot.fetchedAt) + return elapsed >= 0 && elapsed < ttlMs + }, + canWarn: (snapshot, now) => { + if (snapshot.lastWarnedAt === undefined) + return true + const elapsed = (now ?? clock()).getTime() - parseIso(snapshot.lastWarnedAt) + return elapsed >= silenceMs + }, + markWarned: async (host, now) => { + const existing = state.get(host) + if (existing === undefined) + return + const stamped = { ...existing, lastWarnedAt: (now ?? clock()).toISOString() } + state.set(host, stamped) + await persist(path, state) + }, + } +} + +function parseIso(value: string): number { + const t = Date.parse(value) + return Number.isNaN(t) ? 0 : t +} + +async function readDisk(path: string, state: Map): Promise { + let raw: string + try { + raw = await readFile(path, 'utf8') + } + catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') + return + throw err + } + let parsed: DiskShape + try { + parsed = JSON.parse(raw) as DiskShape + } + catch { + return + } + if (parsed.schema !== DISK_SCHEMA || parsed.by_host === undefined) + return + for (const [host, entry] of Object.entries(parsed.by_host)) + state.set(host, fromDisk(entry)) +} + +function fromDisk(entry: DiskSnapshot): CompatSnapshot { + return { + host: entry.host, + fetchedAt: entry.fetched_at, + lastWarnedAt: entry.last_warned_at, + server: entry.server, + compat: { + status: entry.compat.status, + detail: entry.compat.detail, + minDify: entry.compat.min_dify, + maxDify: entry.compat.max_dify, + }, + } +} + +function toDisk(snapshot: CompatSnapshot): DiskSnapshot { + const disk: DiskSnapshot = { + host: snapshot.host, + fetched_at: snapshot.fetchedAt, + server: snapshot.server, + compat: { + status: snapshot.compat.status, + detail: snapshot.compat.detail, + min_dify: snapshot.compat.minDify, + max_dify: snapshot.compat.maxDify, + }, + } + if (snapshot.lastWarnedAt !== undefined) + disk.last_warned_at = snapshot.lastWarnedAt + return disk +} + +async function persist(path: string, state: Map): Promise { + const dir = dirname(path) + await mkdir(dir, { recursive: true, mode: DIR_PERM }) + const disk: DiskShape = { schema: DISK_SCHEMA, by_host: {} } + for (const [host, snapshot] of state) + disk.by_host![host] = toDisk(snapshot) + const tmp = `${path}.${process.pid}.${Date.now()}.tmp` + await writeFile(tmp, JSON.stringify(disk), { mode: FILE_PERM }) + await rename(tmp, path) +} diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts index b01b431148b48a..35b7cd5c3a61cc 100644 --- a/cli/src/commands/_shared/authed-command.ts +++ b/cli/src/commands/_shared/authed-command.ts @@ -3,8 +3,10 @@ import type { HostsBundle } from '../../auth/hosts.js' import type { AppInfoCache } from '../../cache/app-info.js' import type { Command } from '../../framework/command.js' import type { IOStreams } from '../../io/streams.js' +import { MetaClient } from '../../api/meta.js' import { loadHosts } from '../../auth/hosts.js' import { loadAppInfoCache } from '../../cache/app-info.js' +import { loadCompatSnapshotStore } from '../../cache/compat-snapshot.js' import { resolveConfigDir } from '../../config/dir.js' import { BaseError } from '../../errors/base.js' import { ErrorCode } from '../../errors/codes.js' @@ -12,6 +14,7 @@ import { formatErrorForCli } from '../../errors/format.js' import { createClient } from '../../http/client.js' import { realStreams } from '../../io/streams.js' import { hostWithScheme } from '../../util/host.js' +import { maybeNudgeCompat } from '../../version/nudge.js' import { resolveRetryAttempts } from './global-flags.js' export type AuthedContext = { @@ -54,5 +57,30 @@ export async function buildAuthedContext( const cache = opts.withCache === true ? await loadAppInfoCache({ configDir }) : undefined + await runCompatNudge({ configDir, host, io }) + return { bundle, http, host, io, configDir, cache } } + +// Best-effort nudge: never throws, never blocks. Lives here so every authed +// command flows through it without per-command wiring. +async function runCompatNudge(opts: { + readonly configDir: string + readonly host: string + readonly io: IOStreams +}): Promise { + try { + const store = await loadCompatSnapshotStore({ configDir: opts.configDir }) + await maybeNudgeCompat(opts.host, { + store, + probe: async host => new MetaClient(createClient({ host })).serverVersion(), + emit: line => opts.io.err.write(line), + isTty: opts.io.isOutTTY, + format: opts.io.outputFormat, + color: opts.io.isErrTTY, + }) + } + catch { + // already swallowed inside maybeNudgeCompat; this is belt-and-braces + } +} diff --git a/cli/src/version/nudge.test.ts b/cli/src/version/nudge.test.ts new file mode 100644 index 00000000000000..095628cc67794a --- /dev/null +++ b/cli/src/version/nudge.test.ts @@ -0,0 +1,307 @@ +import type { CompatSnapshot, CompatSnapshotStore } from '../cache/compat-snapshot.js' +import type { ServerVersionResponse } from '../types/data-contracts.js' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { loadCompatSnapshotStore } from '../cache/compat-snapshot.js' +import { maybeNudgeCompat } from './nudge.js' + +const HOST = 'https://cloud.dify.ai' +const NOW = new Date('2026-05-20T12:00:00.000Z') +const fixedNow = () => NOW + +function freshSnapshot(overrides: Partial = {}): CompatSnapshot { + return { + host: HOST, + fetchedAt: '2026-05-20T11:00:00.000Z', // 1h before NOW + server: { version: '1.6.4', edition: 'CLOUD' }, + compat: { + status: 'compatible', + detail: 'in range', + minDify: '1.6.0', + maxDify: '1.7.0', + }, + ...overrides, + } +} + +type Probe = (host: string) => Promise + +function emitterSpy() { + const lines: string[] = [] + return { + emit: (line: string) => lines.push(line), + lines, + } +} + +async function buildStore() { + const dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-')) + const store = await loadCompatSnapshotStore({ configDir: dir, now: fixedNow }) + return { dir, store } +} + +describe('maybeNudgeCompat', () => { + let dir: string + let store: CompatSnapshotStore + + beforeEach(async () => { + ;({ dir, store } = await buildStore()) + }) + afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + }) + + it('cold cache + probe ok: persists snapshot, does NOT banner (first-time quiet)', async () => { + const probe: Probe = async () => ({ version: '99.0.0', edition: 'SELF_HOSTED' }) // unsupported + const { emit, lines } = emitterSpy() + + await maybeNudgeCompat(HOST, { + store, + probe, + emit, + isTty: true, + format: '', + now: fixedNow, + }) + + expect(lines).toHaveLength(0) + expect(store.get(HOST)).toBeDefined() + expect(store.get(HOST)!.compat.status).toBe('unsupported') + }) + + it('cold cache + probe rejects: no persist, no banner, no throw', async () => { + const probe: Probe = async () => { + throw new Error('timeout') + } + const { emit, lines } = emitterSpy() + + await maybeNudgeCompat(HOST, { + store, + probe, + emit, + isTty: true, + format: '', + now: fixedNow, + }) + + expect(lines).toHaveLength(0) + expect(store.get(HOST)).toBeUndefined() + }) + + it('warm fresh cache + compatible: no probe, no banner', async () => { + await store.set(freshSnapshot({ compat: { status: 'compatible', detail: '', minDify: '1.6.0', maxDify: '1.7.0' } })) + const probe = vi.fn() as unknown as Probe + const { emit, lines } = emitterSpy() + + await maybeNudgeCompat(HOST, { + store, + probe, + emit, + isTty: true, + format: '', + now: fixedNow, + }) + + expect(probe).not.toHaveBeenCalled() + expect(lines).toHaveLength(0) + }) + + it('warm fresh unsupported + never warned + TTY + text: banner fires + markWarned', async () => { + await store.set(freshSnapshot({ compat: { status: 'unsupported', detail: 'oops', minDify: '1.6.0', maxDify: '1.7.0' } })) + const probe = vi.fn() as unknown as Probe + const { emit, lines } = emitterSpy() + + await maybeNudgeCompat(HOST, { + store, + probe, + emit, + isTty: true, + format: '', + now: fixedNow, + }) + + expect(probe).not.toHaveBeenCalled() + expect(lines).toHaveLength(1) + expect(lines[0]).toContain('warning:') + expect(lines[0]).toContain('may be incompatible') + expect(store.get(HOST)!.lastWarnedAt).toBe(NOW.toISOString()) + }) + + it('warm fresh unsupported + format=json: no banner', async () => { + await store.set(freshSnapshot({ compat: { status: 'unsupported', detail: 'oops', minDify: '1.6.0', maxDify: '1.7.0' } })) + const { emit, lines } = emitterSpy() + + await maybeNudgeCompat(HOST, { + store, + probe: vi.fn() as unknown as Probe, + emit, + isTty: true, + format: 'json', + now: fixedNow, + }) + + expect(lines).toHaveLength(0) + }) + + it.each(['yaml', 'name'])('warm fresh unsupported + format=%s: no banner', async (format) => { + await store.set(freshSnapshot({ compat: { status: 'unsupported', detail: 'oops', minDify: '1.6.0', maxDify: '1.7.0' } })) + const { emit, lines } = emitterSpy() + + await maybeNudgeCompat(HOST, { + store, + probe: vi.fn() as unknown as Probe, + emit, + isTty: true, + format, + now: fixedNow, + }) + + expect(lines).toHaveLength(0) + }) + + it('warm fresh unsupported + !TTY: no banner', async () => { + await store.set(freshSnapshot({ compat: { status: 'unsupported', detail: 'oops', minDify: '1.6.0', maxDify: '1.7.0' } })) + const { emit, lines } = emitterSpy() + + await maybeNudgeCompat(HOST, { + store, + probe: vi.fn() as unknown as Probe, + emit, + isTty: false, + format: '', + now: fixedNow, + }) + + expect(lines).toHaveLength(0) + }) + + it('warm fresh unsupported + lastWarnedAt 2h ago: no banner (silence window)', async () => { + await store.set(freshSnapshot({ + compat: { status: 'unsupported', detail: 'oops', minDify: '1.6.0', maxDify: '1.7.0' }, + lastWarnedAt: '2026-05-20T10:00:00.000Z', // 2h before NOW + })) + const { emit, lines } = emitterSpy() + + await maybeNudgeCompat(HOST, { + store, + probe: vi.fn() as unknown as Probe, + emit, + isTty: true, + format: '', + now: fixedNow, + }) + + expect(lines).toHaveLength(0) + }) + + it('warm fresh unsupported + lastWarnedAt 25h ago: banner fires again', async () => { + await store.set(freshSnapshot({ + compat: { status: 'unsupported', detail: 'oops', minDify: '1.6.0', maxDify: '1.7.0' }, + lastWarnedAt: '2026-05-19T10:00:00.000Z', // 26h before NOW + })) + const { emit, lines } = emitterSpy() + + await maybeNudgeCompat(HOST, { + store, + probe: vi.fn() as unknown as Probe, + emit, + isTty: true, + format: '', + now: fixedNow, + }) + + expect(lines).toHaveLength(1) + expect(store.get(HOST)!.lastWarnedAt).toBe(NOW.toISOString()) + }) + + it('stale cache + probe returns now-compatible: refresh, no banner', async () => { + await store.set(freshSnapshot({ + fetchedAt: '2026-05-19T10:00:00.000Z', // 26h before NOW + compat: { status: 'unsupported', detail: 'old', minDify: '1.6.0', maxDify: '1.7.0' }, + })) + const probe: Probe = async () => ({ version: '1.6.4', edition: 'CLOUD' }) + const { emit, lines } = emitterSpy() + + await maybeNudgeCompat(HOST, { + store, + probe, + emit, + isTty: true, + format: '', + now: fixedNow, + }) + + expect(lines).toHaveLength(0) + expect(store.get(HOST)!.compat.status).toBe('compatible') + expect(store.get(HOST)!.fetchedAt).toBe(NOW.toISOString()) + }) + + it('stale cache + probe rejects: keep prior snapshot, may still banner from stale data', async () => { + await store.set(freshSnapshot({ + fetchedAt: '2026-05-19T10:00:00.000Z', // stale + compat: { status: 'unsupported', detail: 'pre-existing', minDify: '1.6.0', maxDify: '1.7.0' }, + })) + const probe: Probe = async () => { + throw new Error('net down') + } + const { emit, lines } = emitterSpy() + + await maybeNudgeCompat(HOST, { + store, + probe, + emit, + isTty: true, + format: '', + now: fixedNow, + }) + + expect(lines).toHaveLength(1) + // fetchedAt is NOT updated because probe failed + expect(store.get(HOST)!.fetchedAt).toBe('2026-05-19T10:00:00.000Z') + }) + + it('warm fresh unknown: no banner (unknown is too noisy to alert on)', async () => { + await store.set(freshSnapshot({ + compat: { status: 'unknown', detail: 'who knows', minDify: '1.6.0', maxDify: '1.7.0' }, + })) + const { emit, lines } = emitterSpy() + + await maybeNudgeCompat(HOST, { + store, + probe: vi.fn() as unknown as Probe, + emit, + isTty: true, + format: '', + now: fixedNow, + }) + + expect(lines).toHaveLength(0) + }) + + it('never throws even if every dependency explodes', async () => { + const explodingStore: CompatSnapshotStore = { + get: () => { throw new Error('get boom') }, + set: async () => { throw new Error('set boom') }, + isFresh: () => { throw new Error('fresh boom') }, + canWarn: () => { throw new Error('warn boom') }, + markWarned: async () => { throw new Error('mark boom') }, + } + const probe: Probe = async () => { + throw new Error('probe boom') + } + const emit = () => { + throw new Error('emit boom') + } + + await expect(maybeNudgeCompat(HOST, { + store: explodingStore, + probe, + emit, + isTty: true, + format: '', + now: fixedNow, + })).resolves.toBeUndefined() + }) +}) diff --git a/cli/src/version/nudge.ts b/cli/src/version/nudge.ts new file mode 100644 index 00000000000000..850db920fa6738 --- /dev/null +++ b/cli/src/version/nudge.ts @@ -0,0 +1,107 @@ +import type { CompatSnapshot, CompatSnapshotStore } from '../cache/compat-snapshot.js' +import type { ServerVersionResponse } from '../types/data-contracts.js' +import pc from 'picocolors' +import { difyCompat, evaluateCompat } from './compat.js' +import { versionInfo } from './info.js' + +const SUPPRESSED_FORMATS: ReadonlySet = new Set(['json', 'yaml', 'name']) + +export type NudgeDeps = { + readonly store: CompatSnapshotStore + // /openapi/v1/_version is intentionally unauthenticated (mirrors _health), so + // the probe does not need a bearer. + readonly probe: (host: string) => Promise + readonly emit: (line: string) => void + readonly isTty: boolean + readonly format: string + readonly color?: boolean + readonly now?: () => Date +} + +// Public guarantee: never throws. Every internal failure is silenced so that +// the calling authed command continues regardless of probe / disk / parse +// errors. +export async function maybeNudgeCompat( + host: string, + deps: NudgeDeps, +): Promise { + try { + const snapshot = await ensureSnapshot(host, deps) + if (snapshot === undefined) + return + if (!shouldBanner(snapshot, deps)) + return + deps.emit(formatBanner(snapshot, deps.color === true)) + await deps.store.markWarned(host, deps.now?.()) + } + catch { + // swallow: the nudge must never affect the business command + } +} + +async function ensureSnapshot( + host: string, + deps: NudgeDeps, +): Promise { + const existing = deps.store.get(host) + if (existing !== undefined && deps.store.isFresh(existing, deps.now?.())) + return existing + + // stale or missing → try a refresh + let server: ServerVersionResponse + try { + server = await deps.probe(host) + } + catch { + return existing // may be undefined; that signals "first-time-quiet" + } + + const verdict = evaluateCompat(server.version) + const fresh: CompatSnapshot = { + host, + fetchedAt: (deps.now?.() ?? new Date()).toISOString(), + lastWarnedAt: existing?.lastWarnedAt, + server, + compat: { + status: verdict.status, + detail: verdict.detail, + minDify: difyCompat.minDify, + maxDify: difyCompat.maxDify, + }, + } + try { + await deps.store.set(fresh) + } + catch { + // disk failure shouldn't block warn decision + } + + // First-time quiet: cold cache only persists; never warns on the very + // first command after install. + if (existing === undefined) + return undefined + + return fresh +} + +function shouldBanner(snapshot: CompatSnapshot, deps: NudgeDeps): boolean { + if (snapshot.compat.status !== 'unsupported') + return false + if (!deps.isTty) + return false + if (SUPPRESSED_FORMATS.has(deps.format)) + return false + if (!deps.store.canWarn(snapshot, deps.now?.())) + return false + return true +} + +function formatBanner(snapshot: CompatSnapshot, color: boolean): string { + const paint = color ? pc.yellow : (s: string) => s + const { minDify, maxDify } = snapshot.compat + const line + = `warning: difyctl ${versionInfo.version} may be incompatible with server ` + + `${snapshot.server.version} (tested: ${minDify}..${maxDify}). ` + + 'Run `difyctl version` for details.' + return `${paint(line)}\n` +} From 412f5decb3af75aaa13d9909c654036c83a33162 Mon Sep 17 00:00:00 2001 From: L1nSn0w Date: Tue, 19 May 2026 16:44:47 +0800 Subject: [PATCH 3/4] refactor(cli): migrate openapi types to @dify/contracts shared package Aligns with the post-#36380 architecture: openapi-namespace types now live in packages/contracts/generated/api/openapi/, generated alongside console/service/web from the same Swagger spec. CLI imports rewrite to '@dify/contracts/api/openapi/types.gen', the local cli/src/types/data-contracts.ts is removed, and the orphan 'sync-models' npm script (its .sh file was already deleted upstream) is dropped. Behavioral: ServerVersionResponse + every other openapi type is now sourced from the shared package. Zero runtime change. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/package.json | 1 - cli/src/api/meta.ts | 2 +- cli/src/cache/compat-snapshot.ts | 2 +- cli/src/types/data-contracts.ts | 478 ------------------ cli/src/version/nudge.test.ts | 2 +- cli/src/version/nudge.ts | 2 +- cli/src/version/probe.test.ts | 2 +- cli/src/version/probe.ts | 2 +- packages/contracts/README.md | 6 +- .../generated/api/openapi/orpc.gen.ts | 56 +- .../generated/api/openapi/types.gen.ts | 18 + .../generated/api/openapi/zod.gen.ts | 15 + .../contracts/generated/api/readiness.json | 2 +- 13 files changed, 79 insertions(+), 509 deletions(-) delete mode 100644 cli/src/types/data-contracts.ts diff --git a/cli/package.json b/cli/package.json index 6cf9e21470f764..1dd933a9b3f12f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -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": { diff --git a/cli/src/api/meta.ts b/cli/src/api/meta.ts index 4ee2d2c7eb3e78..17cb45c993bcfb 100644 --- a/cli/src/api/meta.ts +++ b/cli/src/api/meta.ts @@ -1,5 +1,5 @@ +import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' import type { KyInstance } from 'ky' -import type { ServerVersionResponse } from '../types/data-contracts.js' export const META_PROBE_TIMEOUT_MS = 2000 diff --git a/cli/src/cache/compat-snapshot.ts b/cli/src/cache/compat-snapshot.ts index be931b0acec15c..199061e2aa6769 100644 --- a/cli/src/cache/compat-snapshot.ts +++ b/cli/src/cache/compat-snapshot.ts @@ -1,4 +1,4 @@ -import type { ServerVersionResponse } from '../types/data-contracts.js' +import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' import type { CompatStatus } from '../version/compat.js' import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' diff --git a/cli/src/types/data-contracts.ts b/cli/src/types/data-contracts.ts deleted file mode 100644 index 9229552c0f75f6..00000000000000 --- a/cli/src/types/data-contracts.ts +++ /dev/null @@ -1,478 +0,0 @@ -/* eslint-disable erasable-syntax-only/enums, ts/no-explicit-any */ -/* tslint:disable */ -/* - * --------------------------------------------------------------- - * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## - * ## ## - * ## AUTHOR: acacode ## - * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## - * --------------------------------------------------------------- - */ - -/** AppMode */ -export enum AppMode { - AdvancedChat = 'advanced-chat', - AgentChat = 'agent-chat', - Channel = 'channel', - Chat = 'chat', - Completion = 'completion', - RagPipeline = 'rag-pipeline', - Workflow = 'workflow', -} - -/** AccountPayload */ -export type AccountPayload = { - /** Email */ - email: string - /** Id */ - id: string - /** Name */ - name: string -} - -/** AccountResponse */ -export type AccountResponse = { - account?: AccountPayload | null - /** Default Workspace Id */ - default_workspace_id?: string | null - /** Subject Email */ - subject_email?: string | null - /** Subject Issuer */ - subject_issuer?: string | null - /** Subject Type */ - subject_type: string - /** - * Workspaces - * @default [] - */ - workspaces?: WorkspacePayload[] -} - -/** AppDescribeInfo */ -export type AppDescribeInfo = { - /** Author */ - author?: string | null - /** Description */ - description?: string | null - /** Id */ - id: string - /** - * Is Agent - * @default false - */ - is_agent?: boolean - /** Mode */ - mode: string - /** Name */ - name: string - /** Service Api Enabled */ - service_api_enabled: boolean - /** - * Tags - * @default [] - */ - tags?: TagItem[] - /** Updated At */ - updated_at?: string | null -} - -/** - * AppDescribeQuery - * `?fields=` allow-list for GET /apps//describe. - * - * Empty / omitted → all blocks. Unknown member → ValidationError → 422. - */ -export type AppDescribeQuery = { - /** - * Fields - * @uniqueItems true - */ - fields?: string[] | null - /** Workspace Id */ - workspace_id?: string | null -} - -/** AppDescribeResponse */ -export type AppDescribeResponse = { - info?: AppDescribeInfo | null - /** Input Schema */ - input_schema?: Record | null - /** Parameters */ - parameters?: Record | null -} - -/** AppInfoResponse */ -export type AppInfoResponse = { - /** Author */ - author?: string | null - /** Description */ - description?: string | null - /** Id */ - id: string - /** Mode */ - mode: string - /** Name */ - name: string - /** - * Tags - * @default [] - */ - tags?: TagItem[] -} - -/** - * AppListQuery - * mode is a closed enum. - */ -export type AppListQuery = { - /** - * Limit - * @min 1 - * @max 200 - * @default 20 - */ - limit?: number - mode?: AppMode | null - /** - * Name - * @maxLength 200 - */ - name?: string | null - /** - * Page - * @min 1 - * @default 1 - */ - page?: number - /** - * Tag - * @maxLength 100 - */ - tag?: string | null - /** Workspace Id */ - workspace_id: string -} - -/** AppListResponse */ -export type AppListResponse = { - /** Data */ - data: AppListRow[] - /** Has More */ - has_more: boolean - /** Limit */ - limit: number - /** Page */ - page: number - /** Total */ - total: number -} - -/** AppListRow */ -export type AppListRow = { - /** Created By Name */ - created_by_name?: string | null - /** Description */ - description?: string | null - /** Id */ - id: string - mode: AppMode - /** Name */ - name: string - /** - * Tags - * @default [] - */ - tags?: TagItem[] - /** Updated At */ - updated_at?: string | null - /** Workspace Id */ - workspace_id?: string | null - /** Workspace Name */ - workspace_name?: string | null -} - -/** AppRunRequest */ -export type AppRunRequest = { - /** - * Auto Generate Name - * @default true - */ - auto_generate_name?: boolean - /** Conversation Id */ - conversation_id?: string | null - /** Files */ - files?: Record[] | null - /** Inputs */ - inputs: Record - /** Query */ - query?: string | null - /** Workflow Id */ - workflow_id?: string | null - /** Workspace Id */ - workspace_id?: string | null -} - -/** DeviceCodeRequest */ -export type DeviceCodeRequest = { - /** Client Id */ - client_id: string - /** Device Label */ - device_label: string -} - -/** DeviceCodeResponse */ -export type DeviceCodeResponse = { - /** Device Code */ - device_code: string - /** Expires In */ - expires_in: number - /** Interval */ - interval: number - /** User Code */ - user_code: string - /** Verification Uri */ - verification_uri: string -} - -/** DeviceLookupQuery */ -export type DeviceLookupQuery = { - /** User Code */ - user_code: string -} - -/** DeviceLookupResponse */ -export type DeviceLookupResponse = { - /** Client Id */ - client_id?: string | null - /** - * Expires In Remaining - * @default 0 - */ - expires_in_remaining?: number - /** Valid */ - valid: boolean -} - -/** DeviceMutateRequest */ -export type DeviceMutateRequest = { - /** User Code */ - user_code: string -} - -/** DeviceMutateResponse */ -export type DeviceMutateResponse = { - /** Status */ - status: string -} - -/** DevicePollRequest */ -export type DevicePollRequest = { - /** Client Id */ - client_id: string - /** Device Code */ - device_code: string -} - -/** HumanInputFormSubmitPayload */ -export type HumanInputFormSubmitPayload = { - /** Action */ - action: string - /** Inputs */ - inputs: Record -} - -export type JsonValue = any - -/** MessageMetadata */ -export type MessageMetadata = { - /** - * Retriever Resources - * @default [] - */ - retriever_resources?: Record[] - usage?: UsageInfo | null -} - -/** - * PermittedExternalAppsListQuery - * Strict (extra='forbid'). - */ -export type PermittedExternalAppsListQuery = { - /** - * Limit - * @min 1 - * @max 200 - * @default 20 - */ - limit?: number - mode?: AppMode | null - /** - * Name - * @maxLength 200 - */ - name?: string | null - /** - * Page - * @min 1 - * @default 1 - */ - page?: number -} - -/** PermittedExternalAppsListResponse */ -export type PermittedExternalAppsListResponse = { - /** Data */ - data: AppListRow[] - /** Has More */ - has_more: boolean - /** Limit */ - limit: number - /** Page */ - page: number - /** Total */ - total: number -} - -/** RevokeResponse */ -export type RevokeResponse = { - /** Status */ - status: string -} - -/** - * ServerVersionResponse - * Meta endpoint payload for `GET /openapi/v1/_version` — no auth required. - */ -export type ServerVersionResponse = { - /** Edition */ - edition: 'CLOUD' | 'SELF_HOSTED' - /** Version */ - version: string -} - -/** SessionListResponse */ -export type SessionListResponse = { - /** Data */ - data: SessionRow[] - /** Has More */ - has_more: boolean - /** Limit */ - limit: number - /** Page */ - page: number - /** Total */ - total: number -} - -/** SessionRow */ -export type SessionRow = { - /** Client Id */ - client_id: string - /** Created At */ - created_at?: string | null - /** Device Label */ - device_label: string - /** Expires At */ - expires_at?: string | null - /** Id */ - id: string - /** Last Used At */ - last_used_at?: string | null - /** Prefix */ - prefix: string -} - -/** TagItem */ -export type TagItem = { - /** Name */ - name: string -} - -/** UsageInfo */ -export type UsageInfo = { - /** - * Completion Tokens - * @default 0 - */ - completion_tokens?: number - /** - * Prompt Tokens - * @default 0 - */ - prompt_tokens?: number - /** - * Total Tokens - * @default 0 - */ - total_tokens?: number -} - -/** WorkflowRunData */ -export type WorkflowRunData = { - /** Created At */ - created_at?: number | null - /** Elapsed Time */ - elapsed_time?: number | null - /** Error */ - error?: string | null - /** Finished At */ - finished_at?: number | null - /** Id */ - id: string - /** Outputs */ - outputs?: Record - /** Status */ - status: string - /** Total Steps */ - total_steps?: number | null - /** Total Tokens */ - total_tokens?: number | null - /** Workflow Id */ - workflow_id: string -} - -/** WorkspaceDetailResponse */ -export type WorkspaceDetailResponse = { - /** Created At */ - created_at?: string | null - /** Current */ - current: boolean - /** Id */ - id: string - /** Name */ - name: string - /** Role */ - role: string - /** Status */ - status: string -} - -/** WorkspaceListResponse */ -export type WorkspaceListResponse = { - /** Workspaces */ - workspaces: WorkspaceSummaryResponse[] -} - -/** WorkspacePayload */ -export type WorkspacePayload = { - /** Id */ - id: string - /** Name */ - name: string - /** Role */ - role: string -} - -/** WorkspaceSummaryResponse */ -export type WorkspaceSummaryResponse = { - /** Current */ - current: boolean - /** Id */ - id: string - /** Name */ - name: string - /** Role */ - role: string - /** Status */ - status: string -} diff --git a/cli/src/version/nudge.test.ts b/cli/src/version/nudge.test.ts index 095628cc67794a..f4c9a9742bd46b 100644 --- a/cli/src/version/nudge.test.ts +++ b/cli/src/version/nudge.test.ts @@ -1,5 +1,5 @@ +import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' import type { CompatSnapshot, CompatSnapshotStore } from '../cache/compat-snapshot.js' -import type { ServerVersionResponse } from '../types/data-contracts.js' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/cli/src/version/nudge.ts b/cli/src/version/nudge.ts index 850db920fa6738..a3807ac56605b5 100644 --- a/cli/src/version/nudge.ts +++ b/cli/src/version/nudge.ts @@ -1,5 +1,5 @@ +import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' import type { CompatSnapshot, CompatSnapshotStore } from '../cache/compat-snapshot.js' -import type { ServerVersionResponse } from '../types/data-contracts.js' import pc from 'picocolors' import { difyCompat, evaluateCompat } from './compat.js' import { versionInfo } from './info.js' diff --git a/cli/src/version/probe.test.ts b/cli/src/version/probe.test.ts index 221548bff5b38b..ea17b41ebe47e3 100644 --- a/cli/src/version/probe.test.ts +++ b/cli/src/version/probe.test.ts @@ -1,5 +1,5 @@ +import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' import type { HostsBundle } from '../auth/hosts.js' -import type { ServerVersionResponse } from '../types/data-contracts.js' import { describe, expect, it } from 'vitest' import { runVersionProbe } from './probe.js' diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts index ed85a89a4dc00d..b8ab4f8f342cf7 100644 --- a/cli/src/version/probe.ts +++ b/cli/src/version/probe.ts @@ -1,5 +1,5 @@ +import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' import type { HostsBundle } from '../auth/hosts.js' -import type { ServerVersionResponse } from '../types/data-contracts.js' import type { CompatVerdict } from './compat.js' import type { Channel } from './info.js' import { MetaClient } from '../api/meta.js' diff --git a/packages/contracts/README.md b/packages/contracts/README.md index e63bb434fac5f9..cff5fe32f930fc 100644 --- a/packages/contracts/README.md +++ b/packages/contracts/README.md @@ -8,15 +8,15 @@ Snapshot generated from `packages/contracts/generated/api/readiness.json` after running `pnpm -C packages/contracts gen-api-contract-from-openapi`. -Are we OpenAPI ready? **No.** Current generated API contracts are **17.9% ready**. +Are we OpenAPI ready? **No.** Current generated API contracts are **18.1% ready**. | Surface | Ready | Not ready | Total | Ready % | | --------- | ------: | --------: | ------: | --------: | | console | 96 | 474 | 570 | 16.8% | -| openapi | 12 | 8 | 20 | 60.0% | +| openapi | 13 | 8 | 21 | 61.9% | | service | 16 | 72 | 88 | 18.2% | | web | 5 | 36 | 41 | 12.2% | -| **total** | **129** | **590** | **719** | **17.9%** | +| **total** | **130** | **590** | **720** | **18.1%** | Readiness here means the generated contract operation is not marked with: diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts index 461571cde17aac..b266b7d0cb49fa 100644 --- a/packages/contracts/generated/api/openapi/orpc.gen.ts +++ b/packages/contracts/generated/api/openapi/orpc.gen.ts @@ -22,6 +22,7 @@ import { zGetOauthDeviceLookupQuery, zGetOauthDeviceLookupResponse, zGetPermittedExternalAppsResponse, + zGetVersionResponse, zGetWorkspacesByWorkspaceIdPath, zGetWorkspacesByWorkspaceIdResponse, zGetWorkspacesResponse, @@ -65,6 +66,20 @@ export const health = { get, } +export const get2 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getVersion', + path: '/_version', + tags: ['openapi'], + }) + .output(zGetVersionResponse) + +export const version = { + get: get2, +} + export const delete_ = oc .route({ inputStructure: 'detailed', @@ -94,7 +109,7 @@ export const bySessionId = { delete: delete2, } -export const get2 = oc +export const get3 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -105,12 +120,12 @@ export const get2 = oc .output(zGetAccountSessionsResponse) export const sessions = { - get: get2, + get: get3, self, bySessionId, } -export const get3 = oc +export const get4 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -121,7 +136,7 @@ export const get3 = oc .output(zGetAccountResponse) export const account = { - get: get3, + get: get4, sessions, } @@ -130,7 +145,7 @@ export const account = { * * @deprecated */ -export const get4 = oc +export const get5 = oc .route({ deprecated: true, description: @@ -150,7 +165,7 @@ export const get4 = oc .output(zGetAppsByAppIdDescribeResponse) export const describe = { - get: get4, + get: get5, } /** @@ -158,7 +173,7 @@ export const describe = { * * @deprecated */ -export const get5 = oc +export const get6 = oc .route({ deprecated: true, description: @@ -197,7 +212,7 @@ export const post = oc .output(zPostAppsByAppIdFormHumanInputByFormTokenResponse) export const byFormToken = { - get: get5, + get: get6, post, } @@ -237,7 +252,7 @@ export const run = { * * @deprecated */ -export const get6 = oc +export const get7 = oc .route({ deprecated: true, description: @@ -252,7 +267,7 @@ export const get6 = oc .output(zGetAppsByAppIdTasksByTaskIdEventsResponse) export const events = { - get: get6, + get: get7, } /** @@ -294,7 +309,7 @@ export const byAppId = { tasks, } -export const get7 = oc +export const get8 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -306,7 +321,7 @@ export const get7 = oc .output(zGetAppsResponse) export const apps = { - get: get7, + get: get8, byAppId, } @@ -355,7 +370,7 @@ export const deny = { post: post6, } -export const get8 = oc +export const get9 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -367,7 +382,7 @@ export const get8 = oc .output(zGetOauthDeviceLookupResponse) export const lookup = { - get: get8, + get: get9, } /** @@ -405,7 +420,7 @@ export const oauth = { device, } -export const get9 = oc +export const get10 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -416,10 +431,10 @@ export const get9 = oc .output(zGetPermittedExternalAppsResponse) export const permittedExternalApps = { - get: get9, + get: get10, } -export const get10 = oc +export const get11 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -431,10 +446,10 @@ export const get10 = oc .output(zGetWorkspacesByWorkspaceIdResponse) export const byWorkspaceId = { - get: get10, + get: get11, } -export const get11 = oc +export const get12 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -445,12 +460,13 @@ export const get11 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get11, + get: get12, byWorkspaceId, } export const contract = { health, + version, account, apps, oauth, diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts index 214fafd738a7c4..eaf1d5ae91561c 100644 --- a/packages/contracts/generated/api/openapi/types.gen.ts +++ b/packages/contracts/generated/api/openapi/types.gen.ts @@ -178,6 +178,11 @@ export type RevokeResponse = { status: string } +export type ServerVersionResponse = { + edition: 'CLOUD' | 'SELF_HOSTED' + version: string +} + export type SessionListResponse = { data: Array has_more: boolean @@ -263,6 +268,19 @@ export type GetHealthResponses = { export type GetHealthResponse = GetHealthResponses[keyof GetHealthResponses] +export type GetVersionData = { + body?: never + path?: never + query?: never + url: '/_version' +} + +export type GetVersionResponses = { + 200: ServerVersionResponse +} + +export type GetVersionResponse = GetVersionResponses[keyof GetVersionResponses] + export type GetAccountData = { body?: never path?: never diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts index 36ba99cb3cc5b9..d7d8b9eff4e0aa 100644 --- a/packages/contracts/generated/api/openapi/zod.gen.ts +++ b/packages/contracts/generated/api/openapi/zod.gen.ts @@ -149,6 +149,16 @@ export const zRevokeResponse = z.object({ status: z.string(), }) +/** + * ServerVersionResponse + * + * Meta endpoint payload for `GET /openapi/v1/_version` — no auth required. + */ +export const zServerVersionResponse = z.object({ + edition: z.enum(['CLOUD', 'SELF_HOSTED']), + version: z.string(), +}) + /** * SessionRow */ @@ -342,6 +352,11 @@ export const zWorkspaceListResponse = z.object({ */ export const zGetHealthResponse = z.record(z.string(), z.unknown()) +/** + * Server version + */ +export const zGetVersionResponse = zServerVersionResponse + /** * Account info */ diff --git a/packages/contracts/generated/api/readiness.json b/packages/contracts/generated/api/readiness.json index 45ebc4e1f7a114..6c38aa166464af 100644 --- a/packages/contracts/generated/api/readiness.json +++ b/packages/contracts/generated/api/readiness.json @@ -6,7 +6,7 @@ }, "openapi": { "notReady": 8, - "total": 20 + "total": 21 }, "service": { "notReady": 72, From 4c36baa43e3ed3628b9dacddb1090f665fdce1e1 Mon Sep 17 00:00:00 2001 From: L1nSn0w Date: Tue, 19 May 2026 17:46:38 +0800 Subject: [PATCH 4/4] =?UTF-8?q?refactor(cli):=20apply=20review=20feedback?= =?UTF-8?q?=20=E2=80=94=20throttle-only=20nudge=20cache=20+=20tighter=20pr?= =?UTF-8?q?obe=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review comments on the version + auto-nudge feature: cache: - Replace `compat-snapshot` (verdict + server-version cache) with `nudge-store` which only persists `lastWarnedAt` per host. The nudge probe is now realtime; the cache only throttles how often we warn. Removes the lost-update / clock- skew / stale-warn classes of bugs in one stroke, net −408 LoC. - `markWarned` re-reads disk inside the write cycle so concurrent CLI invocations on different hosts can't clobber each other's timestamps. nudge: - Throttle-first decision tree: cheap checks (TTY, output format, silence window) run before any I/O. Probe failures are silently swallowed and never drive a warning. - `clientVersion` is now injected via `NudgeDeps` instead of reached through the `versionInfo` global, keeping the banner pure. probe / meta client: - Drop `bearer` from `MetaProbe` signature. `/openapi/v1/_version` is intentionally unauth (mirrors `_health`); the bearer parameter was dead code. - Move `META_PROBE_TIMEOUT_MS` from inside `MetaClient.serverVersion()` to the `createClient(...)` call at both probe sites (version command and per-command nudge). Both now also disable retry, so the default 30s × 3 budget never leaks into version probes. compat: - Clamp malformed `serverVersion` strings to 80 chars before quoting them in the verdict detail, so a hostile server response can't flood stderr. version command: - Emit the formatted report on stdout *before* exiting on `--check-compat`. This makes `difyctl version -o json --check-compat | jq ...` work the same way on the failure path as on success — pipelines get the JSON envelope, stderr gets the one-line reason, exit code signals the verdict. --- cli/src/api/meta.ts | 10 +- cli/src/cache/compat-snapshot.test.ts | 122 --------- cli/src/cache/compat-snapshot.ts | 168 ------------ cli/src/cache/nudge-store.test.ts | 94 +++++++ cli/src/cache/nudge-store.ts | 96 +++++++ cli/src/commands/_shared/authed-command.ts | 13 +- cli/src/commands/version/index.ts | 22 +- cli/src/commands/version/version.test.ts | 31 +++ cli/src/version/build-info.d.ts | 9 + cli/src/version/compat.test.ts | 10 + cli/src/version/compat.ts | 11 +- cli/src/version/info.ts | 5 - cli/src/version/nudge.test.ts | 290 ++++++--------------- cli/src/version/nudge.ts | 119 +++------ cli/src/version/probe.test.ts | 72 ++++- cli/src/version/probe.ts | 19 +- cli/src/version/render.test.ts | 67 ++++- cli/src/version/render.ts | 26 +- 18 files changed, 535 insertions(+), 649 deletions(-) delete mode 100644 cli/src/cache/compat-snapshot.test.ts delete mode 100644 cli/src/cache/compat-snapshot.ts create mode 100644 cli/src/cache/nudge-store.test.ts create mode 100644 cli/src/cache/nudge-store.ts create mode 100644 cli/src/version/build-info.d.ts diff --git a/cli/src/api/meta.ts b/cli/src/api/meta.ts index 17cb45c993bcfb..1ddfdc4461d90a 100644 --- a/cli/src/api/meta.ts +++ b/cli/src/api/meta.ts @@ -1,6 +1,9 @@ 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 { @@ -11,11 +14,6 @@ export class MetaClient { } async serverVersion(): Promise { - return this.http - .get('_version', { - timeout: META_PROBE_TIMEOUT_MS, - retry: { limit: 0 }, - }) - .json() + return this.http.get('_version').json() } } diff --git a/cli/src/cache/compat-snapshot.test.ts b/cli/src/cache/compat-snapshot.test.ts deleted file mode 100644 index 8c840291f11dd8..00000000000000 --- a/cli/src/cache/compat-snapshot.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { CompatSnapshot } from './compat-snapshot.js' -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 { compatSnapshotPath, loadCompatSnapshotStore } from './compat-snapshot.js' - -const HOST = 'https://cloud.dify.ai' - -function fakeSnapshot(overrides: Partial = {}): CompatSnapshot { - return { - host: HOST, - fetchedAt: '2026-05-19T12:00:00.000Z', - server: { version: '1.6.4', edition: 'CLOUD' }, - compat: { - status: 'compatible', - detail: 'server 1.6.4 in [1.6.0, 1.7.0]', - minDify: '1.6.0', - maxDify: '1.7.0', - }, - ...overrides, - } -} - -describe('CompatSnapshotStore', () => { - let dir: string - beforeEach(async () => { - dir = await mkdtemp(join(tmpdir(), 'difyctl-compat-')) - }) - afterEach(async () => { - await rm(dir, { recursive: true, force: true }) - }) - - it('returns undefined when no cache file exists yet', async () => { - const store = await loadCompatSnapshotStore({ configDir: dir }) - expect(store.get(HOST)).toBeUndefined() - }) - - it('treats a corrupt cache file as empty (no throw)', async () => { - const path = compatSnapshotPath(dir) - await writeCacheFile(path, '{ not valid json') - const store = await loadCompatSnapshotStore({ configDir: dir }) - expect(store.get(HOST)).toBeUndefined() - }) - - it('ignores cache file with mismatched schema', async () => { - const path = compatSnapshotPath(dir) - await writeCacheFile(path, JSON.stringify({ schema: 99, by_host: {} })) - const store = await loadCompatSnapshotStore({ configDir: dir }) - expect(store.get(HOST)).toBeUndefined() - }) - - it('persists and reads back a snapshot through a fresh store instance', async () => { - const snap = fakeSnapshot() - const s1 = await loadCompatSnapshotStore({ configDir: dir }) - await s1.set(snap) - const s2 = await loadCompatSnapshotStore({ configDir: dir }) - expect(s2.get(HOST)).toEqual(snap) - }) - - it('writes file with snake_case keys on disk', async () => { - const store = await loadCompatSnapshotStore({ configDir: dir }) - await store.set(fakeSnapshot()) - const raw = await readFile(compatSnapshotPath(dir), 'utf8') - const parsed = JSON.parse(raw) as Record - expect(parsed.schema).toBe(1) - expect(parsed.by_host).toBeDefined() - const entry = (parsed.by_host as Record>)[HOST] - expect(entry).toHaveProperty('fetched_at') - expect(entry).toHaveProperty('compat') - expect((entry.compat as Record).min_dify).toBe('1.6.0') - expect((entry.compat as Record).max_dify).toBe('1.7.0') - }) - - it('isFresh: true when within TTL, false when past TTL', async () => { - const store = await loadCompatSnapshotStore({ configDir: dir }) - const snap = fakeSnapshot({ fetchedAt: '2026-05-19T12:00:00.000Z' }) - expect(store.isFresh(snap, new Date('2026-05-19T13:00:00.000Z'))).toBe(true) - expect(store.isFresh(snap, new Date('2026-05-20T13:00:00.000Z'))).toBe(false) - // boundary: exactly 24h is not fresh - expect(store.isFresh(snap, new Date('2026-05-20T12:00:00.000Z'))).toBe(false) - }) - - it('canWarn: true when no prior warn, false within silence window, true after', async () => { - const store = await loadCompatSnapshotStore({ configDir: dir }) - const cold = fakeSnapshot({ lastWarnedAt: undefined }) - expect(store.canWarn(cold)).toBe(true) - const warmed = fakeSnapshot({ lastWarnedAt: '2026-05-19T12:00:00.000Z' }) - expect(store.canWarn(warmed, new Date('2026-05-19T18:00:00.000Z'))).toBe(false) - expect(store.canWarn(warmed, new Date('2026-05-20T12:00:00.000Z'))).toBe(true) - }) - - it('markWarned only updates lastWarnedAt, leaves other fields intact', async () => { - const store = await loadCompatSnapshotStore({ configDir: dir }) - await store.set(fakeSnapshot({ lastWarnedAt: undefined })) - await store.markWarned(HOST, new Date('2026-05-19T15:30:00.000Z')) - const after = store.get(HOST)! - expect(after.lastWarnedAt).toBe('2026-05-19T15:30:00.000Z') - expect(after.fetchedAt).toBe('2026-05-19T12:00:00.000Z') - expect(after.compat.status).toBe('compatible') - }) - - it('markWarned on absent host is a no-op (no throw)', async () => { - const store = await loadCompatSnapshotStore({ configDir: dir }) - await expect(store.markWarned('https://nowhere')).resolves.toBeUndefined() - }) - - it('multiple hosts cohabit the cache file independently', async () => { - const store = await loadCompatSnapshotStore({ configDir: dir }) - await store.set(fakeSnapshot({ host: 'https://a' })) - await store.set(fakeSnapshot({ host: 'https://b', server: { version: '1.7.0', edition: 'SELF_HOSTED' } })) - const reread = await loadCompatSnapshotStore({ configDir: dir }) - expect(reread.get('https://a')?.server.version).toBe('1.6.4') - expect(reread.get('https://b')?.server.version).toBe('1.7.0') - }) -}) - -async function writeCacheFile(path: string, body: string): Promise { - const { mkdir } = await import('node:fs/promises') - await mkdir(dirname(path), { recursive: true }) - await writeFile(path, body) -} diff --git a/cli/src/cache/compat-snapshot.ts b/cli/src/cache/compat-snapshot.ts deleted file mode 100644 index 199061e2aa6769..00000000000000 --- a/cli/src/cache/compat-snapshot.ts +++ /dev/null @@ -1,168 +0,0 @@ -import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' -import type { CompatStatus } from '../version/compat.js' -import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { DIR_PERM, FILE_PERM } from '../config/dir.js' - -const CACHE_FILE = 'compat-snapshot.json' -const DISK_SCHEMA = 1 -export const COMPAT_TTL_MS = 24 * 60 * 60 * 1000 -export const WARN_SILENCE_MS = 24 * 60 * 60 * 1000 - -export type CompatSnapshot = { - readonly host: string - readonly fetchedAt: string - readonly lastWarnedAt?: string - readonly server: ServerVersionResponse - readonly compat: { - readonly status: CompatStatus - readonly detail: string - readonly minDify: string - readonly maxDify: string - } -} - -export type CompatSnapshotStore = { - readonly get: (host: string) => CompatSnapshot | undefined - readonly set: (snapshot: CompatSnapshot) => Promise - readonly isFresh: (snapshot: CompatSnapshot, now?: Date) => boolean - readonly canWarn: (snapshot: CompatSnapshot, now?: Date) => boolean - readonly markWarned: (host: string, now?: Date) => Promise -} - -export type CompatSnapshotStoreOptions = { - readonly configDir: string - readonly now?: () => Date - readonly ttlMs?: number - readonly silenceMs?: number -} - -type DiskShape = { - schema?: number - by_host?: Record -} - -type DiskSnapshot = { - host: string - fetched_at: string - last_warned_at?: string - server: ServerVersionResponse - compat: { - status: CompatStatus - detail: string - min_dify: string - max_dify: string - } -} - -export function compatSnapshotPath(configDir: string): string { - return join(configDir, 'cache', CACHE_FILE) -} - -export async function loadCompatSnapshotStore( - opts: CompatSnapshotStoreOptions, -): Promise { - const path = compatSnapshotPath(opts.configDir) - const ttlMs = opts.ttlMs ?? COMPAT_TTL_MS - const silenceMs = opts.silenceMs ?? WARN_SILENCE_MS - const clock = opts.now ?? (() => new Date()) - const state = new Map() - await readDisk(path, state) - - return { - get: host => state.get(host), - set: async (snapshot) => { - state.set(snapshot.host, snapshot) - await persist(path, state) - }, - isFresh: (snapshot, now) => { - const elapsed = (now ?? clock()).getTime() - parseIso(snapshot.fetchedAt) - return elapsed >= 0 && elapsed < ttlMs - }, - canWarn: (snapshot, now) => { - if (snapshot.lastWarnedAt === undefined) - return true - const elapsed = (now ?? clock()).getTime() - parseIso(snapshot.lastWarnedAt) - return elapsed >= silenceMs - }, - markWarned: async (host, now) => { - const existing = state.get(host) - if (existing === undefined) - return - const stamped = { ...existing, lastWarnedAt: (now ?? clock()).toISOString() } - state.set(host, stamped) - await persist(path, state) - }, - } -} - -function parseIso(value: string): number { - const t = Date.parse(value) - return Number.isNaN(t) ? 0 : t -} - -async function readDisk(path: string, state: Map): Promise { - let raw: string - try { - raw = await readFile(path, 'utf8') - } - catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') - return - throw err - } - let parsed: DiskShape - try { - parsed = JSON.parse(raw) as DiskShape - } - catch { - return - } - if (parsed.schema !== DISK_SCHEMA || parsed.by_host === undefined) - return - for (const [host, entry] of Object.entries(parsed.by_host)) - state.set(host, fromDisk(entry)) -} - -function fromDisk(entry: DiskSnapshot): CompatSnapshot { - return { - host: entry.host, - fetchedAt: entry.fetched_at, - lastWarnedAt: entry.last_warned_at, - server: entry.server, - compat: { - status: entry.compat.status, - detail: entry.compat.detail, - minDify: entry.compat.min_dify, - maxDify: entry.compat.max_dify, - }, - } -} - -function toDisk(snapshot: CompatSnapshot): DiskSnapshot { - const disk: DiskSnapshot = { - host: snapshot.host, - fetched_at: snapshot.fetchedAt, - server: snapshot.server, - compat: { - status: snapshot.compat.status, - detail: snapshot.compat.detail, - min_dify: snapshot.compat.minDify, - max_dify: snapshot.compat.maxDify, - }, - } - if (snapshot.lastWarnedAt !== undefined) - disk.last_warned_at = snapshot.lastWarnedAt - return disk -} - -async function persist(path: string, state: Map): Promise { - const dir = dirname(path) - await mkdir(dir, { recursive: true, mode: DIR_PERM }) - const disk: DiskShape = { schema: DISK_SCHEMA, by_host: {} } - for (const [host, snapshot] of state) - disk.by_host![host] = toDisk(snapshot) - const tmp = `${path}.${process.pid}.${Date.now()}.tmp` - await writeFile(tmp, JSON.stringify(disk), { mode: FILE_PERM }) - await rename(tmp, path) -} diff --git a/cli/src/cache/nudge-store.test.ts b/cli/src/cache/nudge-store.test.ts new file mode 100644 index 00000000000000..90068a1821368b --- /dev/null +++ b/cli/src/cache/nudge-store.test.ts @@ -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 + expect(parsed.schema).toBe(1) + expect((parsed.warned as Record)[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 { + const { mkdir } = await import('node:fs/promises') + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, body) +} diff --git a/cli/src/cache/nudge-store.ts b/cli/src/cache/nudge-store.ts new file mode 100644 index 00000000000000..2a0d0ab9946887 --- /dev/null +++ b/cli/src/cache/nudge-store.ts @@ -0,0 +1,96 @@ +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { DIR_PERM, FILE_PERM } from '../config/dir.js' + +const CACHE_FILE = 'nudge.json' +const DISK_SCHEMA = 1 +export const WARN_INTERVAL_MS = 24 * 60 * 60 * 1000 + +export type NudgeStore = { + readonly canWarn: (host: string, now?: Date) => boolean + readonly markWarned: (host: string, now?: Date) => Promise +} + +export type NudgeStoreOptions = { + readonly configDir: string + readonly now?: () => Date + readonly intervalMs?: number +} + +type DiskShape = { + schema?: number + warned?: Record +} + +export function nudgeStorePath(configDir: string): string { + return join(configDir, 'cache', CACHE_FILE) +} + +export async function loadNudgeStore(opts: NudgeStoreOptions): Promise { + const path = nudgeStorePath(opts.configDir) + const intervalMs = opts.intervalMs ?? WARN_INTERVAL_MS + const clock = opts.now ?? (() => new Date()) + const memory = await readDisk(path) + + return { + canWarn: (host, now) => { + const last = memory.get(host) + if (last === undefined) + return true + const elapsed = Math.max(0, (now ?? clock()).getTime() - last) + return elapsed >= intervalMs + }, + markWarned: async (host, now) => { + const stamp = (now ?? clock()).getTime() + memory.set(host, stamp) + // Re-read disk inside the write cycle so concurrent processes touching + // different hosts don't clobber each other's stamps. Same-host writers + // converge on a near-identical timestamp, so order doesn't matter. + const onDisk = await readDisk(path) + onDisk.set(host, stamp) + await persist(path, onDisk) + }, + } +} + +async function readDisk(path: string): Promise> { + const out = new Map() + let raw: string + try { + raw = await readFile(path, 'utf8') + } + catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') + return out + throw err + } + let parsed: DiskShape + try { + parsed = JSON.parse(raw) as DiskShape + } + catch { + return out + } + if (parsed.schema !== DISK_SCHEMA || parsed.warned === undefined) + return out + for (const [host, iso] of Object.entries(parsed.warned)) { + const t = Date.parse(iso) + if (!Number.isNaN(t)) + out.set(host, t) + } + return out +} + +async function persist(path: string, state: Map): Promise { + const dir = dirname(path) + await mkdir(dir, { recursive: true, mode: DIR_PERM }) + const disk: DiskShape = { schema: DISK_SCHEMA, warned: {} } + for (const [host, t] of state) + disk.warned![host] = new Date(t).toISOString() + // randomUUID is collision-proof even when two writers stamp the same + // millisecond — pid+timestamp alone can still collide under tight loops. + const tmp = `${path}.${randomUUID()}.tmp` + await writeFile(tmp, JSON.stringify(disk), { mode: FILE_PERM }) + await rename(tmp, path) +} diff --git a/cli/src/commands/_shared/authed-command.ts b/cli/src/commands/_shared/authed-command.ts index 35b7cd5c3a61cc..faeb1eefbb97e3 100644 --- a/cli/src/commands/_shared/authed-command.ts +++ b/cli/src/commands/_shared/authed-command.ts @@ -3,10 +3,10 @@ import type { HostsBundle } from '../../auth/hosts.js' import type { AppInfoCache } from '../../cache/app-info.js' import type { Command } from '../../framework/command.js' import type { IOStreams } from '../../io/streams.js' -import { MetaClient } from '../../api/meta.js' +import { META_PROBE_TIMEOUT_MS, MetaClient } from '../../api/meta.js' import { loadHosts } from '../../auth/hosts.js' import { loadAppInfoCache } from '../../cache/app-info.js' -import { loadCompatSnapshotStore } from '../../cache/compat-snapshot.js' +import { loadNudgeStore } from '../../cache/nudge-store.js' import { resolveConfigDir } from '../../config/dir.js' import { BaseError } from '../../errors/base.js' import { ErrorCode } from '../../errors/codes.js' @@ -14,6 +14,7 @@ import { formatErrorForCli } from '../../errors/format.js' import { createClient } from '../../http/client.js' import { realStreams } from '../../io/streams.js' import { hostWithScheme } from '../../util/host.js' +import { versionInfo } from '../../version/info.js' import { maybeNudgeCompat } from '../../version/nudge.js' import { resolveRetryAttempts } from './global-flags.js' @@ -70,13 +71,17 @@ async function runCompatNudge(opts: { readonly io: IOStreams }): Promise { try { - const store = await loadCompatSnapshotStore({ configDir: opts.configDir }) + const store = await loadNudgeStore({ configDir: opts.configDir }) await maybeNudgeCompat(opts.host, { store, - probe: async host => new MetaClient(createClient({ host })).serverVersion(), + probe: async (host) => { + const http = createClient({ host, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 }) + return new MetaClient(http).serverVersion() + }, emit: line => opts.io.err.write(line), isTty: opts.io.isOutTTY, format: opts.io.outputFormat, + clientVersion: versionInfo.version, color: opts.io.isErrTTY, }) } diff --git a/cli/src/commands/version/index.ts b/cli/src/commands/version/index.ts index bbc7644fbb9ee8..b91169b1d4620e 100644 --- a/cli/src/commands/version/index.ts +++ b/cli/src/commands/version/index.ts @@ -1,5 +1,7 @@ import { Flags } from '../../framework/flags.js' -import { formatted, raw } from '../../framework/output.js' +import { formatted, raw, stringifyOutput } from '../../framework/output.js' +import { colorEnabled } from '../../io/color.js' +import { realStreams } from '../../io/streams.js' import { versionInfo } from '../../version/info.js' import { runVersionProbe } from '../../version/probe.js' import { renderVersionText } from '../../version/render.js' @@ -38,16 +40,24 @@ export default class Version extends DifyCommand { 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({ + const io = realStreams(flags.output) + const useColor = colorEnabled(io.isOutTTY) + const output = formatted({ format: flags.output, data: { text: () => renderVersionText(report, { color: useColor }), json: () => report, }, }) + + if (flags['check-compat'] && report.compat.status !== 'compatible') { + // Emit the full report first so `difyctl version -o json --check-compat | jq` + // works exactly like the success path: stdout gets the canonical envelope, + // stderr gets the one-line failure reason, exit code signals the verdict. + process.stdout.write(stringifyOutput(output)) + this.error(report.compat.detail, { exit: COMPAT_FAIL_EXIT_CODE }) + } + + return output } } diff --git a/cli/src/commands/version/version.test.ts b/cli/src/commands/version/version.test.ts index 6518b9e59d54d4..afd2346478c397 100644 --- a/cli/src/commands/version/version.test.ts +++ b/cli/src/commands/version/version.test.ts @@ -66,6 +66,19 @@ describe('Version command', () => { expect(payload.server.reachable).toBe(true) }) + it('threads -o yaml through formatted output (envelope, not text)', async () => { + const output = await new Version().run(['-o', 'yaml']) + expect(output?.kind).toBe('formatted') + if (output?.kind !== 'formatted') + throw new Error('expected formatted output') + expect(output.format).toBe('yaml') + // The same envelope drives json + yaml — assert the shape via the json + // facet (stringifyOutput uses js-yaml.dump on this object). + const payload = output.data.json() as probe.VersionReport + expect(payload.compat.status).toBe('compatible') + expect(payload.server.version).toBe('1.6.4') + }) + it('--short returns a raw single-line semver output', async () => { const orig = info.versionInfo.version Object.assign(info.versionInfo, { version: '0.2.0' }) @@ -105,10 +118,28 @@ describe('Version command', () => { expect(stderrSpy).toHaveBeenCalled() }) + it('--check-compat -o json emits the JSON envelope on stdout before exiting', async () => { + vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ status: 'unsupported' })) + const exitSpy = stubProcessExit() + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + await expect(new Version().run(['--check-compat', '-o', 'json'])).rejects.toThrow('__exit__') + + // stdout must receive a parseable JSON envelope so pipelines like + // `difyctl version -o json --check-compat | jq` still work on failure. + expect(stdoutSpy).toHaveBeenCalled() + const written = stdoutSpy.mock.calls.map(c => String(c[0])).join('') + const parsed = JSON.parse(written) as { compat: { status: string } } + expect(parsed.compat.status).toBe('unsupported') + expect(exitSpy).toHaveBeenCalledWith(COMPAT_FAIL_EXIT_CODE) + }) + it('--check-compat exits 64 when compat is unknown (no server)', async () => { vi.spyOn(probe, 'runVersionProbe').mockResolvedValue(fakeReport({ reachable: false, status: 'unknown' })) const exitSpy = stubProcessExit() vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + vi.spyOn(process.stdout, 'write').mockImplementation(() => true) await expect(new Version().run(['--check-compat'])).rejects.toThrow('__exit__') expect(exitSpy).toHaveBeenCalledWith(COMPAT_FAIL_EXIT_CODE) diff --git a/cli/src/version/build-info.d.ts b/cli/src/version/build-info.d.ts new file mode 100644 index 00000000000000..caa37c23e2ac40 --- /dev/null +++ b/cli/src/version/build-info.d.ts @@ -0,0 +1,9 @@ +// Build-time globals injected by vite-plus. Single source of truth — both +// info.ts (client identity) and compat.ts (supported dify range) read from +// here. Values are computed in scripts/lib/resolve-buildinfo.ts. +declare const __DIFYCTL_VERSION__: string +declare const __DIFYCTL_COMMIT__: string +declare const __DIFYCTL_BUILD_DATE__: string +declare const __DIFYCTL_CHANNEL__: string +declare const __DIFYCTL_MIN_DIFY__: string +declare const __DIFYCTL_MAX_DIFY__: string diff --git a/cli/src/version/compat.test.ts b/cli/src/version/compat.test.ts index 2ab2d324ecebac..6b913a28b1d9bf 100644 --- a/cli/src/version/compat.test.ts +++ b/cli/src/version/compat.test.ts @@ -53,6 +53,16 @@ describe('evaluateCompat', () => { expect(v.detail).toContain('not valid semver') }) + it('clamps malformed server versions to 80 chars in the detail string', () => { + const malicious = 'x'.repeat(10_000) + const v = evaluateCompat(malicious, range) + expect(v.status).toBe('unknown') + // detail = `server version "<=80 chars + ellipsis>" is not valid semver`; + // a bit of leeway for the surrounding text, but nowhere near 10k. + expect(v.detail.length).toBeLessThan(150) + expect(v.detail).toContain('…') + }) + it('returns unknown when compat range itself is not valid semver', () => { const v = evaluateCompat('1.6.4', { minDify: 'foo', maxDify: 'bar' }) expect(v.status).toBe('unknown') diff --git a/cli/src/version/compat.ts b/cli/src/version/compat.ts index b7309f73246f86..b373ad0906bee3 100644 --- a/cli/src/version/compat.ts +++ b/cli/src/version/compat.ts @@ -1,8 +1,5 @@ import { parseRange, satisfies, tryParse } from 'std-semver' -declare const __DIFYCTL_MIN_DIFY__: string -declare const __DIFYCTL_MAX_DIFY__: string - export type DifyCompat = { readonly minDify: string readonly maxDify: string @@ -24,6 +21,12 @@ export type CompatVerdict = { readonly detail: string } +const DETAIL_MAX_LEN = 80 + +function clamp(s: string): string { + return s.length > DETAIL_MAX_LEN ? `${s.slice(0, DETAIL_MAX_LEN)}…` : s +} + export function evaluateCompat( serverVersion: string | undefined, range: DifyCompat = difyCompat, @@ -33,7 +36,7 @@ export function evaluateCompat( const parsedServer = tryParse(serverVersion) if (parsedServer === undefined) - return { status: 'unknown', detail: `server version ${JSON.stringify(serverVersion)} is not valid semver` } + return { status: 'unknown', detail: `server version ${JSON.stringify(clamp(serverVersion))} is not valid semver` } // The compat range is inclusive at both ends, exactly the format compatString prints. const expr = `>=${range.minDify} <=${range.maxDify}` diff --git a/cli/src/version/info.ts b/cli/src/version/info.ts index 1e98dd6cd5f973..5f4b6245e93249 100644 --- a/cli/src/version/info.ts +++ b/cli/src/version/info.ts @@ -1,10 +1,5 @@ import { compatString } from './compat.js' -declare const __DIFYCTL_VERSION__: string -declare const __DIFYCTL_COMMIT__: string -declare const __DIFYCTL_BUILD_DATE__: string -declare const __DIFYCTL_CHANNEL__: string - export type Channel = 'dev' | 'rc' | 'stable' export type VersionInfo = { diff --git a/cli/src/version/nudge.test.ts b/cli/src/version/nudge.test.ts index f4c9a9742bd46b..2eeaa320503198 100644 --- a/cli/src/version/nudge.test.ts +++ b/cli/src/version/nudge.test.ts @@ -1,292 +1,159 @@ import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' -import type { CompatSnapshot, CompatSnapshotStore } from '../cache/compat-snapshot.js' +import type { NudgeStore } from '../cache/nudge-store.js' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { loadCompatSnapshotStore } from '../cache/compat-snapshot.js' +import { loadNudgeStore } from '../cache/nudge-store.js' import { maybeNudgeCompat } from './nudge.js' const HOST = 'https://cloud.dify.ai' const NOW = new Date('2026-05-20T12:00:00.000Z') const fixedNow = () => NOW -function freshSnapshot(overrides: Partial = {}): CompatSnapshot { - return { - host: HOST, - fetchedAt: '2026-05-20T11:00:00.000Z', // 1h before NOW - server: { version: '1.6.4', edition: 'CLOUD' }, - compat: { - status: 'compatible', - detail: 'in range', - minDify: '1.6.0', - maxDify: '1.7.0', - }, - ...overrides, - } -} - type Probe = (host: string) => Promise +const UNSUPPORTED: ServerVersionResponse = { version: '99.0.0', edition: 'SELF_HOSTED' } +const COMPATIBLE: ServerVersionResponse = { version: '1.6.4', edition: 'CLOUD' } + function emitterSpy() { const lines: string[] = [] - return { - emit: (line: string) => lines.push(line), - lines, - } + return { emit: (line: string) => lines.push(line), lines } } -async function buildStore() { - const dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-')) - const store = await loadCompatSnapshotStore({ configDir: dir, now: fixedNow }) - return { dir, store } +function baseDeps(overrides: Partial<{ + store: NudgeStore + probe: Probe + emit: (line: string) => void + isTty: boolean + format: string + clientVersion: string +}> & { store: NudgeStore } & { probe: Probe } & { emit: (line: string) => void }) { + return { + isTty: true, + format: '', + clientVersion: '0.1.0', + now: fixedNow, + ...overrides, + } } describe('maybeNudgeCompat', () => { let dir: string - let store: CompatSnapshotStore + let store: NudgeStore beforeEach(async () => { - ;({ dir, store } = await buildStore()) + dir = await mkdtemp(join(tmpdir(), 'difyctl-nudge-')) + store = await loadNudgeStore({ configDir: dir, now: fixedNow }) }) afterEach(async () => { await rm(dir, { recursive: true, force: true }) }) - it('cold cache + probe ok: persists snapshot, does NOT banner (first-time quiet)', async () => { - const probe: Probe = async () => ({ version: '99.0.0', edition: 'SELF_HOSTED' }) // unsupported - const { emit, lines } = emitterSpy() - - await maybeNudgeCompat(HOST, { - store, - probe, - emit, - isTty: true, - format: '', - now: fixedNow, - }) - - expect(lines).toHaveLength(0) - expect(store.get(HOST)).toBeDefined() - expect(store.get(HOST)!.compat.status).toBe('unsupported') - }) - - it('cold cache + probe rejects: no persist, no banner, no throw', async () => { - const probe: Probe = async () => { - throw new Error('timeout') - } + it('probes + warns when server is unsupported (TTY, text format, never warned)', async () => { + const probe = vi.fn(async () => UNSUPPORTED) const { emit, lines } = emitterSpy() - await maybeNudgeCompat(HOST, { - store, - probe, - emit, - isTty: true, - format: '', - now: fixedNow, - }) + await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit })) - expect(lines).toHaveLength(0) - expect(store.get(HOST)).toBeUndefined() + expect(probe).toHaveBeenCalledOnce() + expect(lines).toHaveLength(1) + expect(lines[0]).toContain('warning:') + expect(lines[0]).toContain('99.0.0') + expect(store.canWarn(HOST)).toBe(false) }) - it('warm fresh cache + compatible: no probe, no banner', async () => { - await store.set(freshSnapshot({ compat: { status: 'compatible', detail: '', minDify: '1.6.0', maxDify: '1.7.0' } })) - const probe = vi.fn() as unknown as Probe + it('does not probe nor warn when throttled (lastWarnedAt within 24h)', async () => { + await store.markWarned(HOST) + const probe = vi.fn(async () => UNSUPPORTED) const { emit, lines } = emitterSpy() - await maybeNudgeCompat(HOST, { - store, - probe, - emit, - isTty: true, - format: '', - now: fixedNow, - }) + await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit })) expect(probe).not.toHaveBeenCalled() expect(lines).toHaveLength(0) }) - it('warm fresh unsupported + never warned + TTY + text: banner fires + markWarned', async () => { - await store.set(freshSnapshot({ compat: { status: 'unsupported', detail: 'oops', minDify: '1.6.0', maxDify: '1.7.0' } })) - const probe = vi.fn() as unknown as Probe + it('warns again after the silence window has elapsed', async () => { + const yesterday = new Date(NOW.getTime() - 25 * 60 * 60 * 1000) + const tStore = await loadNudgeStore({ configDir: dir, now: () => yesterday }) + await tStore.markWarned(HOST) + const probe = vi.fn(async () => UNSUPPORTED) const { emit, lines } = emitterSpy() - await maybeNudgeCompat(HOST, { - store, - probe, - emit, - isTty: true, - format: '', - now: fixedNow, - }) + const freshStore = await loadNudgeStore({ configDir: dir, now: fixedNow }) + await maybeNudgeCompat(HOST, baseDeps({ store: freshStore, probe, emit })) - expect(probe).not.toHaveBeenCalled() + expect(probe).toHaveBeenCalledOnce() expect(lines).toHaveLength(1) - expect(lines[0]).toContain('warning:') - expect(lines[0]).toContain('may be incompatible') - expect(store.get(HOST)!.lastWarnedAt).toBe(NOW.toISOString()) }) - it('warm fresh unsupported + format=json: no banner', async () => { - await store.set(freshSnapshot({ compat: { status: 'unsupported', detail: 'oops', minDify: '1.6.0', maxDify: '1.7.0' } })) + it('does nothing when probe rejects (no warn, no markWarned)', async () => { + const probe: Probe = async () => { + throw new Error('net down') + } const { emit, lines } = emitterSpy() - await maybeNudgeCompat(HOST, { - store, - probe: vi.fn() as unknown as Probe, - emit, - isTty: true, - format: 'json', - now: fixedNow, - }) + await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit })) expect(lines).toHaveLength(0) + expect(store.canWarn(HOST)).toBe(true) }) - it.each(['yaml', 'name'])('warm fresh unsupported + format=%s: no banner', async (format) => { - await store.set(freshSnapshot({ compat: { status: 'unsupported', detail: 'oops', minDify: '1.6.0', maxDify: '1.7.0' } })) + it('does not warn when server is compatible', async () => { + const probe = vi.fn(async () => COMPATIBLE) const { emit, lines } = emitterSpy() - await maybeNudgeCompat(HOST, { - store, - probe: vi.fn() as unknown as Probe, - emit, - isTty: true, - format, - now: fixedNow, - }) + await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit })) + expect(probe).toHaveBeenCalledOnce() expect(lines).toHaveLength(0) + expect(store.canWarn(HOST)).toBe(true) }) - it('warm fresh unsupported + !TTY: no banner', async () => { - await store.set(freshSnapshot({ compat: { status: 'unsupported', detail: 'oops', minDify: '1.6.0', maxDify: '1.7.0' } })) + it('does not warn when server version yields unknown verdict', async () => { + const probe = vi.fn(async () => ({ version: '', edition: 'SELF_HOSTED' } as ServerVersionResponse)) const { emit, lines } = emitterSpy() - await maybeNudgeCompat(HOST, { - store, - probe: vi.fn() as unknown as Probe, - emit, - isTty: false, - format: '', - now: fixedNow, - }) + await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit })) expect(lines).toHaveLength(0) + expect(store.canWarn(HOST)).toBe(true) }) - it('warm fresh unsupported + lastWarnedAt 2h ago: no banner (silence window)', async () => { - await store.set(freshSnapshot({ - compat: { status: 'unsupported', detail: 'oops', minDify: '1.6.0', maxDify: '1.7.0' }, - lastWarnedAt: '2026-05-20T10:00:00.000Z', // 2h before NOW - })) + it.each(['json', 'yaml', 'name'])('skips probe + banner when format=%s', async (format) => { + const probe = vi.fn(async () => UNSUPPORTED) const { emit, lines } = emitterSpy() - await maybeNudgeCompat(HOST, { - store, - probe: vi.fn() as unknown as Probe, - emit, - isTty: true, - format: '', - now: fixedNow, - }) + await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit, format })) + expect(probe).not.toHaveBeenCalled() expect(lines).toHaveLength(0) }) - it('warm fresh unsupported + lastWarnedAt 25h ago: banner fires again', async () => { - await store.set(freshSnapshot({ - compat: { status: 'unsupported', detail: 'oops', minDify: '1.6.0', maxDify: '1.7.0' }, - lastWarnedAt: '2026-05-19T10:00:00.000Z', // 26h before NOW - })) + it('skips probe + banner when stdout is not a TTY', async () => { + const probe = vi.fn(async () => UNSUPPORTED) const { emit, lines } = emitterSpy() - await maybeNudgeCompat(HOST, { - store, - probe: vi.fn() as unknown as Probe, - emit, - isTty: true, - format: '', - now: fixedNow, - }) - - expect(lines).toHaveLength(1) - expect(store.get(HOST)!.lastWarnedAt).toBe(NOW.toISOString()) - }) - - it('stale cache + probe returns now-compatible: refresh, no banner', async () => { - await store.set(freshSnapshot({ - fetchedAt: '2026-05-19T10:00:00.000Z', // 26h before NOW - compat: { status: 'unsupported', detail: 'old', minDify: '1.6.0', maxDify: '1.7.0' }, - })) - const probe: Probe = async () => ({ version: '1.6.4', edition: 'CLOUD' }) - const { emit, lines } = emitterSpy() - - await maybeNudgeCompat(HOST, { - store, - probe, - emit, - isTty: true, - format: '', - now: fixedNow, - }) + await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit, isTty: false })) + expect(probe).not.toHaveBeenCalled() expect(lines).toHaveLength(0) - expect(store.get(HOST)!.compat.status).toBe('compatible') - expect(store.get(HOST)!.fetchedAt).toBe(NOW.toISOString()) }) - it('stale cache + probe rejects: keep prior snapshot, may still banner from stale data', async () => { - await store.set(freshSnapshot({ - fetchedAt: '2026-05-19T10:00:00.000Z', // stale - compat: { status: 'unsupported', detail: 'pre-existing', minDify: '1.6.0', maxDify: '1.7.0' }, - })) - const probe: Probe = async () => { - throw new Error('net down') - } + it('formats the banner with the injected clientVersion (not a global)', async () => { + const probe = vi.fn(async () => UNSUPPORTED) const { emit, lines } = emitterSpy() - await maybeNudgeCompat(HOST, { - store, - probe, - emit, - isTty: true, - format: '', - now: fixedNow, - }) + await maybeNudgeCompat(HOST, baseDeps({ store, probe, emit, clientVersion: '9.9.9-test' })) - expect(lines).toHaveLength(1) - // fetchedAt is NOT updated because probe failed - expect(store.get(HOST)!.fetchedAt).toBe('2026-05-19T10:00:00.000Z') - }) - - it('warm fresh unknown: no banner (unknown is too noisy to alert on)', async () => { - await store.set(freshSnapshot({ - compat: { status: 'unknown', detail: 'who knows', minDify: '1.6.0', maxDify: '1.7.0' }, - })) - const { emit, lines } = emitterSpy() - - await maybeNudgeCompat(HOST, { - store, - probe: vi.fn() as unknown as Probe, - emit, - isTty: true, - format: '', - now: fixedNow, - }) - - expect(lines).toHaveLength(0) + expect(lines[0]).toContain('difyctl 9.9.9-test') }) - it('never throws even if every dependency explodes', async () => { - const explodingStore: CompatSnapshotStore = { - get: () => { throw new Error('get boom') }, - set: async () => { throw new Error('set boom') }, - isFresh: () => { throw new Error('fresh boom') }, - canWarn: () => { throw new Error('warn boom') }, - markWarned: async () => { throw new Error('mark boom') }, + it('never throws even when every dependency explodes', async () => { + const explodingStore: NudgeStore = { + canWarn: () => { throw new Error('canWarn boom') }, + markWarned: async () => { throw new Error('markWarned boom') }, } const probe: Probe = async () => { throw new Error('probe boom') @@ -295,13 +162,10 @@ describe('maybeNudgeCompat', () => { throw new Error('emit boom') } - await expect(maybeNudgeCompat(HOST, { + await expect(maybeNudgeCompat(HOST, baseDeps({ store: explodingStore, probe, emit, - isTty: true, - format: '', - now: fixedNow, - })).resolves.toBeUndefined() + }))).resolves.toBeUndefined() }) }) diff --git a/cli/src/version/nudge.ts b/cli/src/version/nudge.ts index a3807ac56605b5..f5c9866f92c4e0 100644 --- a/cli/src/version/nudge.ts +++ b/cli/src/version/nudge.ts @@ -1,107 +1,68 @@ import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' -import type { CompatSnapshot, CompatSnapshotStore } from '../cache/compat-snapshot.js' -import pc from 'picocolors' +import type { NudgeStore } from '../cache/nudge-store.js' +import { colorScheme } from '../io/color.js' import { difyCompat, evaluateCompat } from './compat.js' -import { versionInfo } from './info.js' +// Formats whose stdout is structured data (json/yaml) or a single name token — +// any stderr banner from us would pollute machine parsing. Default text format +// (the empty string) intentionally falls through and is allowed to warn. const SUPPRESSED_FORMATS: ReadonlySet = new Set(['json', 'yaml', 'name']) export type NudgeDeps = { - readonly store: CompatSnapshotStore - // /openapi/v1/_version is intentionally unauthenticated (mirrors _health), so - // the probe does not need a bearer. + readonly store: NudgeStore + // /openapi/v1/_version is intentionally unauthenticated (mirrors _health), + // so the probe does not need a bearer. readonly probe: (host: string) => Promise readonly emit: (line: string) => void readonly isTty: boolean readonly format: string + readonly clientVersion: string readonly color?: boolean readonly now?: () => Date } -// Public guarantee: never throws. Every internal failure is silenced so that -// the calling authed command continues regardless of probe / disk / parse -// errors. -export async function maybeNudgeCompat( - host: string, - deps: NudgeDeps, -): Promise { +// Public guarantee: never throws. Every internal failure is silenced so the +// calling authed command continues regardless of probe / disk errors. +// +// Order matters: cheap suppression checks (format, TTY, throttle window) run +// before any I/O so the happy path costs nothing in steady state. +export async function maybeNudgeCompat(host: string, deps: NudgeDeps): Promise { try { - const snapshot = await ensureSnapshot(host, deps) - if (snapshot === undefined) + if (!deps.isTty) return - if (!shouldBanner(snapshot, deps)) + if (SUPPRESSED_FORMATS.has(deps.format)) + return + if (!deps.store.canWarn(host, deps.now?.())) return - deps.emit(formatBanner(snapshot, deps.color === true)) - await deps.store.markWarned(host, deps.now?.()) - } - catch { - // swallow: the nudge must never affect the business command - } -} -async function ensureSnapshot( - host: string, - deps: NudgeDeps, -): Promise { - const existing = deps.store.get(host) - if (existing !== undefined && deps.store.isFresh(existing, deps.now?.())) - return existing + let server: ServerVersionResponse + try { + server = await deps.probe(host) + } + catch { + return + } - // stale or missing → try a refresh - let server: ServerVersionResponse - try { - server = await deps.probe(host) - } - catch { - return existing // may be undefined; that signals "first-time-quiet" - } + const verdict = evaluateCompat(server.version) + if (verdict.status !== 'unsupported') + return - const verdict = evaluateCompat(server.version) - const fresh: CompatSnapshot = { - host, - fetchedAt: (deps.now?.() ?? new Date()).toISOString(), - lastWarnedAt: existing?.lastWarnedAt, - server, - compat: { - status: verdict.status, - detail: verdict.detail, - minDify: difyCompat.minDify, - maxDify: difyCompat.maxDify, - }, - } - try { - await deps.store.set(fresh) + deps.emit(formatBanner(deps.clientVersion, server.version, deps.color === true)) + await deps.store.markWarned(host, deps.now?.()).catch(() => { + // disk failure must not propagate; the user already saw the banner. + }) } catch { - // disk failure shouldn't block warn decision + // belt-and-braces: any unexpected throw must not affect the business command } - - // First-time quiet: cold cache only persists; never warns on the very - // first command after install. - if (existing === undefined) - return undefined - - return fresh -} - -function shouldBanner(snapshot: CompatSnapshot, deps: NudgeDeps): boolean { - if (snapshot.compat.status !== 'unsupported') - return false - if (!deps.isTty) - return false - if (SUPPRESSED_FORMATS.has(deps.format)) - return false - if (!deps.store.canWarn(snapshot, deps.now?.())) - return false - return true } -function formatBanner(snapshot: CompatSnapshot, color: boolean): string { - const paint = color ? pc.yellow : (s: string) => s - const { minDify, maxDify } = snapshot.compat +function formatBanner(clientVersion: string, serverVersion: string, color: boolean): string { + const { yellow } = colorScheme(color) + const { minDify, maxDify } = difyCompat const line - = `warning: difyctl ${versionInfo.version} may be incompatible with server ` - + `${snapshot.server.version} (tested: ${minDify}..${maxDify}). ` + = `warning: difyctl ${clientVersion} may be incompatible with server ` + + `${serverVersion} (tested: ${minDify}..${maxDify}). ` + 'Run `difyctl version` for details.' - return `${paint(line)}\n` + return `${yellow(line)}\n` } diff --git a/cli/src/version/probe.test.ts b/cli/src/version/probe.test.ts index ea17b41ebe47e3..28e4335b12dec0 100644 --- a/cli/src/version/probe.test.ts +++ b/cli/src/version/probe.test.ts @@ -1,6 +1,12 @@ import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.gen' import type { HostsBundle } from '../auth/hosts.js' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import { startMock } from '../../test/fixtures/dify-mock/server.js' +import { saveHosts } from '../auth/hosts.js' +import { ENV_CONFIG_DIR } from '../config/dir.js' import { runVersionProbe } from './probe.js' function bundle(overrides: Partial = {}): HostsBundle { @@ -27,6 +33,21 @@ describe('runVersionProbe', () => { expect(report.compat.detail).toContain('skipped') }) + it('passes only the endpoint to probe (no bearer; /_version is unauth)', async () => { + let observed: string | undefined + const report = await runVersionProbe({ + skipServer: false, + loadBundle: async () => bundle({ tokens: { bearer: 'should-not-be-used' } as HostsBundle['tokens'] }), + probe: async (endpoint) => { + observed = endpoint + return { version: '1.6.4', edition: 'CLOUD' } + }, + }) + + expect(observed).toBe('https://cloud.dify.ai') + expect(report.compat.status).toBe('compatible') + }) + it('returns no-host + unknown compat when bundle is missing', async () => { const report = await runVersionProbe({ skipServer: false, @@ -50,15 +71,23 @@ describe('runVersionProbe', () => { expect(report.compat.status).toBe('unknown') }) - it('treats loadBundle throwing as no-host', async () => { - const report = await runVersionProbe({ + it('distinguishes loadBundle disk failure from no-host configured in the detail', async () => { + const errReport = await runVersionProbe({ skipServer: false, loadBundle: async () => { throw new Error('disk-explode') }, probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), }) + expect(errReport.server.reachable).toBe(false) + expect(errReport.server.endpoint).toBe('') + expect(errReport.compat.detail).toContain('unreadable') - expect(report.server.reachable).toBe(false) - expect(report.server.endpoint).toBe('') + const noHostReport = await runVersionProbe({ + skipServer: false, + loadBundle: async () => undefined, + probe: async () => ({ version: '1.6.4', edition: 'CLOUD' }), + }) + expect(noHostReport.compat.detail).toContain('no host') + expect(noHostReport.compat.detail).not.toContain('unreadable') }) it('returns compatible report when server is reachable and in range', async () => { @@ -121,6 +150,41 @@ describe('runVersionProbe', () => { expect(report.server.endpoint).toBe('http://localhost:5001') }) + it('default DI: reads hosts file + probes a real /_version end-to-end', async () => { + // Integration sanity — no DI overrides. Resolves config dir from the + // DIFY_CONFIG_DIR override, reads a real hosts.yml from disk, builds a + // real ky client, and hits the dify-mock /openapi/v1/_version endpoint. + const mock = await startMock() + const configDir = await mkdtemp(join(tmpdir(), 'difyctl-probe-')) + const url = new URL(mock.url) + const prevConfig = process.env[ENV_CONFIG_DIR] + try { + await saveHosts(configDir, { + current_host: url.host, + scheme: url.protocol.replace(':', ''), + token_storage: 'file', + tokens: { bearer: 'dfoa_test' }, + }) + process.env[ENV_CONFIG_DIR] = configDir + + const report = await runVersionProbe({ skipServer: false }) + + expect(report.server.reachable).toBe(true) + expect(report.server.endpoint).toBe(mock.url) + expect(report.server.version).toBe('1.6.4') + expect(report.server.edition).toBe('CLOUD') + expect(report.compat.status).toBe('compatible') + } + finally { + if (prevConfig === undefined) + delete process.env[ENV_CONFIG_DIR] + else + process.env[ENV_CONFIG_DIR] = prevConfig + await mock.stop() + await rm(configDir, { recursive: true, force: true }) + } + }) + it('always includes client metadata in the report', async () => { const report = await runVersionProbe({ skipServer: true, diff --git a/cli/src/version/probe.ts b/cli/src/version/probe.ts index b8ab4f8f342cf7..25af8b3d61027a 100644 --- a/cli/src/version/probe.ts +++ b/cli/src/version/probe.ts @@ -2,7 +2,7 @@ import type { ServerVersionResponse } from '@dify/contracts/api/openapi/types.ge import type { HostsBundle } from '../auth/hosts.js' import type { CompatVerdict } from './compat.js' import type { Channel } from './info.js' -import { MetaClient } from '../api/meta.js' +import { META_PROBE_TIMEOUT_MS, MetaClient } from '../api/meta.js' import { loadHosts } from '../auth/hosts.js' import { resolveConfigDir } from '../config/dir.js' import { createClient } from '../http/client.js' @@ -37,7 +37,9 @@ export type VersionReport = { readonly compat: CompatBlock } -export type MetaProbe = (endpoint: string, bearer: string | undefined) => Promise +// /openapi/v1/_version is intentionally unauthenticated, so the probe does not +// take a bearer. Same signature shape as the auto-nudge probe — easy to swap. +export type MetaProbe = (endpoint: string) => Promise export type RunVersionProbeOptions = { readonly skipServer: boolean @@ -47,8 +49,8 @@ export type RunVersionProbeOptions = { const defaultLoadBundle = async (): Promise => loadHosts(resolveConfigDir()) -const defaultProbe: MetaProbe = async (endpoint, bearer) => { - const http = createClient({ host: endpoint, bearer }) +const defaultProbe: MetaProbe = async (endpoint) => { + const http = createClient({ host: endpoint, timeoutMs: META_PROBE_TIMEOUT_MS, retryAttempts: 0 }) return new MetaClient(http).serverVersion() } @@ -91,27 +93,28 @@ export async function runVersionProbe(opts: RunVersionProbeOptions): Promise = {}): VersionReport['client'] { @@ -23,6 +23,10 @@ function compatible(): VersionReport['compat'] { } } +// Regex matching the ANSI CSI introducer (ESC `[`). +// eslint-disable-next-line no-control-regex +const ANSI_RE = /\[/ + describe('renderVersionText', () => { it('renders all three blocks for a reachable, compatible server', () => { const report: VersionReport = { @@ -85,13 +89,9 @@ describe('renderVersionText', () => { expect(text).toContain('Version: (unreachable)') }) - it('always contains the verdict line on unsupported, regardless of color toggle', () => { - // picocolors no-ops escape sequences when stdout is not a TTY, which is - // the case under vitest, so the colored output may not actually include - // ANSI codes. We only assert that the rendered text is well-formed in - // both modes — the color path running without error is the real test. + it('color=false produces no ANSI escape sequences regardless of TTY state', () => { const report: VersionReport = { - client: baseClient(), + client: baseClient({ channel: 'rc' }), server: { endpoint: 'https://cloud.dify.ai', reachable: true, version: '99.0.0', edition: 'SELF_HOSTED' }, compat: { minDify: '1.6.0', @@ -100,13 +100,56 @@ describe('renderVersionText', () => { detail: 'server 99.0.0 outside [1.6.0, 1.7.0]', }, } - const colored = renderVersionText(report, { color: true }) const plain = renderVersionText(report, { color: false }) + // Negative-side proof: every code path that could colorize (verdict + + // RC warning) ran, yet the output is byte-clean. + expect(plain).not.toMatch(ANSI_RE) + expect(plain).toContain('Compatibility: incompatible') + expect(plain).toContain('WARNING: This build is a release candidate') + }) - for (const out of [colored, plain]) { - expect(out).toContain('Compatibility: incompatible') - expect(out).toContain('outside [1.6.0, 1.7.0]') - } + describe('with picocolors stubbed to always emit ANSI', () => { + // picocolors caches its capability detection at module load, so vitest + // env-var tricks don't change its behavior at runtime. Instead, stub the + // module to return real ANSI-wrapped strings — this proves the color=true + // path actually routes through the colorizer (otherwise the marker is absent). + beforeEach(() => { + vi.resetModules() + vi.doMock('picocolors', () => ({ + default: { + yellow: (s: string) => `${s}`, + dim: (s: string) => `${s}`, + green: (s: string) => `${s}`, + red: (s: string) => `${s}`, + bold: (s: string) => `${s}`, + cyan: (s: string) => `${s}`, + magenta: (s: string) => `${s}`, + }, + })) + }) + afterEach(() => { + vi.doUnmock('picocolors') + vi.resetModules() + }) + + it('color=true emits ANSI sequences for verdict and RC warning lines', async () => { + const { renderVersionText: render } = await import('./render.js') + const report: VersionReport = { + client: baseClient({ channel: 'rc' }), + server: { endpoint: 'https://cloud.dify.ai', reachable: true, version: '99.0.0', edition: 'SELF_HOSTED' }, + compat: { + minDify: '1.6.0', + maxDify: '1.7.0', + status: 'unsupported', + detail: 'server 99.0.0 outside [1.6.0, 1.7.0]', + }, + } + const colored = render(report, { color: true }) + expect(colored).toMatch(ANSI_RE) + expect(colored).toContain('Compatibility: incompatible') + // RC warning lines also routed through yellow. + expect(colored).toContain('release candidate') + }) }) it('terminates output with a trailing newline', () => { diff --git a/cli/src/version/render.ts b/cli/src/version/render.ts index a5f08014940695..1398622df2fa78 100644 --- a/cli/src/version/render.ts +++ b/cli/src/version/render.ts @@ -1,5 +1,5 @@ import type { VersionReport } from './probe.js' -import pc from 'picocolors' +import { colorScheme } from '../io/color.js' const RC_WARNING_LINES = [ 'WARNING: This build is a release candidate. It is in beta test, not stable,', @@ -10,7 +10,7 @@ export type RenderOptions = { readonly color?: boolean } -const COMPAT_GLYPH: Record = { +const COMPAT_LABEL: Record = { compatible: 'ok', unsupported: 'incompatible', unknown: 'unknown', @@ -20,15 +20,8 @@ function shortCommit(commit: string): string { return commit.length > 7 ? commit.slice(0, 7) : commit } -function colorize(useColor: boolean, fn: (s: string) => string): (s: string) => string { - return useColor ? fn : (s: string) => s -} - export function renderVersionText(report: VersionReport, opts: RenderOptions = {}): string { - const useColor = opts.color === true - const yellow = colorize(useColor, pc.yellow) - const dim = colorize(useColor, pc.dim) - + const c = colorScheme(opts.color === true) const lines: string[] = [] const { client, server, compat } = report @@ -41,11 +34,11 @@ export function renderVersionText(report: VersionReport, opts: RenderOptions = { lines.push('Server:') if (server.endpoint === '') { - lines.push(` ${dim('(skipped — no host configured or --client passed)')}`) + lines.push(` ${c.dim('(skipped — no host configured or --client passed)')}`) } else if (!server.reachable) { lines.push(` Endpoint: ${server.endpoint}`) - lines.push(` Version: ${dim('(unreachable)')}`) + lines.push(` Version: ${c.dim('(unreachable)')}`) } else { lines.push(` Endpoint: ${server.endpoint}`) @@ -53,16 +46,13 @@ export function renderVersionText(report: VersionReport, opts: RenderOptions = { } lines.push('') - const verdictText = `Compatibility: ${COMPAT_GLYPH[compat.status]} — ${compat.detail}` - if (compat.status === 'unsupported') - lines.push(yellow(verdictText)) - else - lines.push(verdictText) + const verdictText = `Compatibility: ${COMPAT_LABEL[compat.status]} — ${compat.detail}` + lines.push(compat.status === 'unsupported' ? c.yellow(verdictText) : verdictText) if (client.channel === 'rc') { lines.push('') for (const line of RC_WARNING_LINES) - lines.push(yellow(line)) + lines.push(c.yellow(line)) } return `${lines.join('\n')}\n`