Fantasy-Map-Generator/scripts/bump-version.js
copilot-swe-agent[bot] af84dfe9ed chore: merge base branch changes (package-lock.json sync, RELEASE_BOT_TOKEN, node 24.x, comment fix)
Co-authored-by: Azgaar <26469650+Azgaar@users.noreply.github.com>
2026-03-07 16:12:07 +00:00

330 lines
12 KiB
JavaScript

#!/usr/bin/env node
"use strict";
/**
* Bump the project version (patch / minor / major).
*
* Updates:
* - public/versioning.js — VERSION constant
* - package.json — "version" field
* - package-lock.json — top-level "version" and packages[""].version fields
* - src/index.html — ?v= cache-busting hashes for changed public/*.js files
* - public/**\/*.js — ?v= cache-busting hashes in dynamic import() calls
*
* Usage:
* node scripts/bump-version.js # interactive prompt
* node scripts/bump-version.js patch # non-interactive
* node scripts/bump-version.js minor # non-interactive
* node scripts/bump-version.js major # non-interactive
* node scripts/bump-version.js --dry-run # preview only, no writes
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const {execSync} = require("child_process");
// ---------------------------------------------------------------------------
// Paths
// ---------------------------------------------------------------------------
const repoRoot = path.resolve(__dirname, "..");
const packageJsonPath = path.join(repoRoot, "package.json");
const packageLockJsonPath = path.join(repoRoot, "package-lock.json");
const versioningPath = path.join(repoRoot, "public", "versioning.js");
const indexHtmlPath = path.join(repoRoot, "src", "index.html");
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function readFile(filePath) {
return fs.readFileSync(filePath, "utf8");
}
function writeFile(filePath, content) {
fs.writeFileSync(filePath, content, "utf8");
}
function parseCurrentVersion() {
const content = readFile(versioningPath);
const match = content.match(/const VERSION = "(\d+\.\d+\.\d+)";/);
if (!match) throw new Error("Could not find VERSION constant in public/versioning.js");
return match[1];
}
function bumpVersion(version, type) {
const [major, minor, patch] = version.split(".").map(Number);
if (type === "major") return `${major + 1}.0.0`;
if (type === "minor") return `${major}.${minor + 1}.0`;
return `${major}.${minor}.${patch + 1}`;
}
function escapeRegExp(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/** Returns true if versionA is strictly greater than versionB (semver). */
function isVersionGreater(versionA, versionB) {
const a = versionA.split(".").map(Number);
const b = versionB.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (a[i] > b[i]) return true;
if (a[i] < b[i]) return false;
}
return false; // equal
}
/**
* Returns public/*.js paths (relative to repo root) that have changed.
* Checks (in order, deduplicating):
* 1. Upstream branch diff — catches everything on a feature/PR branch
* 2. Staged (index) diff — catches files staged but not yet committed
* 3. Last-commit diff — fallback for main / detached HEAD
*/
function getChangedPublicJsFiles() {
const run = cmd => execSync(cmd, {encoding: "utf8", cwd: repoRoot});
const parseFiles = output =>
output
.split("\n")
.map(f => f.trim())
.filter(f => f.startsWith("public/") && f.endsWith(".js"));
const seen = new Set();
const collect = files => files.forEach(f => seen.add(f));
// 1. Upstream branch diff
try {
const upstream = run("git rev-parse --abbrev-ref --symbolic-full-name @{upstream}").trim();
collect(parseFiles(run(`git diff --name-only ${upstream}...HEAD`)));
} catch {
/* no upstream */
}
// 2. Staged changes (useful when building before committing)
try {
collect(parseFiles(run("git diff --name-only --cached")));
} catch {
/* ignore */
}
if (seen.size > 0) return [...seen];
// 3. Fallback: last commit diff
try {
return parseFiles(run("git diff --name-only HEAD~1 HEAD"));
} catch {
/* shallow / single-commit repo */
}
return [];
}
// ---------------------------------------------------------------------------
// File updaters
// ---------------------------------------------------------------------------
function updateVersioningJs(newVersion, dry) {
const original = readFile(versioningPath);
const updated = original.replace(/const VERSION = "\d+\.\d+\.\d+";/, `const VERSION = "${newVersion}";`);
if (original === updated) throw new Error("Failed to update VERSION in public/versioning.js");
if (!dry) writeFile(versioningPath, updated);
console.log(` public/versioning.js → ${newVersion}`);
}
function updatePackageJson(newVersion, dry) {
const original = readFile(packageJsonPath);
const pkg = JSON.parse(original);
const oldVersion = pkg.version;
pkg.version = newVersion;
if (!dry) writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
console.log(` package.json ${oldVersion}${newVersion}`);
}
function updatePackageLockJson(newVersion, dry) {
if (!fs.existsSync(packageLockJsonPath)) {
console.log(" package-lock.json (not found, skipping)");
return;
}
const original = readFile(packageLockJsonPath);
const lock = JSON.parse(original);
const oldVersion = lock.version;
lock.version = newVersion;
if (lock.packages && lock.packages[""]) {
lock.packages[""].version = newVersion;
}
if (!dry) writeFile(packageLockJsonPath, `${JSON.stringify(lock, null, 2)}\n`);
console.log(` package-lock.json ${oldVersion}${newVersion}`);
}
function updateIndexHtmlHashes(changedFiles, newVersion, dry) {
if (changedFiles.length === 0) {
console.log(" src/index.html (no changed public/*.js files detected)");
return;
}
let html = readFile(indexHtmlPath);
const updated = [];
for (const publicPath of changedFiles) {
const htmlPath = publicPath.replace(/^public\//, "");
const pattern = new RegExp(`${escapeRegExp(htmlPath)}\\?v=[0-9.]+`, "g");
if (pattern.test(html)) {
html = html.replace(pattern, `${htmlPath}?v=${newVersion}`);
updated.push(htmlPath);
}
}
if (updated.length > 0) {
if (!dry) writeFile(indexHtmlPath, html);
console.log(` src/index.html hashes updated for:\n - ${updated.join("\n - ")}`);
} else {
console.log(
` src/index.html (changed files not referenced: ${changedFiles.map(f => f.replace("public/", "")).join(", ")})`
);
}
}
/** Returns all .js file paths (relative to repo root, with forward slashes) under public/. */
function getAllPublicJsFiles() {
const results = [];
function walk(dir) {
for (const entry of fs.readdirSync(dir, {withFileTypes: true})) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (entry.isFile() && entry.name.endsWith(".js")) {
results.push(path.relative(repoRoot, full).replace(/\\/g, "/"));
}
}
}
walk(path.join(repoRoot, "public"));
return results;
}
/**
* Scans all public/**\/*.js files for relative dynamic-import ?v= references
* (e.g. import("../dynamic/supporters.js?v=1.97.14")) and updates any that
* point to one of the changed files.
*/
function updatePublicJsDynamicImportHashes(changedFiles, newVersion, dry) {
if (changedFiles.length === 0) {
console.log(" public/**/*.js (no changed public/*.js files detected)");
return;
}
const changedSet = new Set(changedFiles);
const publicJsFiles = getAllPublicJsFiles();
const updatedMap = {};
for (const relJsFile of publicJsFiles) {
const absJsFile = path.join(repoRoot, relJsFile);
const content = readFile(absJsFile);
const dir = path.dirname(absJsFile);
const pattern = /(['"])(\.\.?\/[^'"]*)\?v=[0-9.]+\1/g;
const newContent = content.replace(pattern, (match, quote, relImportPath) => {
// Strip any query string defensively before resolving the path
const cleanImportPath = relImportPath.split("?")[0];
const absImport = path.resolve(dir, cleanImportPath);
const repoRelImport = path.relative(repoRoot, absImport).replace(/\\/g, "/");
if (changedSet.has(repoRelImport)) {
if (!updatedMap[relJsFile]) updatedMap[relJsFile] = [];
updatedMap[relJsFile].push(relImportPath);
return `${quote}${relImportPath}?v=${newVersion}${quote}`;
}
return match;
});
if (updatedMap[relJsFile] && !dry) {
writeFile(absJsFile, newContent);
}
}
if (Object.keys(updatedMap).length > 0) {
const lines = Object.entries(updatedMap)
.map(([file, refs]) => ` ${file}:\n - ${refs.join("\n - ")}`)
.join("\n");
console.log(` public/**/*.js hashes updated:\n${lines}`);
} else {
console.log(" public/**/*.js (no dynamic import ?v= hashes needed updating)");
}
}
// ---------------------------------------------------------------------------
// Prompt
// ---------------------------------------------------------------------------
function promptBumpType(currentVersion) {
return new Promise(resolve => {
const rl = readline.createInterface({input: process.stdin, output: process.stdout});
process.stdout.write(`\nCurrent version: ${currentVersion}\nBump type (patch / minor / major) [patch]: `);
rl.once("line", answer => {
rl.close();
const input = answer.trim().toLowerCase();
if (input === "minor" || input === "mi") return resolve("minor");
if (input === "major" || input === "maj") return resolve("major");
resolve("patch");
});
});
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main() {
const argv = process.argv.slice(2);
const args = argv.map(a => a.toLowerCase());
const dry = args.includes("--dry-run");
// --base-version X.Y.Z — version on master before this PR was merged.
// When provided, the script checks whether the developer already bumped
// the version manually in their branch. If so, the increment is skipped
// and only the ?v= hashes in index.html are refreshed.
const baseVersionFlagIdx = argv.findIndex(a => a === "--base-version");
const baseVersion = baseVersionFlagIdx !== -1 ? argv[baseVersionFlagIdx + 1] : null;
if (dry) console.log("\n[bump-version] DRY RUN — no files will be changed\n");
const currentVersion = parseCurrentVersion();
if (baseVersion && isVersionGreater(currentVersion, baseVersion)) {
// Developer already bumped the version manually in their branch.
console.log(
`\n[bump-version] Version already updated manually: ${baseVersion}${currentVersion} (base was ${baseVersion})\n`
);
console.log(" Skipping version increment — updating ?v= hashes only.\n");
const changedFiles = getChangedPublicJsFiles();
updateIndexHtmlHashes(changedFiles, currentVersion, dry);
updatePublicJsDynamicImportHashes(changedFiles, currentVersion, dry);
console.log(`\n[bump-version] ${dry ? "(dry run) " : ""}done.\n`);
return;
}
// Determine bump type: CLI arg → stdin prompt → default patch
let bumpType;
if (args.includes("major")) bumpType = "major";
else if (args.includes("minor")) bumpType = "minor";
else if (args.includes("patch")) bumpType = "patch";
else if (process.stdin.isTTY) bumpType = await promptBumpType(currentVersion);
else bumpType = "patch"; // non-interactive (CI / pipe)
const newVersion = bumpVersion(currentVersion, bumpType);
console.log(`\n[bump-version] ${bumpType}: ${currentVersion}${newVersion}\n`);
const changedFiles = getChangedPublicJsFiles();
updateVersioningJs(newVersion, dry);
updatePackageJson(newVersion, dry);
updatePackageLockJson(newVersion, dry);
updateIndexHtmlHashes(changedFiles, newVersion, dry);
updatePublicJsDynamicImportHashes(changedFiles, newVersion, dry);
console.log(`\n[bump-version] ${dry ? "(dry run) " : ""}done.\n`);
}
main().catch(err => {
console.error("\n[bump-version] Error:", err.message || err);
process.exit(1);
});