Phase5
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 13:00:01 +02:00
parent 4262464cd5
commit d29a02f2a4
93 changed files with 1875 additions and 55 deletions

View File

@@ -0,0 +1,18 @@
{
"name": "@hexahost/catalog",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"@types/node": "^22.10.2",
"typescript": "^5.7.3",
"vitest": "^3.0.8"
}
}

View File

@@ -0,0 +1,70 @@
import { describe, expect, it } from 'vitest';
import {
isVersionCompatible,
pickPrimaryFile,
resolveInstallDirectory,
resolveModrinthLoader,
resolveProjectType,
} from './compatibility';
import type { ModrinthVersion } from './modrinth';
describe('catalog compatibility', () => {
it('maps paper to plugin installs', () => {
expect(resolveModrinthLoader('PAPER')).toBe('paper');
expect(resolveProjectType('PAPER')).toBe('plugin');
expect(resolveInstallDirectory('plugin')).toBe('plugins');
});
it('maps fabric to mod installs', () => {
expect(resolveModrinthLoader('FABRIC')).toBe('fabric');
expect(resolveProjectType('FABRIC')).toBe('mod');
expect(resolveInstallDirectory('mod')).toBe('mods');
});
it('checks version compatibility', () => {
const version: ModrinthVersion = {
id: 'v1',
project_id: 'p1',
name: '1.0.0',
version_number: '1.0.0',
game_versions: ['1.21.1'],
loaders: ['paper'],
files: [],
date_published: '2026-01-01T00:00:00Z',
};
expect(isVersionCompatible(version, '1.21.1', 'paper')).toBe(true);
expect(isVersionCompatible(version, '1.20.1', 'paper')).toBe(false);
});
it('picks primary jar file', () => {
const version: ModrinthVersion = {
id: 'v1',
project_id: 'p1',
name: '1.0.0',
version_number: '1.0.0',
game_versions: ['1.21.1'],
loaders: ['fabric'],
files: [
{
filename: 'readme.txt',
url: 'https://example.com/readme.txt',
hashes: {},
primary: false,
size: 10,
},
{
filename: 'mod.jar',
url: 'https://example.com/mod.jar',
hashes: { sha512: 'abc' },
primary: true,
size: 100,
},
],
date_published: '2026-01-01T00:00:00Z',
};
expect(pickPrimaryFile(version)?.filename).toBe('mod.jar');
});
});

View File

@@ -0,0 +1,75 @@
import type { ModrinthProjectType, ModrinthVersion, SoftwareFamily } from './modrinth';
export function resolveModrinthLoader(
softwareFamily: SoftwareFamily,
): string | null {
switch (softwareFamily) {
case 'PAPER':
case 'PURPUR':
return 'paper';
case 'FABRIC':
return 'fabric';
case 'FORGE':
return 'forge';
case 'NEOFORGE':
return 'neoforge';
case 'QUILT':
return 'quilt';
default:
return null;
}
}
export function resolveProjectType(
softwareFamily: SoftwareFamily,
): ModrinthProjectType | null {
switch (softwareFamily) {
case 'PAPER':
case 'PURPUR':
return 'plugin';
case 'FABRIC':
case 'FORGE':
case 'NEOFORGE':
case 'QUILT':
return 'mod';
default:
return null;
}
}
export function resolveInstallDirectory(projectType: ModrinthProjectType): string {
switch (projectType) {
case 'plugin':
return 'plugins';
case 'mod':
return 'mods';
case 'modpack':
return 'modpacks';
default:
return 'datapacks';
}
}
export function isVersionCompatible(
version: ModrinthVersion,
gameVersion: string,
loader: string,
): boolean {
const gameMatch = version.game_versions.some(
(value) => value === gameVersion || gameVersion.startsWith(value),
);
const loaderMatch = version.loaders.includes(loader);
return gameMatch && loaderMatch;
}
export function pickPrimaryFile(version: ModrinthVersion): ModrinthVersion['files'][number] | null {
const jarFiles = version.files.filter((file) =>
file.filename.toLowerCase().endsWith('.jar'),
);
if (jarFiles.length === 0) {
return version.files[0] ?? null;
}
return jarFiles.find((file) => file.primary) ?? jarFiles[0] ?? null;
}

View File

@@ -0,0 +1,20 @@
export {
searchModrinthProjects,
getModrinthProject,
getModrinthProjectVersions,
getModrinthVersion,
type ModrinthProject,
type ModrinthSearchResponse,
type ModrinthVersion,
type ModrinthVersionFile,
type ModrinthProjectType,
type SoftwareFamily,
} from './modrinth';
export {
resolveModrinthLoader,
resolveProjectType,
resolveInstallDirectory,
isVersionCompatible,
pickPrimaryFile,
} from './compatibility';

View File

