Build installer and management site from spec

This commit is contained in:
2026-05-07 23:22:34 +09:00
parent 0b061e63b7
commit af6e559682
33 changed files with 7125 additions and 1 deletions

30
src/shared/mojang.ts Normal file
View File

@@ -0,0 +1,30 @@
import { MinecraftRelease } from './types'
const VERSION_MANIFEST_URL = 'https://launchermeta.mojang.com/mc/game/version_manifest.json'
let cachedReleases: MinecraftRelease[] | null = null
export async function fetchReleaseVersions(): Promise<MinecraftRelease[]> {
if (cachedReleases != null) {
return cachedReleases
}
try {
const response = await fetch(VERSION_MANIFEST_URL)
if (!response.ok) {
throw new Error(`Failed to fetch versions: ${response.status}`)
}
const payload = await response.json() as { versions?: MinecraftRelease[] }
cachedReleases = (payload.versions ?? []).filter((entry) => entry.type === 'release')
return cachedReleases
} catch {
cachedReleases = [
{ id: '1.21.4', type: 'release' },
{ id: '1.21.1', type: 'release' },
{ id: '1.20.6', type: 'release' },
{ id: '1.20.1', type: 'release' }
]
return cachedReleases
}
}

9
src/shared/nat-upnp.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
declare module 'nat-upnp' {
const upnp: {
createClient(): {
portMapping(options: Record<string, unknown>, callback: (error?: Error | null) => void): void
}
}
export default upnp
}

9
src/shared/paths.ts Normal file
View File

@@ -0,0 +1,9 @@
import path from 'node:path'
export const projectRoot = process.cwd()
export const manifestRootPath = path.join(projectRoot, 'manifest.json')
export const accountPath = path.join(projectRoot, 'account.json')
export const manifestDir = path.join(projectRoot, 'manifest')
export const fileDir = path.join(projectRoot, 'file')
export const viewsDir = path.join(projectRoot, 'views')
export const publicDir = path.join(projectRoot, 'public')

211
src/shared/store.ts Normal file
View File

