309 lines
8.5 KiB
TypeScript
309 lines
8.5 KiB
TypeScript
|
|
import { initTRPC } from "@trpc/server";
|
||
|
|
import { z } from "zod";
|
||
|
|
import EventEmitter, { on } from "node:events";
|
||
|
|
import { readFileSync } from "node:fs";
|
||
|
|
import path from "node:path";
|
||
|
|
import { getConfig } from "./config";
|
||
|
|
import { modrinthClient, curseforgeClient } from "./clients";
|
||
|
|
import { createVersion } from "@/lib/modrinth";
|
||
|
|
import { uploadFile } from "@/lib/curseforge";
|
||
|
|
import { Template } from "@/lib/utils/template";
|
||
|
|
import type { CreatableVersion, UploadMetadata } from "@/types";
|
||
|
|
|
||
|
|
// ── Zod schemas ────────────────────────────────────────
|
||
|
|
|
||
|
|
const PublishInputSchema = z.object({
|
||
|
|
configName: z.string(),
|
||
|
|
version: z.string(),
|
||
|
|
mcVersions: z.object({
|
||
|
|
modrinth: z.array(z.object({ mc_version: z.string() })),
|
||
|
|
curseforge: z.array(z.object({ mc_version: z.string() })),
|
||
|
|
}),
|
||
|
|
changelog: z.string(),
|
||
|
|
versionType: z.enum(["release", "beta", "alpha"]),
|
||
|
|
});
|
||
|
|
|
||
|
|
const ProgressEventSchema = z.object({
|
||
|
|
platform: z.enum(["modrinth", "curseforge"]),
|
||
|
|
current: z.number().int().min(0),
|
||
|
|
total: z.number().int().min(0),
|
||
|
|
status: z.enum(["running", "completed", "failed"]),
|
||
|
|
errors: z.array(z.string()),
|
||
|
|
});
|
||
|
|
|
||
|
|
const PlatformResultSchema = z.object({
|
||
|
|
success: z.number().int().min(0),
|
||
|
|
fail: z.number().int().min(0),
|
||
|
|
errors: z.array(z.string()),
|
||
|
|
});
|
||
|
|
|
||
|
|
const PublishResultSchema = z.object({
|
||
|
|
modrinth: PlatformResultSchema,
|
||
|
|
curseforge: PlatformResultSchema,
|
||
|
|
});
|
||
|
|
|
||
|
|
// ── Inferred types ─────────────────────────────────────
|
||
|
|
|
||
|
|
type PublishInput = z.infer<typeof PublishInputSchema>;
|
||
|
|
type ProgressEvent = z.infer<typeof ProgressEventSchema>;
|
||
|
|
type PlatformResult = z.infer<typeof PlatformResultSchema>;
|
||
|
|
type PublishResult = z.infer<typeof PublishResultSchema>;
|
||
|
|
|
||
|
|
// ── tRPC init ──────────────────────────────────────────
|
||
|
|
|
||
|
|
const t = initTRPC.create();
|
||
|
|
|
||
|
|
// ── Shared EventEmitter for progress ───────────────────
|
||
|
|
|
||
|
|
const ee = new EventEmitter();
|
||
|
|
|
||
|
|
// ── Helpers ────────────────────────────────────────────
|
||
|
|
|
||
|
|
function resolveVersionName(
|
||
|
|
template: string,
|
||
|
|
templates: Record<string, string> | undefined,
|
||
|
|
values: Record<string, string>,
|
||
|
|
): string {
|
||
|
|
const tmpl = new Template(template, templates);
|
||
|
|
return tmpl.format(values);
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildJarPath(
|
||
|
|
projectDir: string,
|
||
|
|
format: string,
|
||
|
|
version: string,
|
||
|
|
mcVersion: string,
|
||
|
|
): string {
|
||
|
|
const tmpl = new Template(format);
|
||
|
|
const filename = tmpl.format({ version, mc_version: mcVersion });
|
||
|
|
return path.join(projectDir, "build", "libs", filename);
|
||
|
|
}
|
||
|
|
|
||
|
|
function readJarBlob(filePath: string): Blob {
|
||
|
|
try {
|
||
|
|
const buffer = readFileSync(filePath);
|
||
|
|
return new Blob([buffer]);
|
||
|
|
} catch (err) {
|
||
|
|
const msg = err instanceof Error ? err.message : String(err);
|
||
|
|
throw new Error(`Failed to read file "${filePath}": ${msg}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function errorMessage(err: unknown): string {
|
||
|
|
return err instanceof Error ? err.message : String(err);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Platform publish logic ─────────────────────────────
|
||
|
|
|
||
|
|
interface PlatformContext {
|
||
|
|
input: PublishInput;
|
||
|
|
config: Awaited<ReturnType<typeof getConfig>>;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function publishModrinthVersion(
|
||
|
|
ctx: PlatformContext,
|
||
|
|
mcVersion: string,
|
||
|
|
): Promise<void> {
|
||
|
|
const { input, config } = ctx;
|
||
|
|
|
||
|
|
const versionNumber = resolveVersionName(
|
||
|
|
config.modrinth.version,
|
||
|
|
undefined,
|
||
|
|
{ version: input.version, mc_version: mcVersion },
|
||
|
|
);
|
||
|
|
|
||
|
|
const versionName = resolveVersionName(
|
||
|
|
config.modrinth.version_name,
|
||
|
|
undefined,
|
||
|
|
{
|
||
|
|
version: input.version,
|
||
|
|
mc_version: mcVersion,
|
||
|
|
mc_version_range: mcVersion,
|
||
|
|
},
|
||
|
|
);
|
||
|
|
|
||
|
|
const primaryPath = buildJarPath(
|
||
|
|
config.project_dir,
|
||
|
|
config.filename_format,
|
||
|
|
input.version,
|
||
|
|
mcVersion,
|
||
|
|
);
|
||
|
|
const sourcePath = buildJarPath(
|
||
|
|
config.project_dir,
|
||
|
|
config.source_filename_format,
|
||
|
|
input.version,
|
||
|
|
mcVersion,
|
||
|
|
);
|
||
|
|
|
||
|
|
const primaryBlob = readJarBlob(primaryPath);
|
||
|
|
const sourceBlob = readJarBlob(sourcePath);
|
||
|
|
|
||
|
|
const data: CreatableVersion = {
|
||
|
|
project_id: config.modrinth.project_id,
|
||
|
|
name: versionName,
|
||
|
|
version_number: versionNumber,
|
||
|
|
game_versions: [mcVersion],
|
||
|
|
version_type: input.versionType,
|
||
|
|
loaders: config.modrinth.loaders,
|
||
|
|
featured: false,
|
||
|
|
dependencies: config.modrinth.dependencies.map((d) => ({
|
||
|
|
version_id: null,
|
||
|
|
project_id: d.project_id,
|
||
|
|
file_name: null,
|
||
|
|
dependency_type: d.dependency_type,
|
||
|
|
})),
|
||
|
|
file_parts: ["primary", "sources"],
|
||
|
|
primary_file: "primary",
|
||
|
|
file_types: { sources: "sources-jar" },
|
||
|
|
environment: config.modrinth.environment,
|
||
|
|
};
|
||
|
|
|
||
|
|
await createVersion(modrinthClient, data, {
|
||
|
|
primary: primaryBlob,
|
||
|
|
sources: sourceBlob,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
async function publishCurseForgeVersion(
|
||
|
|
ctx: PlatformContext,
|
||
|
|
mcVersion: string,
|
||
|
|
): Promise<void> {
|
||
|
|
const { input, config } = ctx;
|
||
|
|
|
||
|
|
const displayName = resolveVersionName(
|
||
|
|
config.curseforge.version_name,
|
||
|
|
{ filename_format: config.filename_format },
|
||
|
|
{ version: input.version, mc_version: mcVersion },
|
||
|
|
);
|
||
|
|
|
||
|
|
const primaryPath = buildJarPath(
|
||
|
|
config.project_dir,
|
||
|
|
config.filename_format,
|
||
|
|
input.version,
|
||
|
|
mcVersion,
|
||
|
|
);
|
||
|
|
|
||
|
|
const primaryBlob = readJarBlob(primaryPath);
|
||
|
|
|
||
|
|
const metadata: UploadMetadata = {
|
||
|
|
changelog: input.changelog,
|
||
|
|
changelogType: "markdown",
|
||
|
|
displayName,
|
||
|
|
releaseType: input.versionType,
|
||
|
|
relations: config.curseforge.relations,
|
||
|
|
};
|
||
|
|
|
||
|
|
await uploadFile(
|
||
|
|
curseforgeClient,
|
||
|
|
config.curseforge.project_id,
|
||
|
|
metadata,
|
||
|
|
primaryBlob,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function runPlatform(
|
||
|
|
platform: "modrinth" | "curseforge",
|
||
|
|
mcVersions: { mc_version: string }[],
|
||
|
|
ctx: PlatformContext,
|
||
|
|
): Promise<PlatformResult> {
|
||
|
|
const result: PlatformResult = { success: 0, fail: 0, errors: [] };
|
||
|
|
const total = mcVersions.length;
|
||
|
|
|
||
|
|
if (total === 0) {
|
||
|
|
ee.emit("progress", {
|
||
|
|
platform,
|
||
|
|
current: 0,
|
||
|
|
total: 0,
|
||
|
|
status: "completed",
|
||
|
|
errors: [],
|
||
|
|
} satisfies ProgressEvent);
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Emit initial running state
|
||
|
|
ee.emit("progress", {
|
||
|
|
platform,
|
||
|
|
current: 0,
|
||
|
|
total,
|
||
|
|
status: "running",
|
||
|
|
errors: [],
|
||
|
|
} satisfies ProgressEvent);
|
||
|
|
|
||
|
|
const publishFn =
|
||
|
|
platform === "modrinth"
|
||
|
|
? publishModrinthVersion
|
||
|
|
: publishCurseForgeVersion;
|
||
|
|
|
||
|
|
for (let i = 0; i < mcVersions.length; i++) {
|
||
|
|
const { mc_version } = mcVersions[i];
|
||
|
|
|
||
|
|
try {
|
||
|
|
await publishFn(ctx, mc_version);
|
||
|
|
result.success++;
|
||
|
|
|
||
|
|
const isLast = i + 1 === total;
|
||
|
|
ee.emit("progress", {
|
||
|
|
platform,
|
||
|
|
current: i + 1,
|
||
|
|
total,
|
||
|
|
status: isLast ? "completed" : "running",
|
||
|
|
errors: result.errors,
|
||
|
|
} satisfies ProgressEvent);
|
||
|
|
} catch (err) {
|
||
|
|
result.fail++;
|
||
|
|
const msg = `${mc_version}: ${errorMessage(err)}`;
|
||
|
|
result.errors.push(msg);
|
||
|
|
|
||
|
|
ee.emit("progress", {
|
||
|
|
platform,
|
||
|
|
current: i,
|
||
|
|
total,
|
||
|
|
status: "failed",
|
||
|
|
errors: result.errors,
|
||
|
|
} satisfies ProgressEvent);
|
||
|
|
|
||
|
|
// Cancel remaining for this platform only
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ── Router ─────────────────────────────────────────────
|
||
|
|
|
||
|
|
export const appRouter = t.router({
|
||
|
|
publish: t.procedure
|
||
|
|
.input(PublishInputSchema)
|
||
|
|
.mutation(async ({ input }): Promise<PublishResult> => {
|
||
|
|
const config = await getConfig(input.configName);
|
||
|
|
|
||
|
|
const ctx: PlatformContext = { input, config };
|
||
|
|
|
||
|
|
const [modrinthSettled, curseforgeSettled] = await Promise.allSettled([
|
||
|
|
runPlatform("modrinth", input.mcVersions.modrinth, ctx),
|
||
|
|
runPlatform("curseforge", input.mcVersions.curseforge, ctx),
|
||
|
|
]);
|
||
|
|
|
||
|
|
const modrinth: PlatformResult = modrinthSettled.status === "fulfilled"
|
||
|
|
? modrinthSettled.value
|
||
|
|
: { success: 0, fail: 0, errors: [errorMessage(modrinthSettled.reason)] };
|
||
|
|
|
||
|
|
const curseforge: PlatformResult = curseforgeSettled.status === "fulfilled"
|
||
|
|
? curseforgeSettled.value
|
||
|
|
: { success: 0, fail: 0, errors: [errorMessage(curseforgeSettled.reason)] };
|
||
|
|
|
||
|
|
return { modrinth, curseforge };
|
||
|
|
}),
|
||
|
|
|
||
|
|
progress: t.procedure.subscription(async function* (opts) {
|
||
|
|
for await (const [data] of on(ee, "progress", {
|
||
|
|
signal: opts.signal,
|
||
|
|
})) {
|
||
|
|
yield data as ProgressEvent;
|
||
|
|
}
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
export type AppRouter = typeof appRouter;
|