From 5de8d3b2ddd647e77bc31ee4145231187a4f53e4 Mon Sep 17 00:00:00 2001 From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com> Date: Wed, 23 Sep 2026 19:02:20 -0400 Subject: [PATCH 1/6] feat: generalize launch presets and migrate database --- .github/ISSUE_TEMPLATE/app-preset.yml | 4 +- .github/ISSUE_TEMPLATE/game-emulator.yml | 6 +- .github/ISSUE_TEMPLATE/game-epic-games.yml | 4 +- .github/ISSUE_TEMPLATE/game-gog.yml | 6 +- .../ISSUE_TEMPLATE/game-microsoft-store.yml | 4 +- .github/ISSUE_TEMPLATE/game-native.yml | 6 +- .github/ISSUE_TEMPLATE/game-steam.yml | 4 +- .github/workflows/migrate-database.yml | 87 +++++++++++++++++++ README.md | 14 +-- docs/developerSetup.md | 8 ++ docs/presetGuidelines.md | 8 +- gh-pages-template/assets/js/app.js | 50 ++++++++--- gh-pages-template/index.html | 6 +- package.json | 4 +- src/build-site.js | 5 +- src/database.js | 26 +++--- src/issue.js | 4 +- src/migrate-database.js | 81 +++++++++++++++++ src/presets.js | 14 +-- src/record.js | 34 ++++++++ tests/migration.test.js | 60 +++++++++++++ tests/presets.test.js | 79 +++++++++++++---- 22 files changed, 429 insertions(+), 85 deletions(-) create mode 100644 .github/workflows/migrate-database.yml create mode 100644 src/migrate-database.js create mode 100644 src/record.js create mode 100644 tests/migration.test.js diff --git a/.github/ISSUE_TEMPLATE/app-preset.yml b/.github/ISSUE_TEMPLATE/app-preset.yml index ffe9811795..4457a9aeff 100644 --- a/.github/ISSUE_TEMPLATE/app-preset.yml +++ b/.github/ISSUE_TEMPLATE/app-preset.yml @@ -1,6 +1,6 @@ --- name: App launch preset -description: Request one Sunshine launch option for a non-game app. +description: Request one launch option for a non-game app. title: '[APP PRESET]: ' labels: - request-app-preset @@ -52,7 +52,7 @@ body: id: working_dir attributes: label: Working directory - description: Optional Sunshine working-dir value. + description: Optional working directory. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/game-emulator.yml b/.github/ISSUE_TEMPLATE/game-emulator.yml index 5b05081a39..73ec6859e5 100644 --- a/.github/ISSUE_TEMPLATE/game-emulator.yml +++ b/.github/ISSUE_TEMPLATE/game-emulator.yml @@ -1,6 +1,6 @@ --- name: Emulator game preset -description: Request a Sunshine Emulator launch option for a GameDB game. +description: Request an emulator launch option for a GameDB game. title: '[EMULATOR GAME PRESET]: ' labels: - request-game-preset @@ -28,7 +28,7 @@ body: attributes: label: Command description: | - Portable Sunshine command for the emulator. Use {{ROM_PATH}} for the game and + Portable command for the emulator. Use {{ROM_PATH}} for the game and {{HOME}} for a variable home path. Put the emulator executable on the host PATH. Windows-only path placeholders, literal home directories, and reserved system names are rejected. validations: @@ -37,7 +37,7 @@ body: id: working_dir attributes: label: Working directory - description: Optional portable Sunshine working-dir value. + description: Optional portable working directory. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/game-epic-games.yml b/.github/ISSUE_TEMPLATE/game-epic-games.yml index cc218fe74d..73346f9b4c 100644 --- a/.github/ISSUE_TEMPLATE/game-epic-games.yml +++ b/.github/ISSUE_TEMPLATE/game-epic-games.yml @@ -1,6 +1,6 @@ --- name: Epic Games game preset -description: Request a Sunshine Epic Games launch option for a GameDB game. +description: Request an Epic Games launch option for a GameDB game. title: '[EPIC GAMES GAME PRESET]: ' labels: - request-game-preset @@ -25,7 +25,7 @@ body: description: | Three-part Sandbox ID, Catalog ID, and Artifact ID from the Epic launcher shortcut, separated by colons or %3A. For example fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite. - The bot generates the Sunshine command for each supported launcher OS. + The bot generates the command for each supported launcher OS. validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/game-gog.yml b/.github/ISSUE_TEMPLATE/game-gog.yml index 0e06dfd23c..42506964a1 100644 --- a/.github/ISSUE_TEMPLATE/game-gog.yml +++ b/.github/ISSUE_TEMPLATE/game-gog.yml @@ -1,6 +1,6 @@ --- name: GOG game preset -description: Request a Sunshine GOG launch option for a GameDB game. +description: Request a GOG launch option for a GameDB game. title: '[GOG GAME PRESET]: ' labels: - request-game-preset @@ -33,7 +33,7 @@ body: attributes: label: Command description: | - Sunshine command to launch the game. Use {{HOME}} for a variable home path. + Command to launch the game. Use {{HOME}} for a variable home path. Windows also allows {{SYSTEM_DRIVE}}, {{PROGRAM_FILES}}, and {{PROGRAM_FILES_X86}}. Literal home directories and reserved system names are rejected. validations: @@ -42,7 +42,7 @@ body: id: working_dir attributes: label: Working directory - description: Optional Sunshine working-dir value. + description: Optional working directory. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/game-microsoft-store.yml b/.github/ISSUE_TEMPLATE/game-microsoft-store.yml index 753e77a58a..a28ac5ebc9 100644 --- a/.github/ISSUE_TEMPLATE/game-microsoft-store.yml +++ b/.github/ISSUE_TEMPLATE/game-microsoft-store.yml @@ -1,6 +1,6 @@ --- name: Microsoft Store game preset -description: Request a Sunshine Microsoft Store launch option for a GameDB game. +description: Request a Microsoft Store launch option for a GameDB game. title: '[MICROSOFT STORE GAME PRESET]: ' labels: - request-game-preset @@ -25,7 +25,7 @@ body: description: | Installed app AUMID from Get-StartApps, for example Microsoft.WindowsCalculator_8wekyb3d8bbwe!App. A Store product ID will not launch the game. - The bot generates the Sunshine command for each supported launcher OS. + The bot generates the command for each supported launcher OS. validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/game-native.yml b/.github/ISSUE_TEMPLATE/game-native.yml index 808564a39a..35f74998fe 100644 --- a/.github/ISSUE_TEMPLATE/game-native.yml +++ b/.github/ISSUE_TEMPLATE/game-native.yml @@ -1,6 +1,6 @@ --- name: Native game preset -description: Request a Sunshine Native launch option for a GameDB game. +description: Request a native launch option for a GameDB game. title: '[NATIVE GAME PRESET]: ' labels: - request-game-preset @@ -33,7 +33,7 @@ body: attributes: label: Command description: | - Sunshine command to launch the game. Use {{HOME}} for a variable home path. + Command to launch the game. Use {{HOME}} for a variable home path. Windows also allows {{SYSTEM_DRIVE}}, {{PROGRAM_FILES}}, and {{PROGRAM_FILES_X86}}. Literal home directories and reserved system names are rejected. validations: @@ -42,7 +42,7 @@ body: id: working_dir attributes: label: Working directory - description: Optional Sunshine working-dir value. + description: Optional working directory. - type: textarea id: notes attributes: diff --git a/.github/ISSUE_TEMPLATE/game-steam.yml b/.github/ISSUE_TEMPLATE/game-steam.yml index 447e4a39b0..b85177eb1e 100644 --- a/.github/ISSUE_TEMPLATE/game-steam.yml +++ b/.github/ISSUE_TEMPLATE/game-steam.yml @@ -1,6 +1,6 @@ --- name: Steam game preset -description: Request a Sunshine Steam launch option for a GameDB game. +description: Request a Steam launch option for a GameDB game. title: '[STEAM GAME PRESET]: ' labels: - request-game-preset @@ -24,7 +24,7 @@ body: label: Launch ID description: | Steam numeric app ID from its store URL, for example 464920. - The bot generates the Sunshine command for each supported launcher OS. + The bot generates the command for each supported launcher OS. validations: required: true - type: textarea diff --git a/.github/workflows/migrate-database.yml b/.github/workflows/migrate-database.yml new file mode 100644 index 0000000000..dc7580aa99 --- /dev/null +++ b/.github/workflows/migrate-database.yml @@ -0,0 +1,87 @@ +--- +name: Migrate Database +permissions: {} + +env: + NODE_VERSION: '24' + +on: + push: + branches: + - master + workflow_dispatch: + +concurrency: + group: database-migrations + cancel-in-progress: false + +jobs: + migrate: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Checkout database + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: database + path: database-branch + token: ${{ secrets.GH_BOT_TOKEN }} + fetch-depth: 0 + + - name: Setup Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Back up and migrate database + id: migrate + env: + BOT_NAME: ${{ vars.GH_BOT_NAME }} + BOT_EMAIL: ${{ secrets.GH_BOT_EMAIL }} + shell: bash + run: | + set -euo pipefail + git -C database-branch config user.name "$BOT_NAME" + git -C database-branch config user.email "$BOT_EMAIL" + for _ in 1 2 3 4 5; do + git -C database-branch fetch origin database + git -C database-branch reset --hard origin/database + pending=$(node src/migrate-database.js --database database-branch/database --pending) + if [[ -z "$pending" ]]; then + echo 'No pending database migrations' + exit 0 + fi + source_commit=$(git -C database-branch rev-parse HEAD) + backup_branch="database-backup-$source_commit" + backup_remote=$(git -C database-branch ls-remote origin "refs/heads/$backup_branch" | cut -f1) + if [[ -z "$backup_remote" ]]; then + git -C database-branch push origin "$source_commit:refs/heads/$backup_branch" + elif [[ "$backup_remote" != "$source_commit" ]]; then + echo "Backup branch $backup_branch does not match $source_commit" >&2 + exit 1 + fi + node src/migrate-database.js --database database-branch/database \ + --backup-branch "$backup_branch" --source-commit "$source_commit" + git -C database-branch add database + if git -C database-branch diff --cached --quiet; then + echo 'A pending migration produced no database changes' >&2 + exit 1 + fi + git -C database-branch commit -m 'chore: apply database migrations' + if git -C database-branch push origin HEAD:database; then + echo 'migrated=true' >> "$GITHUB_OUTPUT" + exit 0 + fi + done + echo 'Database changed during all five migration attempts' >&2 + exit 1 + + - name: Build updated site + if: steps.migrate.outputs.migrated == 'true' + env: + GH_TOKEN: ${{ secrets.GH_BOT_TOKEN }} + run: gh workflow run build-pages.yml --ref master diff --git a/README.md b/README.md index b8447d815b..0868a04648 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ width="256" />
Community launch options for Windows, Linux, and macOS. Find a game or app, then choose the preset that fits your host.
+Community launch commands for Windows, Linux, and macOS. Browse presets, copy a command, or export Sunshine application JSON.
Contribute a game preset
Contribute an app preset
diff --git a/package.json b/package.json
index 6dbcc7a574..e09e70d63c 100644
--- a/package.json
+++ b/package.json
@@ -2,14 +2,14 @@
"name": "presetdb",
"version": "0.0.0",
"private": true,
- "description": "Community Sunshine game and app launch presets.",
+ "description": "Community game and app launch presets.",
"license": "AGPL-3.0-only",
"engines": {
"node": ">=20"
},
"scripts": {
"test": "node --test tests/*.test.js",
- "lint": "node --check src/presets.js && node --check src/database.js && node --check src/issue.js && node --check src/build-site.js && node --check src/statistics.js && node --check src/approval-queue.js && node --check src/comment-command.js && node --check src/verify-approval.js && node --check src/workflow-queue.js && node --check gh-pages-template/assets/js/app.js",
+ "lint": "node --check src/presets.js && node --check src/record.js && node --check src/migrate-database.js && node --check src/database.js && node --check src/issue.js && node --check src/build-site.js && node --check src/statistics.js && node --check src/approval-queue.js && node --check src/comment-command.js && node --check src/verify-approval.js && node --check src/workflow-queue.js && node --check gh-pages-template/assets/js/app.js",
"test:ci": "node --test --experimental-test-coverage --test-reporter=junit --test-reporter=lcov --test-reporter-destination=junit.xml --test-reporter-destination=lcov.info tests/*.test.js"
}
}
diff --git a/src/build-site.js b/src/build-site.js
index 33b38b70b0..c01d26ec57 100644
--- a/src/build-site.js
+++ b/src/build-site.js
@@ -3,10 +3,11 @@
const fs = require('node:fs');
const path = require('node:path');
const { buildStatistics } = require('./statistics');
+const { normalizeRecord } = require('./record');
function supportedOs(preset) {
if (preset.os) return [preset.os];
- return preset.sunshine_by_os ? Object.keys(preset.sunshine_by_os) : ['Windows', 'Linux', 'macOS'];
+ return preset.commands_by_os ? Object.keys(preset.commands_by_os) : ['Windows', 'Linux', 'macOS'];
}
const PROTON_TIERS = new Set(['borked', 'bronze', 'silver', 'gold', 'platinum', 'native']);
@@ -51,7 +52,7 @@ async function buildSite(database, template, output, fetcher = globalThis.fetch)
const target = path.join(output, folder);
fs.mkdirSync(target, { recursive: true });
for (const file of files) {
- const item = JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8'));
+ const item = normalizeRecord(JSON.parse(fs.readFileSync(path.join(directory, file), 'utf8')));
if (item.kind !== kind || !Array.isArray(item.presets) || item.presets.length === 0 ||
String(item.id) !== path.basename(file, '.json')) {
throw new Error(`Invalid database record: ${folder}/${file}`);
diff --git a/src/database.js b/src/database.js
index 8d235b1e4c..77a14c898c 100644
--- a/src/database.js
+++ b/src/database.js
@@ -3,6 +3,7 @@
const fs = require('node:fs');
const path = require('node:path');
const { PresetError } = require('./presets');
+const { normalizeRecord } = require('./record');
function recordPath(root, preset) {
const folder = preset.kind === 'game' ? 'games' : 'apps';
@@ -12,7 +13,7 @@ function recordPath(root, preset) {
function readRecord(file) {
if (!fs.existsSync(file)) return null;
- const record = JSON.parse(fs.readFileSync(file, 'utf8'));
+ const record = normalizeRecord(JSON.parse(fs.readFileSync(file, 'utf8')));
if (!record || !Array.isArray(record.presets)) throw new PresetError(`Invalid database record: ${file}`);
return record;
}
@@ -25,7 +26,7 @@ function prepareRecord(root, preset) {
let record = readRecord(file);
if (!record) {
record = {
- schema_version: 1, kind: preset.kind, id: preset.kind === 'game' ? preset.gameId : preset.appId,
+ schema_version: 2, kind: preset.kind, id: preset.kind === 'game' ? preset.gameId : preset.appId,
name, source_url: sourceUrl, image_url: null, presets: []
};
}
@@ -79,7 +80,7 @@ function mergePreset(root, preset, {
const { file, record, name } = prepareRecord(root, preset);
const previous = replacementIndex(record, preset);
const originIssue = previous >= 0 ? record.presets[previous].origin_issue : issueNumber;
- const presetId = `issue-${originIssue}`;
+ const presetId = String(originIssue);
const history = previous >= 0 ? [...(record.presets[previous].history || [])] : [];
if (history.some(item => item.issue === issueNumber)) {
throw new PresetError(`Issue #${issueNumber} has already been approved for this preset`);
@@ -93,17 +94,12 @@ function mergePreset(root, preset, {
id: presetId, name: generatedName, os: preset.os, method: preset.method,
...(preset.variantName ? { variant_name: preset.variantName } : {}),
...(preset.launchId ? { launch_id: preset.launchId } : {}),
- ...(preset.commandsByOs ? {
- sunshine_by_os: Object.fromEntries(Object.entries(preset.commandsByOs).map(([os, cmd]) => [
- os, { name: name + ' (' + os + ', ' + methodLabel(preset.method) + ')', cmd }
- ]))
- } : {
- sunshine: {
- name: generatedName,
- cmd: preset.command,
- ...(preset.workingDir ? { 'working-dir': preset.workingDir } : {})
- }
- }),
+ ...(preset.commandsByOs
+ ? { commands_by_os: preset.commandsByOs }
+ : {
+ command: preset.command,
+ ...(preset.workingDir ? { working_directory: preset.workingDir } : {})
+ }),
notes: preset.notes,
origin_issue: originIssue,
source_issue: issueNumber,
@@ -113,7 +109,7 @@ function mergePreset(root, preset, {
};
if (previous >= 0) record.presets[previous] = entry;
else record.presets.push(entry);
- record.presets.sort((a, b) => a.id.localeCompare(b.id));
+ record.presets.sort((a, b) => Number(a.id) - Number(b.id));
if (write) {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`);
diff --git a/src/issue.js b/src/issue.js
index ac01649d47..021cd16231 100644
--- a/src/issue.js
+++ b/src/issue.js
@@ -69,12 +69,12 @@ async function main(args = process.argv.slice(2)) {
});
const item = result.kind === 'game' ? `GameDB game ${result.preset.gameId}` : `app ${result.preset.appName}`;
const entry = result.record.presets.find(preset => preset.id === result.id);
- const preview = entry.sunshine || entry.sunshine_by_os;
+ const preview = entry.commands_by_os || { command: entry.command, ...(entry.working_directory ? { working_directory: entry.working_directory } : {}) };
const methodLine = result.kind === 'game' ? `- Method: ${result.preset.method}\n` : '';
message = `Preset ${result.action === 'replace' ? 'replacement' : 'request'} validated for ${item}.\n\n` +
`- Host: ${result.preset.os || 'OS independent'}\n` + methodLine + `- Preset ID: \`${result.id}\`\n` +
`- Status: ${options.mode === 'approve' ? 'approved and saved' : 'awaiting maintainer review'}\n\n` +
- `Sunshine application JSON preview:\n\n\`\`\`json\n${JSON.stringify(preview, null, 2)}\n\`\`\`\n`;
+ `Launch command preview:\n\n\`\`\`json\n${JSON.stringify(preview, null, 2)}\n\`\`\`\n`;
success = true;
} catch (error) {
message = `Preset validation failed: ${String(error.message).replace(/[\r\n]+/g, ' ').slice(0, 500)}\n`;
diff --git a/src/migrate-database.js b/src/migrate-database.js
new file mode 100644
index 0000000000..b82896019a
--- /dev/null
+++ b/src/migrate-database.js
@@ -0,0 +1,81 @@
+'use strict';
+
+const fs = require('node:fs');
+const path = require('node:path');
+const { normalizeRecord } = require('./record');
+
+const MIGRATIONS = [{
+ id: '001-generic-launch-presets',
+ apply(database) {
+ for (const folder of ['games', 'apps']) {
+ const directory = path.join(database, folder);
+ if (!fs.existsSync(directory)) continue;
+ for (const file of fs.readdirSync(directory).filter(name => name.endsWith('.json'))) {
+ const recordPath = path.join(directory, file);
+ const record = JSON.parse(fs.readFileSync(recordPath, 'utf8'));
+ const migrated = normalizeRecord(record);
+ if (migrated !== record) fs.writeFileSync(recordPath, JSON.stringify(migrated, null, 2) + '\n');
+ }
+ }
+ }
+}];
+
+function migrationHistory(database) {
+ const file = path.join(database, 'migrations.json');
+ if (!fs.existsSync(file)) return { applied: [] };
+ const history = JSON.parse(fs.readFileSync(file, 'utf8'));
+ if (!history || !Array.isArray(history.applied) ||
+ history.applied.some(entry => typeof entry.id !== 'string') ||
+ new Set(history.applied.map(entry => entry.id)).size !== history.applied.length) {
+ throw new Error('Invalid database migration history');
+ }
+ return history;
+}
+
+function pendingMigrations(database) {
+ const applied = new Set(migrationHistory(database).applied.map(entry => entry.id));
+ return MIGRATIONS.filter(migration => !applied.has(migration.id));
+}
+
+function applyMigrations(database, { backupBranch, sourceCommit, appliedAt = new Date().toISOString() } = {}) {
+ const pending = pendingMigrations(database);
+ if (pending.length === 0) return [];
+ if (!/^database-backup-[a-z0-9-]+$/.test(backupBranch || '') ||
+ !/^[0-9a-f]{40}$/.test(sourceCommit || '')) {
+ throw new Error('A database backup branch and its source commit are required');
+ }
+ const history = migrationHistory(database);
+ for (const migration of pending) {
+ migration.apply(database);
+ history.applied.push({
+ id: migration.id,
+ backup_branch: backupBranch,
+ source_commit: sourceCommit,
+ applied_at: appliedAt
+ });
+ }
+ fs.writeFileSync(path.join(database, 'migrations.json'), JSON.stringify(history, null, 2) + '\n');
+ return pending.map(migration => migration.id);
+}
+
+function main(args = process.argv.slice(2)) {
+ const options = {};
+ for (let i = 0; i < args.length; i++) {
+ if (args[i] === '--pending') options.pending = true;
+ else if (args[i].startsWith('--') && args[i + 1]) options[args[i++].slice(2)] = args[i];
+ else throw new Error('Use --database with --pending or --backup-branch and --source-commit');
+ }
+ if (!options.database) throw new Error('--database is required');
+ if (options.pending) {
+ console.log(pendingMigrations(options.database).map(migration => migration.id).join('\n'));
+ return;
+ }
+ const applied = applyMigrations(options.database, {
+ backupBranch: options['backup-branch'], sourceCommit: options['source-commit']
+ });
+ console.log(applied.length ? 'Applied migrations: ' + applied.join(', ') : 'No pending migrations');
+}
+
+if (require.main === module) main();
+
+module.exports = { MIGRATIONS, migrationHistory, pendingMigrations, applyMigrations };
diff --git a/src/presets.js b/src/presets.js
index 3304576f3f..286595275b 100644
--- a/src/presets.js
+++ b/src/presets.js
@@ -72,7 +72,7 @@ function validatePlaceholders(value, os, label) {
}
function validatePortablePath(value, os, label) {
- // Personal home locations cannot be shared between Sunshine hosts.
+ // Personal home locations cannot be shared between hosts.
const normalized = value.replaceAll('\\', '/');
const homeRoot = /(?:^|[\s"'=])(?:~\/|[a-z]:\/(?:users|documents and settings)\/|\/(?:home|users)\/)/i;
const homeVariable = /%userprofile%|%homepath%|\$home|\$\{home\}|\$\(home\)/i;
@@ -127,9 +127,9 @@ function steamLaunch(id) {
}
const uri = 'steam://rungameid/' + id;
return { launchId: id, commandsByOs: {
- Windows: 'cmd /c start "" "' + uri + '"',
- Linux: 'steam "' + uri + '"',
- macOS: 'open "' + uri + '"'
+ Windows: uri,
+ Linux: 'setsid steam ' + uri,
+ macOS: 'open ' + uri
} };
}
@@ -141,8 +141,8 @@ function epicLaunch(id) {
const launchId = parts.join('%3A');
const uri = 'com.epicgames.launcher://apps/' + launchId + '?action=launch&silent=true';
return { launchId, commandsByOs: {
- Windows: 'cmd /c start "" "' + uri + '"',
- macOS: 'open "' + uri + '"'
+ Windows: uri,
+ macOS: 'open ' + uri
} };
}
@@ -308,4 +308,4 @@ async function validateGameDb(preset, fetcher = globalThis.fetch, credentials =
return preset;
}
-module.exports = { PresetError, parseIssue, validateFields, validateGameDb, fetchGameDb, resolveIgdbSlug, validatePlaceholders, slug };
+module.exports = { PresetError, parseIssue, validateFields, validateGameDb, fetchGameDb, resolveIgdbSlug, validatePlaceholders, slug, generatedLaunch };
diff --git a/src/record.js b/src/record.js
new file mode 100644
index 0000000000..63536bdbb1
--- /dev/null
+++ b/src/record.js
@@ -0,0 +1,34 @@
+'use strict';
+
+const { PresetError, generatedLaunch } = require('./presets');
+
+function normalizeRecord(record) {
+ if (!record || !Array.isArray(record.presets)) throw new PresetError('Invalid database record');
+ if (record.schema_version === 2) return record;
+ if (record.schema_version !== 1) throw new PresetError('Unsupported database schema version');
+
+ return {
+ ...record,
+ schema_version: 2,
+ presets: record.presets.map(preset => {
+ const item = { ...preset };
+ const legacyId = /^issue-([1-9]\d*)$/.exec(item.id);
+ if (legacyId) item.id = legacyId[1];
+ if (item.sunshine_by_os) {
+ const method = { steam: 'Steam', 'epic-games': 'Epic Games', 'microsoft-store': 'Microsoft Store' }[item.method];
+ item.commands_by_os = method && item.launch_id
+ ? generatedLaunch({ launchId: item.launch_id }, method).commandsByOs
+ : Object.fromEntries(Object.entries(item.sunshine_by_os).map(([os, launch]) => [os, launch.cmd]));
+ delete item.sunshine_by_os;
+ }
+ if (item.sunshine) {
+ item.command = item.sunshine.cmd;
+ if (item.sunshine['working-dir']) item.working_directory = item.sunshine['working-dir'];
+ delete item.sunshine;
+ }
+ return item;
+ })
+ };
+}
+
+module.exports = { normalizeRecord };
diff --git a/tests/migration.test.js b/tests/migration.test.js
new file mode 100644
index 0000000000..08d41203e0
--- /dev/null
+++ b/tests/migration.test.js
@@ -0,0 +1,60 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { applyMigrations, migrationHistory, pendingMigrations } = require('../src/migrate-database');
+
+test('migration records its backup and does not rerun', t => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migrate-'));
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
+ const games = path.join(root, 'games');
+ fs.mkdirSync(games);
+ const file = path.join(games, '123.json');
+ fs.writeFileSync(file, JSON.stringify({
+ schema_version: 1, kind: 'game', id: 123, name: 'Example',
+ presets: [{ id: 'issue-4', name: 'Example (Native)', method: 'native', os: 'Windows',
+ sunshine: { name: 'Example (Native)', cmd: 'example.exe', 'working-dir': 'C:\\Games' } }]
+ }));
+ const sourceCommit = 'a'.repeat(40);
+ const backupBranch = 'database-backup-' + sourceCommit;
+ assert.deepEqual(pendingMigrations(root).map(item => item.id), ['001-generic-launch-presets']);
+ assert.deepEqual(applyMigrations(root, {
+ backupBranch, sourceCommit, appliedAt: '2026-09-23T00:00:00.000Z'
+ }), ['001-generic-launch-presets']);
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
+ assert.equal(record.schema_version, 2);
+ assert.equal(record.presets[0].id, '4');
+ assert.equal(record.presets[0].command, 'example.exe');
+ assert.equal(record.presets[0].working_directory, 'C:\\Games');
+ assert.ok(!Object.hasOwn(record.presets[0], 'sunshine'));
+ assert.deepEqual(migrationHistory(root), { applied: [{
+ id: '001-generic-launch-presets',
+ backup_branch: backupBranch,
+ source_commit: sourceCommit,
+ applied_at: '2026-09-23T00:00:00.000Z'
+ }] });
+ const content = fs.readFileSync(file, 'utf8');
+ assert.deepEqual(pendingMigrations(root), []);
+ assert.deepEqual(applyMigrations(root, { backupBranch: 'database-backup-other' }), []);
+ assert.equal(fs.readFileSync(file, 'utf8'), content);
+});
+
+test('migration requires a backup reference before changing the database', t => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migrate-backup-'));
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
+ assert.throws(() => applyMigrations(root, {}), /backup branch/);
+ assert.deepEqual(pendingMigrations(root).map(item => item.id), ['001-generic-launch-presets']);
+ assert.ok(!fs.existsSync(path.join(root, 'migrations.json')));
+});
+
+test('migration history rejects duplicate IDs', t => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-migrate-history-'));
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
+ fs.writeFileSync(path.join(root, 'migrations.json'), JSON.stringify({
+ applied: [{ id: '001-generic-launch-presets' }, { id: '001-generic-launch-presets' }]
+ }));
+ assert.throws(() => pendingMigrations(root), /Invalid database migration history/);
+});
diff --git a/tests/presets.test.js b/tests/presets.test.js
index d39dc7cba4..1ec4b64b11 100644
--- a/tests/presets.test.js
+++ b/tests/presets.test.js
@@ -9,7 +9,7 @@ const { parseIssue, validateFields, validateGameDb, resolveIgdbSlug } = require(
const { mergePreset } = require('../src/database');
const { processIssue } = require('../src/issue');
const { buildSite } = require('../src/build-site');
-const { filterItems, sunshineSnippet, normalizeBasePath } = require('../gh-pages-template/assets/js/app');
+const { filterItems, commandForOs, sunshineSnippet, normalizeBasePath } = require('../gh-pages-template/assets/js/app');
const credentials = { clientId: 'client-id', clientSecret: 'client-secret' };
const gameValues = {
@@ -120,14 +120,17 @@ test('store IDs generate commands for each launcher OS without asking for OS', (
const steam = validateFields({ ...base, method: 'Steam', launchId: '464920' }, 'game');
assert.equal(steam.os, null);
assert.deepEqual(steam.commandsByOs, {
- Windows: 'cmd /c start "" "steam://rungameid/464920"',
- Linux: 'steam "steam://rungameid/464920"',
- macOS: 'open "steam://rungameid/464920"'
+ Windows: 'steam://rungameid/464920',
+ Linux: 'setsid steam steam://rungameid/464920',
+ macOS: 'open steam://rungameid/464920'
});
const epic = validateFields({ ...base, method: 'Epic Games',
launchId: 'fn:4fe75bbc5a674f4f9b356b5c90567da5:Fortnite' }, 'game');
assert.equal(epic.launchId, 'fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite');
- assert.deepEqual(Object.keys(epic.commandsByOs), ['Windows', 'macOS']);
+ assert.deepEqual(epic.commandsByOs, {
+ Windows: 'com.epicgames.launcher://apps/fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite?action=launch&silent=true',
+ macOS: 'open com.epicgames.launcher://apps/fn%3A4fe75bbc5a674f4f9b356b5c90567da5%3AFortnite?action=launch&silent=true'
+ });
const aumid = 'Microsoft.WindowsCalculator_8wekyb3d8bbwe!App';
const store = validateFields({ ...base, method: 'Microsoft Store', launchId: aumid }, 'game');
assert.deepEqual(store.commandsByOs, { Windows: 'explorer.exe shell:AppsFolder\\' + aumid });
@@ -176,7 +179,7 @@ test('approved presets get issue IDs and replacements preserve identity', async
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-db-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const first = await validateGameDb(validateFields(gameValues, 'game'), mockApis, credentials);
- assert.equal(mergePreset(root, first, { issueNumber: 10, approvedBy: 'maintainer' }, { write: true }).id, 'issue-10');
+ assert.equal(mergePreset(root, first, { issueNumber: 10, approvedBy: 'maintainer' }, { write: true }).id, '10');
assert.throws(() => mergePreset(root, first, { issueNumber: 11, approvedBy: 'maintainer' }), /already exists/);
assert.throws(() => mergePreset(root, { ...first, variantName: 'retroarch snes9x' },
{ issueNumber: 11, approvedBy: 'maintainer' }), /already exists/);
@@ -184,27 +187,29 @@ test('approved presets get issue IDs and replacements preserve identity', async
mergePreset(root, second, { issueNumber: 12, approvedBy: 'maintainer' }, { write: true });
const replacement = await validateGameDb(validateFields({ ...gameValues, command: 'new-command',
replacementIssue: '10', replacementReason: 'Old command failed' }, 'game'), mockApis, credentials);
- assert.equal(mergePreset(root, replacement, { issueNumber: 13, approvedBy: 'maintainer' }, { write: true }).id, 'issue-10');
+ assert.equal(mergePreset(root, replacement, { issueNumber: 13, approvedBy: 'maintainer' }, { write: true }).id, '10');
const record = JSON.parse(fs.readFileSync(path.join(root, 'games/100245.json')));
+ assert.equal(record.schema_version, 2);
assert.equal(record.presets.length, 2);
- assert.equal(record.presets.find(item => item.id === 'issue-10').sunshine.cmd, 'new-command');
- assert.equal(record.presets.find(item => item.id === 'issue-10').name, 'One Tap Hero (Emulator: RetroArch Snes9x)');
- assert.equal(record.presets.find(item => item.id === 'issue-10').origin_issue, 10);
- assert.equal(record.presets.find(item => item.id === 'issue-10').source_issue, 13);
+ assert.deepEqual(record.presets.map(item => item.id), ['10', '12']);
+ assert.equal(record.presets.find(item => item.id === '10').command, 'new-command');
+ assert.equal(record.presets.find(item => item.id === '10').name, 'One Tap Hero (Emulator: RetroArch Snes9x)');
+ assert.equal(record.presets.find(item => item.id === '10').origin_issue, 10);
+ assert.equal(record.presets.find(item => item.id === '10').source_issue, 13);
assert.throws(() => validateFields({ ...gameValues, replacementIssue: '10' }, 'game'), /replacement needs both/);
assert.throws(() => mergePreset(root, { ...first, replacementIssue: 99, replacementReason: 'Fix' },
{ issueNumber: 14, approvedBy: 'maintainer' }), /No preset/);
});
-test('Steam app ID publishes one logical preset with host-specific Sunshine commands', async t => {
+test('Steam app ID publishes one logical preset with host-specific commands', async t => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-steam-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const steam = await validateGameDb(validateFields({ ...gameValues, method: 'Steam',
variantName: '', command: '', workingDir: '', launchId: '464920' }, 'game'), mockApis, credentials);
const { record } = mergePreset(root, steam, { issueNumber: 30, approvedBy: 'reviewer' }, { write: true });
assert.equal(record.presets[0].launch_id, '464920');
- assert.equal(record.presets[0].sunshine_by_os.Windows.cmd, 'cmd /c start "" "steam://rungameid/464920"');
- assert.equal(record.presets[0].sunshine_by_os.Linux.cmd, 'steam "steam://rungameid/464920"');
+ assert.equal(record.presets[0].commands_by_os.Windows, 'steam://rungameid/464920');
+ assert.equal(record.presets[0].commands_by_os.Linux, 'setsid steam steam://rungameid/464920');
assert.equal(record.presets[0].name, 'One Tap Hero (Steam)');
assert.ok(!Object.hasOwn(record.presets[0], 'detached'));
assert.ok(!Object.hasOwn(record.presets[0], 'image-path'));
@@ -240,7 +245,10 @@ test('site filters and serializes Sunshine application JSON', () => {
apps: [{ id: 'app', name: 'Media', operating_systems: ['Linux'], preset_count: 1 }] };
assert.deepEqual(filterItems(index, 'hal', 'game', 'Windows').map(item => item.name), ['Halo']);
assert.equal(filterItems(index, '', 'all', 'macOS').length, 0);
- assert.equal(JSON.parse(sunshineSnippet({ sunshine: { name: 'Halo', cmd: 'game.exe' } })).cmd, 'game.exe');
+ assert.equal(commandForOs({ command: 'game.exe' }), 'game.exe');
+ assert.deepEqual(JSON.parse(sunshineSnippet({ name: 'Halo', command: 'game.exe', working_directory: 'C:\\Games' })),
+ { name: 'Halo', cmd: 'game.exe', 'working-dir': 'C:\\Games' });
+ assert.throws(() => commandForOs({ commands_by_os: { Windows: 'game.exe' } }, 'Linux'), /available host OS/);
assert.equal(normalizeBasePath('/PresetDB'), '/PresetDB');
assert.equal(normalizeBasePath('en/pr-123'), '/en/pr-123');
});
@@ -263,7 +271,46 @@ test('Pages build adds a ProtonDB tier to Steam presets and keeps the source lin
assert.deepEqual(published.presets[0].protondb, { tier: 'gold', reports: 73 });
assert.equal(published.presets[0].protondb_url, 'https://www.protondb.com/app/464920');
assert.equal(JSON.parse(sunshineSnippet(published.presets[0], 'Linux')).cmd,
- 'steam "steam://rungameid/464920"');
+ 'setsid steam steam://rungameid/464920');
+});
+
+test('legacy approved records publish generic commands and short IDs', async t => {
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'preset-legacy-'));
+ t.after(() => fs.rmSync(root, { recursive: true, force: true }));
+ const database = path.join(root, 'database');
+ const output = path.join(root, 'site');
+ fs.mkdirSync(path.join(database, 'games'), { recursive: true });
+ const record = {
+ schema_version: 1, kind: 'game', id: 100245, name: 'One Tap Hero',
+ source_url: 'https://www.igdb.com/games/one-tap-hero',
+ presets: [{ id: 'issue-4', name: 'One Tap Hero (Steam)', os: null, method: 'steam',
+ launch_id: '464920', origin_issue: 4, source_issue: 4,
+ sunshine_by_os: { Windows: { cmd: 'cmd /c start "" "steam://rungameid/464920"' },
+ Linux: { cmd: 'steam "steam://rungameid/464920"' },
+ macOS: { cmd: 'open "steam://rungameid/464920"' } } }]
+ };
+ fs.writeFileSync(path.join(database, 'games/100245.json'), JSON.stringify(record));
+ await buildSite(database, path.join(__dirname, '..', 'gh-pages-template'), output,
+ async () => ({ ok: false, status: 404 }));
+ const published = JSON.parse(fs.readFileSync(path.join(output, 'games/100245.json')));
+ assert.equal(published.schema_version, 2);
+ assert.equal(published.presets[0].id, '4');
+ assert.deepEqual(published.presets[0].commands_by_os, {
+ Windows: 'steam://rungameid/464920',
+ Linux: 'setsid steam steam://rungameid/464920',
+ macOS: 'open steam://rungameid/464920'
+ });
+ assert.ok(!Object.hasOwn(published.presets[0], 'sunshine_by_os'));
+ const replacement = await validateGameDb(validateFields({ ...gameValues, method: 'Steam',
+ variantName: '', command: '', workingDir: '', launchId: '464920',
+ replacementIssue: '4', replacementReason: 'Refresh the launcher command' }, 'game'), mockApis, credentials);
+ const merged = mergePreset(database, replacement, { issueNumber: 50, approvedBy: 'reviewer' }, { write: true });
+ assert.equal(merged.id, '4');
+ const stored = JSON.parse(fs.readFileSync(path.join(database, 'games/100245.json')));
+ assert.equal(stored.schema_version, 2);
+ assert.equal(stored.presets[0].id, '4');
+ assert.equal(stored.presets[0].source_issue, 50);
+ assert.equal(stored.presets[0].commands_by_os.Linux, 'setsid steam steam://rungameid/464920');
});
test('game method labels select the form and reject conflicting issue fields', async t => {
From 2b3a7be32eab6b5027d0d568d10c0e4011db8d8b Mon Sep 17 00:00:00 2001
From: ReenigneArcher <42013603+ReenigneArcher@users.noreply.github.com>
Date: Wed, 23 Sep 2026 19:14:00 -0400
Subject: [PATCH 2/6] test: cover migration preview and preset exports
---
.github/workflows/build-pages.yml | 9 +++
README.md | 2 +-
docs/developerSetup.md | 2 +
gh-pages-template/assets/js/app.js | 2 +-
src/issue.js | 2 +-
src/migrate-database.js | 8 ++-
tests/issue-cli.test.js | 33 +++++++++++
tests/migration-cli.test.js | 36 ++++++++++++
tests/migration.test.js | 34 +++++++++++
tests/site-card.test.js | 91 ++++++++++++++++++++++++++++++
10 files changed, 213 insertions(+), 6 deletions(-)
create mode 100644 tests/issue-cli.test.js
create mode 100644 tests/migration-cli.test.js
create mode 100644 tests/site-card.test.js
diff --git a/.github/workflows/build-pages.yml b/.github/workflows/build-pages.yml
index 757cbabde7..5d696fd556 100644
--- a/.github/workflows/build-pages.yml
+++ b/.github/workflows/build-pages.yml
@@ -36,6 +36,15 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
+ - name: Preview database migrations
+ if: github.event_name == 'pull_request'
+ shell: bash
+ run: |
+ set -euo pipefail
+ source_commit=$(git -C database-branch rev-parse HEAD)
+ node src/migrate-database.js --database database-branch/database \
+ --backup-branch "database-backup-$source_commit" --source-commit "$source_commit"
+
- name: Assemble site
run: node src/build-site.js --database database-branch/database --output site-build
diff --git a/README.md b/README.md
index 0868a04648..11aa05a6e2 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,7 @@ Game submissions resolve the submitted slug through [IGDB authenticated API](htt
On each push to master, the Migrate Database workflow checks `database/migrations.json` for completed migration IDs. Before applying pending migrations, it creates a `database-backup-