diff --git a/src/app/api/publish/[trpc]/route.ts b/src/app/api/publish/[trpc]/route.ts new file mode 100644 index 0000000..ab9069b --- /dev/null +++ b/src/app/api/publish/[trpc]/route.ts @@ -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 }; diff --git a/src/services/clients.ts b/src/services/clients.ts new file mode 100644 index 0000000..ee6a4f5 --- /dev/null +++ b/src/services/clients.ts @@ -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); diff --git a/src/services/config.ts b/src/services/config.ts new file mode 100644 index 0000000..aa2109c --- /dev/null +++ b/src/services/config.ts @@ -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 { + 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 { + 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 { + 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 { + const fp = configPath(name); + + try { + await unlink(fp); + } catch { + throw new Error(`Config "${name}" not found`); + } +} diff --git a/src/services/meta.ts b/src/services/meta.ts new file mode 100644 index 0000000..2befa1d --- /dev/null +++ b/src/services/meta.ts @@ -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 = { + 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 { + 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 }; +} diff --git a/src/services/project.ts b/src/services/project.ts new file mode 100644 index 0000000..29ca281 --- /dev/null +++ b/src/services/project.ts @@ -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 { + const result: Record = {}; + 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 { + // 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 }; +} diff --git a/src/services/publish.ts b/src/services/publish.ts new file mode 100644 index 0000000..7a4bb35 --- /dev/null +++ b/src/services/publish.ts @@ -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; +type ProgressEvent = z.infer; +type PlatformResult = z.infer; +type PublishResult = z.infer; + +// ── tRPC init ────────────────────────────────────────── + +const t = initTRPC.create(); + +// ── Shared EventEmitter for progress ─────────────────── + +const ee = new EventEmitter(); + +// ── Helpers ──────────────────────────────────────────── + +function resolveVersionName( + template: string, + templates: Record | undefined, + values: Record, +): 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>; +} + +async function publishModrinthVersion( + ctx: PlatformContext, + mcVersion: string, +): Promise { + 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 { + 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 { + 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 => { + 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; diff --git a/src/services/versions.ts b/src/services/versions.ts new file mode 100644 index 0000000..6e8e007 --- /dev/null +++ b/src/services/versions.ts @@ -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 { + return listVersions(modrinthClient, config.modrinth.project_id, { + include_changelog: false, + }); +} + +/** + * 获取 CurseForge 项目的全部已有文件(自动遍历所有分页)。 + */ +export async function fetchCurseForgeVersions( + config: Config, +): Promise { + 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; + curseforge: Record; +} + +/** + * 将待发布版本与两个平台的已有版本进行比对。 + * + * @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 { + // 并行获取两个平台的已有版本 + const [modrinthVersions, curseforgeFiles] = await Promise.all([ + fetchModrinthVersions(config), + fetchCurseForgeVersions(config), + ]); + + // ── Modrinth:按 version 模板生成 version_number 后匹配 ── + const modrinth: Record = {}; + 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 = {}; + 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 }; +} diff --git a/src/types/modrinth/create-version.ts b/src/types/modrinth/create-version.ts index cf23d43..14720be 100644 --- a/src/types/modrinth/create-version.ts +++ b/src/types/modrinth/create-version.ts @@ -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; // endregion