feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

View File

@ -0,0 +1,15 @@
import type { ConfigFileDefinition } from './types.ts'
const registry = new Map<string, ConfigFileDefinition>()
export function registerConfigFile(definition: ConfigFileDefinition): void {
registry.set(definition.filename, definition)
}
export function getConfigFile(filename: string): ConfigFileDefinition | undefined {
return registry.get(filename)
}
export function listConfigFiles(): ConfigFileDefinition[] {
return [...registry.values()]
}

View File

@ -0,0 +1,37 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { serverPropertiesDefinition } from './server-properties.ts'
import { configFieldLabel, resolveConfigField } from './types.ts'
test('declared keys keep their configured kind', () => {
const difficulty = resolveConfigField(serverPropertiesDefinition, 'difficulty', 'normal')
assert.equal(difficulty.kind, 'enum')
assert.deepEqual(difficulty.options, ['peaceful', 'easy', 'normal', 'hard'])
assert.equal(difficulty.inferred, false)
const port = resolveConfigField(serverPropertiesDefinition, 'server-port', '25565')
assert.equal(port.kind, 'integer')
assert.equal(port.min, 1)
assert.equal(port.max, 65535)
const online = resolveConfigField(serverPropertiesDefinition, 'online-mode', 'true')
assert.equal(online.kind, 'boolean')
})
test('unknown keys are inferred from their value', () => {
const booleanField = resolveConfigField(serverPropertiesDefinition, 'some-flag', 'true')
assert.equal(booleanField.kind, 'boolean')
assert.equal(booleanField.inferred, true)
const numericField = resolveConfigField(serverPropertiesDefinition, 'some-count', '12')
assert.equal(numericField.kind, 'integer')
const textField = resolveConfigField(serverPropertiesDefinition, 'some-name', 'hello')
assert.equal(textField.kind, 'string')
})
test('labels are humanized from the key', () => {
assert.equal(configFieldLabel('server-port'), 'Server port')
assert.equal(configFieldLabel('rcon.password'), 'Rcon password')
})

View File

@ -0,0 +1,92 @@
import { inferFieldKindFromValue, type ConfigFileDefinition } from './types.ts'
import { registerConfigFile } from './registry.ts'
const boolean = (key: string) => ({ key, kind: 'boolean' as const })
const integer = (key: string, min?: number, max?: number) => ({
key,
kind: 'integer' as const,
min,
max,
})
const string = (key: string) => ({ key, kind: 'string' as const })
const enumeration = (key: string, options: string[]) => ({ key, kind: 'enum' as const, options })
/**
* Known server.properties fields. Keys not listed here fall back to automatic
* type inference from the current value.
*/
export const serverPropertiesDefinition: ConfigFileDefinition = {
id: 'server-properties',
filename: 'server.properties',
inferFieldKind: (_key, value) => inferFieldKindFromValue(value),
fields: [
integer('server-port', 1, 65535),
enumeration('difficulty', ['peaceful', 'easy', 'normal', 'hard']),
enumeration('gamemode', ['survival', 'creative', 'adventure', 'spectator']),
enumeration('level-type', [
'minecraft:normal',
'minecraft:flat',
'minecraft:large_biomes',
'minecraft:amplified',
'minecraft:single_biome',
'minecraft:debug',
'normal',
'flat',
'largeBiomes',
'amplified',
'default',
]),
integer('max-players', 0, 1000),
integer('view-distance', 2, 32),
integer('simulation-distance', 2, 32),
integer('max-tick-time', -1),
integer('max-world-size', 1, 29999984),
integer('op-permission-level', 0, 4),
integer('function-permission-level', 0, 4),
integer('spawn-protection', 0, 256),
integer('player-idle-timeout', 0),
integer('network-compression-threshold', -1, 1024),
integer('rate-limit', 0),
integer('query.port', 1, 65535),
integer('rcon.port', 1, 65535),
string('level-name'),
string('level-seed'),
string('motd'),
string('resource-pack'),
string('resource-pack-sha1'),
string('resource-pack-prompt'),
string('rcon.password'),
string('server-ip'),
string('text-filtering-config'),
string('initial-enabled-packs'),
boolean('online-mode'),
boolean('white-list'),
boolean('enforce-whitelist'),
boolean('enforce-secure-profile'),
boolean('prevent-proxy-connections'),
boolean('allow-flight'),
boolean('allow-nether'),
boolean('spawn-animals'),
boolean('spawn-monsters'),
boolean('spawn-npcs'),
boolean('pvp'),
boolean('enable-command-block'),
boolean('enable-status'),
boolean('enable-query'),
boolean('enable-rcon'),
boolean('enable-jmx-monitoring'),
boolean('force-gamemode'),
boolean('hardcore'),
boolean('announce-player-achievements'),
boolean('log-ips'),
boolean('hide-online-players'),
boolean('require-resource-pack'),
boolean('sync-chunk-writes'),
boolean('use-native-transport'),
boolean('allow-end'),
boolean('generate-structures'),
boolean('enable-lan'),
],
}
registerConfigFile(serverPropertiesDefinition)

