Skip to content

Commit d7ba8d9

Browse files
committed
fix(platforms): make multi-platform builds work on latest MC versions
When Paper publishes a new calendar-versioned release (e.g. 26.1.2) pluggy init defaults to it, but the downstream platform providers weren't ready: sponge resolved spongeapi to a version only published as -SNAPSHOT on Maven, and folia still hardcoded the old <mc>-R0.1-SNAPSHOT artifact name even though folia-api moved to the build-stamped form (26.1.2.build.8-stable) like paper did. Both now probe maven-metadata and pick a published variant. Also surfaced while testing: build output displayed absolute paths, and --platform bungeecord was rejected even though that's the natural name for the waterfall-backed proxy. - sponge: resolvePublishedApiVersion checks the spongeapi metadata for a release variant before falling back to -SNAPSHOT. - folia/paper: share papermc.resolveApiVersion, which prefers the highest <mc>.build.<N>-<channel> entry and falls back to the SNAPSHOT form on miss. - build: print outputPath relative to the cwd pluggy was invoked from (paper/bin/foo.jar instead of /Users/.../paper/bin/foo.jar); absolute path retained when --output points outside cwd and in the JSON payload. - platforms: new resolve()/aliases() on the registry; parsePlatform resolves aliases (bungee, bungeecord -> waterfall).
1 parent e8b905e commit d7ba8d9

8 files changed

Lines changed: 154 additions & 59 deletions

File tree

src/commands/build.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { relative } from "node:path";
12
import process from "node:process";
23

34
import { Command, InvalidArgumentError } from "commander";
@@ -91,18 +92,32 @@ export async function runBuildCommand(opts: BuildCommandOptions): Promise<BuildC
9192

9293
const targets = selectBuildTargets(context, opts);
9394

94-
const initial = await buildTargets(targets, opts);
95+
const initial = await buildTargets(targets, opts, cwd);
9596

9697
if (opts.watch === true) {
97-
await runWatchLoop(targets, opts);
98+
await runWatchLoop(targets, opts, cwd);
9899
}
99100

100101
return initial;
101102
}
102103

