Add publish API with modular service layer

Add tRPC publish endpoint, project version parsing, config CRUD, and
client services for Modrinth/CurseForge integration.
This commit is contained in:
CPTProgrammer 2026-07-08 07:05:48 +08:00
parent e23a1204e8
commit db1cb28296
No known key found for this signature in database
8 changed files with 738 additions and 1 deletions

View File

@ -0,0 +1,12 @@
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
import { appRouter } from "@/services/publish";
const handler = (req: Request) =>
fetchRequestHandler({
endpoint: "/api/publish",
req,
router: appRouter,
createContext: () => ({}),
});
export { handler as GET, handler as POST };

36
src/services/clients.ts Normal file
View File

@ -0,0 +1,36 @@
import path from "node:path";
import fs from "node:fs";
import { ModrinthClient } from "@/lib/modrinth";
import { CurseForgeClient } from "@/lib/curseforge";
import type { Secrets } from "@/types";
import { SecretsSchema } from "@/types";
function loadSecrets(): Secrets {
const secretsPath = path.join(process.cwd(), "secrets.json");
let raw: unknown;
try {
raw = JSON.parse(fs.readFileSync(secretsPath, "utf-8"));
} catch {
throw new Error(
`Failed to read secrets.json (${secretsPath}). Please ensure the file exists and is valid JSON.`,
);
}
const result = SecretsSchema.safeParse(raw);
if (!result.success) {
throw new Error(
`secrets.json validation failed: ${result.error.message}`,
);
}
return result.data;
}
const secrets = loadSecrets();
export const modrinthClient = new ModrinthClient(
secrets.modrinth,
"ModReleaser/1.0.0 (local)",
);
export const curseforgeClient = new CurseForgeClient(secrets.curseforge);

85
src/services/config.ts Normal file
View File

@ -0,0 +1,85 @@
"use server";
import { readdir, readFile, writeFile, unlink } from "fs/promises";
import path from "path";
import { ConfigSchema, type Config } from "@/types";
function configDir(): string {
return path.join(process.cwd(), "configs");
}
function configPath(name: string): string {
return path.join(configDir(), `${name}.json`);
}
export async function listConfigs(): Promise<{ name: string; file: string }[]> {
const dir = configDir();
let files: string[];
try {
files = await readdir(dir);
} catch {
return [];
}
const results: { name: string; file: string }[] = [];
for (const file of files) {
if (!file.endsWith(".json")) continue;
try {
const raw = await readFile(path.join(dir, file), "utf-8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.name === "string") {
results.push({ name: parsed.name, file });
}
} catch (err) {
console.error(`Failed to read config file: ${file}`, err);
}
}
return results;
}
export async function getConfig(name: string): Promise<Config> {
const fp = configPath(name);
let raw: string;
try {
raw = await readFile(fp, "utf-8");
} catch {
throw new Error(`Config "${name}" not found`);
}
const data = JSON.parse(raw);
return ConfigSchema.parse(data);
}
export async function createConfig(data: Config): Promise<void> {
const fp = configPath(data.name);
const json = JSON.stringify(data, null, 2);
await writeFile(fp, json, "utf-8");
}
export async function updateConfig(name: string, data: Config): Promise<void> {
const fp = configPath(name);
try {
await readFile(fp, "utf-8");
} catch {
throw new Error(`Config "${name}" not found`);
}
const json = JSON.stringify(data, null, 2);
await writeFile(fp, json, "utf-8");
}
export async function deleteConfig(name: string): Promise<void> {
const fp = configPath(name);
try {
await unlink(fp);
} catch {
throw new Error(`Config "${name}" not found`);
}
}

79
src/services/meta.ts Normal file
View File