View File

@ -0,0 +1,85 @@
import type { PropertiesEntry } from '../properties.ts'
export type ConfigFieldKind = 'boolean' | 'integer' | 'number' | 'string' | 'enum'
export interface ConfigFieldDefinition {
key: string
kind: ConfigFieldKind
/** Allowed values for `enum` fields. */
options?: string[]
min?: number
max?: number
}
/**
* Describes an editable configuration file. Formats plug in through the
* `entries` accessors so new files (bukkit.yml, whitelist.json, ...) only need
* a schema definition and, when needed, their own parse/serialize functions.
*/
export interface ConfigFileDefinition {
id: string
filename: string
fields: ConfigFieldDefinition[]
/** Fallback inference for keys not present in `fields`. */
inferFieldKind: (key: string, value: string) => ConfigFieldKind
}
export interface ResolvedConfigField extends ConfigFieldDefinition {
/** True when the kind was inferred from the value rather than declared. */
inferred: boolean
}
const TRUE_VALUES = new Set(['true', 'false'])
const INTEGER_RE = /^-?\d+$/
const NUMBER_RE = /^-?\d+(\.\d+)?$/
export function inferFieldKindFromValue(value: string): ConfigFieldKind {
if (TRUE_VALUES.has(value.toLowerCase())) return 'boolean'
if (INTEGER_RE.test(value)) return 'integer'
if (NUMBER_RE.test(value)) return 'number'
return 'string'
}
export function resolveConfigField(
definition: ConfigFileDefinition,
key: string,
value: string,
): ResolvedConfigField {
const declared = definition.fields.find((field) => field.key === key)
if (declared) return { ...declared, inferred: false }
return { key, kind: definition.inferFieldKind(key, value), inferred: true }
}
/** Humanizes a config key (`server-port` -> `Server port`) as a fallback label. */
export function configFieldLabel(key: string): string {
const parts = key.split(/[-_.]/)
if (parts.length === 0) return key
const first = parts[0]
return (
(first.length > 0 ? first[0].toUpperCase() + first.slice(1) : first) +
' ' +
parts.slice(1).join(' ')
).trimEnd()
}
export interface ConfigFileDocument {
definition: ConfigFileDefinition
entries: PropertiesEntry[]
}
export function getRawValue(document: ConfigFileDocument, key: string): string | undefined {
const entry = document.entries.find(
(entry): entry is Extract<PropertiesEntry, { type: 'pair' }> =>
entry.type === 'pair' && entry.key === key,
)
return entry?.value
}
export function setRawValue(document: ConfigFileDocument, key: string, value: string): void {
const index = document.entries.findIndex((entry) => entry.type === 'pair' && entry.key === key)
if (index === -1) {
document.entries.push({ type: 'pair', key, value, separator: '=' })
return
}
document.entries[index] = { type: 'pair', key, value, separator: '=' }
}

View File

