Build music-quiz installer and management site per spec
Implements the full spec described in README.md: Management site (Node + TypeScript + Express + EJS): - Public main page lists packs registered in manifest.json. - /op login (account.json, internal-only), /op/dashboard manages packs with horizontal-scroll cards, add/select-and-delete flow, and the /op/dashboard/:packName editor (Mojang release dropdown, dynamic mods/resourcepacks lists, platform/RAM fields, file rename). - Routes for /manifest.json (public) and /file/* (server pack files). - Middleware blocks /account.json and /manifest/* directory access. Installer (Electron): - Five page renderer driven by IPC (preload contextBridge API): pack pick → single/multi → server install (path no-Korean check, JDK detect, file download, EULA, RAM gating, local web config editor, UPnP/port-forward check) → client install (.mc_custom mods + resourcepacks + launcher_profiles.json gameDir/javaArgs) → finish toggles (server folder, shortcut, server start, launcher start). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
60
src/shared/mojang.ts
Normal file
60
src/shared/mojang.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import https from 'node:https'
|
||||
|
||||
interface MojangVersionEntry {
|
||||
id: string
|
||||
type: string
|
||||
}
|
||||
|
||||
interface MojangVersionManifest {
|
||||
versions: MojangVersionEntry[]
|
||||
}
|
||||
|
||||
const MANIFEST_URL = 'https://piston-meta.mojang.com/mc/game/version_manifest_v2.json'
|
||||
|
||||
let cachedReleases: string[] | null = null
|
||||
let cachedAt = 0
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||
|
||||
export async function fetchReleaseVersions(): Promise<string[]> {
|
||||
if (cachedReleases && Date.now() - cachedAt < CACHE_TTL_MS) {
|
||||
return cachedReleases
|
||||
}
|
||||
try {
|
||||
const data = await fetchJson<MojangVersionManifest>(MANIFEST_URL)
|
||||
const releases = data.versions.filter((entry) => entry.type === 'release').map((entry) => entry.id)
|
||||
cachedReleases = releases
|
||||
cachedAt = Date.now()
|
||||
return releases
|
||||
} catch {
|
||||
return cachedReleases ?? FALLBACK_RELEASES
|
||||
}
|
||||
}
|
||||
|
||||
function fetchJson<T>(url: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(url, { timeout: 8000 }, (response) => {
|
||||
if (response.statusCode !== 200) {
|
||||
response.resume()
|
||||
reject(new Error(`Mojang manifest HTTP ${response.statusCode}`))
|
||||
return
|
||||
}
|
||||
const chunks: Buffer[] = []
|
||||
response.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
response.on('end', () => {
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')) as T)
|
||||
} catch (error) {
|
||||
reject(error as Error)
|
||||
}
|
||||
})
|
||||
})
|
||||
request.on('error', reject)
|
||||
request.on('timeout', () => {
|
||||
request.destroy(new Error('Mojang manifest timeout'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const FALLBACK_RELEASES = [
|
||||
'1.21', '1.20.6', '1.20.4', '1.20.2', '1.20.1', '1.19.4', '1.19.2', '1.18.2', '1.17.1', '1.16.5'
|
||||
]
|
||||
18
src/shared/nat-upnp.d.ts
vendored
Normal file
18
src/shared/nat-upnp.d.ts
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
declare module 'nat-upnp' {
|
||||
interface PortMappingOptions {
|
||||
public: number | { host?: string; port: number }
|
||||
private: number | { host?: string; port: number }
|
||||
ttl?: number
|
||||
description?: string
|
||||
protocol?: 'tcp' | 'udp'
|
||||
}
|
||||
|
||||
interface UpnpClient {
|
||||
portMapping(options: PortMappingOptions, callback: (err: Error | null) => void): void
|
||||
portUnmapping(options: { public: number; protocol?: 'tcp' | 'udp' }, callback: (err: Error | null) => void): void
|
||||
externalIp(callback: (err: Error | null, ip: string) => void): void
|
||||
close(): void
|
||||
}
|
||||
|
||||
export function createClient(): UpnpClient
|
||||
}
|
||||
10
src/shared/paths.ts
Normal file
10
src/shared/paths.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import path from 'node:path'
|
||||
|
||||
// 컴파일 후 dist/shared/paths.js → 2단계 상위가 프로젝트 루트.
|
||||
export const projectRoot = path.resolve(__dirname, '..', '..')
|
||||
export const manifestRootPath = path.join(projectRoot, 'manifest.json')
|
||||
export const manifestDirPath = path.join(projectRoot, 'manifest')
|
||||
export const accountFilePath = path.join(projectRoot, 'account.json')
|
||||
export const fileDirPath = path.join(projectRoot, 'file')
|
||||
export const viewsDirPath = path.join(projectRoot, 'views')
|
||||
export const publicDirPath = path.join(projectRoot, 'public')
|
||||
219
src/shared/store.ts
Normal file
219
src/shared/store.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import fs from 'node:fs'
|
||||
import fsp from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { manifestRootPath, manifestDirPath, accountFilePath } from './paths'
|
||||
import type { Manifest, ManifestEntry, PackDefinition, AccountEntry, LoaderType } from './types'
|
||||
|
||||
export async function readManifest(): Promise<Manifest> {
|
||||
try {
|
||||
const raw = await fsp.readFile(manifestRootPath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as Partial<Manifest>
|
||||
if (!parsed || !Array.isArray(parsed.packs)) {
|
||||
return { packs: [] }
|
||||
}
|
||||
return {
|
||||
packs: parsed.packs.filter((entry): entry is ManifestEntry =>
|
||||
typeof entry?.name === 'string' && typeof entry?.file === 'string')
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { packs: [] }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeManifest(manifest: Manifest): Promise<void> {
|
||||
await fsp.writeFile(manifestRootPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export function defaultPackDefinition(name: string): PackDefinition {
|
||||
return {
|
||||
name,
|
||||
mcVersion: '1.20.1',
|
||||
platform: { type: 'vanilla' },
|
||||
mods: [],
|
||||
resourcepacks: [],
|
||||
serverMinRam: 2048,
|
||||
serverMaxRam: 4096,
|
||||
clientMinRam: 2048,
|
||||
clientRecommendedRam: 4096,
|
||||
packPath: ''
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge']
|
||||
|
||||
export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition {
|
||||
const fallback = defaultPackDefinition(typeof input.name === 'string' ? input.name : 'new')
|
||||
const platform = (input.platform ?? {}) as Partial<PackDefinition['platform']>
|
||||
const platformType = ALLOWED_LOADERS.includes(platform.type as LoaderType)
|
||||
? (platform.type as LoaderType)
|
||||
: 'vanilla'
|
||||
|
||||
const modsSource = Array.isArray(input.mods) ? input.mods : []
|
||||
const mods = modsSource
|
||||
.map((entry) => {
|
||||
const value = entry as Partial<PackDefinition['mods'][number]>
|
||||
return {
|
||||
name: typeof value?.name === 'string' ? value.name.trim() : '',
|
||||
downloadUrl: typeof value?.downloadUrl === 'string' ? value.downloadUrl.trim() : ''
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.name.length > 0 || entry.downloadUrl.length > 0)
|
||||
|
||||
const resourcePacksSource = Array.isArray(input.resourcepacks) ? input.resourcepacks : []
|
||||
const resourcepacks = resourcePacksSource
|
||||
.map((entry) => {
|
||||
const value = entry as Partial<PackDefinition['resourcepacks'][number]>
|
||||
return {
|
||||
name: typeof value?.name === 'string' ? value.name.trim() : '',
|
||||
downloadUrl: typeof value?.downloadUrl === 'string' ? value.downloadUrl.trim() : ''
|
||||
}
|
||||
})
|
||||
.filter((entry) => entry.name.length > 0 || entry.downloadUrl.length > 0)
|
||||
|
||||
return {
|
||||
name: typeof input.name === 'string' && input.name.trim().length > 0 ? input.name.trim() : fallback.name,
|
||||
mcVersion: typeof input.mcVersion === 'string' && input.mcVersion.trim().length > 0
|
||||
? input.mcVersion.trim()
|
||||
: fallback.mcVersion,
|
||||
platform: {
|
||||
type: platformType,
|
||||
downloadUrl: typeof platform.downloadUrl === 'string' && platform.downloadUrl.trim().length > 0
|
||||
? platform.downloadUrl.trim()
|
||||
: undefined
|
||||
},
|
||||
mods,
|
||||
resourcepacks,
|
||||
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
|
||||
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
|
||||
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
|
||||
clientRecommendedRam: clampNumber(input.clientRecommendedRam, fallback.clientRecommendedRam),
|
||||
packPath: typeof input.packPath === 'string' ? input.packPath.trim() : ''
|
||||
}
|
||||
}
|
||||
|
||||
function clampNumber(input: unknown, fallback: number): number {
|
||||
const value = typeof input === 'number' ? input : Number(input)
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback
|
||||
return Math.floor(value)
|
||||
}
|
||||
|
||||
export function packKeyFromFile(fileName: string): string {
|
||||
return fileName.replace(/\.json$/i, '')
|
||||
}
|
||||
|
||||
export async function loadPackDefinition(packKey: string): Promise<PackDefinition | null> {
|
||||
const filePath = path.join(manifestDirPath, `${packKey}.json`)
|
||||
try {
|
||||
const raw = await fsp.readFile(filePath, 'utf8')
|
||||
const parsed = JSON.parse(raw) as Partial<PackDefinition>
|
||||
return normalizePackDefinition(parsed)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function savePackDefinition(packKey: string, pack: PackDefinition): Promise<void> {
|
||||
await fsp.mkdir(manifestDirPath, { recursive: true })
|
||||
const filePath = path.join(manifestDirPath, `${packKey}.json`)
|
||||
await fsp.writeFile(filePath, `${JSON.stringify(pack, null, 2)}\n`, 'utf8')
|
||||
}
|
||||
|
||||
export async function listPackKeys(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fsp.readdir(manifestDirPath)
|
||||
return entries
|
||||
.filter((name) => name.toLowerCase().endsWith('.json'))
|
||||
.map(packKeyFromFile)
|
||||
.sort((a, b) => a.localeCompare(b, 'ko'))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export async function nextNewKey(): Promise<string> {
|
||||
const used = new Set(await listPackKeys())
|
||||
if (!used.has('new')) return 'new'
|
||||
for (let i = 2; i < 1000; i += 1) {
|
||||
const candidate = `new${i}`
|
||||
if (!used.has(candidate)) return candidate
|
||||
}
|
||||
return `new-${Date.now()}`
|
||||
}
|
||||
|
||||
export async function createPack(): Promise<{ key: string; pack: PackDefinition }> {
|
||||
const key = await nextNewKey()
|
||||
const pack = defaultPackDefinition(key)
|
||||
await savePackDefinition(key, pack)
|
||||
await syncManifestWith(key, pack.name, 'add')
|
||||
return { key, pack }
|
||||
}
|
||||
|
||||
export async function deletePackKeys(keys: string[]): Promise<void> {
|
||||
for (const key of keys) {
|
||||
if (!key) continue
|
||||
const filePath = path.join(manifestDirPath, `${key}.json`)
|
||||
try {
|
||||
await fsp.unlink(filePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await syncManifestWith(key, '', 'remove')
|
||||
}
|
||||
}
|
||||
|
||||
export async function renamePack(oldKey: string, newKey: string, pack: PackDefinition): Promise<string> {
|
||||
const safeNew = sanitizePackKey(newKey) || oldKey
|
||||
const targetPath = path.join(manifestDirPath, `${safeNew}.json`)
|
||||
const sourcePath = path.join(manifestDirPath, `${oldKey}.json`)
|
||||
|
||||
if (safeNew !== oldKey && fs.existsSync(targetPath)) {
|
||||
throw new Error(`이미 ${safeNew}.json 이름의 음악퀴즈가 있습니다.`)
|
||||
}
|
||||
|
||||
await savePackDefinition(safeNew, pack)
|
||||
if (safeNew !== oldKey) {
|
||||
try {
|
||||
await fsp.unlink(sourcePath)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
await syncManifestWith(oldKey, '', 'remove')
|
||||
}
|
||||
await syncManifestWith(safeNew, pack.name, 'upsert')
|
||||
return safeNew
|
||||
}
|
||||
|
||||
export function sanitizePackKey(value: string): string {
|
||||
return value.replace(/[^a-zA-Z0-9_\-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
type ManifestSyncAction = 'add' | 'remove' | 'upsert'
|
||||
|
||||
async function syncManifestWith(key: string, name: string, action: ManifestSyncAction): Promise<void> {
|
||||
const manifest = await readManifest()
|
||||
const filtered = manifest.packs.filter((entry) => entry.file !== key)
|
||||
if (action === 'remove') {
|
||||
await writeManifest({ packs: filtered })
|
||||
return
|
||||
}
|
||||
filtered.push({ name: name || key, file: key })
|
||||
await writeManifest({ packs: filtered })
|
||||
}
|
||||
|
||||
export async function readAccounts(): Promise<AccountEntry[]> {
|
||||
try {
|
||||
const raw = await fsp.readFile(accountFilePath, 'utf8')
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((entry): entry is AccountEntry =>
|
||||
typeof entry?.id === 'string' && typeof entry?.password === 'string')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
38
src/shared/types.ts
Normal file
38
src/shared/types.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
export type LoaderType = 'vanilla' | 'forge' | 'fabric' | 'neoforge'
|
||||
|
||||
export interface PackPlatform {
|
||||
type: LoaderType
|
||||
downloadUrl?: string
|
||||
}
|
||||
|
||||
export interface PackAsset {
|
||||
name: string
|
||||
downloadUrl: string
|
||||
}
|
||||
|
||||
export interface PackDefinition {
|
||||
name: string
|
||||
mcVersion: string
|
||||
platform: PackPlatform
|
||||
mods: PackAsset[]
|
||||
resourcepacks: PackAsset[]
|
||||
serverMinRam: number
|
||||
serverMaxRam: number
|
||||
clientMinRam: number
|
||||
clientRecommendedRam: number
|
||||
packPath: string
|
||||
}
|
||||
|
||||
export interface ManifestEntry {
|
||||
name: string
|
||||
file: string
|
||||
}
|
||||
|
||||
export interface Manifest {
|
||||
packs: ManifestEntry[]
|
||||
}
|
||||
|
||||
export interface AccountEntry {
|
||||
id: string
|
||||
password: string
|
||||
}
|
||||
Reference in New Issue
Block a user