import { SDK_API_CONTRACT, SDK_API_MAJOR, SDK_VERSION } from './version.js'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; import { sdkCliStateDirPath } from './config.js'; export type SdkCompatibilityStatus = | 'current' | 'update_available' | 'deprecated' | 'unsupported'; export type SdkCompatibilityResponse = { ok: boolean; status: SdkCompatibilityStatus; current: string | null; latest: string; minimum_supported: string; deprecated_below: string; api_contract: string; update_available: boolean; update_required: boolean; message: string; update_command: string; update_summary?: { version: string; summary: string; }; command?: string | null; /** * Present only when the queried command postdates this CLI. Advisory: the * client is not unsupported, it simply predates the command. */ command_introduced_in?: { command: string; introduced_in: string; reason: string; }; auto_update?: { should_auto_update: boolean; required: boolean; reason: 'required' | 'deprecated' | 'patch_lag' | 'rollback_forced' | null; patch_lag: number | null; patch_lag_threshold: number; update_command: string; }; cli_family?: { action: 'force_python'; reason: 'rollback_forced'; }; skills?: SdkCompatibilitySkillsUpdate; }; export type SdkCompatibilitySkillsUpdate = { needs_update: boolean; local: { version: string | null; }; remote: { version: string; }; }; const CHECK_TIMEOUT_MS = 2_000; export const SDK_COMPATIBILITY_CACHE_TTL_MS = 5 * 60 * 1000; type CompatCacheFile = { entries?: Record< string, { savedAt: number; response: SdkCompatibilityResponse; } >; }; function shouldSkipCompatibilityCheck(): boolean { const value = process.env.DEEPLINE_SKIP_SDK_COMPAT_CHECK?.trim().toLowerCase(); return value === '1' || value === 'true' || value === 'yes'; } export function sdkCompatibilityCachePath( baseUrl: string, homeDir = homedir(), ): string { return join(sdkCliStateDirPath(baseUrl, homeDir), 'compat-cache.json'); } function legacySdkCompatibilityCachePath(homeDir = homedir()): string { return join(homeDir, '.cache', 'deepline', 'sdk-compat-cache.json'); } function compatibilityCacheKey( baseUrl: string, command: string | null | undefined, skillsVersion: string | null | undefined, ): string { return JSON.stringify({ baseUrl: baseUrl.replace(/\/$/, ''), version: SDK_VERSION, apiContract: SDK_API_CONTRACT, apiMajor: SDK_API_MAJOR, command: command?.trim() || null, skillsVersion: skillsVersion ?? null, }); } function legacyCompatibilityCacheKey( baseUrl: string, command: string | null | undefined, ): string { return JSON.stringify({ baseUrl: baseUrl.replace(/\/$/, ''), version: SDK_VERSION, apiContract: SDK_API_CONTRACT, command: command?.trim() || null, }); } function readCachedCompatibility( baseUrl: string, command: string | null | undefined, skillsVersion: string | null | undefined, ): SdkCompatibilityResponse | null { try { const path = sdkCompatibilityCachePath(baseUrl); const legacyPath = legacySdkCompatibilityCachePath(); const useLegacyCache = !existsSync(path) && existsSync(legacyPath); const cachePath = useLegacyCache ? legacyPath : path; if (!existsSync(cachePath)) { return null; } const parsed = JSON.parse( readFileSync(cachePath, 'utf8'), ) as CompatCacheFile; const entry = parsed.entries?.[ compatibilityCacheKey(baseUrl, command, skillsVersion) ] ?? (useLegacyCache ? parsed.entries?.[legacyCompatibilityCacheKey(baseUrl, command)] : undefined); if (!entry || Date.now() - entry.savedAt > SDK_COMPATIBILITY_CACHE_TTL_MS) { return null; } return entry.response; } catch { return null; } } function writeCachedCompatibility( baseUrl: string, command: string | null | undefined, skillsVersion: string | null | undefined, response: SdkCompatibilityResponse, ): void { try { const path = sdkCompatibilityCachePath(baseUrl); const existing = existsSync(path) ? (JSON.parse(readFileSync(path, 'utf8')) as CompatCacheFile) : {}; const entries = existing.entries ?? {}; entries[compatibilityCacheKey(baseUrl, command, skillsVersion)] = { savedAt: Date.now(), response, }; mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify({ entries }, null, 2)}\n`); } catch { // Compatibility checks are advisory unless the server explicitly responds // with a blocking policy. Cache failures must not block normal CLI usage. } } export async function checkSdkCompatibility( baseUrl: string, options: { command?: string | null; skillsVersion?: string | null } = {}, ): Promise<{ response: SdkCompatibilityResponse | null; error: Error | null; }> { if (shouldSkipCompatibilityCheck()) { return { response: null, error: null }; } const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS); try { const url = new URL('/api/v2/sdk/compat', baseUrl); url.searchParams.set('version', SDK_VERSION); if (options.command?.trim()) { url.searchParams.set('command', options.command.trim()); } if (options.skillsVersion !== undefined) { url.searchParams.set('skills_version', options.skillsVersion ?? ''); } const response = await fetch(url, { method: 'GET', headers: { 'User-Agent': `deepline-ts-sdk/${SDK_VERSION}`, 'X-Deepline-SDK-Version': SDK_VERSION, 'X-Deepline-API-Major': String(SDK_API_MAJOR), 'X-Deepline-API-Contract': SDK_API_CONTRACT, }, signal: controller.signal, }); const data = (await response .json() .catch(() => null)) as SdkCompatibilityResponse | null; if (data) { writeCachedCompatibility( baseUrl, options.command, options.skillsVersion, data, ); } return { response: data, error: null }; } catch (error) { // Live compat policy must win when the server is reachable: rollback and // support-floor changes are blocking controls. The cache is only a // resilience fallback for transient network failures. const cached = readCachedCompatibility( baseUrl, options.command, options.skillsVersion, ); if (cached) { return { response: cached, error: null }; } return { response: null, error: error instanceof Error ? error : new Error(String(error)), }; } finally { clearTimeout(timeout); } } export function enforceSdkCompatibilityResponse( response: SdkCompatibilityResponse | null, ): void { if (!response) { return; } if (response.update_required) { throw new Error(response.message); } if ( response.status === 'deprecated' || response.status === 'update_available' ) { process.stderr.write(`${response.message}\n`); } } export async function enforceSdkCompatibility( baseUrl: string, options: { command?: string | null } = {}, ): Promise { const { response, error } = await checkSdkCompatibility(baseUrl, options); if (error || !response) { return; } enforceSdkCompatibilityResponse(response); }