@@ -0,0 +1,148 @@
export type SoftwareFamily =
| 'VANILLA'
| 'PAPER'
| 'PURPUR'
| 'FABRIC'
| 'FORGE'
| 'NEOFORGE'
| 'QUILT'
| 'BEDROCK_DEDICATED';
export type ModrinthProjectType = 'mod' | 'plugin' | 'modpack' | 'resourcepack' | 'shader';
export interface ModrinthProject {
project_id: string;
slug: string;
title: string;
description: string;
project_type: ModrinthProjectType;
downloads: number;
icon_url?: string;
}
export interface ModrinthSearchResponse {
hits: ModrinthProject[];
offset: number;
limit: number;
total_hits: number;
}
export interface ModrinthVersionFile {
hashes: Record<string, string>;
url: string;
filename: string;
primary: boolean;
size: number;
}
export interface ModrinthVersion {
id: string;
project_id: string;
name: string;
version_number: string;
game_versions: string[];
loaders: string[];
files: ModrinthVersionFile[];
date_published: string;
}
const MODRINTH_API = 'https://api.modrinth.com/v2';
export async function searchModrinthProjects(input: {
query: string;
projectType: ModrinthProjectType;
gameVersion: string;
loader?: string;
limit?: number;
}): Promise<ModrinthSearchResponse> {
const facets: string[][] = [
[`project_type:${input.projectType}`],
[`versions:${input.gameVersion}`],
];
if (input.loader) {
facets.push([`categories:${input.loader}`]);
}
const params = new URLSearchParams({
query: input.query,
limit: String(input.limit ?? 20),
index: 'relevance',
facets: JSON.stringify(facets),
});
const response = await fetch(`${MODRINTH_API}/search?${params.toString()}`, {
headers: {
'User-Agent': 'HexaHost-GameCloud/1.0 (catalog-sync)',
},
});
if (!response.ok) {
throw new Error(`Modrinth search failed: ${response.status}`);
}
return response.json() as Promise<ModrinthSearchResponse>;
}
export async function getModrinthProjectVersions(
projectId: string,
): Promise<ModrinthVersion[]> {
const response = await fetch(
`${MODRINTH_API}/project/${encodeURIComponent(projectId)}/version`,
{
headers: {
'User-Agent': 'HexaHost-GameCloud/1.0 (catalog-sync)',
},
},
);
if (!response.ok) {
throw new Error(`Modrinth versions failed: ${response.status}`);
}
return response.json() as Promise<ModrinthVersion[]>;
}
export interface ModrinthProjectDetails {
id: string;
slug: string;
title: string;
description: string;
project_type: ModrinthProjectType;
downloads: number;
icon_url?: string;
}
export async function getModrinthProject(projectId: string): Promise<ModrinthProjectDetails> {
const response = await fetch(
`${MODRINTH_API}/project/${encodeURIComponent(projectId)}`,
{
headers: {
'User-Agent': 'HexaHost-GameCloud/1.0 (catalog-sync)',
},
},
);
if (!response.ok) {
throw new Error(`Modrinth project failed: ${response.status}`);
}
return response.json() as Promise<ModrinthProjectDetails>;
}
export async function getModrinthVersion(versionId: string): Promise<ModrinthVersion> {
const response = await fetch(
`${MODRINTH_API}/version/${encodeURIComponent(versionId)}`,
{
headers: {
'User-Agent': 'HexaHost-GameCloud/1.0 (catalog-sync)',
},
},
);
if (!response.ok) {
throw new Error(`Modrinth version failed: ${response.status}`);
}
return response.json() as Promise<ModrinthVersion>;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@hexahost/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,49 @@
import { z } from 'zod';
export const addonTypeSchema = z.enum(['PLUGIN', 'MOD', 'MODPACK', 'DATAPACK']);
export const addonStatusSchema = z.enum([
'PENDING',
'INSTALLING',
'INSTALLED',
'FAILED',
'REMOVING',
'REMOVED',
]);
export const installedAddonSchema = z.object({
id: z.string().uuid(),
serverId: z.string().uuid(),
provider: z.string(),
projectId: z.string(),
projectSlug: z.string(),
projectName: z.string(),
versionId: z.string(),
versionNumber: z.string(),
addonType: addonTypeSchema,
fileName: z.string(),
filePath: z.string(),
status: addonStatusSchema,
failureReason: z.string().nullable(),
installedAt: z.string().datetime().nullable(),
createdAt: z.string().datetime(),
});
export const addonListResponseSchema = z.object({
addons: z.array(installedAddonSchema),
});
export const installAddonRequestSchema = z.object({
projectId: z.string().min(1),
versionId: z.string().min(1),
});
export const installAddonResponseSchema = z.object({
addon: installedAddonSchema,
jobId: z.string().optional(),
});
export type InstalledAddon = z.infer<typeof installedAddonSchema>;
export type AddonListResponse = z.infer<typeof addonListResponseSchema>;
export type InstallAddonRequest = z.infer<typeof installAddonRequestSchema>;
export type InstallAddonResponse = z.infer<typeof installAddonResponseSchema>;

View File

@@ -0,0 +1,50 @@
import { z } from 'zod';
export const catalogProjectSchema = z.object({
id: z.string(),
slug: z.string(),
name: z.string(),
description: z.string(),
projectType: z.enum(['mod', 'plugin', 'modpack', 'resourcepack', 'shader']),
downloads: z.number().int(),
iconUrl: z.string().url().nullable(),
});
export const catalogSearchQuerySchema = z.object({
q: z.string().min(1).max(128),
gameVersion: z.string().min(1).max(32),
softwareFamily: z.enum([
'PAPER',
'PURPUR',
'FABRIC',
'FORGE',
'NEOFORGE',
'QUILT',
]),
limit: z.coerce.number().int().min(1).max(50).optional(),
});
export const catalogSearchResponseSchema = z.object({
projects: z.array(catalogProjectSchema),
});
export const catalogVersionSchema = z.object({
id: z.string(),
projectId: z.string(),
name: z.string(),
versionNumber: z.string(),
gameVersions: z.array(z.string()),
loaders: z.array(z.string()),
compatible: z.boolean(),
publishedAt: z.string().datetime(),
});
export const catalogVersionsResponseSchema = z.object({
versions: z.array(catalogVersionSchema),
});
export type CatalogProject = z.infer<typeof catalogProjectSchema>;
export type CatalogSearchQuery = z.infer<typeof catalogSearchQuerySchema>;
export type CatalogSearchResponse = z.infer<typeof catalogSearchResponseSchema>;
export type CatalogVersion = z.infer<typeof catalogVersionSchema>;
export type CatalogVersionsResponse = z.infer<typeof catalogVersionsResponseSchema>;

View File

@@ -154,3 +154,29 @@ export {
type WorldUploadResponse,
type WorldDownloadUrlResponse,
} from './worlds';
export {
catalogProjectSchema,
catalogSearchQuerySchema,
catalogSearchResponseSchema,
catalogVersionSchema,
catalogVersionsResponseSchema,
type CatalogProject,
type CatalogSearchQuery,
type CatalogSearchResponse,
type CatalogVersion,
type CatalogVersionsResponse,
} from './catalog';
export {
addonTypeSchema,
addonStatusSchema,
installedAddonSchema,
addonListResponseSchema,
installAddonRequestSchema,
installAddonResponseSchema,
type InstalledAddon,
type AddonListResponse,
type InstallAddonRequest,
type InstallAddonResponse,
} from './addons';

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,32 @@
-- Phase 5: Software & Add-ons
CREATE TYPE "AddonType" AS ENUM ('PLUGIN', 'MOD', 'MODPACK', 'DATAPACK');
CREATE TYPE "AddonInstallStatus" AS ENUM ('PENDING', 'INSTALLING', 'INSTALLED', 'FAILED', 'REMOVING', 'REMOVED');
CREATE TABLE "installed_addons" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"provider" TEXT NOT NULL DEFAULT 'modrinth',
"projectId" TEXT NOT NULL,
"projectSlug" TEXT NOT NULL,
"projectName" TEXT NOT NULL,
"versionId" TEXT NOT NULL,
"versionNumber" TEXT NOT NULL,
"addonType" "AddonType" NOT NULL,
"fileName" TEXT NOT NULL,
"filePath" TEXT NOT NULL,
"fileSha512" TEXT,
"status" "AddonInstallStatus" NOT NULL DEFAULT 'PENDING',
"failureReason" TEXT,
"installedAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "installed_addons_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "installed_addons_serverId_idx" ON "installed_addons"("serverId");
CREATE INDEX "installed_addons_status_idx" ON "installed_addons"("status");
CREATE UNIQUE INDEX "installed_addons_serverId_projectId_versionId_key" ON "installed_addons"("serverId", "projectId", "versionId");
ALTER TABLE "installed_addons" ADD CONSTRAINT "installed_addons_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -96,6 +96,22 @@ enum WorldUploadStatus {
FAILED
}
enum AddonType {
PLUGIN
MOD
MODPACK
DATAPACK
}
enum AddonInstallStatus {
PENDING
INSTALLING
INSTALLED
FAILED
REMOVING
REMOVED
}
model User {
id String @id @default(uuid())
email String @unique
@@ -265,6 +281,7 @@ model GameServer {
backupRestores BackupRestore[]
backupSchedule BackupSchedule?
worldUploads WorldUpload[]
installedAddons InstalledAddon[]
@@index([userId])
@@index([nodeId])
@@ -502,3 +519,30 @@ model WorldUpload {
@@index([status])
@@map("world_uploads")
}
model InstalledAddon {
id String @id @default(uuid())
serverId String
provider String @default("modrinth")
projectId String
projectSlug String
projectName String
versionId String
versionNumber String
addonType AddonType
fileName String
filePath String
fileSha512 String?
status AddonInstallStatus @default(PENDING)
failureReason String?
installedAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@unique([serverId, projectId, versionId])
@@index([serverId])
@@index([status])
@@map("installed_addons")
}

View File

@@ -16,6 +16,7 @@ export type {
BackupRestore,
BackupSchedule,
WorldUpload,
InstalledAddon,
GameNode,
GameNodeHeartbeat,
Plan,

File diff suppressed because one or more lines are too long