@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { parseEula, setEulaAccepted } from './eula.ts'
const SAMPLE_EULA = [
'#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://aka.ms/MinecraftEULA).',
'#Wed Jan 01 00:00:00 UTC 2025',
'eula=false',
].join('\n')
test('parses eula state', () => {
assert.equal(parseEula(SAMPLE_EULA).accepted, false)
assert.equal(parseEula(SAMPLE_EULA.replace('eula=false', 'eula=true')).accepted, true)
assert.equal(parseEula('').accepted, false)
})
test('accepting the eula preserves the surrounding text', () => {
const accepted = setEulaAccepted(SAMPLE_EULA, true)
assert.equal(parseEula(accepted).accepted, true)
assert.equal(accepted.split('\n')[0], SAMPLE_EULA.split('\n')[0])
assert.equal(accepted.split('\n')[1], SAMPLE_EULA.split('\n')[1])
})
test('declining rewrites eula back to false', () => {
const accepted = setEulaAccepted(SAMPLE_EULA, true)
const declined = setEulaAccepted(accepted, false)
assert.equal(parseEula(declined).accepted, false)
})

View File

@ -0,0 +1,32 @@
import { parseProperties, serializeProperties, type PropertiesEntry } from './properties.ts'
const EULA_KEY = 'eula'
export interface EulaDocument {
entries: PropertiesEntry[]
accepted: boolean
}
export function parseEula(text: string): EulaDocument {
const entries = parseProperties(text)
const value = entries.find(
(entry): entry is Extract<PropertiesEntry, { type: 'pair' }> =>
entry.type === 'pair' && entry.key === EULA_KEY,
)
return { entries, accepted: value?.value === 'true' }
}
export function setEulaAccepted(text: string, accepted: boolean): string {
const { entries } = parseEula(text)
const updated = entries.map((entry) =>
entry.type === 'pair' && entry.key === EULA_KEY ? { ...entry, value: String(accepted) } : entry,
)
const hasEulaKey = updated.some((entry) => entry.type === 'pair' && entry.key === EULA_KEY)
const finalEntries = hasEulaKey
? updated
: [
...updated,
{ type: 'pair' as const, key: EULA_KEY, value: String(accepted), separator: '=' },
]
return serializeProperties(finalEntries)
}

View File

