diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08069de..5ddfd9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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" diff --git a/README.md b/README.md index 35c6767..fa712e4 100644 --- a/README.md +++ b/README.md @@ -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 ` | `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 ` 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. diff --git a/build.gradle.kts b/build.gradle.kts index a33d294..4864332 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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)) @@ -12,6 +13,7 @@ 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. @@ -19,6 +21,7 @@ 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")) @@ -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) } diff --git a/docs/decisions.md b/docs/decisions.md index f6ca4bc..b72a7cb 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 --json `, 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. diff --git a/docs/releases/v0.2.1.md b/docs/releases/v0.2.1.md new file mode 100644 index 0000000..bcb6702 --- /dev/null +++ b/docs/releases/v0.2.1.md @@ -0,0 +1,14 @@ +Leaf Juice can now give Packmule each new pack build (D189). + +## Added + +- **`/packmule pack set `** 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 `** 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. diff --git a/src/main/java/com/extantape/packmule/Packmule.java b/src/main/java/com/extantape/packmule/Packmule.java index 7131473..ce637d9 100644 --- a/src/main/java/com/extantape/packmule/Packmule.java +++ b/src/main/java/com/extantape/packmule/Packmule.java @@ -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()); @@ -125,6 +128,59 @@ public List 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 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 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; diff --git a/src/main/java/com/extantape/packmule/command/PackSet.java b/src/main/java/com/extantape/packmule/command/PackSet.java new file mode 100644 index 0000000..bc9e04d --- /dev/null +++ b/src/main/java/com/extantape/packmule/command/PackSet.java @@ -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 }: 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. + * + *

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(); + } +} diff --git a/src/main/java/com/extantape/packmule/command/PackmuleCommand.java b/src/main/java/com/extantape/packmule/command/PackmuleCommand.java index 9d6625e..83904d4 100644 --- a/src/main/java/com/extantape/packmule/command/PackmuleCommand.java +++ b/src/main/java/com/extantape/packmule/command/PackmuleCommand.java @@ -3,6 +3,10 @@ import com.extantape.packmule.Packmule; import com.extantape.packmule.check.LinkChecker.Outcome; import com.extantape.packmule.config.PackmuleConfig; +import com.extantape.signalbox.Answer; +import com.extantape.signalbox.Describe; +import com.extantape.signalbox.Errors; +import com.extantape.signalbox.Request; import com.mojang.brigadier.Command; import com.mojang.brigadier.arguments.StringArgumentType; import io.papermc.paper.command.brigadier.CommandSourceStack; @@ -20,6 +24,14 @@ public final class PackmuleCommand { private static final String RECHECK = "packmule.recheck"; private static final String RELOAD = "packmule.reload"; private static final String STATUS = "packmule.status"; + static final String PACK_SET = "packmule.pack.set"; + + /** The format-1 commands (Signalbox), for tools such as Leaf Juice. Each also answers people in plain words. */ + public static java.util.List described() { + return List.of( + new Describe.Command("pack set", PACK_SET, List.of(Describe.Arg.of("pack", "string"), Describe.Arg.of("url", "string")), List.of(), false), + new Describe.Command("describe", STATUS, List.of(), List.of(), false)); + } private final Packmule plugin; @@ -36,11 +48,20 @@ public void register(Commands commands) { private com.mojang.brigadier.tree.LiteralCommandNode tree(String label, boolean old) { return Commands.literal(label) .requires(source -> has(source, RECHECK) || has(source, RELOAD) || has(source, STATUS)) - .executes(ctx -> run(old, ctx, () -> reply(ctx.getSource().getSender(), "Usage: /packmule "))) + .executes(ctx -> run(old, ctx, () -> reply(ctx.getSource().getSender(), "Usage: /packmule |describe>"))) .then(Commands.literal("recheck").requires(source -> has(source, RECHECK)) .executes(ctx -> run(old, ctx, () -> recheck(ctx.getSource().getSender())))) .then(Commands.literal("reload").requires(source -> has(source, RELOAD)) .executes(ctx -> run(old, ctx, () -> reload(ctx.getSource().getSender())))) + .then(Commands.literal("pack").requires(source -> has(source, PACK_SET)) + .then(Commands.literal("set") + .executes(ctx -> run(old, ctx, () -> packSet(ctx.getSource().getSender(), new String[0]))) + .then(Commands.argument("args", StringArgumentType.greedyString()) + .executes(ctx -> run(old, ctx, () -> packSet(ctx.getSource().getSender(), split(StringArgumentType.getString(ctx, "args")))))))) + .then(Commands.literal("describe").requires(source -> has(source, STATUS)) + .executes(ctx -> run(old, ctx, () -> describe(ctx.getSource().getSender(), new String[0]))) + .then(Commands.argument("args", StringArgumentType.greedyString()) + .executes(ctx -> run(old, ctx, () -> describe(ctx.getSource().getSender(), split(StringArgumentType.getString(ctx, "args"))))))) .then(Commands.literal("status").requires(source -> has(source, STATUS)) .executes(ctx -> run(old, ctx, () -> status(ctx.getSource().getSender()))) .then(Commands.argument("player", StringArgumentType.word()) @@ -72,6 +93,57 @@ private static int reply(CommandSender sender, String text) { return Command.SINGLE_SUCCESS; } + private static String[] split(String raw) { + String t = raw.trim(); + return t.isEmpty() ? new String[0] : t.split(" +"); + } + + /** One answer line for a tool, or plain words for a person. */ + private static int answer(CommandSender sender, Request r, String code, String message) { + if (r != null && r.json()) sender.sendMessage(Answer.error(r.id(), code, message)); + else sender.sendMessage(Component.text(message, NamedTextColor.RED)); + return 0; + } + + private int describe(CommandSender sender, String[] args) { + Request r; + try { r = Request.parse(args); } catch (Request.Bad bad) { + if (bad.id != null) sender.sendMessage(Answer.error(bad.id, bad.code, bad.getMessage())); else reply(sender, bad.getMessage()); + return 0; + } + if (r.json()) sender.sendMessage(Answer.ok(r.id(), Describe.body("Packmule", plugin.getPluginMeta().getVersion(), described()))); + else for (Describe.Command d : described()) reply(sender, "/packmule " + d.name() + (d.name().equals("pack set") ? " " : "")); + return Command.SINGLE_SUCCESS; + } + + /** + * /packmule pack set [--json id]: checks the new per-build address, and only on a pass writes it to + * config.yml and reloads. Refuses http, a file name that isn't the SHA-1, a download whose SHA-1 doesn't match it, + * and an unknown pack, leaving everything as it was. + */ + private int packSet(CommandSender sender, String[] args) { + Request r; + try { r = Request.parse(args); } catch (Request.Bad bad) { + if (bad.id != null) sender.sendMessage(Answer.error(bad.id, bad.code, bad.getMessage())); else reply(sender, bad.getMessage()); + return 0; + } + if (r.tokens().size() != 2 || r.freeText() != null) return answer(sender, r, Errors.BAD_ARGUMENT, "Use: /packmule pack set "); + PackSet.Plan plan; + try { + plan = PackSet.plan(plugin.config(), r.tokens().get(0), r.tokens().get(1)); + } catch (PackSet.Refused no) { + return answer(sender, r, no.code, no.getMessage()); + } + boolean started = plugin.setPack(plan, refused -> { + if (refused != null) { answer(sender, r, refused.code, refused.getMessage()); return; } + if (r.json()) sender.sendMessage(Answer.ok(r.id(), new java.util.LinkedHashMap<>(java.util.Map.of("pack", plan.pack(), "sha1", plan.sha1(), "check", "passed")))); + else sender.sendMessage(Component.text("Pack '" + plan.pack() + "' now points at " + plan.url() + ". It passed its check and the config was reloaded.", NamedTextColor.GREEN)); + }); + if (!started) return answer(sender, r, Errors.BUSY, "Another pack change is still being checked. Try again in a moment."); + if (!r.json()) reply(sender, "Checking the new address for pack '" + plan.pack() + "'. Nothing changes unless it passes."); + return Command.SINGLE_SUCCESS; + } + private int recheck(CommandSender sender) { if (plugin.config() == null) { sender.sendMessage(Component.text("config.yml has problems, so there's nothing to check. Fix it and run /packmule reload.", NamedTextColor.RED)); diff --git a/src/main/resources/paper-plugin.yml b/src/main/resources/paper-plugin.yml index 7fd67bf..b36cf16 100644 --- a/src/main/resources/paper-plugin.yml +++ b/src/main/resources/paper-plugin.yml @@ -14,6 +14,9 @@ permissions: packmule.status: description: Use /packmule status. default: op + packmule.pack.set: + description: Use /packmule pack set, which points a pack at a new per-build address after checking it. Leaf Juice uses it. + default: op packmule.notify: description: See warnings about dead links and failed downloads in chat. default: op diff --git a/src/test/java/com/extantape/packmule/check/LinkCheckerTest.java b/src/test/java/com/extantape/packmule/check/LinkCheckerTest.java index 621c10d..6dc2eef 100644 --- a/src/test/java/com/extantape/packmule/check/LinkCheckerTest.java +++ b/src/test/java/com/extantape/packmule/check/LinkCheckerTest.java @@ -112,6 +112,16 @@ void wrongHashIsDead() throws Exception { assertTrue(outcome.deadReason().startsWith("wrong hash"), outcome.deadReason()); } + @Test + void aPerBuildFileThatIsntWhatItsNameSaysIsDead() throws Exception { + // pack set relies on this: a file named for one SHA-1 that serves another is refused, so the old url stays. + String named = "c".repeat(40); + files.put("/p/" + named + ".zip", pack("not that build")); + var outcome = checker.check(new PackSource("a", base + "/p/" + named + ".zip", named, null), null); + assertFalse(outcome.live()); + assertTrue(outcome.deadReason().startsWith("wrong hash"), outcome.deadReason()); + } + @Test void timeoutIsDeadAndQuick() { files.put("/slow", pack("s")); diff --git a/src/test/java/com/extantape/packmule/command/PackSetTest.java b/src/test/java/com/extantape/packmule/command/PackSetTest.java new file mode 100644 index 0000000..c26ca85 --- /dev/null +++ b/src/test/java/com/extantape/packmule/command/PackSetTest.java @@ -0,0 +1,97 @@ +package com.extantape.packmule.command; + +import com.extantape.packmule.config.PackmuleConfig; +import com.extantape.signalbox.Describe; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.junit.jupiter.api.Assertions.*; + +/** /packmule pack set (D189): what a new address must be, and the config edit. The download check is in LinkCheckerTest. */ +class PackSetTest { + static final String H = "0123456789abcdef0123456789abcdef01234567"; + static final String CONFIG = """ + # Packmule + # A comment the owner wrote. + packs: + # the main pack + main: + url: https://packs.example.com/old/pack.zip + sha1-url: https://packs.example.com/old/pack.zip.sha1 + extra: + url: https://example.com/extra.zip + sha1: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + worlds: + world: [main, extra] + """; + + static PackmuleConfig config() throws Exception { + var y = new YamlConfiguration(); + y.loadFromString(CONFIG); + var r = PackmuleConfig.parse(y); + assertTrue(r.ok(), r.errors().toString()); + return r.config(); + } + + @Test void aPerBuildHttpsAddressIsPlannedWithItsHash() throws Exception { + var p = PackSet.plan(config(), "main", "https://join.example.org/p/abc/" + H.toUpperCase() + ".zip"); + assertEquals("main", p.pack()); + assertEquals(H, p.sha1(), "the SHA-1 comes from the file name, lowercased"); + } + + @ParameterizedTest + @ValueSource(strings = { + "http://join.example.org/p/" + H + ".zip", + "ftp://join.example.org/p/" + H + ".zip", + "https:///p/" + H + ".zip", + "https://user" + "@" + "join.example.org/p/" + H + ".zip", + "https://join.example.org/p/" + H + ".zip?x=1", + "https://join.example.org/p/" + H + ".zip#x", + "https://join.example.org/p/pack.zip", + "https://join.example.org/p/" + H + ".zip.txt", + "not a url"}) + void anythingButAnHttpsPerBuildAddressIsRefused(String url) throws Exception { + var e = assertThrows(PackSet.Refused.class, () -> PackSet.plan(config(), "main", url)); + assertEquals("bad-argument", e.code); + } + + @Test void anUnknownPackIsRefused() throws Exception { + assertEquals("not-found", assertThrows(PackSet.Refused.class, () -> PackSet.plan(config(), "nope", "https://x.org/" + H + ".zip")).code); + } + + @Test void withABrokenConfigNothingCanBeSet() { + assertEquals("config-problem", assertThrows(PackSet.Refused.class, () -> PackSet.plan(null, "main", "https://x.org/" + H + ".zip")).code); + } + + @Test void theEditChangesOnlyThatPackAndKeepsTheComments() throws Exception { + String url = "https://join.example.org/p/abc/" + H + ".zip"; + String out = PackSet.rewrite(CONFIG, new PackSet.Plan("main", url, H)); + assertTrue(out.contains("# A comment the owner wrote."), out); + assertTrue(out.contains("# the main pack"), out); + var y = new YamlConfiguration(); + y.loadFromString(out); + assertEquals(url, y.getString("packs.main.url")); + assertNull(y.get("packs.main.sha1-url"), "the file name carries the hash now"); + assertNull(y.get("packs.main.sha1")); + assertEquals("https://example.com/extra.zip", y.getString("packs.extra.url")); + assertEquals("a".repeat(40), y.getString("packs.extra.sha1"), "other packs are untouched"); + assertEquals(java.util.List.of("main", "extra"), y.getStringList("worlds.world")); + var parsed = PackmuleConfig.parse(y); + assertTrue(parsed.ok(), parsed.errors().toString()); + assertEquals(H, parsed.config().packs().get("main").sha1()); + } + + @Test void theEditRefusesYamlItCantRead() { + assertEquals("config-problem", assertThrows(PackSet.Refused.class, + () -> PackSet.rewrite("packs: [", new PackSet.Plan("main", "https://x.org/" + H + ".zip", H))).code); + } + + @Test void describeListsPackSetForLeafJuice() { + var body = Describe.body("Packmule", "0.2.1", PackmuleCommand.described()); + assertEquals(1, body.get("format")); + assertTrue(body.toString().contains("name=pack set"), body.toString()); + assertTrue(body.toString().contains("permission=packmule.pack.set")); + } +}