@@ -0,0 +1,211 @@
import fs from 'node:fs'
import fsp from 'node:fs/promises'
import path from 'node:path'
import { accountPath, fileDir, manifestDir, manifestRootPath } from './paths'
import { AccountEntry, PackDefinition, PackListEntry, RootManifest } from './types'
const defaultRootManifest: RootManifest = {
packs: [
{
name: 'Sample Pack',
file: 'sample-pack'
}
]
}
const defaultAccount: AccountEntry[] = [
{
id: 'admin',
password: 'change-me'
}
]
const defaultPackDefinition: PackDefinition = {
mcVersion: '1.20.1',
serverMinRam: 2048,
serverMaxRam: 4096,
clientMinRam: 4096,
clientRecommendedRam: 8192,
packPath: 'sample-pack.zip',
description: '새 서버팩',
configEditableFiles: ['server.properties', 'bukkit.yml']
}
async function ensureDir(targetPath: string): Promise<void> {
await fsp.mkdir(targetPath, { recursive: true })
}
async function ensureJsonFile<T>(targetPath: string, defaultValue: T): Promise<void> {
if (!fs.existsSync(targetPath)) {
await fsp.writeFile(targetPath, `${JSON.stringify(defaultValue, null, 2)}\n`, 'utf8')
}
}
export async function ensureProjectFiles(): Promise<void> {
await ensureDir(manifestDir)
await ensureDir(fileDir)
await ensureJsonFile(manifestRootPath, defaultRootManifest)
await ensureJsonFile(accountPath, defaultAccount)
const samplePackPath = path.join(manifestDir, 'sample-pack.json')
await ensureJsonFile(samplePackPath, defaultPackDefinition)
}
async function readJsonFile<T>(targetPath: string, fallback: T): Promise<T> {
try {
const raw = await fsp.readFile(targetPath, 'utf8')
return JSON.parse(raw) as T
} catch {
return fallback
}
}
async function writeJsonFile(targetPath: string, payload: unknown): Promise<void> {
await fsp.writeFile(targetPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
}
function sanitizePackKey(name: string): string {
const trimmed = name.trim().replace(/\.json$/i, '')
const normalized = trimmed.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-')
return normalized.length > 0 ? normalized : 'new'
}
export async function loadRootManifest(): Promise<RootManifest> {
await ensureProjectFiles()
return readJsonFile<RootManifest>(manifestRootPath, defaultRootManifest)
}
export async function saveRootManifest(manifest: RootManifest): Promise<void> {
await writeJsonFile(manifestRootPath, manifest)
}
export async function loadAccounts(): Promise<AccountEntry[]> {
await ensureProjectFiles()
return readJsonFile<AccountEntry[]>(accountPath, defaultAccount)
}
export async function listManifestFiles(): Promise<string[]> {
await ensureProjectFiles()
const entries = await fsp.readdir(manifestDir, { withFileTypes: true })
return entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => entry.name.replace(/\.json$/i, ''))
.sort((left, right) => left.localeCompare(right))
}
export async function loadPackDefinition(packKey: string): Promise<PackDefinition | null> {
await ensureProjectFiles()
const safeKey = sanitizePackKey(packKey)
const filePath = path.join(manifestDir, `${safeKey}.json`)
if (!fs.existsSync(filePath)) {
return null
}
return readJsonFile<PackDefinition>(filePath, defaultPackDefinition)
}
export async function savePackDefinition(packKey: string, payload: PackDefinition): Promise<void> {
const safeKey = sanitizePackKey(packKey)
await writeJsonFile(path.join(manifestDir, `${safeKey}.json`), payload)
}
function nextAvailableNewKey(existing: string[]): string {
if (!existing.includes('new')) {
return 'new'
}
let index = 2
while (existing.includes(`new${index}`)) {
index += 1
}
return `new${index}`
}
export async function createNewPack(): Promise<string> {
const existing = await listManifestFiles()
const packKey = nextAvailableNewKey(existing)
await savePackDefinition(packKey, {
...defaultPackDefinition,
description: `새 서버팩 (${packKey})`
})
const manifest = await loadRootManifest()
manifest.packs.push({
name: `새 서버팩 (${packKey})`,
file: packKey
})
await saveRootManifest(manifest)
return packKey
}
export async function deletePacks(packKeys: string[]): Promise<void> {
const targetKeys = new Set(packKeys.map((entry) => sanitizePackKey(entry)))
const manifest = await loadRootManifest()
manifest.packs = manifest.packs.filter((entry) => !targetKeys.has(entry.file))
await saveRootManifest(manifest)
await Promise.all(
[...targetKeys].map(async (packKey) => {
const filePath = path.join(manifestDir, `${packKey}.json`)
if (fs.existsSync(filePath)) {
await fsp.unlink(filePath)
}
})
)
}
export async function updatePack(
currentKey: string,
nextName: string,
nextKey: string,
definition: PackDefinition
): Promise<string> {
const safeCurrentKey = sanitizePackKey(currentKey)
const safeNextKey = sanitizePackKey(nextKey)
const currentPath = path.join(manifestDir, `${safeCurrentKey}.json`)
const nextPath = path.join(manifestDir, `${safeNextKey}.json`)
if (safeCurrentKey !== safeNextKey && fs.existsSync(nextPath)) {
throw new Error('같은 이름의 JSON 파일이 이미 존재합니다.')
}
await savePackDefinition(safeNextKey, definition)
if (safeCurrentKey !== safeNextKey && fs.existsSync(currentPath)) {
await fsp.unlink(currentPath)
}
const manifest = await loadRootManifest()
const targetIndex = manifest.packs.findIndex((entry) => entry.file === safeCurrentKey)
const nextEntry: PackListEntry = {
name: nextName.trim(),
file: safeNextKey
}
if (targetIndex >= 0) {
manifest.packs[targetIndex] = nextEntry
} else {
manifest.packs.push(nextEntry)
}
await saveRootManifest(manifest)
return safeNextKey
}
export function normalizePackDefinition(input: Partial<PackDefinition>): PackDefinition {
return {
mcVersion: String(input.mcVersion ?? '1.20.1').trim() || '1.20.1',
serverMinRam: Number(input.serverMinRam ?? 2048),
serverMaxRam: Number(input.serverMaxRam ?? 4096),
clientMinRam: Number(input.clientMinRam ?? 4096),
clientRecommendedRam: Number(input.clientRecommendedRam ?? 8192),
packPath: String(input.packPath ?? '').trim(),
description: String(input.description ?? '').trim(),
files: Array.isArray(input.files)
? input.files.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
: undefined,
configEditableFiles: Array.isArray(input.configEditableFiles)
? input.configEditableFiles.map((entry) => String(entry).trim()).filter((entry) => entry.length > 0)
: ['server.properties', 'bukkit.yml']
}
}

30
src/shared/types.ts Normal file
View File

@@ -0,0 +1,30 @@
export interface PackListEntry {
name: string
file: string
}
export interface RootManifest {
packs: PackListEntry[]
}
export interface PackDefinition {
mcVersion: string
serverMinRam: number
serverMaxRam: number
clientMinRam: number
clientRecommendedRam: number
packPath: string
description?: string
files?: string[]
configEditableFiles?: string[]
}
export interface AccountEntry {
id: string
password: string
}
export interface MinecraftRelease {
id: string
type: string
}