feat(launcher): sync release assets from manifest or attachment list (no fixed exe name)

- default files []: resolve sync list from patch-manifest keys, else discover
  release attachments (exclude launcher artifacts).
- Explicit files[] still overrides; strip deprecated Wow-patched.exe on merge.
- listReleaseAttachmentNames + fetchGiteaReleaseRecord helpers.
- Version 1.0.7; README config docs.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Docker Build
2026-05-10 22:04:48 -05:00
parent f409ffad12
commit 9cef99f0ff
9 changed files with 193 additions and 29 deletions
@@ -5,14 +5,16 @@ const fs = require('fs').promises;
const { normalizeWinGameDir } = require('./win-game-dir');
/** Sources no longer shipped; drop from merged files so old launcher.json does not keep fetching them. */
const DEPRECATED_FILE_SOURCES = new Set(['patch-Z.MPQ']);
const DEPRECATED_FILE_SOURCES = new Set(['patch-Z.MPQ', 'Wow-patched.exe']);
function mergeFilesList(defaults, user) {
const raw =
Array.isArray(user.files) && user.files.length ? user.files.map((e) => ({ ...e })) : defaults.files.map((e) => ({ ...e }));
const filtered = raw.filter((e) => !DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim()));
if (filtered.length) return filtered;
return defaults.files.map((e) => ({ ...e }));
const dep = (e) => DEPRECATED_FILE_SOURCES.has(String(e && e.source ? e.source : '').trim());
if (Array.isArray(user.files) && user.files.length) {
const filtered = user.files.map((e) => ({ ...e })).filter((e) => !dep(e));
if (filtered.length) return filtered;
}
const defList = Array.isArray(defaults.files) ? defaults.files : [];
return defList.map((e) => ({ ...e })).filter((e) => !dep(e));
}
function userFilesContainDeprecated(user) {
@@ -33,7 +33,7 @@ function useGiteaReleases(cfg) {
return !!(String(g.base_url || '').trim() && String(g.owner || '').trim() && String(g.repo || '').trim());
}
async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
async function fetchGiteaReleaseRecord(cfg) {
const api = giteaApiBase(cfg);
const { owner, repo } = cfg.gitea;
const tag = (cfg.gitea.release_tag || 'latest').trim() || 'latest';
@@ -54,7 +54,18 @@ async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
if (res.status === 401 || res.status === 403) hint = ' (set GITEA_TOKEN or gitea.token_env)';
throw new Error(`Gitea release ${res.status}${hint}: ${text.slice(0, 600)}`);
}
const rel = JSON.parse(text);
return JSON.parse(text);
}
async function listGiteaReleaseAttachmentNames(cfg) {
const rel = await fetchGiteaReleaseRecord(cfg);
const list = rel.attachments || rel.assets || [];
return list.map((x) => x.name).filter(Boolean);
}
async function downloadGiteaReleaseAsset(cfg, assetName, destPath) {
const token = giteaToken(cfg);
const rel = await fetchGiteaReleaseRecord(cfg);
const list = rel.attachments || rel.assets || [];
let downloadUrl = '';
for (const a of list) {
@@ -101,6 +112,8 @@ async function getGiteaUpdaterFeedBase(cfg) {
module.exports = {
downloadGiteaReleaseAsset,
fetchGiteaReleaseRecord,
listGiteaReleaseAttachmentNames,
giteaToken,
useGiteaReleases,
getGiteaUpdaterFeedBase,
@@ -3,7 +3,7 @@
const path = require('path');
const fs = require('fs').promises;
const { githubToken } = require('./github-token');
const { downloadGiteaReleaseAsset, useGiteaReleases } = require('./gitea-release');
const { downloadGiteaReleaseAsset, useGiteaReleases, listGiteaReleaseAttachmentNames } = require('./gitea-release');
const { fetchToFile, downloadBodyToFile, fetchOrThrow } = require('./http-download');
function encodeRepoPath(repoPath) {
@@ -65,10 +65,7 @@ async function downloadGitHubRepoFile(cfg, repoPath, destPath) {
throw new Error(`unexpected GitHub response for ${repoPath}`);
}
async function downloadReleaseAsset(cfg, assetName, destPath) {
if (useGiteaReleases(cfg)) {
return downloadGiteaReleaseAsset(cfg, assetName, destPath);
}
async function fetchGitHubReleaseJson(cfg) {
const token = githubToken(cfg);
const tag = (cfg.github.release_tag || 'latest').trim() || 'latest';
const { owner, repo } = cfg.github;
@@ -89,7 +86,24 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
if (res.status === 401 || res.status === 403) hint = ' (set GITHUB_TOKEN or token_env PAT)';
throw new Error(`releases list ${res.status}${hint}: ${text.slice(0, 600)}`);
}
const rel = JSON.parse(text);
return JSON.parse(text);
}
async function listReleaseAttachmentNames(cfg) {
if (useGiteaReleases(cfg)) {
return listGiteaReleaseAttachmentNames(cfg);
}
const rel = await fetchGitHubReleaseJson(cfg);
const assets = rel.assets || [];
return assets.map((a) => a.name).filter(Boolean);
}
async function downloadReleaseAsset(cfg, assetName, destPath) {
if (useGiteaReleases(cfg)) {
return downloadGiteaReleaseAsset(cfg, assetName, destPath);
}
const token = githubToken(cfg);
const rel = await fetchGitHubReleaseJson(cfg);
const assets = rel.assets || [];
let assetURL = '';
for (const a of assets) {
@@ -118,4 +132,10 @@ async function downloadReleaseAsset(cfg, assetName, destPath) {
await downloadBodyToFile(dl, destPath);
}
module.exports = { downloadGitHubRepoFile, downloadReleaseAsset, encodeRepoPath };
module.exports = {
downloadGitHubRepoFile,
downloadReleaseAsset,
encodeRepoPath,
fetchGitHubReleaseJson,
listReleaseAttachmentNames,
};
@@ -75,8 +75,10 @@ async function loadManifest(cfg) {
*/
async function patchesMatchManifest(cfg, manifest, onStatus) {
if (!validateManifest(manifest)) return false;
const { buildResolvedReleaseFiles } = require('./release-sync');
const entries = await buildResolvedReleaseFiles(cfg, manifest);
const gameDir = cfg.game_dir;
for (const entry of cfg.files || []) {
for (const entry of entries) {
if (!entry.from_release) continue;
const spec = manifest.files[entry.source];
if (!spec || !spec.sha256) return false;
@@ -98,7 +100,9 @@ async function patchesMatchManifest(cfg, manifest, onStatus) {
async function verifyInstalledAgainstManifest(cfg, manifest) {
if (!validateManifest(manifest)) return;
for (const entry of cfg.files || []) {
const { buildResolvedReleaseFiles } = require('./release-sync');
const entries = await buildResolvedReleaseFiles(cfg, manifest);
for (const entry of entries) {
if (!entry.from_release) continue;
const spec = manifest.files[entry.source];
if (!spec || !spec.sha256) {
@@ -119,8 +123,10 @@ async function verifyInstalledAgainstManifest(cfg, manifest) {
async function recordPatchState(cfg, manifest) {
if (!validateManifest(manifest)) return;
const { buildResolvedReleaseFiles } = require('./release-sync');
const entries = await buildResolvedReleaseFiles(cfg, manifest);
const shas = {};
for (const entry of cfg.files || []) {
for (const entry of entries) {
if (!entry.from_release) continue;
const parts = String(entry.dest).replace(/\\/g, '/').split('/').filter(Boolean);
const destAbs = path.join(cfg.game_dir, ...parts);
@@ -5,6 +5,8 @@ const fs = require('fs').promises;
const fsSync = require('fs');
const { downloadGitHubRepoFile, downloadReleaseAsset } = require('./github');
const { normalizeWinGameDir } = require('./win-game-dir');
const { loadManifest } = require('./patch-manifest');
const { buildResolvedReleaseFiles } = require('./release-sync');
function pad2(n) {
return String(n).padStart(2, '0');
@@ -94,7 +96,12 @@ async function applyRealmlist(cfg) {
}
async function applyPatches(cfg, onStatus) {
for (const f of cfg.files || []) {
let manifest = null;
if (cfg.patch_manifest && cfg.patch_manifest.enabled) {
manifest = await loadManifest(cfg);
}
const entries = await buildResolvedReleaseFiles(cfg, manifest);
for (const f of entries) {
if (onStatus) onStatus(`Updating ${f.dest}`);
try {
await installFile(cfg, f);
@@ -0,0 +1,122 @@
'use strict';
const path = require('path');
const { listReleaseAttachmentNames } = require('./github');
/** Legacy launcher.json rows — ignored when merging explicit files. */
const DEPRECATED_SOURCES = new Set(['patch-Z.MPQ', 'Wow-patched.exe']);
function filterExplicitFiles(files) {
if (!Array.isArray(files)) return [];
return files
.filter((e) => e && String(e.source || '').trim())
.filter((e) => !DEPRECATED_SOURCES.has(String(e.source).trim()))
.map((e) => ({
source: String(e.source).trim(),
dest: String(e.dest || '').trim(),
backup: e.backup !== false,
from_release: e.from_release !== false,
}))
.filter((e) => e.dest);
}
function manifestLooksUsable(m) {
return !!(m && m.files && typeof m.files === 'object' && Object.keys(m.files).length > 0);
}
/** Launcher / updater attachments — never copied into the WoW folder. */
function isExcludedFromGameSync(fileName) {
const n = String(fileName || '');
const lower = n.toLowerCase();
if (lower === 'patch-manifest.json') return true;
if (/^fractured-launcher/i.test(n)) return true;
if (/\.blockmap$/i.test(n)) return true;
if (/^latest.*\.ya?ml$/i.test(n) || lower === 'latest.yml') return true;
if (lower.includes('builder-debug')) return true;
if (/\.appimage$/i.test(n)) return true;
return false;
}
function destForReleaseSource(source, cfg) {
const base = path.basename(String(source || ''));
if (/\.mpq$/i.test(base)) return `Data/${base}`;
if (/\.exe$/i.test(base)) return (cfg.launch && cfg.launch.exe) || 'Wow.exe';
return base;
}
/**
* Explicit `files` in config wins. Otherwise use patch-manifest keys if present,
* else discover attachments on the release (excluding launcher artifacts).
*/
async function buildResolvedReleaseFiles(cfg, manifestMaybeNull) {
const explicit = filterExplicitFiles(cfg.files);
if (explicit.length) return explicit;
const manifest = manifestMaybeNull;
if (manifestLooksUsable(manifest)) {
const keys = Object.keys(manifest.files).filter((k) => k && !isExcludedFromGameSync(k));
if (!keys.length) {
throw new Error('patch-manifest.json has no file entries — add files or attach assets to the release.');
}
return keys.map((source) => ({
source,
dest: destForReleaseSource(source, cfg),
backup: true,
from_release: true,
}));
}
const names = await listReleaseAttachmentNames(cfg);
const game = names.filter((n) => n && !isExcludedFromGameSync(n));
if (!game.length) {
throw new Error(
'No patch files on this release (after excluding launcher installers). ' +
'Attach MPQ/exe assets or ship patch-manifest.json listing filenames.'
);
}
const exes = game.filter((n) => /\.exe$/i.test(n));
const mpqs = game.filter((n) => /\.mpq$/i.test(n));
const rest = game.filter((n) => !/\.(exe|mpq)$/i.test(n));
if (exes.length > 1) {
throw new Error(
`Release has multiple .exe files (${exes.join(', ')}). ` +
'Remove extras or publish patch-manifest.json with the exact filenames to install.'
);
}
const out = [];
for (const n of mpqs) {
out.push({
source: n,
dest: path.posix.join('Data', path.basename(n)),
backup: true,
from_release: true,
});
}
if (exes.length === 1) {
out.push({
source: exes[0],
dest: (cfg.launch && cfg.launch.exe) || 'Wow.exe',
backup: true,
from_release: true,
});
}
for (const n of rest) {
out.push({
source: n,
dest: path.basename(n),
backup: true,
from_release: true,
});
}
return out;
}
module.exports = {
buildResolvedReleaseFiles,
filterExplicitFiles,
isExcludedFromGameSync,
DEPRECATED_SOURCES,
};