9 Commits

Author SHA1 Message Date
39998917ab Removes dependabot submissions.
Some checks failed
Java CI with Gradle / build (push) Has been cancelled
2026-09-27 01:26:48 -07:00
cd3c58c942 Updates to docs and ci. 2026-09-27 01:14:52 -07:00
676407dbcb Fixes dependabot includes. 2026-09-27 00:25:52 -07:00
186afc7813 Adds modrinth dependency for ScoreKeeper. 2026-09-26 23:57:24 -07:00
c2abca419f Adds github actions to build and archive the jar. 2026-09-26 23:48:13 -07:00
9c69197e7f Adds tasks. 2026-09-26 23:47:13 -07:00
8096aca633 Adds tasks.
Some checks failed
Gradle Build / build (push) Has been cancelled
2026-09-26 22:01:38 -07:00
37020ecf9c Adds task. 2026-09-26 20:46:58 -07:00
12099685bf Updates to work with ScoreKeeper 0.2.2 buckets. 2026-09-26 20:29:13 -07:00
18 changed files with 494 additions and 150 deletions

View File

@@ -1,9 +1,26 @@
# Dev-server config for this project's tools/ scripts. # Configuration for tools/*.sh. Values are read as KEY=value data, not executed.
# Plain KEY=value only (no shell expansion). Environment variables take precedence. # Unquoted values may contain spaces; matching outer quotes are removed.
# Paper server
PAPER_VERSION=26.2 PAPER_VERSION=26.2
PAPER_BUILD=129 PAPER_BUILD=129
PAPER_USER_AGENT="majinnaibu-plugin-dev/1.0 (https://github.com/majinnaibu)"
JAVA_OPTS="-Xms2G -Xmx2G"
STOP_TIMEOUT=60
MINECRAFT_SERVER_JAR=paper-26.2-129.jar MINECRAFT_SERVER_JAR=paper-26.2-129.jar
# Relative paths are resolved from this directory; ~/ is expanded.
# Relative paths are resolved from the project root; ~/ is expanded.
MINECRAFT_SERVER_PATH=./server MINECRAFT_SERVER_PATH=./server
MINECRAFT_BACKUP_PATH=./backups MINECRAFT_BACKUP_PATH=./backups
# Modrinth publishing
# Release version is read from Gradle and checked against the jar's plugin.yml.
MODRINTH_TOKEN=
MODRINTH_PROJECT_ID=mob-scores
# Optional Markdown override; empty generates notes from commits since the last tag.
MODRINTH_CHANGELOG=
MODRINTH_GAME_VERSIONS=26.2,26.3
MODRINTH_LOADERS=paper
MODRINTH_VERSION_TYPE=
MODRINTH_DEPENDENCIES='[{"project_id":"5NYwxysp","version_id":null,"file_name":null,"dependency_type":"required"}]'
MODRINTH_FILE=

View File

