Phase5
This commit is contained in:
18
packages/catalog/package.json
Normal file
18
packages/catalog/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
70
packages/catalog/src/compatibility.test.ts
Normal file
70
packages/catalog/src/compatibility.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
75
packages/catalog/src/compatibility.ts
Normal file
75
packages/catalog/src/compatibility.ts
Normal 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;
|
||||
}
|
||||
20
packages/catalog/src/index.ts
Normal file
20
packages/catalog/src/index.ts
Normal 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';
|
||||
148
packages/catalog/src/modrinth.ts
Normal file
148
packages/catalog/src/modrinth.ts
Normal 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>;
|
||||
}
|
||||
9
packages/catalog/tsconfig.json
Normal file
9
packages/catalog/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@hexahost/typescript-config/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
49
packages/contracts/src/addons.ts
Normal file
49
packages/contracts/src/addons.ts
Normal 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>;
|
||||
50
packages/contracts/src/catalog.ts
Normal file
50
packages/contracts/src/catalog.ts
Normal 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>;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export type {
|
||||
BackupRestore,
|
||||
BackupSchedule,
|
||||
WorldUpload,
|
||||
InstalledAddon,
|
||||
GameNode,
|
||||
GameNodeHeartbeat,
|
||||
Plan,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user