@ -0,0 +1,79 @@
"use server";
import { modrinthClient, curseforgeClient } from "./clients";
import { getLoaders } from "@/lib/modrinth";
import { getVersionTypes, getGameVersions } from "@/lib/curseforge";
import { EnvironmentSchema } from "@/types";
const ENVIRONMENT_LABELS: Record<string, string> = {
unknown: "Unknown environment",
client_only: "Client-side only",
server_only: "Server-side only (singleplayer compatible)",
dedicated_server_only: "Dedicated server only",
client_and_server: "Required on both",
server_only_client_optional: "Optional on client",
client_only_server_optional: "Optional on server",
client_or_server_prefers_both: "Optional on both (prefers both)",
client_or_server: "Optional on both (either side)",
singleplayer_only: "Singleplayer only",
};
/**
* Modrinth
* ConfigModal loaders
*/
export async function getModrinthLoaders(): Promise<string[]> {
const loaders = await getLoaders(modrinthClient);
return loaders.map((l) => l.name);
}
/**
* Modrinth Ant Design Select
* 使 OptGroup Client / Server / Both / Other
*/
export async function getModrinthEnvironments(): Promise<
{ value: string; label: string }[]
> {
return EnvironmentSchema.options.map((value) => ({
value,
label: ENVIRONMENT_LABELS[value] ?? value,
}));
}
/**
* CurseForge
* Game Version Types Environment / Modloader ID
* Game Versions gameVersionTypeID
*/
export async function getCurseForgeMeta(): Promise<{
environments: string[];
loaders: string[];
}> {
const [versionTypes, gameVersions] = await Promise.all([
getVersionTypes(curseforgeClient),
getGameVersions(curseforgeClient),
]);
const environmentTypeId = versionTypes.find(
(t) => t.name === "Environment",
)?.id;
const modloaderTypeId = versionTypes.find(
(t) => t.name === "Modloader",
)?.id;
const environments =
environmentTypeId != null
? gameVersions
.filter((v) => v.gameVersionTypeID === environmentTypeId)
.map((v) => v.name)
: [];
const loaders =
modloaderTypeId != null
? gameVersions
.filter((v) => v.gameVersionTypeID === modloaderTypeId)
.map((v) => v.name)
: [];
return { environments, loaders };
}

104
src/services/project.ts Normal file
View File

@ -0,0 +1,104 @@
"use server";
import { readFile, readdir, stat } from "fs/promises";
import path from "path";
import type { Config } from "@/types";
import { Template } from "@/lib/utils/template";
export interface ParsedProject {
version: string;
artifacts: {
mc_version: string;
artifact: string | null;
sources: string | null;
artifact_path: string | null;
sources_path: string | null;
}[];
}
function parseProperties(content: string): Record<string, string> {
const result: Record<string, string> = {};
for (const line of content.split("\n")) {
const trimmed = line.trim();
if (trimmed === "" || trimmed.startsWith("#") || trimmed.startsWith("!")) {
continue;
}
const eqIdx = trimmed.indexOf("=");
if (eqIdx === -1) continue;
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim();
result[key] = value;
}
return result;
}
export async function parseProject(config: Config): Promise<ParsedProject> {
// 1. Read project properties and extract version
const propsPath = path.join(
config.project_dir,
config.project_properties_path,
);
const propsRaw = await readFile(propsPath, "utf-8");
const props = parseProperties(propsRaw);
const version = props[config.mod_version_field];
if (version == null) {
throw new Error(
`Key "${config.mod_version_field}" not found in ${config.project_properties_path}`,
);
}
// 2. Scan minecraft properties directory for *.properties files
const mcPropsDir = path.join(
config.project_dir,
config.minecraft_properties_dir,
);
let entries: string[];
try {
entries = await readdir(mcPropsDir);
} catch {
return { version, artifacts: [] };
}
const mcVersions = entries
.filter((f) => f.endsWith(".properties"))
.map((f) => f.slice(0, -".properties".length));
// 3. Format filenames and check existence under build/libs/
const buildDir = path.join(config.project_dir, "build", "libs");
const artifactTmpl = new Template(config.filename_format);
const sourcesTmpl = new Template(config.source_filename_format);
const artifacts = await Promise.all(
mcVersions.map(async (mc_version) => {
const artifactFilename = artifactTmpl.format({ version, mc_version });
const sourcesFilename = sourcesTmpl.format({ version, mc_version });
const artifactPath = path.join(buildDir, artifactFilename);
const sourcesPath = path.join(buildDir, sourcesFilename);
const [artifactExists, sourcesExists] = await Promise.all([
stat(artifactPath)
.then(() => true)
.catch(() => false),
stat(sourcesPath)
.then(() => true)
.catch(() => false),
]);
return {
mc_version,
artifact: artifactExists ? artifactFilename : null,
sources: sourcesExists ? sourcesFilename : null,
artifact_path: artifactExists ? artifactPath : null,
sources_path: sourcesExists ? sourcesPath : null,
};
}),
);
// 4. Sort by mc_version descending (numeric-aware)
artifacts.sort((a, b) =>
b.mc_version.localeCompare(a.mc_version, undefined, { numeric: true }),
);
return { version, artifacts };
}