@ -0,0 +1,159 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { classifyServerLogLine, summarizeServerExit } from './log-parse.ts'
import { computeServerStatus } from './status.ts'
import {
pickFabricInstallerVersion,
requiredJavaMajorVersion,
resolveServerJar,
type PaperBuildsResponse,
latestStablePaperBuild,
} from './server-types.ts'
test('maps legacy game versions to their required Java major', () => {
assert.equal(requiredJavaMajorVersion('1.21.4'), 21)
assert.equal(requiredJavaMajorVersion('1.20.5'), 21)
assert.equal(requiredJavaMajorVersion('1.20.4'), 17)
assert.equal(requiredJavaMajorVersion('1.17.1'), 17)
assert.equal(requiredJavaMajorVersion('1.16.5'), 8)
assert.equal(requiredJavaMajorVersion('1.12.2'), 8)
})
test('maps year-based game versions to Java 25', () => {
assert.equal(requiredJavaMajorVersion('26.2'), 25)
assert.equal(requiredJavaMajorVersion('26.1'), 25)
assert.equal(requiredJavaMajorVersion('26w14a'), 25)
assert.equal(requiredJavaMajorVersion('25w46a'), 21)
assert.equal(requiredJavaMajorVersion('unknown'), 25)
})
test('detects the eula notice in server logs', () => {
assert.equal(
classifyServerLogLine('[ServerMain/ERROR]: Failed to start the minecraft server').eulaRequired,
undefined,
)
assert.equal(
classifyServerLogLine(
'[ServerMain/ERROR]: You need to agree to the EULA in order to run the server. Go to eula.txt for more info.',
).eulaRequired,
true,
)
})
test('detects a fully started server', () => {
assert.equal(
classifyServerLogLine('[Server thread/INFO]: Done (3.542s)! For help, type "help"').started,
true,
)
})
test('first-run eula exit is not a crash', () => {
const lines = [
'Starting minecraft server version 1.21',
'You need to agree to the EULA in order to run the server.',
]
assert.deepEqual(summarizeServerExit(lines, 1), { crashed: false, eulaRequired: true })
assert.deepEqual(summarizeServerExit(['Done (1.0s)!'], 0), {
crashed: false,
eulaRequired: false,
})
assert.deepEqual(summarizeServerExit(['Exception in thread "main"'], 1), {
crashed: true,
eulaRequired: false,
})
})
test('resolves vanilla server jar from version info', () => {
const jar = resolveServerJar('vanilla', {
gameVersion: '1.21.4',
vanillaVersionInfo: {
downloads: { server: { sha1: 'abc', size: 100, url: 'https://example.com/server.jar' } },
},
})
assert.equal(jar?.url, 'https://example.com/server.jar')
assert.equal(jar?.filename, 'server.jar')
})
test('resolves fabric server launcher url', () => {
const jar = resolveServerJar('fabric', {
gameVersion: '1.21.4',
loaderVersion: '0.16.9',
installerVersion: '1.0.3',
})
assert.equal(
jar?.url,
'https://meta.fabricmc.net/v2/versions/loader/1.21.4/0.16.9/1.0.3/server/jar',
)
assert.equal(jar?.filename, 'fabric-server.jar')
})
test('fabric server jar requires an installer version', () => {
assert.equal(resolveServerJar('fabric', { gameVersion: '1.21.4', loaderVersion: '0.16.9' }), null)
})
test('picks the newest fabric installer version', () => {
assert.equal(pickFabricInstallerVersion([{ version: '1.1.2', stable: true }]), '1.1.2')
assert.equal(pickFabricInstallerVersion([]), null)
})
test('resolves paper server jar from the newest stable fill build', () => {
const builds: PaperBuildsResponse = [
{
id: 112,
channel: 'STABLE',
downloads: {
'server:default': {
name: 'paper-26.2-112.jar',
url: 'https://fill-data.papermc.io/v1/objects/abc/paper-26.2-112.jar',
},
},
},
{
id: 113,
channel: 'EXPERIMENTAL',
downloads: {
'server:default': { name: 'paper-26.2-113.jar', url: 'https://fill-data.papermc.io/v3/x' },
},
},
]
const build = latestStablePaperBuild(builds)
assert.equal(build?.id, 112)
const jar = resolveServerJar('paper', { gameVersion: '26.2', paperBuild: build ?? undefined })
assert.equal(jar?.url, 'https://fill-data.papermc.io/v1/objects/abc/paper-26.2-112.jar')
assert.equal(jar?.filename, 'server.jar')
assert.equal(latestStablePaperBuild([]), null)
assert.equal(
resolveServerJar('paper', {
gameVersion: '26.2',
paperBuild: { id: 1, channel: 'STABLE', downloads: {} },
}),
null,
)
})
test('installer-based types resolve to null until implemented', () => {
assert.equal(resolveServerJar('forge', { gameVersion: '1.21.4' }), null)
assert.equal(resolveServerJar('neoforge', { gameVersion: '1.21.4' }), null)
})
test('computes server status precedence', () => {
const base = {
manifest: { id: 'a' },
isRunning: false,
isStarting: false,
lastExitWasCrash: false,
eulaAccepted: false,
eulaFileExists: false,
}
assert.equal(computeServerStatus(base), 'created')
assert.equal(computeServerStatus({ ...base, eulaFileExists: true }), 'eula_pending')
assert.equal(computeServerStatus({ ...base, eulaAccepted: true }), 'ready')
assert.equal(computeServerStatus({ ...base, isStarting: true }), 'starting')
assert.equal(computeServerStatus({ ...base, isRunning: true }), 'running')
assert.equal(
computeServerStatus({ ...base, lastExitWasCrash: true, eulaAccepted: true }),
'crashed',
)
})

View File

