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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 89 additions & 27 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,32 +122,69 @@ function projectVersions(record: Record<string, unknown>): 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<string, unknown>): FabAssetSummary {
Expand Down Expand Up @@ -194,32 +231,43 @@ export async function listLibrary(tokens: AuthTokens): Promise<FabAssetSummary[]
return items;
}

export async function getAsset(
export interface ResolvedAsset {
summary: FabAssetSummary;
namespace: string;
resolution: EngineResolution;
}

export async function resolveAsset(
tokens: AuthTokens,
assetId: string,
engineVersion: string = "UE_5.7",
): Promise<FabAssetDetail> {
// 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<ResolvedAsset> {
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<FabAssetDetail> {
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",
Expand All @@ -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;
Expand All @@ -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)`,
);
}

Expand All @@ -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);

Expand All @@ -296,6 +344,20 @@ export async function getAsset(
};
}

export async function getAsset(
tokens: AuthTokens,
assetId: string,
engineVersion: string = "UE_5.7",
): Promise<FabAssetDetail> {
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 };
}
90 changes: 83 additions & 7 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -25,6 +34,7 @@ Commands:
logout Delete persisted auth tokens

Options:
--engine <version> UE engine version for artifact selection (default: UE_5.7)
-h, --help Show this help
-v, --version Show version
`;
Expand Down Expand Up @@ -58,6 +68,55 @@ function assetSlug(asset: FabAssetSummary): string {
return slug.length > 0 ? slug : asset.id;
}

async function promptEngineVersion(available: string[]): Promise<string | null> {
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<ResolvedAsset | null> {
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<number> {
try {
const tokens = await startBrowserAuth();
Expand Down Expand Up @@ -94,7 +153,7 @@ async function cmdList(): Promise<number> {
}
}

async function cmdDownload(argv: ReadonlyArray<string>): Promise<number> {
async function cmdDownload(argv: ReadonlyArray<string>, engineVersion: string): Promise<number> {
const assetId = argv[0];
if (!assetId || assetId.startsWith("-")) {
console.error("download: missing <asset-id>");
Expand All @@ -109,7 +168,11 @@ async function cmdDownload(argv: ReadonlyArray<string>): Promise<number> {

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,
Expand Down Expand Up @@ -146,7 +209,7 @@ async function hasUprojectFile(path: string): Promise<boolean> {
return entries.some((entry) => entry.endsWith(".uproject"));
}

async function cmdSync(argv: ReadonlyArray<string>): Promise<number> {
async function cmdSync(argv: ReadonlyArray<string>, engineVersion: string): Promise<number> {
const projectPath = findFlagValue(argv, "--project");
if (!projectPath) {
console.error("sync: missing --project <path>");
Expand Down Expand Up @@ -175,7 +238,14 @@ async function cmdSync(argv: ReadonlyArray<string>): Promise<number> {
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({
Expand Down Expand Up @@ -234,16 +304,22 @@ async function main(argv: string[]): Promise<number> {
}

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":
return cmdAuth();
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":
Expand Down