104+
/**
105+
* Render an absolute jar path relative to where `pluggy build` ran. At a
106+
* multi-workspace root that produces `paper/bin/foo.jar` instead of the full
107+
* `/Users/.../paper/bin/foo.jar`; for paths outside cwd (rare, but possible
108+
* via `--output`) it falls back to the absolute form so the user can still
109+
* find the file.
110+
*/
111+
function displayPath(absPath: string, cwd: string): string {
112+
const rel = relative(cwd, absPath);
113+
if (rel === "" || rel.startsWith("..")) return absPath;
114+
return rel;
115+
}
116+
103117
async function buildTargets(
104118
targets: WorkspaceNode[],
105119
opts: BuildCommandOptions,
120+
cwd: string,
106121
): Promise<BuildCommandResult> {
107122
const runResults = await runWorkspaces<BuildOneResult>(
108123
targets,
@@ -137,7 +152,7 @@ async function buildTargets(
137152
}
138153

139154
log.success(
140-
`${bold(label)}${build.outputPath} ${dim(`(${formatBytes(build.sizeBytes)}, ${build.durationMs}ms)`)}`,
155+
`${bold(label)}${displayPath(build.outputPath, cwd)} ${dim(`(${formatBytes(build.sizeBytes)}, ${build.durationMs}ms)`)}`,
141156
);
142157
return { build, platformChecks };
143158
},
@@ -202,7 +217,7 @@ async function buildTargets(
202217
for (const r of results) {
203218
if (r.ok) {
204219
log.step(
205-
`${r.workspace}${r.outputPath} ${dim(`(${formatBytes(r.sizeBytes ?? 0)}, ${r.durationMs}ms)`)}`,
220+
`${r.workspace}${r.outputPath !== undefined ? displayPath(r.outputPath, cwd) : "(no output)"} ${dim(`(${formatBytes(r.sizeBytes ?? 0)}, ${r.durationMs}ms)`)}`,
206221
);
207222
} else {
208223
log.step(`${red(r.workspace)} failed: ${r.error ?? "unknown error"}`);
@@ -223,7 +238,11 @@ async function buildTargets(
223238
* Watch-mode output stays unbuffered: users expect live progress as they
224239
* iterate, even when `--concurrency > 1`.
225240
*/
226-
async function runWatchLoop(allTargets: WorkspaceNode[], opts: BuildCommandOptions): Promise<void> {
241+
async function runWatchLoop(
242+
allTargets: WorkspaceNode[],
243+
opts: BuildCommandOptions,
244+
cwd: string,
245+
): Promise<void> {
227246
if (allTargets.length === 0) return;
228247
const debounceMs = opts.watchDebounceMs ?? 100;
229248
const reverseGraph = computeReverseDependents(allTargets);
@@ -245,7 +264,7 @@ async function runWatchLoop(allTargets: WorkspaceNode[], opts: BuildCommandOptio
245264
log.heading(
246265
`Rebuild triggered for ${subset.length} workspace${subset.length === 1 ? "" : "s"} (${subset.map((s) => s.name).join(", ")})`,
247266
);
248-
await buildTargets(subset, { ...opts, watch: false }).catch((err) => {
267+
await buildTargets(subset, { ...opts, watch: false }, cwd).catch((err) => {
249268
log.error(err instanceof Error ? err.message : String(err));
250269
});
251270
building = false;

src/commands/parsers.ts

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,12 @@ export function parseSemver(value: string): string {
3131
}
3232

3333
export function parsePlatform(value: string): string {
34+
const resolved = platforms.resolve(value);
35+
if (resolved !== undefined) return resolved;
3436
const registered = platforms.list();
35-
const id = value.toLowerCase();
36-
if (!registered.includes(id)) {
37-
throw new InvalidArgumentError(
38-
`Invalid platform: "${value}". Available platforms: ${registered.join(", ")}`,
39-
);
40-
}
41-
return id;
37+
throw new InvalidArgumentError(
38+
`Invalid platform: "${value}". Available platforms: ${registered.join(", ")}`,
39+
);
4240
}
4341

4442
export function parseMcVersion(value: string): string {

src/platform/papermc/folia.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,13 +31,15 @@ export default createPlatform((ctx) => ({
3131
return versionsList.map((v) => v.version.id);
3232
},
3333

34-
api(version) {
35-
return Promise.resolve({
34+
async api(version: string) {
35+
const resolved = await papermc.resolveApiVersion(
36+
"https://repo.papermc.io/repository/maven-public/dev/folia/folia-api/maven-metadata.xml",
37+
version,
38+
);
39+
return {
3640
repositories: ["https://repo.papermc.io/repository/maven-public/"],
37-
dependencies: [
38-
{ groupId: "dev.folia", artifactId: "folia-api", version: `${version}-R0.1-SNAPSHOT` },
39-
],
40-
});
41+
dependencies: [{ groupId: "dev.folia", artifactId: "folia-api", version: resolved }],
42+
};
4143
},
4244

4345
async download(version: Version, ignoreCache = false) {

src/platform/papermc/paper.ts

Lines changed: 1 addition & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,41 +10,6 @@ import * as papermc from "./papermc.ts";
1010
const PAPER_MAVEN_METADATA =
1111
"https://repo.papermc.io/repository/maven-public/io/papermc/paper/paper-api/maven-metadata.xml";
1212

13-
/**
14-
* Resolve an MC version (e.g. `"1.21.8"`, `"26.1.2"`) to the Maven
15-
* coordinate Paper publishes for `paper-api`.
16-
*
17-
* Paper has two formats in the wild:
18-
* - Old SNAPSHOT form: `<mc>-R0.1-SNAPSHOT` (1.17 to 1.21.x)
19-
* - New build-stamped form: `<mc>.build.<N>-alpha` (26.x+)
20-
*
21-
* The provider fetches Paper's top-level `maven-metadata.xml` and picks
22-
* the highest matching entry. Falls back to the old SNAPSHOT form when
23-
* no published artifact is found, so the Maven resolver can surface a
24-
* specific 404 instead of a cryptic "no match".
25-
*/
26-
async function resolvePaperApiVersion(mcVersion: string): Promise<string> {
27-
const res = await fetch(PAPER_MAVEN_METADATA);
28-
if (!res.ok) return `${mcVersion}-R0.1-SNAPSHOT`;
29-
const xml = await res.text();
30-
const all = Array.from(xml.matchAll(/<version>([^<]+)<\/version>/g), (m) => m[1]);
31-
32-
const newFormat = all.filter((v) => v.startsWith(`${mcVersion}.build.`));
33-
if (newFormat.length > 0) {
34-
return newFormat.sort((a, b) => buildNumber(b) - buildNumber(a))[0];
35-
}
36-
37-
const oldFormat = all.find((v) => v === `${mcVersion}-R0.1-SNAPSHOT`);
38-
if (oldFormat !== undefined) return oldFormat;
39-
40-
return `${mcVersion}-R0.1-SNAPSHOT`;
41-
}
42-
43-
function buildNumber(versionString: string): number {
44-
const match = versionString.match(/\.build\.(\d+)/);
45-
return match ? Number.parseInt(match[1], 10) : 0;
46-
}
47-
4813
export default createPlatform((ctx) => ({
4914
id: "paper",
5015
descriptor: bukkitDescriptor,
@@ -71,7 +36,7 @@ export default createPlatform((ctx) => ({
7136

7237
async api(version: string) {
7338
const repo = "https://repo.papermc.io/repository/maven-public/";
74-
const resolved = await resolvePaperApiVersion(version);
39+
const resolved = await papermc.resolveApiVersion(PAPER_MAVEN_METADATA, version);
7540
return {
7641
repositories: [repo],
7742
dependencies: [

src/platform/papermc/papermc.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,39 @@ export function versions(project: Project): Promise<Version[]> {
3939
.then((data) => data.versions.filter((v) => v.builds.length > 0));
4040
}
4141

42+
/**
43+
* Resolve a Minecraft version (e.g. `1.21.8`, `26.1.2`) to the Maven
44+
* coordinate published for a PaperMC artifact (paper-api, folia-api, …).
45+
*
46+
* Two formats coexist:
47+
* - Old SNAPSHOT form: `<mc>-R0.1-SNAPSHOT` (used through 1.21.x)
48+
* - New build-stamped form: `<mc>.build.<N>-<channel>` (26.x+)
49+
*
50+
* Fetches the artifact's top-level `maven-metadata.xml` and prefers the
51+
* highest build-stamped entry. Falls back to the SNAPSHOT form when no
52+
* build-stamped entry exists (or metadata isn't reachable) so the Maven
53+
* resolver surfaces a specific 404 instead of a cryptic "no match".
54+
*/
55+
export async function resolveApiVersion(metadataUrl: string, mcVersion: string): Promise<string> {
56+
const snapshotForm = `${mcVersion}-R0.1-SNAPSHOT`;
57+
const res = await fetch(metadataUrl);
58+
if (!res.ok) return snapshotForm;
59+
const xml = await res.text();
60+
const all = Array.from(xml.matchAll(/<version>([^<]+)<\/version>/g), (m) => m[1]);
61+
62+
const buildStamped = all.filter((v) => v.startsWith(`${mcVersion}.build.`));
63+
if (buildStamped.length > 0) {
64+
return buildStamped.sort((a, b) => buildNumber(b) - buildNumber(a))[0];
65+
}
66+
67+
return snapshotForm;
68+
}
69+
70+
function buildNumber(versionString: string): number {
71+
const match = versionString.match(/\.build\.(\d+)/);
72+
return match ? Number.parseInt(match[1], 10) : 0;
73+
}
74+
4275
/**
4376
* Download a specific build (or the latest build when `build` is omitted)
4477
* for `project`/`version` and return the bytes. `target` selects the

src/platform/platform.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,29 @@ describe("platforms.assertSameFamily", () => {
3737
expect(() => platforms.assertSameFamily(["paper", "bogus"])).toThrow(/'bogus' not found/);
3838
});
3939
});
40+
41+
describe("platforms.resolve", () => {
42+
test("returns the canonical id for a registered platform", () => {
43+
expect(platforms.resolve("paper")).toBe("paper");
44+
});
45+
46+
test("is case-insensitive", () => {
47+
expect(platforms.resolve("PAPER")).toBe("paper");
48+
});
49+
50+
test("resolves bungeecord to waterfall", () => {
51+
expect(platforms.resolve("bungeecord")).toBe("waterfall");
52+
expect(platforms.resolve("BungeeCord")).toBe("waterfall");
53+
expect(platforms.resolve("bungee")).toBe("waterfall");
54+
});
55+
56+
test("returns undefined for unknown ids", () => {
57+
expect(platforms.resolve("totally-fake")).toBeUndefined();
58+
});
59+
});
60+
61+
describe("platforms.get with aliases", () => {
62+
test("get('bungeecord') returns the waterfall provider", () => {
63+
expect(platforms.get("bungeecord").id).toBe("waterfall");
64+
});
65+
});

src/platform/platform.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,17 @@ export interface PlatformContext {
9696

9797
const PLATFORMS: Record<string, PlatformProvider> = {};
9898

99+
/**
100+
* Human-friendly aliases that resolve to a registered platform id.
101+
* BungeeCord proxies are served by the Waterfall provider (Waterfall is the
102+
* actively-maintained PaperMC fork of BungeeCord), so users typing the
103+
* upstream name land on the right provider.
104+
*/
105+
const ALIASES: Record<string, string> = {
106+
bungee: "waterfall",
107+
bungeecord: "waterfall",
108+
};
109+
99110
/**
100111
* Define and register a platform provider. The factory runs at module-load
101112
* time and must not perform I/O. The Bun-compiled binary ships a read-only
@@ -115,10 +126,10 @@ export function createPlatform<T extends PlatformProvider>(
115126
* supplies context, so the actions stay concise.
116127
*/
117128
export const platforms = {
118-
/** Look up a registered platform by id (case-insensitive). Throws if missing. */
129+
/** Look up a registered platform by id or alias (case-insensitive). Throws if missing. */
119130
get(this: void, providerId: string): PlatformProvider {
120-
const id = providerId.toLowerCase();
121-
if (!PLATFORMS[id]) {
131+
const id = platforms.resolve(providerId);
132+
if (id === undefined) {
122133
const known = Object.keys(PLATFORMS).sort();
123134
throw new UserError(`Platform with id '${providerId}' not found`, {
124135
code: "E_PLATFORM_UNKNOWN",
@@ -130,11 +141,29 @@ export const platforms = {
130141
return PLATFORMS[id];
131142
},
132143

144+
/**
145+
* Resolve an id-or-alias to a canonical platform id. Returns `undefined`
146+
* when neither matches. Callers that want a hard failure should use
147+
* `platforms.get()`.
148+
*/
149+
resolve(this: void, providerId: string): string | undefined {
150+
const key = providerId.toLowerCase();
151+
if (PLATFORMS[key]) return key;
152+
const aliased = ALIASES[key];
153+
if (aliased !== undefined && PLATFORMS[aliased]) return aliased;
154+
return undefined;
155+
},
156+
133157
/** List every registered platform id, lowercased. */
134158
list(this: void): string[] {
135159
return Object.keys(PLATFORMS);
136160
},
137161

162+
/** Aliases that resolve to canonical ids. Keys are user-facing inputs. */
163+
aliases(this: void): Record<string, string> {
164+
return { ...ALIASES };
165+
},
166+
138167
/**
139168
* Validate that every id in `ids` resolves to a registered platform and
140169
* that they all share one `descriptor.family`. Returns that family.

src/platform/sponge/sponge.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,28 @@ async function findLatestArtifactForMc(mcVersion: string): Promise<ResolvedArtif
131131
return matches[0];
132132
}
133133

134+
/**
135+
* SpongeAPI tags from the dl-api (e.g. `19.0.0`) often outpace what's
136+
* published as a release on the Maven repo. Anything past the current
137+
* `<release>` only exists as `<api>-SNAPSHOT`. Probe the spongeapi
138+
* `maven-metadata.xml` and pick the matching variant; fall back to the raw
139+
* tag so the Maven resolver can surface a precise 404 if the metadata fetch
140+
* itself fails.
141+
*/
142+
async function resolvePublishedApiVersion(api: string): Promise<string> {
143+
try {
144+
const res = await fetch(`${SPONGE_REPO}org/spongepowered/spongeapi/maven-metadata.xml`);
145+
if (!res.ok) return api;
146+
const xml = await res.text();
147+
const versions = new Set(Array.from(xml.matchAll(/<version>([^<]+)<\/version>/g), (m) => m[1]));
148+
if (versions.has(api)) return api;
149+
if (versions.has(`${api}-SNAPSHOT`)) return `${api}-SNAPSHOT`;
150+
return api;
151+
} catch {
152+
return api;
153+
}
154+
}
155+
134156
export default createPlatform((ctx) => ({
135157
id: "sponge",
136158
descriptor: spongeDescriptor,
@@ -161,13 +183,14 @@ export default createPlatform((ctx) => ({
161183

162184
async api(mcVersion: string) {
163185
const artifact = await findLatestArtifactForMc(mcVersion);
186+
const version = await resolvePublishedApiVersion(artifact.api);
164187
return {
165188
repositories: [SPONGE_REPO],
166189
dependencies: [
167190
{
168191
groupId: "org.spongepowered",
169192
artifactId: "spongeapi",
170-
version: artifact.api,
193+
version,
171194
},
172195
],
173196
};

0 commit comments

Comments
 (0)