diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml new file mode 100644 index 00000000..264d9bb3 --- /dev/null +++ b/.github/workflows/bump-version.yml @@ -0,0 +1,83 @@ +name: Bump version on PR merge + +on: + pull_request: + branches: [master] + types: [closed] + +permissions: + contents: write + +jobs: + bump: + # Only run when the PR was actually merged (not just closed) + if: github.event.pull_request.merged == true + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + with: + # Check out the base branch (master) so we are on a real branch, not + # a detached PR merge ref, which makes git push work without arguments. + ref: ${{ github.event.pull_request.base.ref }} + # fetch-depth 2 so HEAD~1 resolves to the pre-merge master commit + fetch-depth: 2 + # Use a PAT so the pushed bump commit can trigger deploy.yml + token: ${{ secrets.RELEASE_BOT_TOKEN }} + + - name: Set up Node + uses: actions/setup-node@v6 + with: + node-version: "24.x" + cache: npm + + - name: Get PR diff + run: git diff HEAD~1 HEAD > /tmp/pr.diff + + - name: AI-detect bump type from diff + id: bump + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + TYPE=$(node scripts/detect-bump-type.js --diff-file /tmp/pr.diff) + echo "type=$TYPE" >> "$GITHUB_OUTPUT" + echo "AI-determined bump type: $TYPE" + + - name: Extract base version (master before this PR merged) + id: base + run: | + BASE=$(git show HEAD~1:public/versioning.js \ + | grep -oP 'const VERSION = "\K[\d.]+') + echo "version=$BASE" >> "$GITHUB_OUTPUT" + echo "Base version: $BASE" + + - name: Run version bump script + run: | + node scripts/bump-version.js ${{ steps.bump.outputs.type }} \ + --base-version ${{ steps.base.outputs.version }} + + - name: Commit and push bump + run: | + NEW_VERSION=$(node -e " + const m = require('fs') + .readFileSync('public/versioning.js', 'utf8') + .match(/const VERSION = \"([\d.]+)\"/); + console.log(m[1]); + ") + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + # Stage versioning files + any public/**/*.js whose dynamic import + # hashes may have been refreshed by updatePublicJsDynamicImportHashes + git add public/versioning.js package.json package-lock.json src/index.html + git add public/ 2>/dev/null || true + + if ! git diff --cached --quiet; then + git commit -m "chore: bump version to $NEW_VERSION" + git push + echo "Pushed version bump → $NEW_VERSION" + else + echo "Nothing changed, skipping commit." + fi diff --git a/package-lock.json b/package-lock.json index 2f6fc9ed..e607bc5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "fantasy-map-generator", - "version": "1.113.2", + "version": "1.113.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "fantasy-map-generator", - "version": "1.113.2", + "version": "1.113.3", "license": "MIT", "dependencies": { "alea": "^1.0.1", @@ -1353,6 +1353,7 @@ "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1393,6 +1394,7 @@ "integrity": "sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/browser": "4.0.18", "@vitest/mocker": "4.0.18", @@ -1874,6 +1876,7 @@ "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", "license": "ISC", + "peer": true, "engines": { "node": ">=12" } @@ -2160,6 +2163,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2471,6 +2475,7 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -2546,6 +2551,7 @@ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", diff --git a/public/modules/dynamic/supporters.js b/public/modules/dynamic/supporters.js index 985e391d..e0b120f9 100644 --- a/public/modules/dynamic/supporters.js +++ b/public/modules/dynamic/supporters.js @@ -606,4 +606,6 @@ dr_not_sam Mie96 Riley Amber Davis -tomtom1969vlbg`; +tomtom1969vlbg +Eric Knight +Adeline Lefizelier`; diff --git a/public/versioning.js b/public/versioning.js index 528f7cf2..e6660a66 100644 --- a/public/versioning.js +++ b/public/versioning.js @@ -5,15 +5,18 @@ * We use Semantic Versioning: major.minor.patch. Refer to https://semver.org * Our .map file format is considered the public API. * - * Update the version MANUALLY on each merge to main: + * Update the version on each merge to master: * 1. MAJOR version: Incompatible changes that break existing maps * 2. MINOR version: Additions or changes that are backward-compatible but may require old .map files to be updated - * 3. PATCH version: Backward-compatible bug fixes and small features that do not affect the .map file format + * 3. PATCH version: Backward-compatible bug fixes and small features that don't affect the .map file format * * Example: 1.102.2 -> Major version 1, Minor version 102, Patch version 2 + * Version bumping is automated via GitHub Actions on PR merge. + * + * For the changes that may be interesting to end users, update the `latestPublicChanges` array below (new changes on top). */ -const VERSION = "1.113.4"; +const VERSION = "1.113.3"; if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format or parsing function"); { @@ -26,6 +29,22 @@ if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format o setTimeout(showUpdateWindow, 6000); } + const latestPublicChanges = [ + "Search input in Overview dialogs", + "Custom burg grouping and icon selection", + "Ability to set custom image as Marker or Regiment icon", + "Submap and Transform tools rework", + "Azgaar Bot to answer questions and provide help", + "Labels: ability to set letter spacing", + "Zones performance improvement", + "Notes Editor: on-demand AI text generation", + "New style preset: Dark Seas", + "New routes generation algorithm", + "Routes overview tool", + "Configurable longitude", + "Export zones to GeoJSON" + ]; + function showUpdateWindow() { const changelog = "https://github.com/Azgaar/Fantasy-Map-Generator/wiki/Changelog"; const reddit = "https://www.reddit.com/r/FantasyMapGenerator"; @@ -37,19 +56,7 @@ if (parseMapVersion(VERSION) !== VERSION) alert("versioning.js: Invalid format o

