Files
EpicNext-Cms/scripts/migrate-nitro-to-hab.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

304 lines
9.1 KiB
TypeScript

#!/usr/bin/env tsx
/**
* Rename on-disk furniture bundles from `.nitro` to `.hab`.
*
* Imports have written `<classname>.hab` since the bundle-extension switch, but
* everything already on disk kept its old name. That matters at runtime: the
* client asks for `.hab`, so a catalogue whose assets are still `.nitro` shows
* furniture that renders as nothing. This script closes that gap.
*
* It is deliberately conservative:
* - refuses to run without `--dry-run` or `--yes`;
* - never overwrites an existing `.hab` — a conflict is reported, not resolved;
* - never deletes anything, so a half-finished run is recoverable by hand;
* - idempotent: a second run over migrated dirs is a no-op.
*
* Run it in a maintenance window. The rename is per-file, so a client request
* for a given `.nitro` 404s from the moment that file is renamed until the
* client asks for `.hab`.
*
* Usage:
* pnpm tsx scripts/migrate-nitro-to-hab.ts --dry-run
* pnpm tsx scripts/migrate-nitro-to-hab.ts --yes
* pnpm tsx scripts/migrate-nitro-to-hab.ts --yes --dir /var/www/extra/bundles
* pnpm tsx scripts/migrate-nitro-to-hab.ts --yes --skip-generic
*/
import "./load-env";
import { existsSync, promises as fs } from "node:fs";
import path from "node:path";
import { db } from "@/lib/db";
import {
getFurniAssetWriteTargets,
getGamedataRoot,
} from "@/lib/services/furni-asset-dirs";
import { getPublicAssetRoot } from "@/lib/services/public-asset-root";
import { siteSettings } from "@/lib/services/site-settings";
import { getRuntimePath } from "@/lib/utils/runtime-path";
const LEGACY_EXT = ".nitro";
const TARGET_EXT = ".hab";
interface Args {
dryRun: boolean;
yes: boolean;
skipGeneric: boolean;
dirs: string[];
}
function parseArgs(argv: string[]): Args {
const dirs: string[] = [];
let dryRun = false;
let yes = false;
let skipGeneric = false;
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--dry-run") dryRun = true;
else if (arg === "--yes" || arg === "-y") yes = true;
else if (arg === "--skip-generic") skipGeneric = true;
else if (arg === "--dir") {
const value = argv[++i];
if (!value) throw Error("--dir needs a path");
dirs.push(path.resolve(value));
} else throw Error(`Unknown argument: ${arg}`);
}
return { dryRun, yes, skipGeneric, dirs };
}
/**
* Where the app keeps bundles. Mirrors the resolution in figure-import.ts /
* effect-import.ts / pet-import.ts so the script follows the same site settings
* the running instance uses. Settings are best-effort: a database that is down
* must not stop an operator from migrating files, so failures fall back to the
* on-disk defaults rather than aborting.
*/
async function resolveDirs(skipGeneric: boolean): Promise<string[]> {
const found: string[] = [];
const push = (dir: string | null | undefined) => {
if (dir?.trim()) found.push(path.resolve(dir.trim()));
};
// Furniture: primary + every configured mirror (gamedata, nitro-files).
try {
const targets = await getFurniAssetWriteTargets();
push(targets.nitroDir);
for (const mirror of targets.mirrorDirs) push(mirror.nitroDir);
} catch (error) {
console.warn(
` ! could not read furniture asset settings (${(error as Error).message}); using defaults`,
);
push(
getRuntimePath(process.cwd(), "public/nitro-assets/bundled/furniture"),
);
push("/var/www/Gamedata/bundled/furniture");
}
let gamedataRoot = "";
try {
gamedataRoot = await getGamedataRoot();
} catch {
/* defaults below cover it */
}
for (const type of ["figure", "effect"]) {
let configured = "";
try {
configured = (
(await siteSettings.get(`${type}_nitro_dir`, "")) ?? ""
).trim();
} catch {
/* fall through to defaults */
}
if (configured) push(configured);
else if (gamedataRoot)
push(getRuntimePath(gamedataRoot, `bundled/${type}`));
else
push(
getRuntimePath(
getPublicAssetRoot(),
`public/nitro-assets/bundled/${type}`,
),
);
}
// Pets: the CMS dir is `pet`, this deployment's gamedata dir is `pets`.
// Both spellings are listed so neither is silently skipped.
push(getRuntimePath(getPublicAssetRoot(), "public/nitro-assets/bundled/pet"));
if (gamedataRoot) {
push(getRuntimePath(gamedataRoot, "bundled/pet"));
push(getRuntimePath(gamedataRoot, "bundled/pets"));
}
// `generic` holds the stock client UI bundles (selection_arrow, room,
// tile_cursor, place_holder…) that this CMS never imported. They are included
// by default because the renderer's `generic.asset.url` template resolves to
// `.hab` too — leaving them behind breaks the room view, not just furniture.
if (!skipGeneric && gamedataRoot) {
push(getRuntimePath(gamedataRoot, "bundled/generic"));
}
return [...new Set(found)];
}
interface DirResult {
dir: string;
renamed: number;
alreadyHab: number;
conflicts: Array<{ from: string; to: string }>;
errors: Array<{ file: string; message: string }>;
}
async function migrateDir(dir: string, dryRun: boolean): Promise<DirResult> {
const result: DirResult = {
dir,
renamed: 0,
alreadyHab: 0,
conflicts: [],
errors: [],
};
let entries: string[];
try {
entries = await fs.readdir(dir);
} catch (error) {
result.errors.push({ file: dir, message: (error as Error).message });
return result;
}
for (const entry of entries) {
if (!entry.toLowerCase().endsWith(LEGACY_EXT)) continue;
const from = path.join(dir, entry);
const to = path.join(
dir,
`${entry.slice(0, -LEGACY_EXT.length)}${TARGET_EXT}`,
);
// Never clobber. A pre-existing `.hab` is a live bundle the client is
// already serving; leaving the `.nitro` alone is the only safe answer.
if (existsSync(to)) {
result.conflicts.push({
from: path.basename(from),
to: path.basename(to),
});
continue;
}
if (dryRun) {
result.renamed++;
continue;
}
try {
await fs.rename(from, to);
result.renamed++;
} catch (error) {
result.errors.push({ file: entry, message: (error as Error).message });
}
}
// Report the target state too, so a run confirms the end condition rather
// than just the work it did.
for (const entry of await fs.readdir(dir)) {
if (entry.toLowerCase().endsWith(TARGET_EXT)) result.alreadyHab++;
}
return result;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.dryRun && !args.yes) {
console.error(
"Nothing to do: pass --dry-run to preview, or --yes to rename for real.",
);
process.exitCode = 2;
}
const dirs = [
...new Set([...args.dirs, ...(await resolveDirs(args.skipGeneric))]),
];
const existing = dirs.filter((dir) => existsSync(dir));
console.log(
args.dryRun
? "DRY RUN — no files will be touched."
: "Renaming .nitro bundles to .hab.",
);
if (!args.dryRun) {
console.log(
"Run this in a maintenance window: the client 404s on each file between its rename and its switch to .hab.",
);
}
console.log(`\nDirectories (${existing.length} of ${dirs.length} exist):`);
for (const dir of existing) console.log(` ${dir}`);
const missing = dirs.filter((dir) => !existsSync(dir));
if (missing.length) {
console.log(`\nNot present, skipped:`);
for (const dir of missing) console.log(` ${dir}`);
}
if (!existing.length) {
console.log("\nNo bundle directories found — nothing to migrate.");
return;
}
console.log("");
const results: DirResult[] = [];
for (const dir of existing) {
results.push(await migrateDir(dir, args.dryRun));
}
let totalRenamed = 0;
let totalHab = 0;
let totalConflicts = 0;
let totalErrors = 0;
for (const result of results) {
totalRenamed += result.renamed;
totalHab += result.alreadyHab;
totalConflicts += result.conflicts.length;
totalErrors += result.errors.length;
console.log(
`${result.dir}\n` +
` ${args.dryRun ? "would rename" : "renamed"}: ${result.renamed}\n` +
` .hab present: ${result.alreadyHab}`,
);
for (const conflict of result.conflicts) {
console.log(
` CONFLICT: ${conflict.from} — ${conflict.to} already exists`,
);
}
for (const error of result.errors) {
console.log(` ERROR: ${error.file} — ${error.message}`);
}
}
console.log(
`\n${args.dryRun ? "Would rename" : "Renamed"} ${totalRenamed} file(s). ` +
`${totalHab} .hab bundle(s) present afterwards.`,
);
if (totalConflicts) {
console.log(
`\n${totalConflicts} conflict(s): a .hab with that name already exists. ` +
"The .nitro was left in place. Resolve these by hand — the client can only load one of the two.",
);
}
if (totalErrors)
console.log(`\n${totalErrors} error(s); re-run once they are fixed.`);
// Non-zero on a partial migration so a wrapper cannot report success.
if (totalConflicts || totalErrors) process.exitCode = 1;
}
// The settings lookups open a mysql2 pool, which keeps the event loop alive —
// the script prints its whole report and then sits there burning a timeout
// instead of exiting. Closing the pool is not enough on its own here, so the
// exit is explicit, matching scripts/furni-diagnose-now.ts.
main()
.catch((error) => {
console.error(error);
process.exitCode = 1;
})
.then(async () => {
await db.$client.end().catch(() => {});
process.exit(process.exitCode ?? 0);
});