Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ jobs:
- name: Release
env:
GH_TOKEN: ${{ github.token }}
run: gh release create "$GITHUB_REF_NAME" build/libs/Packmule-*.jar --title "$GITHUB_REF_NAME" --notes-file "docs/releases/$GITHUB_REF_NAME.md"
run: gh release create "$GITHUB_REF_NAME" "build/libs/Packmule-${GITHUB_REF_NAME#v}.jar" --title "$GITHUB_REF_NAME" --notes-file "docs/releases/$GITHUB_REF_NAME.md"
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,15 @@ If `config.yml` has a mistake, Packmule says what it is. On `/packmule reload` i
| `/packmule recheck` | `packmule.recheck` | Re-test every link and re-read every hash file |
| `/packmule reload` | `packmule.reload` | Read `config.yml` again, then re-check |
| `/packmule status [player]` | `packmule.status` | Each pack's state, each world's list, or what one player has |
| `/packmule pack set <pack> <url>` | `packmule.pack.set` | Point a pack at a new per-build address. Packmule downloads it first and checks its SHA-1 against the file name. Only a pass is written to `config.yml`, with its comments kept, and then the config is reloaded. Anything else leaves the old address. |
| `/packmule describe` | `packmule.status` | Lists the commands tools can use |

`pack set` and `describe` also answer tools such as Leaf Juice. Add `--json <id>` at the end and the answer is one line in the [Signalbox](https://github.com/ExtantApe/Signalbox) format. A pass answers `{"ok":true,"pack":"main","sha1":"…","check":"passed"}`. A refusal answers `ok:false` with a code:
- `bad-argument` for an address that isn't https, or a file name that isn't the SHA-1;
- `not-found` for an unknown pack;
- `check-failed` when the download fails or its SHA-1 doesn't match;
- `busy` while another change is being checked;
- `config-problem` when `config.yml` can't be read or changed.

`packmule.notify` (default op) shows dead-link and failed-download warnings in chat.

Expand Down
16 changes: 15 additions & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
plugins {
java
id("com.gradleup.shadow") version "9.6.1"
}

group = "com.extantape"
version = "0.2.0"
version = "0.2.1"

java {
toolchain.languageVersion.set(JavaLanguageVersion.of(25))
Expand All @@ -12,13 +13,15 @@ java {
repositories {
mavenCentral()
maven("https://repo.papermc.io/repository/maven-public/")
maven("https://jitpack.io")
}

// Built against the oldest supported version, 26.1, so nothing newer slips in. Tested on 26.1, 26.2 and 26.3.
val paperApi = "io.papermc.paper:paper-api:26.1.2.build.74-stable"

dependencies {
compileOnly(paperApi)
implementation("com.github.ExtantApe:Signalbox:v0.3.0")

testImplementation(paperApi)
testImplementation(platform("org.junit:junit-bom:5.11.4"))
Expand All @@ -43,5 +46,16 @@ tasks.test {

tasks.jar {
archiveBaseName.set("Packmule")
archiveClassifier.set("plain")
from("LICENSE")
}

// Signalbox, for the format-1 commands tools such as Leaf Juice use, goes inside the jar, relocated, so it can never
// clash with another plugin's copy.
tasks.shadowJar {
archiveBaseName.set("Packmule")
archiveClassifier.set("")
from("LICENSE")
relocate("com.extantape.signalbox", "com.extantape.packmule.libs.signalbox")
}
tasks.build { dependsOn(tasks.shadowJar) }
1 change: 1 addition & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ Owner decisions that shape Butler, with the date each was made. Change these onl
- **Test hygiene:** one test server at a time, deleted after its run.
- **Going public:** the owner said yes to making this repo public at its first release, after the first-public-push audit passed.
- **Name (2026-11-08, owner):** ResourcePackButler is renamed Packmule, one word in every place: repo, plugin name, jar, package and prose. Released as 0.2.0. Existing servers get their `plugins/ResourcePackButler/` folder copied on first start (never overwritten), and the old commands and permissions keep working for one release with a log warning. Pack IDs keep the old `resourcepackbutler:pack:` namespace for good, because clients see them.
- **pack set for Leaf Juice (2026-11-14, D189):** each pack build is published at a new unguessable path on the join host, and Leaf Juice hands Packmule the new link through `/packmule pack set <pack> <url> --json <id>`, as the Leaf Juice App session proposed and Helm assigned (K644). It takes https only, with the SHA-1 as the file name. Packmule downloads the file and checks the SHA-1 before writing `config.yml` (comments kept) and reloading, and a failure keeps the old address. Released as 0.2.1, so the old ResourcePackButler names stay for this 0.2.x release as promised.
14 changes: 14 additions & 0 deletions docs/releases/v0.2.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
Leaf Juice can now give Packmule each new pack build (D189).

## Added

- **`/packmule pack set <pack> <url>`** points a pack at a new per-build address. The address must be https, with the pack's SHA-1 as the file name. Packmule downloads the file first and checks that its SHA-1 matches. Only then does it write the new address to `config.yml`, keeping the file's comments, and reload. A refusal leaves the old address and the config untouched. Permission `packmule.pack.set`, op by default.
- **`--json <id>`** on `pack set` and the new `/packmule describe` gives one-line answers in the Signalbox format, for Leaf Juice. A pass answers `{"ok":true,"pack":"main","sha1":"…","check":"passed"}`. Refusals carry a code: `bad-argument`, `not-found`, `check-failed`, `busy` or `config-problem`.

## Upgrading

Replace the jar and restart. Nothing else changes. The ResourcePackButler names still work in 0.2.x.

## Tested

47 unit tests pass. They cover refusing every address that isn't an https per-build one, refusing an unknown pack, the config edit keeping comments and other packs, a file that doesn't match its name failing the check, and `describe`. Not yet run on a server with bots, or called by Leaf Juice.
56 changes: 56 additions & 0 deletions src/main/java/com/extantape/packmule/Packmule.java
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ public final class Packmule extends JavaPlugin implements Listener {
private record Checked(PackSource source, Outcome outcome) {
}

/** One pack change at a time, so two callers can't race on config.yml. */
private volatile boolean setting;

@Override
public void onEnable() {
String moved = Migration.copyIfMissing(getDataFolder().toPath().resolveSibling("ResourcePackButler"), getDataFolder().toPath());
Expand Down Expand Up @@ -125,6 +128,59 @@ public List<String> reload() {
return List.of();
}

/**
* Points a pack at a new per-build address (D189). Downloads it off the main thread and checks its SHA-1 against
* the file name. Only a pass is written to config.yml (comments kept), and then the config is reloaded as if by
* /packmule reload. Any failure leaves config.yml and the pack as they were. {@code done} gets the outcome on the
* main thread: null when it worked, or the refusal.
*/
public boolean setPack(com.extantape.packmule.command.PackSet.Plan plan, java.util.function.Consumer<com.extantape.packmule.command.PackSet.Refused> done) {
if (setting) return false;
setting = true;
LinkChecker checker = new LinkChecker(Duration.ofSeconds(config.checkTimeoutSeconds()));
CompletableFuture.supplyAsync(() -> checker.check(new PackSource(plan.pack(), plan.url(), plan.sha1(), null), null), checks)
.whenComplete((outcome, error) -> {
if (!isEnabled()) return;
Bukkit.getScheduler().runTask(this, () -> {
try {
if (error != null) done.accept(com.extantape.packmule.command.PackSet.refusal(com.extantape.packmule.command.PackSet.CHECK_FAILED, "The check stopped early, so nothing changed."));
else if (!outcome.live()) done.accept(com.extantape.packmule.command.PackSet.refusal(com.extantape.packmule.command.PackSet.CHECK_FAILED,
"The new address failed its check (" + outcome.deadReason() + "), so the old one stays."));
else done.accept(write(plan));
} finally {
setting = false;
}
});
});
return true;
}

/** Writes the checked address and reloads. On any problem config.yml goes back to what it was. */
private com.extantape.packmule.command.PackSet.Refused write(com.extantape.packmule.command.PackSet.Plan plan) {
java.nio.file.Path file = getDataFolder().toPath().resolve("config.yml");
String before;
try {
before = java.nio.file.Files.readString(file);
String after = com.extantape.packmule.command.PackSet.rewrite(before, plan);
java.nio.file.Path tmp = file.resolveSibling("config.yml.packmule-new");
java.nio.file.Files.writeString(tmp, after);
java.nio.file.Files.move(tmp, file, java.nio.file.StandardCopyOption.REPLACE_EXISTING, java.nio.file.StandardCopyOption.ATOMIC_MOVE);
} catch (com.extantape.packmule.command.PackSet.Refused r) {
return r;
} catch (java.io.IOException e) {
getLogger().warning("Couldn't write config.yml for pack '" + plan.pack() + "': " + e.getMessage());
return com.extantape.packmule.command.PackSet.refusal("internal", "config.yml couldn't be written, so nothing changed. The server log has the details.");
}
List<String> errors = reload();
if (!errors.isEmpty()) {
try { java.nio.file.Files.writeString(file, before); } catch (java.io.IOException e) { getLogger().severe("Couldn't put config.yml back: " + e.getMessage()); }
reload();
return com.extantape.packmule.command.PackSet.refusal("config-problem", "The changed config.yml didn't load (" + String.join("; ", errors) + "), so it was put back.");
}
getLogger().info("Pack '" + plan.pack() + "' now points at its new build, " + plan.sha1() + ".");
return null;
}

/** Re-tests every link and re-reads every hash file. Players online keep what they have until they change worlds. */
public void startCheck(org.bukkit.command.CommandSender reportTo, boolean first) {
PackmuleConfig snapshot = config;
Expand Down
67 changes: 67 additions & 0 deletions src/main/java/com/extantape/packmule/command/PackSet.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.extantape.packmule.command;

import com.extantape.packmule.config.PackmuleConfig;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.file.YamlConfiguration;

import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;

/**
* {@code /packmule pack set <pack> <url>}: points a pack at a new per-build address (D189). This class holds the
* parts that need no server, so they're tested on their own: what a new address must be, and the config edit.
*
* <p>The address must be https, with the SHA-1 as the file name, so the hash can't be given separately and get out of
* step. The plugin downloads it and checks the SHA-1 before anything is written; a refusal leaves the old address.
*/
public final class PackSet {
private PackSet() {}

/** A refusal with a Signalbox error code and plain words. */
public static final class Refused extends Exception {
public final String code;
Refused(String code, String message) { super(message); this.code = code; }
}

public static final String CHECK_FAILED = "check-failed";

public static Refused refusal(String code, String message) { return new Refused(code, message); }

public record Plan(String pack, String url, String sha1) {}

/** What the command was asked to do, or why it won't. Nothing is fetched or written here. */
public static Plan plan(PackmuleConfig config, String pack, String url) throws Refused {
if (config == null) throw new Refused("config-problem", "config.yml has problems, so no pack can be changed. Fix it and run /packmule reload.");
if (!config.packs().containsKey(pack)) throw new Refused("not-found", "There's no pack called " + pack + " in config.yml.");
URI u;
try { u = new URI(url); } catch (URISyntaxException e) { throw new Refused("bad-argument", "That isn't a valid address."); }
if (!"https".equals(u.getScheme())) throw new Refused("bad-argument", "The address must start with https://, so the pack can't be swapped on the way.");
if (u.getHost() == null || u.getHost().isBlank()) throw new Refused("bad-argument", "The address has no host name.");
if (u.getRawUserInfo() != null || u.getRawQuery() != null || u.getRawFragment() != null)
throw new Refused("bad-argument", "The address can't carry a user name, a query or a #.");
String sha1 = PackmuleConfig.sha1FromAddress(url);
if (sha1 == null) throw new Refused("bad-argument", "The file name must be the pack's SHA-1, such as …/0123456789abcdef0123456789abcdef01234567.zip.");
return new Plan(pack, url, sha1.toLowerCase(Locale.ROOT));
}

/**
* config.yml with the pack pointed at the new address, as text. Its sha1 and sha1-url go, since the file name now
* carries the hash. Everything else, comments included, stays as it was.
*/
public static String rewrite(String configText, Plan plan) throws Refused {
YamlConfiguration yaml = new YamlConfiguration();
yaml.options().parseComments(true);
try {
yaml.loadFromString(configText);
} catch (org.bukkit.configuration.InvalidConfigurationException e) {
throw new Refused("config-problem", "config.yml can't be read as YAML, so it wasn't changed.");
}
ConfigurationSection p = yaml.getConfigurationSection("packs." + plan.pack());
if (p == null) throw new Refused("not-found", "There's no pack called " + plan.pack() + " in config.yml.");
p.set("url", plan.url());
p.set("sha1", null);
p.set("sha1-url", null);
return yaml.saveToString();
}
}
Loading
Loading