Join our Discord server and Reddit community to ask questions, share maps, discuss the Generator and Worlbuilding, report bugs and propose new features.

diff --git a/scripts/bump-version.js b/scripts/bump-version.js new file mode 100644 index 00000000..fffa86ed --- /dev/null +++ b/scripts/bump-version.js @@ -0,0 +1,326 @@ +#!/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 have no ?v= entry: ${changedFiles.map(f => f.replace("public/", "")).join(", ")}` + ); + } +} + +/** + * For each changed public JS file, scans ALL other public JS files for + * dynamic import() calls that reference it via a relative ?v= path, and + * updates the hash to newVersion. + * + * Example: public/modules/dynamic/installation.js changed → + * main.js: import("./modules/dynamic/installation.js?v=1.89.19") + * → import("./modules/dynamic/installation.js?v=1.113.4") + */ +function updatePublicJsDynamicImportHashes(changedFiles, newVersion, dry) { + if (changedFiles.length === 0) { + console.log(" public/**/*.js (no changed files, skipping dynamic import hashes)"); + return; + } + + // Absolute paths of every changed file for O(1) lookup + const changedAbsPaths = new Set(changedFiles.map(f => path.join(repoRoot, f))); + + // Collect all public JS files, skipping public/libs (third-party) + const publicRoot = path.join(repoRoot, "public"); + const allJsFiles = []; + (function walk(dir) { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (path.relative(publicRoot, full).replace(/\\/g, "/") === "libs") continue; + walk(full); + } else if (entry.isFile() && entry.name.endsWith(".js")) { + allJsFiles.push(full); + } + } + })(publicRoot); + + const updatedMap = {}; + + for (const absJsFile of allJsFiles) { + const content = readFile(absJsFile); + // Matches: import("../path/file.js?v=1.2.3") or import('../path/file.js?v=1.2.3') + const pattern = /(['"])(\.{1,2}\/[^'"?]+)\?v=[0-9.]+\1/g; + let anyChanged = false; + const newContent = content.replace(pattern, (match, quote, relImportPath) => { + const absImport = path.resolve(path.dirname(absJsFile), relImportPath); + if (!changedAbsPaths.has(absImport)) return match; + const repoRelFile = path.relative(repoRoot, absJsFile).replace(/\\/g, "/"); + if (!updatedMap[repoRelFile]) updatedMap[repoRelFile] = []; + updatedMap[repoRelFile].push(relImportPath); + anyChanged = true; + return `${quote}${relImportPath}?v=${newVersion}${quote}`; + }); + if (anyChanged && !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 dynamic import hashes updated:\n${lines}`); + } else { + console.log(" public/**/*.js (no dynamic import ?v= hashes to update)"); + } +} + +// --------------------------------------------------------------------------- +// 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); +}); diff --git a/scripts/detect-bump-type.js b/scripts/detect-bump-type.js new file mode 100644 index 00000000..44a17c0f --- /dev/null +++ b/scripts/detect-bump-type.js @@ -0,0 +1,158 @@ +#!/usr/bin/env node +"use strict"; + +/** + * Uses the GitHub Models API (gpt-4o-mini, no extra secrets required — + * GITHUB_TOKEN is enough when running inside GitHub Actions) to analyse + * a PR diff and decide whether the release is a patch, minor, or major bump. + * + * Versioning rules (from public/versioning.js): + * MAJOR — incompatible changes that break existing .map files + * MINOR — backward-compatible additions or changes that may require + * old .map files to be updated / migrated + * PATCH — backward-compatible bug fixes and small features that do + * NOT affect the .map file format at all + * + * Usage (called by bump-version.yml): + * node scripts/detect-bump-type.js --diff-file + * + * Output: prints exactly one of: patch | minor | major + * Exit 0 always (falls back to "patch" on any error). + */ + +const fs = require("fs"); +const https = require("https"); +const path = require("path"); + +// --------------------------------------------------------------------------- +// Config +// --------------------------------------------------------------------------- + +const MODEL = "gpt-4o-mini"; +const ENDPOINT_HOST = "models.inference.ai.azure.com"; +const ENDPOINT_PATH = "/chat/completions"; +// Keep the diff under ~20 000 chars to stay within the model's context window. +const MAX_DIFF_CHARS = 20_000; + +// --------------------------------------------------------------------------- +// System prompt — contains the project-specific versioning rules +// --------------------------------------------------------------------------- + +const SYSTEM_PROMPT = `\ +You are a semantic-version expert for Azgaar's Fantasy Map Generator. + +The project uses semantic versioning where the PUBLIC API is the .map save-file format. + +Rules: +• MAJOR — any change that makes existing .map files incompatible or unreadable + (e.g. removing or renaming top-level save-data fields, changing data types of + stored values, restructuring the save format) +• MINOR — backward-compatible additions or changes that may require old .map + files to be silently migrated on load (e.g. adding new optional fields to the + save format, changing default values that affect saved maps, adding new + generators that store new data) +• PATCH — everything else: UI improvements, bug fixes, refactors, new features + that do not touch the .map file format at all, dependency updates, docs + +Respond with EXACTLY one lowercase word: patch | minor | major +No explanation, no punctuation, no extra words.`; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function httpsPost(host, pathStr, headers, body) { + return new Promise((resolve, reject) => { + const data = JSON.stringify(body); + const req = https.request( + {host, path: pathStr, method: "POST", headers: {...headers, "Content-Length": Buffer.byteLength(data)}}, + res => { + let raw = ""; + res.on("data", c => (raw += c)); + res.on("end", () => { + if (res.statusCode >= 200 && res.statusCode < 300) resolve(raw); + else reject(new Error(`HTTP ${res.statusCode}: ${raw.slice(0, 300)}`)); + }); + } + ); + req.on("error", reject); + req.write(data); + req.end(); + }); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +async function main() { + const args = process.argv.slice(2); + const diffFileIdx = args.indexOf("--diff-file"); + const diffFile = diffFileIdx !== -1 ? args[diffFileIdx + 1] : null; + + if (!diffFile || !fs.existsSync(diffFile)) { + console.error("[detect-bump-type] --diff-file is required and must exist."); + process.stdout.write("patch\n"); + process.exit(0); + } + + const token = process.env.GITHUB_TOKEN; + if (!token) { + console.error("[detect-bump-type] GITHUB_TOKEN not set — falling back to patch."); + process.stdout.write("patch\n"); + process.exit(0); + } + + let diff = fs.readFileSync(diffFile, "utf8").trim(); + if (!diff) { + console.error("[detect-bump-type] Diff is empty — falling back to patch."); + process.stdout.write("patch\n"); + process.exit(0); + } + + // Trim diff to avoid exceeding context window + if (diff.length > MAX_DIFF_CHARS) { + diff = diff.slice(0, MAX_DIFF_CHARS) + "\n\n[diff truncated]"; + console.error(`[detect-bump-type] Diff truncated to ${MAX_DIFF_CHARS} chars.`); + } + + const userMessage = `Analyse this git diff and respond with exactly one word (patch, minor, or major):\n\n${diff}`; + + try { + const raw = await httpsPost( + ENDPOINT_HOST, + ENDPOINT_PATH, + { + "Content-Type": "application/json", + Authorization: `Bearer ${token}` + }, + { + model: MODEL, + messages: [ + {role: "system", content: SYSTEM_PROMPT}, + {role: "user", content: userMessage} + ], + temperature: 0, + max_tokens: 5 + } + ); + + const json = JSON.parse(raw); + const answer = json.choices?.[0]?.message?.content?.trim().toLowerCase() ?? "patch"; + + if (answer === "major" || answer === "minor" || answer === "patch") { + console.error(`[detect-bump-type] AI decision: ${answer}`); + process.stdout.write(`${answer}\n`); + } else { + console.error(`[detect-bump-type] Unexpected AI response "${answer}" — defaulting to patch.`); + process.stdout.write("patch\n"); + } + } catch (err) { + console.error(`[detect-bump-type] API error: ${err.message} — falling back to patch.`); + process.stdout.write("patch\n"); + } + + process.exit(0); +} + +main();