Files
EpicNext-Cms/src/lib/furni/bundle-file.ts
T
openhands adffac7360
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 29s
CI / tests-unit (push) Failing after 1m37s
CI / tests-integration (push) Successful in 1m37s
CI / tests-ui (push) Successful in 2m17s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
feat(catalog): store furniture bundles as .hab instead of .nitro
Every bundle the CMS writes — upload, clone, sync, repair and the pet /
effect / figure importers — now lands as `<classname>.hab`, the extension
this deployment's renderer asks for. `.hab` and `.nitro` are the same
container, so an upload of either extension is accepted.

Resolution goes through one module, src/lib/furni/bundle-file.ts, so
nothing has to know the extension twice. Every existence check probes
`.hab` first and falls back to `.nitro`: the on-disk asset set is still
predominantly `.nitro`, and without the fallback Studio would report every
imported item as missing and the cleanup scan would classify 18k live
bundles as fake leftovers. Downloads are unchanged — Habbo's CDN and every
configured clone source still serve `.nitro`, so the conversion happens on
write, not on request.

Deliberately unchanged: the staged-attachment store in furni-attachment.ts
keys on a UUID and never reaches the client, so renaming it would break
in-flight recovery jobs.

Adds scripts/migrate-nitro-to-hab.ts to rename the existing asset set. It
refuses to run without --dry-run or --yes, never overwrites an existing
.hab, never deletes, and is idempotent.

Note: renderer-config.json lives outside this repo and was patched to
.hab separately; that file is served with a 30-day max-age, so returning
clients need a cms-client cache purge to pick the change up.
2026-10-10 17:09:36 +02:00

92 lines
3.0 KiB
TypeScript

import { existsSync } from "node:fs";
import path from "node:path";
import { LOCAL_BUNDLE_EXT, LOCAL_BUNDLE_EXTS } from "./classname";
/**
* Local furniture-bundle lookups.
*
* Bundles are written as `<classname>.hab`, but a large body of pre-existing
* assets is still `.nitro`, so every existence check has to consider both. These
* helpers are the single place that knows that, which keeps "is this item's
* asset present?" from silently flipping to `false` for legacy files and the
* nitro-cleanup scan from classifying them as fake leftovers.
*
* Remote URLs deliberately do NOT go through here: sources still serve `.nitro`.
*/
export function bundleFileNames(base: string): string[] {
return LOCAL_BUNDLE_EXTS.map((ext) => `${base}${ext}`);
}
/** The classname stem of a bundle file name, or null for any other extension. */
export function stripBundleExt(fileName: string): string | null {
for (const ext of LOCAL_BUNDLE_EXTS) {
if (fileName.toLowerCase().endsWith(ext)) {
return fileName.slice(0, -ext.length);
}
}
return null;
}
/** Path of `base`'s bundle inside `dir`, or null when neither extension is there. */
export function resolveBundleInDir(dir: string, base: string): string | null {
for (const fileName of bundleFileNames(base)) {
const candidate = path.join(dir, fileName);
if (existsSync(candidate)) return candidate;
}
return null;
}
/** File name of `base`'s bundle inside `dir`, preferring `.hab`. */
export function resolveBundleFileNameInDir(
dir: string,
base: string,
): string | null {
for (const fileName of bundleFileNames(base)) {
if (existsSync(path.join(dir, fileName))) return fileName;
}
return null;
}
export function bundleExistsInDir(dir: string, base: string): boolean {
return resolveBundleInDir(dir, base) !== null;
}
/** True when the bundle exists in `dir` or in any mirror dir. */
export function bundleExistsInDirs(dirs: string[], base: string): boolean {
return dirs.some((dir) => bundleExistsInDir(dir, base));
}
/** First hit across `dirs`, primary first — callers pass the primary dir first. */
export function resolveBundleInDirs(
dirs: string[],
base: string,
): string | null {
for (const dir of dirs) {
const found = resolveBundleInDir(dir, base);
if (found) return found;
}
return null;
}
/**
* The path a newly imported bundle should be written to. Colliding with an
* existing `.nitro` is reported so the caller can decide — writing `.hab`
* beside a live `.nitro` would leave two competing bundles.
*/
export function bundleWritePathInDir(dir: string, base: string): string {
return path.join(dir, `${base}${LOCAL_BUNDLE_EXT}`);
}
/** Every existing bundle file for `base`, across all dirs — for cleanup/deletes. */
export function existingBundlePaths(dirs: string[], base: string): string[] {
const out: string[] = [];
for (const dir of dirs) {
for (const fileName of bundleFileNames(base)) {
const candidate = path.join(dir, fileName);
if (existsSync(candidate)) out.push(candidate);
}
}
return out;
}