Files
openhands 678d22ceab
CI / check (push) Failing after 8s
CI / release (push) Skipped
CI / deploy (push) Skipped
feat: bulletproof updater + fixes for client links (icons/furniture/gamedata)
2026-08-30 18:19:46 +02:00

112 lines
3.1 KiB
JavaScript

const fs = require("node:fs");
const { parse, printParseErrorCode } = require("jsonc-parser");
const exampleFile = process.argv[2];
const targetFile = process.argv[3];
if (!exampleFile || !targetFile) {
process.exit(1);
}
function isPlainObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
// Merge the upstream example into the live config with strict "add missing,
// never remove or overwrite" semantics so hotel configs survive every update:
// - every key already present in the live config is PRESERVED (scalars,
// arrays and nested objects always win over the upstream example),
// - nested objects are merged recursively so upstream-only sub-keys are
// added while the hotel's own sub-keys stay untouched,
// - keys that do not exist in the live config yet are ADDED from the example,
// - a source object NEVER replaces an existing scalar/array (would corrupt).
function deepMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (isPlainObject(source[key])) {
if (isPlainObject(result[key])) {
result[key] = deepMerge(result[key], source[key]);
} else if (!(key in result)) {
result[key] = deepMerge({}, source[key]);
}
// Else: live value is a scalar/array/null -> keep it untouched.
} else if (!(key in result)) {
result[key] = source[key];
}
}
return result;
}
function parseJsonc(file) {
if (!fs.existsSync(file)) {
return undefined;
}
const content = fs.readFileSync(file, "utf-8");
const errors = [];
const value = parse(content, errors, {
allowTrailingComma: true,
allowEmptyContent: true,
});
if (errors.length > 0) {
console.error(
`[merge-config] parse error in ${file}: ${errors
.map((e) => printParseErrorCode(e.error))
.join(", ")}`,
);
return undefined;
}
return value;
}
let example;
try {
example = parseJsonc(exampleFile);
} catch {
process.exit(1);
}
if (example === undefined) {
process.exit(1);
}
let current = {};
try {
const parsed = parseJsonc(targetFile);
if (parsed !== undefined && parsed !== null) {
current = parsed;
}
} catch {
current = {};
}
const merged = deepMerge(current, example);
// Sanity: the merged result must stay an object and the written file must be
// re-parseable. If anything goes wrong we keep the live file untouched rather
// than shipping a corrupted config to the client.
if (merged === undefined || merged === null || typeof merged !== "object") {
process.stderr.write(
`[merge-config] refused to write non-object result for ${targetFile}\n`,
);
process.exit(1);
}
// Verify the result parses round-trip before overwriting the live config.
const serialized = `${JSON.stringify(merged, null, 4)}\n`;
let check;
try {
check = parse(serialized, [], {
allowTrailingComma: true,
allowEmptyContent: true,
});
} catch {
check = undefined;
}
if (!check || typeof check !== "object" || Array.isArray(check)) {
process.stderr.write(
`[merge-config] refused to write unparseable result for ${targetFile}\n`,
);
process.exit(1);
}
fs.writeFileSync(targetFile, serialized);