diff --git a/scripts/README.md b/scripts/README.md index 2fcb4668..6e542fff 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -66,13 +66,49 @@ The menu asks which sources to compare (Habbo (COM) is the preset) and what may be written. With no arguments it needs a terminal; with any flag it skips the menu, so it is usable from cron. +Every step the engine exposes is available as a flag: + +| Flag | What it does | +| :--- | :--- | +| `--sources=` | Compare against these sources (id or display name) | +| `--icons` | Re-download missing furniture icons | +| `--nitros` | Re-download missing bundles (stored as `.hab`) | +| `--furnidata` | Repair `FurnitureData.json` (add missing, drop duplicates) | +| `--structure` | Repair orphaned / duplicate catalog rows | +| `--check-ids` | Check `FurnitureData.json` for spriteId conflicts | +| `--sql` / `--apply-sql` | Generate / apply the catalog SQL | +| `--organize-sql` | Also emit the `catalog_pages` INSERTs into the SQL file | +| `--import-new` | Import furniture the source has and this hotel lacks | +| `--only=`, `--limit=N`, `--concurrency=N` | Narrow and tune the import | +| `--reconcile` | Align `FurnitureData.json` with `items_base` after importing | +| `--fix-sprite-ids` | Correct `items_base.sprite_id` drift | +| `--rebuild-offers` | Rebuild catalog `offer_id` mirrors | +| `--align-ids` | Move `items_base` ids onto their furnidata ids (report; `--apply` runs it) | +| `--rename-bundles` | Rename legacy `.nitro` bundles to `.hab` (report; `--apply` runs it) | +| `--everything` | Every repair + import step, but still none of the destructive ones | + +`--import-new` is the step that actually adds furniture: `cloneSingleFurni` +downloads the icon and the bundle (sources serve `.nitro`, the local copy is +stored as `.hab`), appends the `FurnitureData.json` entry **and** creates the +catalog page plus the offer, exactly like the app's own import. It skips +anything already present, so it is safe to re-run. + Safety notes: - **An audit writes nothing** — no repair flag means read-only. -- `--apply-sql` is the only flag that changes the catalog, and the generated SQL - is always written to `storage/catalog-sync/*.sql` (gitignored) so it can be - reviewed or replayed by hand. +- `--dry-run` together with `--import-new` lists what would be imported and + imports nothing; use it first on a source before importing thousands of items. +- Generating the catalog SQL creates the `catalog_pages` sub-pages it references + (same as the app's import), so a "generate only" run is not entirely + read-only — only the `catalog_items` rows wait for `--apply-sql`. The SQL also + lands in `storage/catalog-sync/*.sql` (gitignored) so it can be reviewed or + replayed by hand. +- `--apply-sql` is the only flag that changes the catalog; `--apply` authorises + the two heavyweight child scripts (`align-db-ids`, `migrate-nitro-to-hab`). + Both only report without it. - The engine is the same one the admin *Catalog audit* page streams (`runCatalogAudit`), so a terminal run and an in-admin run agree. - `--json` prints one machine-readable report; `--fail-on-errors` exits `3` when error-severity issues remain, which is what a scheduled check should use. +- Items that no compared source can provide stay missing: the run reports them + instead of inventing an icon or a bundle. diff --git a/scripts/catalog-sync.sh b/scripts/catalog-sync.sh index 1c2a8865..7f2b0cce 100755 --- a/scripts/catalog-sync.sh +++ b/scripts/catalog-sync.sh @@ -99,9 +99,15 @@ What should be done? (comparing with: $SOURCE_LABEL) 1) Audit only — nothing is written 2) Repair furniture icons + nitro bundles 3) 2 + FurnitureData.json + catalog structure - 4) 3 + generate the catalog SQL (saved, not applied) - 5) 4 + APPLY the catalog SQL to the catalog - q) Cancel + 4) 3 + generate the catalog SQL (saved, not applied) + 5) 4 + APPLY the catalog SQL to the catalog + 6) 5 + import the furniture the source has and this hotel lacks + (downloads icon + bundle, adds FurnitureData.json, creates the catalog + page and the offer for every new item) + 7) 6 + reconcile FurnitureData.json / sprite ids / offer ids + 8) 7 + align items_base ids and rename legacy .nitro bundles (heaviest) + d) Dry run — only report what 6 would import + q) Cancel EOF read -r -p "Action [1]: " ACTION_CHOICE @@ -132,6 +138,35 @@ case "$ACTION_CHOICE" in ACTION_FLAGS=(--all --sql --apply-sql) CONFIRM="This writes to the catalog. Continue? [y/N]: " ;; + 6) + ACTIONS=(audit "everything + catalog SQL + import new furniture") + ACTION_FLAGS=(--all --sql --apply-sql --import-new) + CONFIRM="This writes to the catalog AND imports every missing item. Continue? [y/N]: " + ;; + 7) + ACTIONS=(audit "everything + import + reconcile") + ACTION_FLAGS=( + --all --sql --apply-sql --import-new --reconcile --fix-sprite-ids + --rebuild-offers + ) + CONFIRM="This writes to the catalog AND imports every missing item. Continue? [y/N]: " + ;; + 8) + ACTIONS=(audit "everything + import + reconcile + id alignment + rename") + ACTION_FLAGS=( + --all --sql --apply-sql --import-new --reconcile --fix-sprite-ids + --rebuild-offers --align-ids --rename-bundles --apply + ) + read -r -p " This rewrites items_base ids and renames bundle files. Type RUN to confirm: " REPLY + if [ "$REPLY" != "RUN" ]; then + echo "Cancelled." + exit 0 + fi + ;; + d | D) + ACTIONS=(audit "report only — nothing is imported") + ACTION_FLAGS=(--import-new --dry-run) + ;; q | Q) echo "Cancelled." exit 0 diff --git a/scripts/catalog-sync.test.ts b/scripts/catalog-sync.test.ts index 5be8e68a..827ac8e1 100644 --- a/scripts/catalog-sync.test.ts +++ b/scripts/catalog-sync.test.ts @@ -6,9 +6,13 @@ import { DEFAULT_CATALOG_SYNC_FLAGS, describeActions, parseCatalogSyncArgs, + selectImportCandidates, writeGeneratedSql, } from "./catalog-sync"; +const entry = (classname: string) => + ({ classname, name: classname, itemType: "s" }) as never; + describe("catalog-sync argument parsing", () => { it("defaults to a read-only audit with no filters", () => { expect(parseCatalogSyncArgs([])).toEqual({ @@ -83,6 +87,116 @@ describe("catalog-sync argument parsing", () => { }); }); +describe("catalog-sync import selection", () => { + it("skips furniture this hotel already has", () => { + const candidates = selectImportCandidates( + [entry("chair"), entry("table"), entry("") as never], + new Set(["chair"]), + ); + expect(candidates.map((c) => c.classname)).toEqual(["table"]); + }); + + it("is case-insensitive against the present set", () => { + const candidates = selectImportCandidates( + [entry("Chair"), entry("table")], + new Set(["chair"]), + ); + expect(candidates.map((c) => c.classname)).toEqual(["table"]); + }); + + it("honours the --only filter, also case-insensitively", () => { + const candidates = selectImportCandidates( + [entry("Chair"), entry("table"), entry("lamp")], + new Set(), + { only: ["chair"] }, + ); + expect(candidates.map((c) => c.classname)).toEqual(["Chair"]); + }); + + it("caps the list with --limit", () => { + const candidates = selectImportCandidates( + [entry("a"), entry("b"), entry("c")], + new Set(), + { limit: 2 }, + ); + expect(candidates.map((c) => c.classname)).toEqual(["a", "b"]); + }); + + it("returns everything when no limit is given", () => { + expect( + selectImportCandidates([entry("a"), entry("b")], new Set(), { + limit: 0, + }).length, + ).toBe(2); + }); +}); + +describe("catalog-sync step composition", () => { + it("--everything turns on every step except the writing ones", () => { + const flags = parseCatalogSyncArgs(["--everything"]); + expect(describeActions(flags)).toBe( + [ + "audit", + "icons", + "nitros", + "furnidata", + "structure", + "id-check", + "import", + "reconcile", + "sprite-ids", + "offers", + ].join(" + "), + ); + // Destructive steps stay opt-in even inside --everything. + expect(flags.applySql).toBe(false); + expect(flags.apply).toBe(false); + expect(flags.alignIds).toBe(false); + expect(flags.renameBundles).toBe(false); + }); + + it("marks an import as a dry run", () => { + expect( + describeActions(parseCatalogSyncArgs(["--import-new", "--dry-run"])), + ).toContain("import (dry)"); + }); + + it("marks the child steps as reporting unless --apply is given", () => { + expect( + describeActions( + parseCatalogSyncArgs(["--align-ids", "--rename-bundles"]), + ), + ).toContain("align-ids"); + expect( + describeActions( + parseCatalogSyncArgs(["--align-ids", "--rename-bundles", "--apply"]), + ), + ).toContain("rename=apply-bundles"); + }); + + it("parses the import tuning flags", () => { + const flags = parseCatalogSyncArgs([ + "--import-new", + "--limit=50", + "--concurrency=8", + "--only=chair,table", + ]); + expect(flags.importLimit).toBe(50); + expect(flags.importConcurrency).toBe(8); + expect(flags.importOnly).toEqual(["chair", "table"]); + }); + + it("rejects a non-numeric --limit", () => { + expect(() => parseCatalogSyncArgs(["--limit=abc"])).toThrow(/integer/); + }); + + it("rejects --dry-run without an import to preview", () => { + expect(() => parseCatalogSyncArgs(["--dry-run"])).toThrow( + /together with --import-new/, + ); + }); +}); + describe("generated catalog SQL output", () => { let cwd = ""; diff --git a/scripts/catalog-sync.ts b/scripts/catalog-sync.ts index b616a3a1..b3cd9ab5 100644 --- a/scripts/catalog-sync.ts +++ b/scripts/catalog-sync.ts @@ -17,7 +17,7 @@ // --tsv with the above, print `id\tname\tfurnidataUrl` rows // --sources= compare against these sources (id or name, comma-separated) // --icons re-download missing furniture icons from the sources -// --nitros re-download missing .nitro / swf bundles +// --nitros re-download missing .nitro / swf bundles (stored as .hab) // --furnidata repair FurnitureData.json (add missing, drop duplicates) // --structure repair orphaned / duplicate catalog rows // --check-ids check FurnitureData.json for spriteId conflicts @@ -25,7 +25,18 @@ // --apply-sql apply the generated SQL (implies --sql) // --organize-sql order the generated SQL by catalog page (implies --sql) // --all --icons --nitros --furnidata --structure --check-ids +// --import-new import furniture the source has and this hotel lacks +// --only= restrict the import to these classnames +// --limit=N stop the import after N items +// --concurrency=N parallel imports (default 3) +// --reconcile align FurnitureData.json with items_base after importing +// --fix-sprite-ids correct items_base.sprite_id drift +// --rebuild-offers rebuild catalog offer_id mirrors +// --align-ids move items_base ids onto their furnidata ids (report; +// add --apply to let the script execute it) +// --rename-bundles rename legacy .nitro bundles to .hab (report; --apply runs it) // --json print one machine-readable JSON report +// --dry-run with --import-new: list what would be imported, import nothing // --fail-on-errors exit 3 when error-severity issues remain // // Audit is read-only by default: nothing is written until one of the repair @@ -35,18 +46,21 @@ // `--organize-sql` to also emit those page INSERTs into the .sql file. import "./load-env"; +import { spawnSync } from "node:child_process"; import { mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { LOCAL_BUNDLE_EXT } from "@/lib/furni/classname"; import type { AuditEvent, AuditSummary } from "@/lib/services/catalog-audit"; +import type { SourceFurni } from "@/lib/services/clone-import"; +import type { CloneSource } from "@/lib/services/clone-sources"; /** Where generated catalog SQL is written (`storage/` is gitignored). */ function sqlOutputDir(): string { return join(process.cwd(), "storage", "catalog-sync"); } -export type SourceRef = { id: string; name: string; furnidataUrl: string }; +export type SourceRef = CloneSource; export type AuditIssue = NonNullable[number]; export type MissingFromSource = NonNullable< @@ -59,6 +73,16 @@ export interface AuditReport { issues: AuditIssue[]; missingFromSources: MissingFromSource[]; sqlFile?: string; + imported?: ImportOutcome; +} + +export interface ImportOutcome { + source: string; + candidates: number; + imported: number; + skipped: number; + failed: number; + failures: Array<{ classname: string; error: string }>; } export interface CatalogSyncFlags { @@ -75,6 +99,17 @@ export interface CatalogSyncFlags { organizeSql: boolean; json: boolean; failOnErrors: boolean; + importNew: boolean; + dryRun: boolean; + importLimit: number; + importConcurrency: number; + importOnly: string[]; + reconcile: boolean; + fixSpriteIds: boolean; + rebuildOffers: boolean; + alignIds: boolean; + renameBundles: boolean; + apply: boolean; } export const DEFAULT_CATALOG_SYNC_FLAGS: Omit = { @@ -90,6 +125,17 @@ export const DEFAULT_CATALOG_SYNC_FLAGS: Omit = { organizeSql: false, json: false, failOnErrors: false, + importNew: false, + dryRun: false, + importLimit: 0, + importConcurrency: 3, + importOnly: [], + reconcile: false, + fixSpriteIds: false, + rebuildOffers: false, + alignIds: false, + renameBundles: false, + apply: false, }; const BOOLEAN_FLAGS: Record = { @@ -109,6 +155,24 @@ const BOOLEAN_FLAGS: Record = { "--organize-sql": "organizeSql", "--json": "json", "--fail-on-errors": "failOnErrors", + "--import-new": "importNew", + "--dry-run": "dryRun", + "--reconcile": "reconcile", + "--fix-sprite-ids": "fixSpriteIds", + "--rebuild-offers": "rebuildOffers", + "--align-ids": "alignIds", + "--rename-bundles": "renameBundles", + // Authorises the two heavyweight child steps that rewrite stored data. + "--apply": "apply", +}; + +const NUMBER_FLAGS: Record = { + "--limit": "importLimit", + "--concurrency": "importConcurrency", +}; + +const LIST_FLAGS: Record = { + "--only": "importOnly", }; export function parseCatalogSyncArgs(raw: string[]): CatalogSyncFlags { @@ -129,6 +193,19 @@ export function parseCatalogSyncArgs(raw: string[]): CatalogSyncFlags { continue; } + if (arg === "--everything") { + flags.repairIcons = true; + flags.repairNitros = true; + flags.repairFurniData = true; + flags.repairStructure = true; + flags.checkFurniDataIds = true; + flags.importNew = true; + flags.reconcile = true; + flags.fixSpriteIds = true; + flags.rebuildOffers = true; + continue; + } + const separator = arg.indexOf("="); const name = separator >= 0 ? arg.slice(0, separator) : arg; @@ -152,6 +229,28 @@ export function parseCatalogSyncArgs(raw: string[]): CatalogSyncFlags { continue; } + if (NUMBER_FLAGS[name]) { + if (separator < 0) + throw new Error(`${name} needs a value, e.g. ${name}=10`); + const parsed = Number(arg.slice(separator + 1)); + if (!Number.isInteger(parsed) || parsed < 0) + throw new Error(`${name} needs a non-negative integer`); + flags[NUMBER_FLAGS[name]] = parsed as never; + continue; + } + + if (LIST_FLAGS[name]) { + if (separator < 0) + throw new Error(`${name} needs a value, e.g. ${name}=chair,table`); + const value = arg.slice(separator + 1); + for (const part of value + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean)) + flags.importOnly.push(part); + continue; + } + throw new Error( `Unknown flag: ${arg} (run with --help for the supported flags)`, ); @@ -160,9 +259,47 @@ export function parseCatalogSyncArgs(raw: string[]): CatalogSyncFlags { // Applying SQL implies generating it, and organising it implies generating. if (flags.applySql) flags.generateSql = true; if (flags.organizeSql) flags.generateSql = true; + + // A dry run without an import step has nothing to preview. + if (flags.dryRun && !flags.importNew) + throw new Error("--dry-run only makes sense together with --import-new"); + + if (flags.importNew && flags.applySql) { + // Both write; that is allowed but it is worth making the operator think. + logNote?.("--import-new and --apply-sql will both write to the catalog"); + } return flags; } +let logNote: ((line: string) => void) | undefined; + +/** + * Pick the furniture to import from a source's furnidata. + * + * Pure so the filtering rules (skip what we already have, `--only` filter, + * `--limit` cap) can be tested without a database. + */ +export function selectImportCandidates( + entries: SourceFurni[], + present: Set, + options: { only?: string[]; limit?: number } = {}, +): SourceFurni[] { + const normalize = (value: string) => value.trim().toLowerCase(); + const only = options.only?.length + ? new Set(options.only.map(normalize)) + : null; + + const candidates = entries.filter((entry) => { + if (!entry.classname) return false; + if (present.has(normalize(entry.classname))) return false; + if (only && !only.has(normalize(entry.classname))) return false; + return true; + }); + + const limit = options.limit ?? 0; + return limit > 0 ? candidates.slice(0, limit) : candidates; +} + /** Human-readable list of what the run will do. */ export function describeActions(flags: CatalogSyncFlags): string { const actions = ["audit"]; @@ -171,6 +308,13 @@ export function describeActions(flags: CatalogSyncFlags): string { if (flags.repairFurniData) actions.push("furnidata"); if (flags.repairStructure) actions.push("structure"); if (flags.checkFurniDataIds) actions.push("id-check"); + if (flags.importNew) actions.push(flags.dryRun ? "import (dry)" : "import"); + if (flags.reconcile) actions.push("reconcile"); + if (flags.fixSpriteIds) actions.push("sprite-ids"); + if (flags.rebuildOffers) actions.push("offers"); + if (flags.alignIds) actions.push(`align-ids${flags.apply ? "=apply" : ""}`); + if (flags.renameBundles) + actions.push(`rename${flags.apply ? "=apply" : "-dry"}-bundles`); if (flags.applySql) actions.push("apply-sql"); else if (flags.generateSql) actions.push("generate-sql"); if (flags.organizeSql) actions.push("organised"); @@ -188,13 +332,8 @@ function isEntryPoint(): boolean { async function loadSourceCatalogue(): Promise { const { listSources } = await import("@/lib/services/clone-sources"); const sources = await listSources(); - return sources - .filter((source) => source.furnidataUrl) - .map((source) => ({ - id: source.id, - name: source.name, - furnidataUrl: source.furnidataUrl, - })); + // Full source objects: the import step needs the icon/nitro base URLs too. + return sources.filter((source) => source.furnidataUrl); } async function commandListSources(flags: CatalogSyncFlags): Promise { @@ -224,6 +363,186 @@ export async function writeGeneratedSql(sql: string): Promise { return file.replace(`${process.cwd()}/`, ""); } +/** + * Run one of the repo's existing standalone maintenance scripts as a child + * process, with the same Node conditions this CLI needs. + */ +function runChildScript( + script: string, + args: string[], + log: (line: string) => void, +): number { + const nodeArgs = [ + "--conditions=react-server", + "--import", + "tsx", + script, + ...args, + ]; + log(`· running scripts/${script} ${args.join(" ")}`.trimEnd()); + const result = spawnSync(process.execPath, nodeArgs, { + cwd: process.cwd(), + stdio: "inherit", + env: process.env, + }); + if (result.error) { + console.error(`! ${script} failed to start: ${result.error.message}`); + return 1; + } + return result.status ?? 1; +} + +/** + * Import furniture that exists at the compared source but not in this hotel. + * + * `cloneSingleFurni` downloads the bundle + icon, stores the bundle as + * `LOCAL_BUNDLE_EXT`, appends the furnidata entry and creates the catalog page + * plus the offer itself — so an import is not usable for furniture you want to + * keep out of the catalog. + */ +async function stepImportNew( + flags: CatalogSyncFlags, + sources: SourceRef[], + log: (line: string) => void, +): Promise { + const { getClonableClassnames, fetchSourceFurnidata } = await import( + "@/lib/services/clone-import" + ); + const { cloneSingleFurni } = await import("@/lib/services/clone-import"); + + let planned = 0; + let imported = 0; + let skipped = 0; + let failed = 0; + const failures: Array<{ classname: string; error: string }> = []; + const deferredEntries: Array<{ + entry: Record; + itemType: string; + }> = []; + let lastSource = ""; + + for (const source of sources) { + if (flags.importLimit > 0 && planned >= flags.importLimit) break; + const remaining = flags.importLimit + ? flags.importLimit - planned + : Number.POSITIVE_INFINITY; + + log(`· listing furniture missing at ${source.name}…`); + const clonable = new Set(await getClonableClassnames(source)); + const entries = await fetchSourceFurnidata(source.furnidataUrl); + const candidates = selectImportCandidates(entries, clonable, { + only: flags.importOnly, + limit: Number.isFinite(remaining) ? remaining : 0, + }); + if (candidates.length === 0) { + log(` nothing new to import from ${source.name}`); + continue; + } + lastSource = source.name; + + if (flags.dryRun) { + log( + ` ${candidates.length} would be imported from ${source.name}${flags.importOnly.length ? " (filtered by --only)" : ""}`, + ); + for (const entry of candidates.slice(0, 20)) + log(` ${entry.classname} — ${entry.name}`); + if (candidates.length > 20) + log(` … and ${candidates.length - 20} more`); + planned += candidates.length; + continue; + } + + planned += candidates.length; + const started = Date.now(); + let done = 0; + let index = 0; + + const worker = async () => { + while (index < candidates.length) { + const entry = candidates[index++]; + const result = await cloneSingleFurni({ + source, + entry, + deferFurniData: true, + }); + done += 1; + if (result.ok) imported += 1; + else if (result.skipped) skipped += 1; + else { + failed += 1; + failures.push({ + classname: entry.classname, + error: result.error ?? "unknown error", + }); + } + if (result.furniDataEntry) + deferredEntries.push( + result.furniDataEntry as { + entry: Record; + itemType: string; + }, + ); + + if (done % 10 === 0 || done === candidates.length) { + const rate = Math.max(done / ((Date.now() - started) / 1000), 1); + const etaMin = Math.round((candidates.length - done) / rate / 60); + log( + ` ${source.name}: ${done}/${candidates.length} ${rate.toFixed(1)}/s, ETA ~${etaMin}m`, + ); + } + } + }; + + const workers = Math.max( + 1, + Math.min(flags.importConcurrency, candidates.length), + ); + await Promise.all(Array.from({ length: workers }, worker)); + } + + if (!flags.dryRun && deferredEntries.length > 0) { + const { appendFurniEntriesBulk } = await import( + "@/lib/services/furni-data" + ); + await appendFurniEntriesBulk(deferredEntries); + log(`· FurnitureData.json: +${deferredEntries.length} entries (one write)`); + } + + return { + source: lastSource, + candidates: planned, + imported, + skipped, + failed, + failures: failures.slice(0, 25), + }; +} + +/** Keep FurnitureData.json consistent with items_base after the import. */ +async function stepReconcile(log: (line: string) => void): Promise { + const { reconcileFurniDataWithItemsBase } = await import( + "@/lib/services/furni-data" + ); + const result = await reconcileFurniDataWithItemsBase(); + log( + `· reconcile: ${result.fixedIds} sprite id(s), ${result.fixedOfferIds} offer id(s), ${result.normalizedIds} normalised, ${result.fixedConflicts} conflict(s) resolved, ${result.missing} still missing`, + ); +} + +/** items_base.sprite_id must equal its id wherever furnidata carries the item. */ +async function stepFixSpriteIds(log: (line: string) => void): Promise { + const { verifyAndFixSpriteIds } = await import("@/lib/services/furni-data"); + const result = await verifyAndFixSpriteIds(); + log(`· sprite ids: ${result.checked} checked, ${result.fixed} corrected`); +} + +/** offer_id mirrors the row id, never a literal -1. */ +async function stepRebuildOffers(log: (line: string) => void): Promise { + const { rebuildCatalogOfferIds } = await import("@/lib/services/furni-data"); + const result = await rebuildCatalogOfferIds(); + log(`· offer ids: ${JSON.stringify(result)}`); +} + export async function commandRun(flags: CatalogSyncFlags): Promise { const catalogue = await loadSourceCatalogue(); @@ -268,6 +587,19 @@ export async function commandRun(flags: CatalogSyncFlags): Promise { (flags.applySql ? " [SQL WILL BE APPLIED]" : ""), ); + // 1) Move items_base ids onto their furnidata ids before anything else, so + // every later step sees stable ids. The script only reports without + // `--apply`. + if (flags.alignIds) { + runChildScript( + "scripts/align-db-ids-with-furnidata.ts", + flags.apply ? ["--apply"] : [], + log, + ); + } + + // 2) Audit + repair of what is already here. + await runCatalogAudit( (event) => { switch (event.type) { @@ -331,6 +663,24 @@ export async function commandRun(flags: CatalogSyncFlags): Promise { if (pendingSql?.trim()) report.sqlFile = await writeGeneratedSql(pendingSql); + // 3) Import what the source has and this hotel does not. + if (flags.importNew) { + if (selected.length === 0) log("· no source selected, nothing to import"); + else report.imported = await stepImportNew(flags, selected, log); + } + + // 4) Post-import consistency, then the legacy bundle rename. + if (flags.reconcile) await stepReconcile(log); + if (flags.fixSpriteIds) await stepFixSpriteIds(log); + if (flags.rebuildOffers) await stepRebuildOffers(log); + if (flags.renameBundles) { + runChildScript( + "scripts/migrate-nitro-to-hab.ts", + flags.apply ? ["--yes"] : ["--dry-run"], + log, + ); + } + if (flags.json) { console.log( JSON.stringify( @@ -381,6 +731,19 @@ function printSummary(report: AuditReport, started: number): void { ` not restorable ${summary.unrepairableCount} (no icon/bundle on any compared source)`, ); + if (report.imported) { + const outcome = report.imported; + console.log( + ` imported from source ${outcome.imported} new (${outcome.skipped} already present, ${outcome.failed} failed)`, + ); + for (const failure of outcome.failures) + console.log(` ${failure.classname}: ${failure.error}`); + if (outcome.failures.length > 0) + console.log( + ` import failures are per-item: a failed download never aborts the run.`, + ); + } + if (report.missingFromSources.length > 0) { const bySource = new Map(); for (const entry of report.missingFromSources)