308
src/services/publish.ts Normal file
View File

@ -0,0 +1,308 @@
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;

113
src/services/versions.ts Normal file
View File

@ -0,0 +1,113 @@
"use server";
import type { Config, Version, CurseForgeFile } from "@/types";
import { modrinthClient, curseforgeClient } from "./clients";
import { listVersions } from "@/lib/modrinth";
import { getFiles } from "@/lib/curseforge";
import { Template } from "@/lib/utils/template";
/**
* Modrinth changelog
*/
export async function fetchModrinthVersions(
config: Config,
): Promise<Version[]> {
return listVersions(modrinthClient, config.modrinth.project_id, {
include_changelog: false,
});
}
/**
* CurseForge
*/
export async function fetchCurseForgeVersions(
config: Config,
): Promise<CurseForgeFile[]> {
const pageSize = 50;
let index = 0;
const allFiles: CurseForgeFile[] = [];
while (true) {
const response = await getFiles(curseforgeClient, {
modId: config.curseforge.project_id,
index,
pageSize,
});
allFiles.push(...response.data);
if (allFiles.length >= response.pagination.totalCount) {
break;
}
index += pageSize;
}
return allFiles;
}
/** 单个 MC 版本的比较结果 */
export interface VersionMatch {
exists: boolean;
existingVersion?: Version;
}
/** 单个 MC 版本的文件比较结果 */
export interface FileMatch {
exists: boolean;
existingFile?: CurseForgeFile;
}
/** 版本确认的完整比较结果 */
export interface VersionComparison {
modrinth: Record<string, VersionMatch>;
curseforge: Record<string, FileMatch>;
}
/**
*
*
* @param config
* @param version "1.0.0"
* @param mc_versions MC
* @returns MC
*/
export async function compareVersions(
config: Config,
version: string,
mc_versions: string[],
): Promise<VersionComparison> {
// 并行获取两个平台的已有版本
const [modrinthVersions, curseforgeFiles] = await Promise.all([
fetchModrinthVersions(config),
fetchCurseForgeVersions(config),
]);
// ── Modrinth按 version 模板生成 version_number 后匹配 ──
const modrinth: Record<string, VersionMatch> = {};
const modrinthTmpl = new Template(config.modrinth.version);
for (const mc_version of mc_versions) {
const generatedVersion = modrinthTmpl.format({ version, mc_version });
const existing = modrinthVersions.find(
(v) => v.version_number === generatedVersion,
);
modrinth[mc_version] = existing
? { exists: true, existingVersion: existing }
: { exists: false };
}
// ── CurseForge按 filename_format 模板生成文件名后匹配 ──
const curseforge: Record<string, FileMatch> = {};
const curseforgeTmpl = new Template(config.filename_format);
for (const mc_version of mc_versions) {
const generatedFilename = curseforgeTmpl.format({ version, mc_version });
const existing = curseforgeFiles.find(
(f) => f.fileName === generatedFilename,
);
curseforge[mc_version] = existing
? { exists: true, existingFile: existing }
: { exists: false };
}
return { modrinth, curseforge };
}

View File

@ -37,7 +37,7 @@ export const CreatableVersionSchema = BaseVersionSchema.extend({
environment: EnvironmentSchema.optional(),
/** multipart 字段名 → 文件类型的映射,用于标记 sources-jar 等附属文件 */
file_types: z.record(z.string(), FileTypeEnumSchema).optional(),
});
}).partial({ status: true });
export type CreatableVersion = z.infer<typeof CreatableVersionSchema>;
// endregion