@ -0,0 +1,36 @@
export interface ServerLogSignal {
eulaRequired?: boolean
started?: boolean
stopping?: boolean
}
const EULA_RE = /you need to agree to the eula|eula\.txt/i
const DONE_RE = /Done \([\d.]+s\)!/
const STOPPING_RE = /Stopping (the )?server|Stopping singleplayer server/i
/** Classifies a dedicated server log line for status tracking. */
export function classifyServerLogLine(line: string): ServerLogSignal {
const signals: ServerLogSignal = {}
if (EULA_RE.test(line)) signals.eulaRequired = true
if (DONE_RE.test(line)) signals.started = true
if (STOPPING_RE.test(line)) signals.stopping = true
return signals
}
export interface ServerExitSummary {
crashed: boolean
eulaRequired: boolean
}
/**
* Summarizes the outcome of a server process run from its log lines and exit
* code. A clean exit right after the EULA notice is the expected first-run
* behavior, not a crash.
*/
export function summarizeServerExit(lines: string[], exitCode: number | null): ServerExitSummary {
const eulaRequired = lines.some((line) => classifyServerLogLine(line).eulaRequired)
return {
crashed: exitCode !== null && exitCode !== 0 && !eulaRequired,
eulaRequired,
}
}

View File

@ -0,0 +1,59 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { getProperty, parseProperties, serializeProperties, setProperty } from './properties.ts'
test('parses simple pairs with = separator', () => {
const entries = parseProperties('server-port=25565\nonline-mode=true')
assert.deepEqual(
entries.map((e) => (e.type === 'pair' ? [e.key, e.value] : e.type)),
[
['server-port', '25565'],
['online-mode', 'true'],
],
)
})
test('preserves comments, blank lines and key order on round-trip', () => {
const text =
'#Minecraft server properties\n#Wed Jan 01 00:00:00 UTC 2025\nserver-port=25565\n\nmotd=A Minecraft Server\nwhite-list=false'
const entries = parseProperties(text)
const roundTripped = serializeProperties(entries)
assert.equal(roundTripped.split('\n')[0], '#Minecraft server properties')
assert.equal(roundTripped.split('\n')[3], '')
const reparsed = parseProperties(roundTripped)
assert.equal(getProperty(reparsed, 'motd'), 'A Minecraft Server')
assert.equal(getProperty(reparsed, 'white-list'), 'false')
})
test('supports colon separators and whitespace', () => {
const entries = parseProperties('gamemode : creative')
const pair = entries[0]
assert.equal(pair.type, 'pair')
if (pair.type === 'pair') {
assert.equal(pair.key, 'gamemode')
assert.equal(pair.value, 'creative')
assert.equal(pair.separator, ':')
}
})
test('escapes and unescapes values', () => {
const entries = parseProperties('motd=Line one\\nLine two')
assert.equal(getProperty(entries, 'motd'), 'Line one\nLine two')
const serialized = serializeProperties(entries)
assert.equal(getProperty(parseProperties(serialized), 'motd'), 'Line one\nLine two')
})
test('setProperty updates existing keys and appends new ones', () => {
const entries = parseProperties('difficulty=easy')
const updated = setProperty(entries, 'difficulty', 'hard')
assert.equal(getProperty(updated, 'difficulty'), 'hard')
const appended = setProperty(updated, 'new-key', 'value')
assert.equal(getProperty(appended, 'new-key'), 'value')
assert.equal(appended.length, updated.length + 1)
})
test('handles values containing escaped separators', () => {
const entries = parseProperties('level-name=World\\=Two')
assert.equal(getProperty(entries, 'level-name'), 'World=Two')
})

View File