@@ -1,23 +1,45 @@
name: Gradle Build name: Java CI with Gradle
on: on:
push: push:
branches: [ "develop" ]
pull_request: pull_request:
branches: [ "develop" ]
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: Set up JDK 25
uses: actions/setup-java@v6
with: with:
repository: headhunter45/ScoreKeeper
path: ScoreKeeper
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '25' java-version: '25'
- uses: gradle/actions/setup-gradle@v4 distribution: 'temurin'
- run: ./gradlew publishToMavenLocal
working-directory: ScoreKeeper - name: Setup Gradle 9.8.0
- run: ./gradlew test build uses: gradle/actions/setup-gradle@v5
with:
gradle-version: '9.8.0'
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Build with Gradle Wrapper
run: ./tools/build-plugin.sh
- name: Extract Project Version
id: project_info
run: |
VERSION=$(./gradlew properties --no-daemon -q | awk -F ': ' '$1 == "version" {print $2}')
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Upload Plugin Artifact
uses: actions/upload-artifact@v7
with:
name: MobScores-${{ steps.project_info.outputs.version }}
path: build/libs/*.jar
if-no-files-found: error
retention-days: 14

View File

@@ -4,15 +4,17 @@ MobScores awards ScoreKeeper points for configured mob kills. Set a score for ea
## Requirements ## Requirements
- Paper 1.21.7 or a compatible newer server. - Paper 26.2 or 26.3.
- Java 25 or newer at runtime and for building. - Java 25 or newer at runtime and for building.
- ScoreKeeper 0.2.2 or a compatible release. - ScoreKeeper 0.2.2 or newer.
Install ScoreKeeper and MobScores in the server's `plugins` directory. ScoreKeeper is a required dependency and must be enabled for MobScores to load. Install ScoreKeeper and MobScores in the server's `plugins` directory. ScoreKeeper is a required dependency and must be enabled for MobScores to load.
MobScores targets Paper only; Bukkit and Spigot servers are not supported.
## Configuration ## Configuration
MobScores creates `plugins/MobScores/config.yml` from its defaults. Edit `ScoreTable` to configure point values by Bukkit `EntityType` name: MobScores creates `plugins/MobScores/config.yml` from its defaults. Edit `ScoreTable` to configure point values by Paper API `EntityType` name:
```yaml ```yaml
ScoreTable: ScoreTable:
@@ -25,7 +27,7 @@ Set an entity's value to `0` to disable its points and hide it from the join-tim
## Scoring ## Scoring
MobScores records credit for configured entity types damaged directly by a player or by a projectile fired by a player. When a credited player remains online when the entity dies, its configured points are added to that player's active ScoreKeeper bucket. Other mob attribution is not currently handled. MobScores records credit for configured entity types damaged directly by a player or by a projectile fired by a player. When a credited player remains online when the entity dies, its configured points are added to the dedicated `mob-scores` ScoreKeeper bucket. Use `/score-bucket mob-scores` to view mob points with ScoreKeeper commands. Other mob attribution is not currently handled.
MobScores has no commands of its own. Players see the nonzero score table when they join. MobScores has no commands of its own. Players see the nonzero score table when they join.

View File

@@ -1,6 +1,6 @@
{ {
"name": "MobScores", "name": "Mob Scores",
"url": "https://modrinth.com/plugin/mobscores", "url": "https://modrinth.com/plugin/mob-scores",
"summary": "Award ScoreKeeper points for configured mob kills with a customizable per-entity score table.", "summary": "Award ScoreKeeper points for configured mob kills with a customizable per-entity score table.",
"visibility": "public", "visibility": "public",
"links": { "links": {

View File

@@ -1,12 +1,12 @@
{ {
"id": "mob-scores", "id": "mob-scores",
"title": "MobScores", "title": "MobScores",
"description": "Legacy Bukkit plugin that awards configurable scores for killing mobs.", "description": "A Paper 26.2/26.3 integration plugin that attributes mob kills, including projectile kills, and awards configurable ScoreKeeper points by entity type.",
"docId": "MobScores", "docId": "MobScores",
"image": { "image": {
"url": "Retro Minecraft Bukkit server plugin artwork showing a blocky player defeating a hostile zombie, floating score numbers, a simple scoreboard motif, pixel-art style, dark forest-green and charcoal palette, no words or logos.", "url": "Retro Minecraft Paper server plugin artwork showing a blocky player defeating a hostile zombie, floating score numbers, a simple scoreboard motif, pixel-art style, dark forest-green and charcoal palette, no words or logos.",
"alt": "Pixel-art scene of a Minecraft player defeating a zombie with score numbers floating nearby", "alt": "Pixel-art scene of a Minecraft player defeating a zombie with score numbers floating nearby",
"prompt": "Retro Minecraft Bukkit server plugin artwork showing a blocky player defeating a hostile zombie, floating score numbers, a simple scoreboard motif, pixel-art style, dark forest-green and charcoal palette, no words or logos.", "prompt": "Retro Minecraft Paper server plugin artwork showing a blocky player defeating a hostile zombie, floating score numbers, a simple scoreboard motif, pixel-art style, dark forest-green and charcoal palette, no words or logos.",
"width": 0, "width": 0,
"height": 0, "height": 0,
"backgroundClassName": "bg-surface" "backgroundClassName": "bg-surface"

View File

@@ -1,16 +1,12 @@
# MobScores # MobScores
MobScores is a legacy Bukkit plugin that awards configurable scores to players for killing mobs. It works with the `ScoreKeeper` plugin, records the player who last damaged a mob, and awards the configured value when that mob dies. MobScores turns combat into a configurable scoring system. When a player damages a tracked mob, the plugin remembers the attribution and awards the matching ScoreKeeper bucket when that mob dies, including projectile kills.
## Details It is a deliberately small Paper integration plugin with a clear boundary: Paper entity events on one side, ScoreKeeper's public scoring API on the other. The per-entity score table is configurable, zero-value entries can be disabled, and players see the active mob-score table when they join. It targets Paper 26.2 and 26.3 only, not Bukkit or Spigot.
- Platform: Bukkit/Minecraft server plugin ## Engineering Notes
- Language: Java
- Dependency: ScoreKeeper
- Configuration: `plugins/MobScores/config.yml`
- Commands: None
- License: GNU Affero General Public License v3
## Image prompt - Built for modern Paper with Java 25 and Gradle.
- Uses UUID-safe player attribution.
Retro Minecraft Bukkit server plugin artwork showing a blocky player defeating a hostile zombie, floating score numbers, a simple scoreboard motif, pixel-art style, dark forest-green and charcoal palette, no words or logos. - Depends on ScoreKeeper 0.2.2 or newer.
- Tested through the standard server-free build workflow.

View File

@@ -1,18 +1,18 @@
# MobScores # MobScores
MobScores awards ScoreKeeper points for configured mob kills. It is a Paper plugin and requires the ScoreKeeper plugin to be installed and enabled first. MobScores awards ScoreKeeper points for configured mob kills. It is a Paper-only plugin for Paper 26.2 and 26.3, and requires the ScoreKeeper plugin to be installed and enabled first.
## Requirements ## Requirements
- Paper 1.21.7 or a compatible newer server. - Paper 26.2 or 26.3.
- Java 25 to build the plugin. - Java 25 to build the plugin.
- ScoreKeeper 0.2.2 or the version declared by `build.gradle`. - ScoreKeeper 0.2.2 or newer.
Install both plugin JARs in the server's `plugins` directory. MobScores declares ScoreKeeper as a hard dependency, so it will not enable without it. Install both plugin JARs in the server's `plugins` directory. MobScores declares ScoreKeeper as a hard dependency, so it will not enable without it. Bukkit and Spigot servers are not supported.
## Score Table ## Score Table
On first startup, MobScores creates `plugins/MobScores/config.yml` with default scores for supported entity types. Edit the `ScoreTable` mapping to change points; keys are Bukkit `EntityType` names and values are integer points: On first startup, MobScores creates `plugins/MobScores/config.yml` with default scores for supported entity types. Edit the `ScoreTable` mapping to change points; keys are Paper API `EntityType` names and values are integer points:
```yaml ```yaml
ScoreTable: ScoreTable:
@@ -21,9 +21,9 @@ ScoreTable:
SILVERFISH: 0 SILVERFISH: 0
``` ```
Restart the server after editing the configuration. A configured value of `0` suppresses that entity from the score table shown to players when they join. Restart the server after editing the configuration. A configured value of `0` suppresses that entity from the score table shown to players when they join. Entity type keys use the Paper API's `EntityType` names.
MobScores has no commands of its own. It records credit for configured entity types damaged directly by a player or by a projectile fired by a player. Other mob attribution is not currently handled. Points are awarded through ScoreKeeper's existing player API and follow the player's UUID-backed score. MobScores has no commands of its own. It records credit for configured entity types damaged directly by a player or by a projectile fired by a player. Other mob attribution is not currently handled. Points go into ScoreKeeper's dedicated `mob-scores` bucket and are tracked by player UUID. Use `/score-bucket mob-scores` before `/score-get` to view mob points; MobScores does not change the player's active bucket automatically.
## Build ## Build

View File

@@ -17,10 +17,13 @@
| MS-13 | Done | Build the plugin with Gradle (./gradlew build) and test on a modern Paper server using the provided bash scripts. | MS-13 | Done | Build the plugin with Gradle (./gradlew build) and test on a modern Paper server using the provided bash scripts.
| MS-14 | Done | Address any bugs or incompatibilities found during testing on a modern server. | MS-14 | Done | Address any bugs or incompatibilities found during testing on a modern server.
| MS-15 | Done | Update README.md and CONTRIBUTING.md with new build, usage, and development instructions. _(Depends on: bugfixes)_ | MS-15 | Done | Update README.md and CONTRIBUTING.md with new build, usage, and development instructions. _(Depends on: bugfixes)_
| MS-16 | Done | (Optional) Add new features, quality-of-life improvements, automated tests, or CI configuration. _(Depends on: docs update)_ | MS-16 | Planning | (Optional) Add new features, quality-of-life improvements, automated tests, or CI configuration. _(Depends on: docs update)_
| MS-17 | Done | Update MobScores to depend on the latest version of the ScoreKeeper plugin (update dependency in build.gradle and plugin.yml as needed). | MS-17 | Done | Update MobScores to depend on the latest version of the ScoreKeeper plugin (update dependency in build.gradle and plugin.yml as needed).
| MS-18 | Done | Test MobScores with the latest ScoreKeeper to ensure score tracking, awarding, and all integration points work as expected (including with players who have changed names). | MS-18 | Done | Test MobScores with the latest ScoreKeeper to ensure score tracking, awarding, and all integration points work as expected (including with players who have changed names).
| MS-19 | Done | Refactor MobDeathListener and PlayerConnectListener to implement Listener interface and use @EventHandler annotations instead of extending EntityListener/PlayerListener. | MS-19 | Done | Refactor MobDeathListener and PlayerConnectListener to implement Listener interface and use @EventHandler annotations instead of extending EntityListener/PlayerListener.
| MS-20 | Done | Update event registration in MobScoresPlugin to use getServer().getPluginManager().registerEvents(...). | MS-20 | Done | Update event registration in MobScoresPlugin to use getServer().getPluginManager().registerEvents(...).
| MS-21 | Done | Replace all usage of org.bukkit.util.config.Configuration with the modern Bukkit configuration API (getConfig(), saveConfig(), reloadConfig(), etc.). | MS-21 | Done | Replace all usage of org.bukkit.util.config.Configuration with the modern Bukkit configuration API (getConfig(), saveConfig(), reloadConfig(), etc.).
| MS-22 | Done | Remove or refactor any code using deprecated or removed Bukkit/Spigot/Paper APIs that are not available in the modern Paper API. | MS-22 | Done | Remove or refactor any code using deprecated or removed Bukkit/Spigot/Paper APIs that are not available in the modern Paper API.
| MS-23 | Ready | Add JUnit 5 test infrastructure and keep the default Gradle `test` task server-free. Use an in-memory Paper test harness such as MockBukkit for plugin lifecycle and Bukkit/Paper behavior; use Mockito or test doubles for ScoreKeeper and narrow API boundaries where a harness is unnecessary. |
| MS-24 | Ready | Add fast unit tests for MobScores behavior: default and configured score-table loading, serialization and malformed or unknown entity entries; direct-player and projectile damage attribution; ignoring untracked entities and non-player damage; death awards, offline credited players, zero-value scores, and join-time score messages. |
| MS-25 | Ready | Add a separate Gradle `integrationTest` source set/task for behavior requiring a real Paper server with the built MobScores and ScoreKeeper jars installed. Verify plugin loading and ScoreKeeper compatibility, mob-death score awards and player-facing messages, and score-table persistence across a server restart. Run it only when explicitly requested; keep it out of the normal `test` and `build` tasks. |

View File

@@ -18,17 +18,31 @@ gitSemVer {
repositories { repositories {
mavenCentral() mavenCentral()
maven { url = 'https://repo.papermc.io/repository/maven-public/' } maven { url = 'https://repo.papermc.io/repository/maven-public/' }
// Modrinth Maven repository for ScoreKeeper
exclusiveContent {
forRepository {
maven {
name = 'Modrinth'
url = 'https://api.modrinth.com/maven'
}
}
filter {
includeGroup 'maven.modrinth'
}
}
mavenLocal() mavenLocal()
} }
dependencies { dependencies {
compileOnly 'com.majinnaibu.minecraft.plugins:ScoreKeeper:0.2.2-SNAPSHOT' // Group is always 'maven.modrinth', module is your project slug/id
compileOnly 'io.papermc.paper:paper-api:1.21.7-R0.1-SNAPSHOT' compileOnly 'maven.modrinth:scorekeeper:0.2.2'
compileOnly 'io.papermc.paper:paper-api:26.2.build.129-stable'
testImplementation libs.junit.junit testImplementation libs.junit.junit
} }
group = 'com.majinnaibu.minecraft.plugins' group = 'com.majinnaibu.minecraft.plugins'
description = 'MobScores' description = 'MobScores'

View File

@@ -1,5 +1,5 @@
org.gradle.configuration-cache=true
org.gradle.daemon=true
org.gradle.java.installations.auto-download=true org.gradle.java.installations.auto-download=true
org.gradle.java.installations.auto-detect=false org.gradle.java.installations.auto-detect=false
org.gradle.java.installations.fromEnv=JAVA_HOME org.gradle.java.installations.fromEnv=JAVA_HOME
org.gradle.configuration-cache=true
org.gradle.daemon=true

View File

@@ -19,6 +19,7 @@ package com.majinnaibu.minecraft.plugins.mobscores;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.UUID; import java.util.UUID;
import java.util.logging.Level; import java.util.logging.Level;
@@ -36,6 +37,9 @@ import com.majinnaibu.minecraft.plugins.mobscores.listeners.PlayerConnectListene
import com.majinnaibu.minecraft.plugins.scorekeeper.ScoreKeeperPlugin; import com.majinnaibu.minecraft.plugins.scorekeeper.ScoreKeeperPlugin;
public class MobScoresPlugin extends JavaPlugin { public class MobScoresPlugin extends JavaPlugin {
private static final String SCORE_BUCKET_ID = "mob-scores";
private static final String MIN_SCORE_KEEPER_VERSION = "0.2.2";
private Map<UUID, UUID> _claimedMobs = new HashMap<UUID, UUID>(); private Map<UUID, UUID> _claimedMobs = new HashMap<UUID, UUID>();
private Map<EntityType, Integer> _scoreTable = new HashMap<EntityType, Integer>(); private Map<EntityType, Integer> _scoreTable = new HashMap<EntityType, Integer>();
private ScoreKeeperPlugin _scoreKeeper = null; private ScoreKeeperPlugin _scoreKeeper = null;
@@ -66,11 +70,28 @@ public class MobScoresPlugin extends JavaPlugin {
} }
PluginManager pm = getServer().getPluginManager(); PluginManager pm = getServer().getPluginManager();
_scoreKeeper = (ScoreKeeperPlugin)pm.getPlugin("ScoreKeeper"); ScoreKeeperPlugin scoreKeeper = (ScoreKeeperPlugin)pm.getPlugin("ScoreKeeper");
if(_scoreKeeper == null){ if (scoreKeeper == null) {
logWarning("Unable to find ScoreKeeper plugin."); logWarning("Unable to find ScoreKeeper plugin.");
pm.disablePlugin(this); pm.disablePlugin(this);
return;
}
String scoreKeeperVersion = scoreKeeper.getPluginMeta().getVersion();
if (!isVersionAtLeast(scoreKeeperVersion, MIN_SCORE_KEEPER_VERSION)) {
logWarning(
"ScoreKeeper "
+ MIN_SCORE_KEEPER_VERSION
+ " or newer is required; found "
+ scoreKeeperVersion
+ ".");
pm.disablePlugin(this);
return;
}
_scoreKeeper = scoreKeeper;
if (_scoreKeeper.getBucket(SCORE_BUCKET_ID) == null) {
_scoreKeeper.createBucket(SCORE_BUCKET_ID, "mob point", "mob points", 0);
} }
pm.registerEvents(new MobDeathListener(this), this); pm.registerEvents(new MobDeathListener(this), this);
@@ -124,7 +145,7 @@ public class MobScoresPlugin extends JavaPlugin {
return; return;
} }
int score = _scoreTable.get(type); int score = _scoreTable.get(type);
_scoreKeeper.addScore(player, score); _scoreKeeper.addScore(player, SCORE_BUCKET_ID, score);
} else { } else {
logWarning("Unable to award score for {" + type.toString() + "}"); logWarning("Unable to award score for {" + type.toString() + "}");
} }
@@ -135,9 +156,9 @@ public class MobScoresPlugin extends JavaPlugin {
for (Map.Entry<EntityType, Integer> entry : _scoreTable.entrySet()) { for (Map.Entry<EntityType, Integer> entry : _scoreTable.entrySet()) {
if (entry.getValue() != 0) { if (entry.getValue() != 0) {
EntityType type = entry.getKey(); EntityType type = entry.getKey();
Component message = Component.translatable(type.translationKey()) Component message = Component.translatable(Objects.requireNonNull(type.translationKey()))
.append(Component.text(" = ")) .append(Component.text(" = "))
.append(Component.text(String.valueOf(entry.getValue())) .append(Component.text(Objects.requireNonNull(String.valueOf(entry.getValue())))
.color(NamedTextColor.GREEN)); .color(NamedTextColor.GREEN));
sendPlayerMessage(player, message); sendPlayerMessage(player, message);
} }
@@ -172,11 +193,11 @@ public class MobScoresPlugin extends JavaPlugin {
} }
public void sendPlayerMessage(Player player, Component message) { public void sendPlayerMessage(Player player, Component message) {
player.sendMessage(_messagePrefix.append(message)); player.sendMessage(_messagePrefix.append(Objects.requireNonNull(message)));
} }
public void sendPlayerMessage(Player player, String message) { public void sendPlayerMessage(Player player, String message) {
player.sendMessage(_messagePrefix.append(Component.text(message))); player.sendMessage(_messagePrefix.append(Component.text(Objects.requireNonNull(message))));
} }
public void logError(Exception ex) { public void logError(Exception ex) {
@@ -190,4 +211,53 @@ public class MobScoresPlugin extends JavaPlugin {
public void logWarning(String message) { public void logWarning(String message) {
getLogger().warning(_logPrefix + message); getLogger().warning(_logPrefix + message);
} }
private boolean isVersionAtLeast(String version, String minimumVersion) {
int[] actual = parseVersion(version);
int[] minimum = parseVersion(minimumVersion);
if (actual == null || minimum == null) {
return false;
}
for (int index = 0; index < actual.length; index++) {
if (actual[index] != minimum[index]) {
return actual[index] > minimum[index];
}
}
return !isPrerelease(version);
}
private int[] parseVersion(String version) {
if (version == null) {
return null;
}
String coreVersion = version.replaceFirst("^[vV]", "");
int metadataIndex = coreVersion.indexOf('+');
if (metadataIndex >= 0) {
coreVersion = coreVersion.substring(0, metadataIndex);
}
int prereleaseIndex = coreVersion.indexOf('-');
if (prereleaseIndex >= 0) {
coreVersion = coreVersion.substring(0, prereleaseIndex);
}
String[] parts = coreVersion.split("\\.");
if (parts.length < 3) {
return null;
}
try {
return new int[] {
Integer.parseInt(parts[0]), Integer.parseInt(parts[1]), Integer.parseInt(parts[2])
};
} catch (NumberFormatException ex) {
return null;
}
}
private boolean isPrerelease(String version) {
String release = version.replaceFirst("^[vV]", "");
int metadataIndex = release.indexOf('+');
if (metadataIndex >= 0) {
release = release.substring(0, metadataIndex);
}
return release.indexOf('-') >= 0;
}
} }

View File

@@ -2,4 +2,4 @@ name: MobScores
main: com.majinnaibu.minecraft.plugins.mobscores.MobScoresPlugin main: com.majinnaibu.minecraft.plugins.mobscores.MobScoresPlugin
depend: [ScoreKeeper] depend: [ScoreKeeper]
version: ${version} version: ${version}
api-version: 1.21 api-version: '26.2'

View File

@@ -8,8 +8,47 @@
set -euo pipefail set -euo pipefail
TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=setup-env.sh PROJECT_ROOT="$(cd "$TOOLS_DIR/.." && pwd)"
source "$TOOLS_DIR/setup-env.sh" if [[ ! -r "$PROJECT_ROOT/.env" ]]; then
echo "Error: $PROJECT_ROOT/.env is missing or unreadable. Copy .env.example to .env and set its values." >&2
exit 1
fi
load_project_env() {
local env_line env_key env_value first_char last_char
while IFS= read -r env_line || [[ -n "$env_line" ]]; do
env_line="${env_line%$'\r'}"
[[ "$env_line" =~ ^[[:space:]]*(#|$) ]] && continue
[[ "$env_line" == *=* ]] || continue
env_key="${env_line%%=*}"
env_value="${env_line#*=}"
env_key="${env_key#"${env_key%%[![:space:]]*}"}"
env_key="${env_key%"${env_key##*[![:space:]]}"}"
[[ "$env_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
env_value="${env_value#"${env_value%%[![:space:]]*}"}"
env_value="${env_value%"${env_value##*[![:space:]]}"}"
if (( ${#env_value} >= 2 )); then
first_char="${env_value:0:1}"
last_char="${env_value:$((${#env_value} - 1)):1}"
if [[ "$first_char" == "$last_char" && ( "$first_char" == '"' || "$first_char" == "'" ) ]]; then
env_value="${env_value:1:$((${#env_value} - 2))}"
fi
fi
if [[ -z "${!env_key:-}" ]]; then
export "$env_key=$env_value"
fi
done < "$PROJECT_ROOT/.env"
}
load_project_env
MINECRAFT_SERVER_PATH="${MINECRAFT_SERVER_PATH:-MCServer}"
MINECRAFT_SERVER_PATH="${MINECRAFT_SERVER_PATH/#\~/$HOME}"
[[ "$MINECRAFT_SERVER_PATH" == /* ]] || MINECRAFT_SERVER_PATH="$PROJECT_ROOT/$MINECRAFT_SERVER_PATH"
MINECRAFT_BACKUP_PATH="${MINECRAFT_BACKUP_PATH:-backups}"
MINECRAFT_BACKUP_PATH="${MINECRAFT_BACKUP_PATH/#\~/$HOME}"
[[ "$MINECRAFT_BACKUP_PATH" == /* ]] || MINECRAFT_BACKUP_PATH="$PROJECT_ROOT/$MINECRAFT_BACKUP_PATH"
FORCE=false FORCE=false
LABEL="" LABEL=""

View File

@@ -4,8 +4,43 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# shellcheck source=setup-env.sh if [[ ! -r "$PROJECT_ROOT/.env" ]]; then
source "$SCRIPT_DIR/setup-env.sh" echo "Error: $PROJECT_ROOT/.env is missing or unreadable. Copy .env.example to .env and set its values." >&2
exit 1
fi
load_project_env() {
local env_line env_key env_value first_char last_char
while IFS= read -r env_line || [[ -n "$env_line" ]]; do
env_line="${env_line%$'\r'}"
[[ "$env_line" =~ ^[[:space:]]*(#|$) ]] && continue
[[ "$env_line" == *=* ]] || continue
env_key="${env_line%%=*}"
env_value="${env_line#*=}"
env_key="${env_key#"${env_key%%[![:space:]]*}"}"
env_key="${env_key%"${env_key##*[![:space:]]}"}"
[[ "$env_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
env_value="${env_value#"${env_value%%[![:space:]]*}"}"
env_value="${env_value%"${env_value##*[![:space:]]}"}"
if (( ${#env_value} >= 2 )); then
first_char="${env_value:0:1}"
last_char="${env_value:$((${#env_value} - 1)):1}"
if [[ "$first_char" == "$last_char" && ( "$first_char" == '"' || "$first_char" == "'" ) ]]; then
env_value="${env_value:1:$((${#env_value} - 2))}"
fi
fi
if [[ -z "${!env_key:-}" ]]; then
export "$env_key=$env_value"
fi
done < "$PROJECT_ROOT/.env"
}
load_project_env
MINECRAFT_SERVER_PATH="${MINECRAFT_SERVER_PATH:-MCServer}"
MINECRAFT_SERVER_PATH="${MINECRAFT_SERVER_PATH/#\~/$HOME}"
[[ "$MINECRAFT_SERVER_PATH" == /* ]] || MINECRAFT_SERVER_PATH="$PROJECT_ROOT/$MINECRAFT_SERVER_PATH"
if [[ -z "$MINECRAFT_SERVER_PATH" ]]; then if [[ -z "$MINECRAFT_SERVER_PATH" ]]; then
echo "Error: MINECRAFT_SERVER_PATH environment variable is not set." echo "Error: MINECRAFT_SERVER_PATH environment variable is not set."

View File

@@ -4,18 +4,90 @@ set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
# shellcheck source=setup-env.sh if [[ ! -r "$PROJECT_ROOT/.env" ]]; then
source "$SCRIPT_DIR/setup-env.sh" echo "Error: $PROJECT_ROOT/.env is missing or unreadable. Copy .env.example to .env and set its values." >&2
exit 1
fi
load_project_env() {
local env_line env_key env_value first_char last_char
while IFS= read -r env_line || [[ -n "$env_line" ]]; do
env_line="${env_line%$'\r'}"
[[ "$env_line" =~ ^[[:space:]]*(#|$) ]] && continue
[[ "$env_line" == *=* ]] || continue
env_key="${env_line%%=*}"
env_value="${env_line#*=}"
env_key="${env_key#"${env_key%%[![:space:]]*}"}"
env_key="${env_key%"${env_key##*[![:space:]]}"}"
[[ "$env_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
env_value="${env_value#"${env_value%%[![:space:]]*}"}"
env_value="${env_value%"${env_value##*[![:space:]]}"}"
if (( ${#env_value} >= 2 )); then
first_char="${env_value:0:1}"
last_char="${env_value:$((${#env_value} - 1)):1}"
if [[ "$first_char" == "$last_char" && ( "$first_char" == '"' || "$first_char" == "'" ) ]]; then
env_value="${env_value:1:$((${#env_value} - 2))}"
fi
fi
if [[ -z "${!env_key:-}" ]]; then
export "$env_key=$env_value"
fi
done < "$PROJECT_ROOT/.env"
}
load_project_env
MODRINTH_TOKEN="${MODRINTH_TOKEN:-}" MODRINTH_TOKEN="${MODRINTH_TOKEN:-}"
MODRINTH_PROJECT_ID="${MODRINTH_PROJECT_ID:-${MODRINTH_PROJECT_SLUG:-mob-scores}}" MODRINTH_PROJECT_ID="${MODRINTH_PROJECT_ID:-mob-scores}"
MODRINTH_VERSION="${MODRINTH_VERSION:-${VERSION:-$(grep -E '^version=' gradle.properties | head -n1 | cut -d= -f2-)}}" generate_changelog() {
MODRINTH_CHANGELOG="${MODRINTH_CHANGELOG:-Auto release via publish-modrinth.sh}" local current_tag previous_tag range commits
MODRINTH_GAME_VERSIONS="${MODRINTH_GAME_VERSIONS:-1.21,1.21.1,1.21.2,1.21.3,1.21.4,1.21.5,1.21.6,1.21.7}" current_tag=$(git -C "$PROJECT_ROOT" describe --tags --exact-match HEAD 2>/dev/null || true)
if [[ -n "$current_tag" ]]; then
previous_tag=$(git -C "$PROJECT_ROOT" describe --tags --abbrev=0 "${current_tag}^" 2>/dev/null || true)
range="${previous_tag:+$previous_tag..}$current_tag"
else
previous_tag=$(git -C "$PROJECT_ROOT" describe --tags --abbrev=0 2>/dev/null || true)
range="${previous_tag:+$previous_tag..}HEAD"
fi
commits=$(git -C "$PROJECT_ROOT" log --no-merges --format='- %s (%h)' "$range" 2>/dev/null || true)
if [[ -z "$commits" ]]; then
commits='- No commit messages found for this release.'
fi
printf '%s\n\n%s\n' "## What's Changed" "$commits"
}
MODRINTH_CHANGELOG="${MODRINTH_CHANGELOG:-$(generate_changelog)}"
MODRINTH_GAME_VERSIONS="${MODRINTH_GAME_VERSIONS:-26.2,26.3}"
MODRINTH_LOADERS="${MODRINTH_LOADERS:-paper}" MODRINTH_LOADERS="${MODRINTH_LOADERS:-paper}"
MODRINTH_VERSION_TYPE="${MODRINTH_VERSION_TYPE:-}"
MODRINTH_FILE="${MODRINTH_FILE:-}" MODRINTH_FILE="${MODRINTH_FILE:-}"
MODRINTH_DEPENDENCIES="${MODRINTH_DEPENDENCIES:-}" MODRINTH_DEPENDENCIES="${MODRINTH_DEPENDENCIES:-}"
MODRINTH_VERSION=$(
"$PROJECT_ROOT/gradlew" -q -p "$PROJECT_ROOT" properties --property version \
| sed -n 's/^version: //p'
)
if [[ -z "$MODRINTH_VERSION" ]]; then
echo "Error: could not determine the project version from Gradle." >&2
exit 1
fi
if [[ -z "$MODRINTH_VERSION_TYPE" ]]; then
if [[ "$MODRINTH_VERSION" == *-* ]]; then
MODRINTH_VERSION_TYPE="beta"
else
MODRINTH_VERSION_TYPE="release"
fi
fi
case "$MODRINTH_VERSION_TYPE" in
release|beta|alpha) ;;
*) echo "Error: MODRINTH_VERSION_TYPE must be release, beta, or alpha." >&2; exit 1 ;;
esac
MODRINTH_VERSION_NAME="MobScores $MODRINTH_VERSION"
if [[ -n "$MODRINTH_FILE" && "$MODRINTH_FILE" != /* ]]; then
MODRINTH_FILE="$PROJECT_ROOT/$MODRINTH_FILE"
fi
if [[ -z "$MODRINTH_TOKEN" ]]; then if [[ -z "$MODRINTH_TOKEN" ]]; then
echo "Error: MODRINTH_TOKEN is not set in $PROJECT_ROOT/.env" >&2 echo "Error: MODRINTH_TOKEN is not set in $PROJECT_ROOT/.env" >&2
exit 1 exit 1
@@ -28,6 +100,19 @@ if [[ -z "$MODRINTH_FILE" || ! -f "$MODRINTH_FILE" ]]; then
echo "Error: no jar found for Modrinth upload. Build the project first or set MODRINTH_FILE." >&2 echo "Error: no jar found for Modrinth upload. Build the project first or set MODRINTH_FILE." >&2
exit 1 exit 1
fi fi
if ! command -v unzip >/dev/null; then
echo "Error: 'unzip' is required to verify the plugin version in the jar." >&2
exit 1
fi
JAR_VERSION=$(unzip -p "$MODRINTH_FILE" plugin.yml 2>/dev/null | sed -n 's/^version: //p' | head -n1 || true)
if [[ -z "$JAR_VERSION" ]]; then
echo "Error: could not read the plugin version from $MODRINTH_FILE." >&2
exit 1
fi
if [[ "$JAR_VERSION" != "$MODRINTH_VERSION" ]]; then
echo "Error: jar version $JAR_VERSION does not match Gradle version $MODRINTH_VERSION. Rebuild the plugin and retry." >&2
exit 1
fi
csv_to_json_array() { csv_to_json_array() {
jq -cn --arg csv "$1" '$csv | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))' jq -cn --arg csv "$1" '$csv | split(",") | map(gsub("^\\s+|\\s+$"; "")) | map(select(length > 0))'
@@ -36,19 +121,41 @@ MODRINTH_GAME_VERSIONS_JSON=$(csv_to_json_array "$MODRINTH_GAME_VERSIONS")
MODRINTH_LOADERS_JSON=$(csv_to_json_array "$MODRINTH_LOADERS") MODRINTH_LOADERS_JSON=$(csv_to_json_array "$MODRINTH_LOADERS")
if [[ -n "$MODRINTH_DEPENDENCIES" ]]; then if [[ -n "$MODRINTH_DEPENDENCIES" ]]; then
MODRINTH_DEPENDENCIES_ARG=(--form "dependencies=$MODRINTH_DEPENDENCIES") if ! MODRINTH_DEPENDENCIES_JSON=$(jq -ce 'if type == "array" then . else error("dependencies must be a JSON array") end' <<<"$MODRINTH_DEPENDENCIES"); then
echo "Error: MODRINTH_DEPENDENCIES must be a JSON array." >&2
exit 1
fi
else else
MODRINTH_DEPENDENCIES_ARG=() MODRINTH_DEPENDENCIES_JSON='[]'
fi fi
curl -fsSL \ MODRINTH_DATA=$(jq -cn \
-X POST "https://api.modrinth.com/v2/version" \ --arg project_id "$MODRINTH_PROJECT_ID" \
-H "Authorization: $MODRINTH_TOKEN" \ --arg name "$MODRINTH_VERSION_NAME" \
-F "project_id=$MODRINTH_PROJECT_ID" \ --arg version_number "$MODRINTH_VERSION" \
-F "version_number=$MODRINTH_VERSION" \ --arg version_type "$MODRINTH_VERSION_TYPE" \
-F "changelog=$MODRINTH_CHANGELOG" \ --arg changelog "$MODRINTH_CHANGELOG" \
-F "game_versions=${MODRINTH_GAME_VERSIONS_JSON}" \ --argjson featured false \
-F "loaders=${MODRINTH_LOADERS_JSON}" \ --argjson dependencies "$MODRINTH_DEPENDENCIES_JSON" \
-F "file=@$MODRINTH_FILE" \ --argjson game_versions "$MODRINTH_GAME_VERSIONS_JSON" \
"${MODRINTH_DEPENDENCIES_ARG[@]}" \ --argjson loaders "$MODRINTH_LOADERS_JSON" \
| jq . '{project_id: $project_id, name: $name, version_number: $version_number, version_type: $version_type, featured: $featured, changelog: $changelog, dependencies: $dependencies, game_versions: $game_versions, loaders: $loaders, file_parts: ["file"], primary_file: "file"}')
CURL_ARGS=(
-X POST "https://api.modrinth.com/v2/version"
-H "Authorization: $MODRINTH_TOKEN"
-F "data=$MODRINTH_DATA;type=application/json"
-F "file=@$MODRINTH_FILE"
)
HTTP_RESPONSE=$(curl -sS -w $'\n%{http_code}' "${CURL_ARGS[@]}")
HTTP_STATUS="${HTTP_RESPONSE##*$'\n'}"
HTTP_BODY="${HTTP_RESPONSE%$'\n'*}"
if [[ "$HTTP_STATUS" != 2?? ]]; then
echo "Error: Modrinth API returned HTTP $HTTP_STATUS:" >&2
if ! printf '%s\n' "$HTTP_BODY" | jq -r '.description // .error // .' >&2; then
printf '%s\n' "$HTTP_BODY" >&2
fi
exit 1
fi
printf '%s\n' "$HTTP_BODY" | jq .

View File

@@ -9,8 +9,47 @@
set -euo pipefail set -euo pipefail
TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=setup-env.sh PROJECT_ROOT="$(cd "$TOOLS_DIR/.." && pwd)"
source "$TOOLS_DIR/setup-env.sh" if [[ ! -r "$PROJECT_ROOT/.env" ]]; then
echo "Error: $PROJECT_ROOT/.env is missing or unreadable. Copy .env.example to .env and set its values." >&2
exit 1
fi
load_project_env() {
local env_line env_key env_value first_char last_char
while IFS= read -r env_line || [[ -n "$env_line" ]]; do
env_line="${env_line%$'\r'}"
[[ "$env_line" =~ ^[[:space:]]*(#|$) ]] && continue
[[ "$env_line" == *=* ]] || continue
env_key="${env_line%%=*}"
env_value="${env_line#*=}"
env_key="${env_key#"${env_key%%[![:space:]]*}"}"
env_key="${env_key%"${env_key##*[![:space:]]}"}"
[[ "$env_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
env_value="${env_value#"${env_value%%[![:space:]]*}"}"
env_value="${env_value%"${env_value##*[![:space:]]}"}"
if (( ${#env_value} >= 2 )); then
first_char="${env_value:0:1}"
last_char="${env_value:$((${#env_value} - 1)):1}"
if [[ "$first_char" == "$last_char" && ( "$first_char" == '"' || "$first_char" == "'" ) ]]; then
env_value="${env_value:1:$((${#env_value} - 2))}"
fi
fi
if [[ -z "${!env_key:-}" ]]; then
export "$env_key=$env_value"
fi
done < "$PROJECT_ROOT/.env"
}
load_project_env
MINECRAFT_SERVER_PATH="${MINECRAFT_SERVER_PATH:-MCServer}"
MINECRAFT_SERVER_PATH="${MINECRAFT_SERVER_PATH/#\~/$HOME}"
[[ "$MINECRAFT_SERVER_PATH" == /* ]] || MINECRAFT_SERVER_PATH="$PROJECT_ROOT/$MINECRAFT_SERVER_PATH"
MINECRAFT_BACKUP_PATH="${MINECRAFT_BACKUP_PATH:-backups}"
MINECRAFT_BACKUP_PATH="${MINECRAFT_BACKUP_PATH/#\~/$HOME}"
[[ "$MINECRAFT_BACKUP_PATH" == /* ]] || MINECRAFT_BACKUP_PATH="$PROJECT_ROOT/$MINECRAFT_BACKUP_PATH"
ASSUME_YES=false ASSUME_YES=false
SAFETY_BACKUP=true SAFETY_BACKUP=true

View File

@@ -1,39 +0,0 @@
#!/usr/bin/env bash
# Source (bash or zsh) to export the shared server config from ../.env
# (relative to this script):
# PAPER_VERSION, PAPER_BUILD, MINECRAFT_SERVER_PATH, MINECRAFT_SERVER_JAR, MINECRAFT_BACKUP_PATH
_setup_env_self="${BASH_SOURCE[0]:-${(%):-%x}}"
_setup_env_root="$(cd "$(dirname "$_setup_env_self")/.." && pwd)"
_setup_env_file="${MINECRAFT_ENV_FILE:-$_setup_env_root/.env}"
if [[ -f "$_setup_env_file" ]]; then
while IFS= read -r _line || [[ -n "$_line" ]]; do
_line="${_line%$'\r'}"
[[ "$_line" =~ ^[[:space:]]*(#|$) ]] && continue
_key="${_line%%=*}"
_value="${_line#*=}"
_key="${_key//[[:space:]]/}"
[[ "$_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
_value="${_value#\"}"; _value="${_value%\"}"
_value="${_value#\'}"; _value="${_value%\'}"
eval "_current=\"\${$_key:-}\""
[[ -z "$_current" ]] && export "$_key=$_value"
done < "$_setup_env_file"
fi
PAPER_VERSION="${PAPER_VERSION:-}"
PAPER_BUILD="${PAPER_BUILD:-}"
MINECRAFT_SERVER_PATH="${MINECRAFT_SERVER_PATH:-MCServer}"
MINECRAFT_SERVER_PATH="${MINECRAFT_SERVER_PATH/#\~/$HOME}"
[[ "$MINECRAFT_SERVER_PATH" == /* ]] || MINECRAFT_SERVER_PATH="$_setup_env_root/$MINECRAFT_SERVER_PATH"
MINECRAFT_BACKUP_PATH="${MINECRAFT_BACKUP_PATH:-backups}"
MINECRAFT_BACKUP_PATH="${MINECRAFT_BACKUP_PATH/#\~/$HOME}"
[[ "$MINECRAFT_BACKUP_PATH" == /* ]] || MINECRAFT_BACKUP_PATH="$_setup_env_root/$MINECRAFT_BACKUP_PATH"
if [[ -z "${MINECRAFT_SERVER_JAR:-}" && -n "$PAPER_VERSION" && -n "$PAPER_BUILD" ]]; then
MINECRAFT_SERVER_JAR="paper-${PAPER_VERSION}-${PAPER_BUILD}.jar"
fi
export PAPER_VERSION PAPER_BUILD MINECRAFT_SERVER_PATH MINECRAFT_SERVER_JAR MINECRAFT_BACKUP_PATH
unset _setup_env_self _setup_env_root _setup_env_file _line _key _value _current

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Downloads (if missing) and runs a Paper server via the Fill v3 API. # Downloads (if missing) and runs a Paper server via the Fill v3 API.
# Server settings come from ../.env relative to this script (see setup-env.sh). # Server settings come from the project .env file.
# #
# Env vars: # Env vars:
# JAVA_OPTS Extra JVM args (default: -Xms2G -Xmx2G) # JAVA_OPTS Extra JVM args (default: -Xms2G -Xmx2G)
@@ -8,8 +8,47 @@
set -euo pipefail set -euo pipefail
TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TOOLS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=setup-env.sh PROJECT_ROOT="$(cd "$TOOLS_DIR/.." && pwd)"
source "$TOOLS_DIR/setup-env.sh" if [[ ! -r "$PROJECT_ROOT/.env" ]]; then
echo "Error: $PROJECT_ROOT/.env is missing or unreadable. Copy .env.example to .env and set its values." >&2
exit 1
fi
load_project_env() {
local env_line env_key env_value first_char last_char
while IFS= read -r env_line || [[ -n "$env_line" ]]; do
env_line="${env_line%$'\r'}"
[[ "$env_line" =~ ^[[:space:]]*(#|$) ]] && continue
[[ "$env_line" == *=* ]] || continue
env_key="${env_line%%=*}"
env_value="${env_line#*=}"
env_key="${env_key#"${env_key%%[![:space:]]*}"}"
env_key="${env_key%"${env_key##*[![:space:]]}"}"
[[ "$env_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]] || continue
env_value="${env_value#"${env_value%%[![:space:]]*}"}"
env_value="${env_value%"${env_value##*[![:space:]]}"}"
if (( ${#env_value} >= 2 )); then
first_char="${env_value:0:1}"
last_char="${env_value:$((${#env_value} - 1)):1}"
if [[ "$first_char" == "$last_char" && ( "$first_char" == '"' || "$first_char" == "'" ) ]]; then
env_value="${env_value:1:$((${#env_value} - 2))}"
fi
fi
if [[ -z "${!env_key:-}" ]]; then
export "$env_key=$env_value"
fi
done < "$PROJECT_ROOT/.env"
}
load_project_env
PAPER_VERSION="${PAPER_VERSION:-}"
PAPER_BUILD="${PAPER_BUILD:-}"
MINECRAFT_SERVER_JAR="${MINECRAFT_SERVER_JAR:-}"
MINECRAFT_SERVER_PATH="${MINECRAFT_SERVER_PATH:-MCServer}"
MINECRAFT_SERVER_PATH="${MINECRAFT_SERVER_PATH/#\~/$HOME}"
[[ "$MINECRAFT_SERVER_PATH" == /* ]] || MINECRAFT_SERVER_PATH="$PROJECT_ROOT/$MINECRAFT_SERVER_PATH"
[[ -n "$PAPER_VERSION" ]] || { echo "Error: PAPER_VERSION is not set (see .env)." >&2; exit 1; } [[ -n "$PAPER_VERSION" ]] || { echo "Error: PAPER_VERSION is not set (see .env)." >&2; exit 1; }