From 65167ef1297bc7a0662a9931193ab21d0d65a6c9 Mon Sep 17 00:00:00 2001 From: Renan Ribeiro Date: Wed, 1 Jul 2026 15:48:17 -0300 Subject: [PATCH] feat: add --engine flag for UE version selection Engine version was hardcoded to UE_5.7. Now configurable via --engine flag on download and sync commands, with smart resolution: - Exact match: proceeds silently - Fallback to lower: informs user, proceeds with highest compatible - Only higher available: shows versions, prompts user to pick or abort - Invalid format (e.g. UE_58): rejects early with clear error Also fixes pre-existing bug where fallback to higher versions failed (bestScore initialized to -1 instead of -Infinity). Refactors getAsset() into resolveAsset() + fetchAssetDetail() so CLI can inspect engine match before committing to manifest fetch. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/api.ts | 116 ++++++++++++++++++++++++++++++++++++++++------------- src/cli.ts | 90 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 172 insertions(+), 34 deletions(-) diff --git a/src/api.ts b/src/api.ts index a9d24c0..4b83854 100644 --- a/src/api.ts +++ b/src/api.ts @@ -122,32 +122,69 @@ function projectVersions(record: Record): ProjectVersion[] { return result; } +export const ENGINE_VERSION_RE = /^UE_\d+\.\d+$/; + // UE_5.7 → 507 (major*100 + minor), for ordering. Returns -1 on unparseable strings. -function parseEngineVersion(s: string): number { +export function parseEngineVersion(s: string): number { const match = s.match(/^UE_(\d+)\.(\d+)/); if (!match) return -1; return parseInt(match[1] ?? "0", 10) * 100 + parseInt(match[2] ?? "0", 10); } -function pickArtifactId(versions: ProjectVersion[], preferredEngine: string): string { +export interface EngineResolution { + artifactId: string; + matchType: "exact" | "fallback" | "higher-only" | "none"; + requested: string; + selected: string; + available: string[]; +} + +function pickArtifact(versions: ProjectVersion[], preferredEngine: string): EngineResolution { + const available = [...new Set(versions.flatMap((v) => v.engineVersions))].sort( + (a, b) => parseEngineVersion(a) - parseEngineVersion(b), + ); + // Exact match wins (e.g., asset offers UE_5.7 and we target UE_5.7). const exact = versions.find((v) => v.engineVersions.includes(preferredEngine)); - if (exact) return exact.artifactId; - // Otherwise highest version wins. UE generally opens older content forward-compatibly, - // so the highest available is the safest fallback. + if (exact) { + return { artifactId: exact.artifactId, matchType: "exact", requested: preferredEngine, selected: preferredEngine, available }; + } + const target = parseEngineVersion(preferredEngine); - let best: ProjectVersion | undefined; - let bestScore = -1; + + // Try versions <= target first (forward-compatible — UE opens older content fine). + let bestLower: ProjectVersion | undefined; + let bestLowerScore = -Infinity; + // Track versions > target separately. + let bestHigher: ProjectVersion | undefined; + let bestHigherScore = Infinity; + for (const v of versions) { const maxVersion = v.engineVersions.reduce((acc, ev) => Math.max(acc, parseEngineVersion(ev)), -1); - // Prefer versions <= target (forward-compatible) but fall back to anything if none match. - const score = maxVersion <= target ? maxVersion : maxVersion - 1000; - if (score > bestScore) { - bestScore = score; - best = v; + if (maxVersion <= target) { + if (maxVersion > bestLowerScore) { + bestLowerScore = maxVersion; + bestLower = v; + } + } else { + if (maxVersion < bestHigherScore) { + bestHigherScore = maxVersion; + bestHigher = v; + } } } - return best?.artifactId ?? ""; + + if (bestLower) { + const selected = bestLower.engineVersions.reduce((a, b) => (parseEngineVersion(a) > parseEngineVersion(b) ? a : b)); + return { artifactId: bestLower.artifactId, matchType: "fallback", requested: preferredEngine, selected, available }; + } + + if (bestHigher) { + const selected = bestHigher.engineVersions.reduce((a, b) => (parseEngineVersion(a) < parseEngineVersion(b) ? a : b)); + return { artifactId: bestHigher.artifactId, matchType: "higher-only", requested: preferredEngine, selected, available }; + } + + return { artifactId: "", matchType: "none", requested: preferredEngine, selected: "", available }; } function summarizeLibraryItem(record: Record): FabAssetSummary { @@ -194,32 +231,43 @@ export async function listLibrary(tokens: AuthTokens): Promise { - // Library results carry the artifact + namespace needed for the manifest call. We re-walk - // the library to find the matching record rather than caching, because the listing is cheap - // and Fab's TTL on the manifest pointers makes stale lookups risky anyway. +): Promise { const library = await listLibrary(tokens); const match = library.find((item) => item.id === assetId); if (!match) { throw new Error(`Asset ${assetId} not found in library`); } - // Verified Fab API 2026-05-23: namespace lives at `assetNamespace` (top-level), artifactId - // lives nested in `projectVersions[].artifactId`. Multi-engine assets carry one entry per - // UE version; we pick the artifact matching `engineVersion` (defaulting to UE_5.7). const namespace = stringField(match.raw, "assetNamespace", "namespace", "ns"); const versions = projectVersions(match.raw); - const artifactId = pickArtifactId(versions, engineVersion); - if (artifactId.length === 0 || namespace.length === 0) { + const resolution = pickArtifact(versions, engineVersion); + + if (namespace.length === 0) { throw new Error( - `Asset ${assetId} lacks artifactId or assetNamespace in library response (found ${versions.length} project versions, namespace=${namespace ? "ok" : "missing"})`, + `Asset ${assetId} lacks assetNamespace in library response`, ); } + return { summary: match, namespace, resolution }; +} + +export async function fetchAssetDetail( + tokens: AuthTokens, + resolved: ResolvedAsset, +): Promise { + const { summary: match, namespace, resolution } = resolved; + const { artifactId } = resolution; + const manifestUrl = `${FAB_HOST}/e/artifacts/${encodeURIComponent(artifactId)}/manifest`; const manifestResponse = await fetch(manifestUrl, { method: "POST", @@ -235,7 +283,7 @@ export async function getAsset( }); if (!manifestResponse.ok) { - throw new Error(`Fab manifest POST failed for ${assetId}: HTTP ${manifestResponse.status}`); + throw new Error(`Fab manifest POST failed for ${match.id}: HTTP ${manifestResponse.status}`); } const manifestPayload = (await manifestResponse.json()) as FabManifestResponse; @@ -261,7 +309,7 @@ export async function getAsset( if (manifestPointers.length === 0) { throw new Error( - `Fab manifest response for ${assetId} contained no manifest URLs (received ${artifacts.length} artifact entries)`, + `Fab manifest response for ${match.id} contained no manifest URLs (received ${artifacts.length} artifact entries)`, ); } @@ -270,7 +318,7 @@ export async function getAsset( // expiry) the caller can re-fetch via getAsset(). Multi-CDN failover lives in download.ts. const primary = manifestPointers[0]; if (!primary) { - throw new Error(`Fab manifest response for ${assetId} had no usable pointer`); + throw new Error(`Fab manifest response for ${match.id} had no usable pointer`); } const manifest = await parseManifest(primary.manifestUrl); @@ -296,6 +344,20 @@ export async function getAsset( }; } +export async function getAsset( + tokens: AuthTokens, + assetId: string, + engineVersion: string = "UE_5.7", +): Promise { + const resolved = await resolveAsset(tokens, assetId, engineVersion); + if (resolved.resolution.artifactId.length === 0) { + throw new Error( + `No engine version match for ${assetId} (requested ${engineVersion}, available: ${resolved.resolution.available.join(", ") || "none"})`, + ); + } + return fetchAssetDetail(tokens, resolved); +} + export function whoami(tokens: AuthTokens): { displayName: string; accountId: string } { return { displayName: tokens.displayName, accountId: tokens.accountId }; } diff --git a/src/cli.ts b/src/cli.ts index 363641f..56a2e8a 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -11,8 +11,17 @@ import { startBrowserAuth, type AuthTokens, } from "./auth.ts"; -import { getAsset, listLibrary, whoami, type FabAssetSummary } from "./api.ts"; +import { + ENGINE_VERSION_RE, + fetchAssetDetail, + listLibrary, + resolveAsset, + whoami, + type FabAssetSummary, + type ResolvedAsset, +} from "./api.ts"; import { downloadAsset } from "./download.ts"; +import { createInterface } from "node:readline/promises"; const USAGE = `epic-fab — Epic Games / Fab.com asset library on Linux @@ -25,6 +34,7 @@ Commands: logout Delete persisted auth tokens Options: + --engine UE engine version for artifact selection (default: UE_5.7) -h, --help Show this help -v, --version Show version `; @@ -58,6 +68,55 @@ function assetSlug(asset: FabAssetSummary): string { return slug.length > 0 ? slug : asset.id; } +async function promptEngineVersion(available: string[]): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question("Enter a version from the list above (or 'q' to abort): "); + const trimmed = answer.trim(); + if (trimmed === "q" || trimmed === "") return null; + if (!available.includes(trimmed)) { + console.error(`"${trimmed}" is not in the available versions.`); + return null; + } + return trimmed; + } finally { + rl.close(); + } +} + +async function handleEngineResolution( + tokens: import("./auth.ts").AuthTokens, + resolved: ResolvedAsset, +): Promise { + const { resolution } = resolved; + + switch (resolution.matchType) { + case "exact": + return resolved; + + case "fallback": + console.error( + `Engine ${resolution.requested} not available for "${resolved.summary.title}". Using ${resolution.selected} (highest compatible version).`, + ); + return resolved; + + case "higher-only": + console.error( + `Engine ${resolution.requested} not available for "${resolved.summary.title}".`, + ); + console.error(`Available versions: ${resolution.available.join(", ")}`); + const picked = await promptEngineVersion(resolution.available); + if (!picked) return null; + return resolveAsset(tokens, resolved.summary.id, picked); + + case "none": + console.error( + `No engine versions found for "${resolved.summary.title}".`, + ); + return null; + } +} + async function cmdAuth(): Promise { try { const tokens = await startBrowserAuth(); @@ -94,7 +153,7 @@ async function cmdList(): Promise { } } -async function cmdDownload(argv: ReadonlyArray): Promise { +async function cmdDownload(argv: ReadonlyArray, engineVersion: string): Promise { const assetId = argv[0]; if (!assetId || assetId.startsWith("-")) { console.error("download: missing "); @@ -109,7 +168,11 @@ async function cmdDownload(argv: ReadonlyArray): Promise { const into = findFlagValue(argv, "--into") ?? "."; try { - const asset = await getAsset(tokens, assetId); + const resolved = await resolveAsset(tokens, assetId, engineVersion); + const finalResolved = await handleEngineResolution(tokens, resolved); + if (!finalResolved) return EXIT_USER_ERROR; + + const asset = await fetchAssetDetail(tokens, finalResolved); const result = await downloadAsset(asset, { targetDir: resolve(into), preserveStructure: true, @@ -146,7 +209,7 @@ async function hasUprojectFile(path: string): Promise { return entries.some((entry) => entry.endsWith(".uproject")); } -async function cmdSync(argv: ReadonlyArray): Promise { +async function cmdSync(argv: ReadonlyArray, engineVersion: string): Promise { const projectPath = findFlagValue(argv, "--project"); if (!projectPath) { console.error("sync: missing --project "); @@ -175,7 +238,14 @@ async function cmdSync(argv: ReadonlyArray): Promise { const synced: Array<{ id: string; title: string; files: number; bytes: number }> = []; for (const item of items) { - const detail = await getAsset(tokens, item.id); + const resolved = await resolveAsset(tokens, item.id, engineVersion); + const finalResolved = await handleEngineResolution(tokens, resolved); + if (!finalResolved) { + console.error(`Skipping "${item.title}" (no compatible engine version)`); + continue; + } + + const detail = await fetchAssetDetail(tokens, finalResolved); const targetDir = join(fabRoot, assetSlug(detail)); const result = await downloadAsset(detail, { targetDir, preserveStructure: true }); synced.push({ @@ -234,6 +304,12 @@ async function main(argv: string[]): Promise { } const rest = argv.slice(1); + const engineVersion = findFlagValue(argv, "--engine") ?? "UE_5.7"; + + if (!ENGINE_VERSION_RE.test(engineVersion)) { + console.error(`Invalid --engine value: "${engineVersion}". Expected format: UE_X.Y (e.g., UE_5.7)`); + return EXIT_USER_ERROR; + } switch (command) { case "auth": @@ -241,9 +317,9 @@ async function main(argv: string[]): Promise { case "list": return cmdList(); case "download": - return cmdDownload(rest); + return cmdDownload(rest, engineVersion); case "sync": - return cmdSync(rest); + return cmdSync(rest, engineVersion); case "whoami": return cmdWhoami(); case "logout":