@ -0,0 +1,141 @@
export type PropertiesEntry =
| { type: 'comment'; text: string }
| { type: 'blank' }
| { type: 'pair'; key: string; value: string; separator: string }
const COMMENT_RE = /^\s*[#!]/
const ESCAPE_RE = /\\(.)/g
function unescapeValue(value: string): string {
return value.replace(ESCAPE_RE, (_, char: string) => {
switch (char) {
case 'n':
return '\n'
case 't':
return '\t'
case 'r':
return '\r'
case 'f':
return '\f'
default:
return char
}
})
}
function escapeValue(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/\n/g, '\\n')
.replace(/\t/g, '\\t')
.replace(/\r/g, '\\r')
}
/** Finds the first unescaped `=` or `:` separator, falling back to whitespace. */
function findSeparator(line: string): number {
for (let i = 0; i < line.length; i++) {
const char = line[i]
if (char === '\\') {
i++
continue
}
if (char === '=' || char === ':') return i
if (/\s/.test(char)) {
let j = i
while (j < line.length && /\s/.test(line[j])) j++
if (line[j] === '=' || line[j] === ':') return j
return i
}
}
return -1
}
function unescapeKey(key: string): string {
return unescapeValue(key).replace(/\\([ =:])/g, '$1')
}
/**
* Parses a Java `.properties` document, preserving comment lines, blank lines,
* key order, and the original `=`/`:` separators so it round-trips safely.
*/
export function parseProperties(text: string): PropertiesEntry[] {
const entries: PropertiesEntry[] = []
let pendingContinuation: { type: 'pair'; key: string; value: string; separator: string } | null =
null
for (const rawLine of text.split(/\r?\n/)) {
const line = pendingContinuation ? rawLine : rawLine.trim()
if (pendingContinuation) {
const continued = line.replace(/\\\s*$/, '')
pendingContinuation.value += continued
if (/\\\s*$/.test(rawLine)) continue
entries.push(pendingContinuation)
pendingContinuation = null
continue
}
if (line === '') {
entries.push({ type: 'blank' })
continue
}
if (COMMENT_RE.test(line)) {
entries.push({ type: 'comment', text: line })
continue
}
const separatorIndex = findSeparator(line)
if (separatorIndex === -1) {
entries.push({ type: 'pair', key: unescapeKey(line), value: '', separator: '=' })
continue
}
const key = unescapeKey(line.slice(0, separatorIndex).trimEnd())
const separator = line[separatorIndex]
const value = line.slice(separatorIndex + 1).trim()
if (/\\\s*$/.test(value)) {
pendingContinuation = {
type: 'pair',
key,
value: value.replace(/\\\s*$/, ''),
separator,
}
continue
}
entries.push({ type: 'pair', key, value: unescapeValue(value), separator })
}
if (pendingContinuation) entries.push(pendingContinuation)
return entries
}
export function serializeProperties(entries: PropertiesEntry[]): string {
return entries
.map((entry) => {
if (entry.type === 'blank') return ''
if (entry.type === 'comment') return entry.text
return `${entry.key.replace(/([ =:])/g, '\\$1')}${entry.separator}${escapeValue(entry.value)}`
})
.join('\n')
}
export function getProperty(entries: PropertiesEntry[], key: string): string | undefined {
const entry = entries.find(
(entry): entry is Extract<PropertiesEntry, { type: 'pair' }> =>
entry.type === 'pair' && entry.key === key,
)
return entry?.value
}
export function setProperty(
entries: PropertiesEntry[],
key: string,
value: string,
): PropertiesEntry[] {
let updated = false
const next = entries.map((entry) => {
if (entry.type === 'pair' && entry.key === key) {
updated = true
return { ...entry, value }
}
return entry
})
if (!updated) next.push({ type: 'pair', key, value, separator: '=' })
return next
}

View File

