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:
parent
e23a1204e8
commit
db1cb28296
12
src/app/api/publish/[trpc]/route.ts
Normal file
12
src/app/api/publish/[trpc]/route.ts
Normal 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
36
src/services/clients.ts
Normal 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
85
src/services/config.ts
Normal 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
79
src/services/meta.ts
Normal 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
104
src/services/project.ts
Normal 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
308
src/services/publish.ts
Normal 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
113
src/services/versions.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -37,7 +37,7 @@ export const CreatableVersionSchema = BaseVersionSchema.extend({
|
|||||||
environment: EnvironmentSchema.optional(),
|
environment: EnvironmentSchema.optional(),
|
||||||
/** multipart 字段名 → 文件类型的映射,用于标记 sources-jar 等附属文件 */
|
/** multipart 字段名 → 文件类型的映射,用于标记 sources-jar 等附属文件 */
|
||||||
file_types: z.record(z.string(), FileTypeEnumSchema).optional(),
|
file_types: z.record(z.string(), FileTypeEnumSchema).optional(),
|
||||||
});
|
}).partial({ status: true });
|
||||||
export type CreatableVersion = z.infer<typeof CreatableVersionSchema>;
|
export type CreatableVersion = z.infer<typeof CreatableVersionSchema>;
|
||||||
// endregion
|
// endregion
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user