@ -0,0 +1,206 @@
import type {
PaperBuild,
ResolveServerJarInput,
ServerJarDownload,
ServerTypeDefinition,
ServerTypeId,
} from './types.ts'
const FABRIC_META_URL = 'https://meta.fabricmc.net/v2'
const QUILT_META_URL = 'https://meta.quiltmc.org/v3'
const PAPER_API_URL = 'https://fill.papermc.io/v3'
const PAPER_PROJECT = 'paper'
/**
* Known server types. `forge`, `neoforge` and `quilt` require an installer run
* step that is not implemented yet (TODO) but are registered so the UI and
* future CLI share one source of truth.
*/
export const SERVER_TYPES: Record<ServerTypeId, ServerTypeDefinition> = {
vanilla: {
id: 'vanilla',
label: 'Vanilla',
installMode: 'direct',
needsLoaderVersion: false,
implemented: true,
},
fabric: {
id: 'fabric',
label: 'Fabric',
installMode: 'direct',
needsLoaderVersion: true,
implemented: true,
},
paper: {
id: 'paper',
label: 'Paper',
installMode: 'direct',
needsLoaderVersion: false,
implemented: true,
},
forge: {
id: 'forge',
label: 'Forge',
installMode: 'installer',
needsLoaderVersion: false,
implemented: true,
},
neoforge: {
id: 'neoforge',
label: 'NeoForge',
installMode: 'installer',
needsLoaderVersion: true,
implemented: false,
},
quilt: {
id: 'quilt',
label: 'Quilt',
installMode: 'installer',
needsLoaderVersion: true,
implemented: false,
},
}
export function listServerTypes(): ServerTypeDefinition[] {
return Object.values(SERVER_TYPES)
}
export function isServerTypeSupported(type: ServerTypeId): boolean {
return SERVER_TYPES[type].implemented
}
/** Base URL of the Forge Maven repository hosting installer and launcher artifacts. */
export const FORGE_MAVEN_URL = 'https://maven.minecraftforge.net/net/minecraftforge/forge'
/** Base URL of the Forge web host that publishes the promotions manifest. */
export const FORGE_FILES_URL = 'https://files.minecraftforge.net/net/minecraftforge/forge'
/** URL of the Forge promotions manifest, mapping `<mc>-recommended`/`latest` to a build. */
export function forgePromotionsSlimUrl(): string {
return `${FORGE_FILES_URL}/promotions_slim.json`
}
/** URL of the Fabric server launcher jar for a specific game/loader/installer combination. */
export function fabricServerJarUrl(
gameVersion: string,
loaderVersion: string,
installerVersion: string,
): string {
return `${FABRIC_META_URL}/versions/loader/${gameVersion}/${loaderVersion}/${installerVersion}/server/jar`
}
export function fabricInstallerVersionsUrl(): string {
return `${FABRIC_META_URL}/versions/installer`
}
export function fabricLoaderVersionsForGameUrl(gameVersion: string): string {
return `${FABRIC_META_URL}/versions/loader/${gameVersion}`
}
/** URL of the Quilt server launcher jar for a specific game/loader/installer combination. */
export function quiltServerJarUrl(
gameVersion: string,
loaderVersion: string,
installerVersion: string,
): string {
return `${QUILT_META_URL}/versions/loader/${gameVersion}/${loaderVersion}/${installerVersion}/server/jar`
}
export function quiltInstallerVersionsUrl(): string {
return `${QUILT_META_URL}/versions/installer`
}
export function quiltLoaderVersionsForGameUrl(gameVersion: string): string {
return `${QUILT_META_URL}/versions/loader/${gameVersion}`
}
export function paperBuildsUrl(gameVersion: string): string {
return `${PAPER_API_URL}/projects/${PAPER_PROJECT}/versions/${gameVersion}/builds`
}
/**
* Resolves the server jar download for a server type from metadata the caller
* fetched. Returns null when the type needs an installer step or required
* metadata is missing.
*/
export function resolveServerJar(
type: ServerTypeId,
input: ResolveServerJarInput,
): ServerJarDownload | null {
switch (type) {
case 'vanilla': {
const server = input.vanillaVersionInfo?.downloads.server
if (!server) return null
return { url: server.url, filename: 'server.jar', sha1: server.sha1, size: server.size }
}
case 'fabric': {
if (!input.loaderVersion || !input.installerVersion) return null
return {
url: fabricServerJarUrl(input.gameVersion, input.loaderVersion, input.installerVersion),
filename: 'fabric-server.jar',
}
}
case 'quilt': {
if (!input.loaderVersion || !input.installerVersion) return null
return {
url: quiltServerJarUrl(input.gameVersion, input.loaderVersion, input.installerVersion),
filename: 'quilt-server.jar',
}
}
case 'paper': {
const download = input.paperBuild?.downloads['server:default']
if (!download) return null
return { url: download.url, filename: 'server.jar' }
}
default:
return null
}
}
export type PaperBuildsResponse = PaperBuild[]
/** The newest stable build from a Fill v3 builds response (builds are newest first). */
export function latestStablePaperBuild(response: PaperBuildsResponse): PaperBuild | null {
return response?.find((build) => build.channel === 'STABLE') ?? null
}
export interface FabricInstallerVersionsResponse {
version: string
stable: boolean
}
/** The newest installer version from the `/v2/versions/installer` response (a top-level array). */
export function pickFabricInstallerVersion(
response: FabricInstallerVersionsResponse[],
): string | null {
return response?.[0]?.version ?? null
}
/** Minimum Java major version required to run a given game version. */
/**
* Minimum Java major version required to run a given game version.
* Handles both the legacy `1.x` scheme and the year-based scheme (`26.2`,
* `26w14a`), which needs Java 25.
*/
export function requiredJavaMajorVersion(gameVersion: string): number {
const yearSnapshot = /^(\d{2})w/.exec(gameVersion)
if (yearSnapshot) {
return Number(yearSnapshot[1]) >= 26 ? 25 : 21
}
const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(gameVersion)
if (!match) return 25
const major = Number(match[1])
const minor = Number(match[2] ?? 0)
const patch = Number(match[3] ?? 0)
// Year-based releases (26.1+) require Java 25
if (major >= 21) return 25
// Legacy 1.x releases
if (major === 1) {
if (minor > 20 || (minor === 20 && patch >= 5)) return 21
if (minor >= 17) return 17
return 8
}
return 25
}

View File

@ -0,0 +1,9 @@
import type { ServerStatus, ServerStatusInput } from './types.ts'
export function computeServerStatus(input: ServerStatusInput): ServerStatus {
if (input.isRunning) return 'running'
if (input.isStarting) return 'starting'
if (input.lastExitWasCrash) return 'crashed'
if (!input.eulaAccepted) return input.eulaFileExists ? 'eula_pending' : 'created'
return 'ready'
}

View File

@ -0,0 +1,102 @@
export type ServerTypeId = 'vanilla' | 'fabric' | 'paper' | 'forge' | 'neoforge' | 'quilt'
/**
* How a server jar is obtained and booted for a given server type.
* `direct`: the downloaded jar is the server itself.
* `installer`: an installer must run in the server directory before launch.
* `todo`: support is planned but not implemented yet.
*/
export type ServerInstallMode = 'direct' | 'installer' | 'todo'
export interface ServerTypeDefinition {
id: ServerTypeId
label: string
installMode: ServerInstallMode
needsLoaderVersion: boolean
/**
* Whether the launcher can actually create and boot this server type today.
* `installer` types flip this on as their installer run step lands; types
* still in planning stay `false` so the UI hides them until they work.
*/
implemented: boolean
}
export interface ServerJarDownload {
url: string
filename: string
sha1?: string
size?: number
}
export interface VanillaVersionInfoDownload {
sha1: string
size: number
url: string
}
export interface VanillaVersionInfo {
downloads: { server?: VanillaVersionInfoDownload }
}
export interface PaperBuildDownload {
name: string
url: string
checksums?: { sha256?: string }
size?: number
}
/** A build from the PaperMC Fill v3 downloads service. */
export interface PaperBuild {
id: number
channel: string
downloads: { 'server:default'?: PaperBuildDownload }
}
export interface ResolveServerJarInput {
gameVersion: string
loaderVersion?: string
installerVersion?: string
vanillaVersionInfo?: VanillaVersionInfo
paperBuild?: PaperBuild
}
export type ServerStatus = 'created' | 'eula_pending' | 'ready' | 'starting' | 'running' | 'crashed'
/** Persisted server manifest, stored as `axolotl-server.json` in the server directory. */
export interface ManagedServerManifest {
id: string
name: string
serverType: ServerTypeId
gameVersion: string
loaderVersion?: string
createdAt: string
javaPath?: string
memoryMb?: number
jvmArgs?: string[]
lastStartedAt?: string
}
export interface ManagedServer extends ManagedServerManifest {
path: string
status: ServerStatus
port?: number
eulaAccepted: boolean
}
export interface ServerStatusInput {
manifest: Pick<ManagedServerManifest, 'id'>
isRunning: boolean
isStarting: boolean
lastExitWasCrash: boolean
eulaAccepted: boolean
eulaFileExists: boolean
}
export interface ServerLaunchOptions {
javaPath: string
memoryMb: number
jvmArgs?: string[]
}
export const DEFAULT_SERVER_PORT = 25565
export const DEFAULT_SERVER_MEMORY_MB = 2048