server init

This commit is contained in:
Oleksandr Honcharov 2025-02-10 13:54:47 +02:00
commit b6ec8de3ba
20 changed files with 697 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@ -0,0 +1,4 @@
node_modules
bun.lock
.idea

5
package.json Normal file
View File

@ -0,0 +1,5 @@
{
"dependencies": {
"inquirer": "^12.3.3"
}
}

179
packages/server/.gitignore vendored Normal file
View File

@ -0,0 +1,179 @@
# Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
# Logs
logs
_.log
npm-debug.log_
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Caches
.cache
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# Runtime data
pids
_.pid
_.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
profiles
manifests
assets

View File

@ -0,0 +1,6 @@
# Ignore artifacts:
assets
dist
manifests
node_modules
profiles

View File

@ -0,0 +1 @@
{}

15
packages/server/README.md Normal file
View File

@ -0,0 +1,15 @@
# skymp-server
To install dependencies:
```bash
bun install
```
To run:
```bash
bun run src/index.ts
```
This project was created using `bun init` in bun v1.2.2. [Bun](https://bun.sh) is a fast all-in-one JavaScript runtime.

View File

@ -0,0 +1,22 @@
{
"name": "skymp-server",
"module": "src/index.ts",
"type": "module",
"scripts": {
"dev": "bun --watch run src/index.ts",
"build:win": "bun build --compile --target=bun-windows-x64 src/index.ts --outfile dist/build"
},
"devDependencies": {
"@types/bun": "latest",
"prettier": "3.4.2"
},
"peerDependencies": {
"typescript": "^5.7.3"
},
"dependencies": {
"@elysiajs/static": "^1.2.0",
"chalk": "^5.4.1",
"elysia": "^1.2.10",
"uuid": "^11.0.5"
}
}

View File

@ -0,0 +1,37 @@
import chalk from "chalk";
import { commandHandler, helpers } from "../core";
import type { Command } from "../core";
const changeProfileEnabled: Command = {
name: "change-profile-enabled",
description:
"Enable profile with the given name. Usage: change-profile-enabled [true/false] <profile name>",
execute: async (args: string[]) => {
if (args.length < 2) {
console.log(chalk.red("Please provide profile status and name"));
return;
}
const status = String(args[0]).toLowerCase() === "true";
const profileName = args.slice(1).join(" ");
const profiles = await helpers.profile.getAll();
const profile = profiles.find((profile) => profile.name === profileName);
if (!profile) {
console.log(chalk.red(`Profile "${profileName}" not found`));
return;
}
profile.enabled = status;
await helpers.profile.save(profile.id, JSON.stringify(profile));
console.log(
chalk.yellow(
`Profile "${profileName}" is now ${status ? chalk.green("enabled") : chalk.red("disabled")}`,
),
);
},
};
commandHandler.registerCommand(changeProfileEnabled);

View File

@ -0,0 +1,39 @@
import { v4 as uuidv4 } from "uuid";
import chalk from "chalk";
import { commandHandler, helpers } from "../core";
import type { Command } from "../core";
import type { ProfileFile } from "./types";
const createProfileCommand: Command = {
name: "create-profile",
description:
"Creates a new profile with the given name. Usage: create-profile <profile name>",
execute: async (args: string[]) => {
if (args.length < 1) {
console.log(chalk.red("Please provide a profile name"));
return;
}
const profileName = args.join(" ");
console.log(chalk.yellow(`Creating profile for ${profileName}...`));
const id = uuidv4();
const assetsFolder = profileName.split(" ").join("-").toLowerCase();
const profileData: ProfileFile = {
id,
name: profileName,
assetsFolder,
enabled: false,
};
await helpers.profile.save(id, JSON.stringify(profileData));
await helpers.asset.init(assetsFolder);
console.log(chalk.green(`Profile "${profileName}" created with id: ${id}`));
},
};
commandHandler.registerCommand(createProfileCommand);

View File

@ -0,0 +1,3 @@
import "./createProfile";
import "./syncup";
import "./changeProfileEnabled";

View File

@ -0,0 +1,26 @@
import { readdir } from "node:fs/promises";
import * as path from "node:path";
import chalk from "chalk";
import { commandHandler, helpers } from "../core";
import { ASSETS_FOLDER, PROFILES_FOLDER } from "../consts";
import type { ProfileFile } from "./types";
import type { Command } from "../core";
const syncUpCommand: Command = {
name: "sync-up",
description: "Syncs up all assets folders with the manifest. Usage: sync-up",
execute: async (args: string[]) => {
const profileName = args.slice(1).join(" ");
const profiles = await helpers.profile.getAll();
const profile = profiles.find((profile) => profile.name === profileName);
if (!profile) {
console.log(chalk.red(`Profile "${profileName}" not found`));
return;
}
},
};
commandHandler.registerCommand(syncUpCommand);

View File

@ -0,0 +1,41 @@
import { readdir } from "node:fs/promises";
import * as path from "node:path";
import chalk from "chalk";
import { commandHandler, helpers } from "../core";
import { ASSETS_FOLDER, PROFILES_FOLDER } from "../consts";
import type { ProfileFile } from "./types";
import type { Command } from "../core";
const syncUpCommand: Command = {
name: "sync-up",
description: "Syncs up all assets folders with the manifest. Usage: sync-up",
execute: async () => {
if (await helpers.manifest.isFolderExists()) {
const dirEntries = await readdir(PROFILES_FOLDER, {
withFileTypes: true,
});
for (const entry of dirEntries) {
const file = Bun.file(path.join(PROFILES_FOLDER, entry.name));
const data: ProfileFile = JSON.parse(await file.text());
console.log(chalk.green(`Syncing up profile: ${data.id}`));
const pathToAssets = path.join(ASSETS_FOLDER, data.assetsFolder);
const generatedManifest = await helpers.manifest.generate(pathToAssets);
await helpers.manifest.save(
data.id,
JSON.stringify(generatedManifest),
await helpers.manifest.isExists(data.id),
);
}
console.log(chalk.green("Sync up complete!"));
}
},
};
commandHandler.registerCommand(syncUpCommand);

View File

@ -0,0 +1,11 @@
export interface ProfileFile {
id: string;
name: string;
enabled: boolean;
assetsFolder: string;
}
export interface ManifestFile {
path: string;
hash: string;
}

View File

@ -0,0 +1,5 @@
import * as path from "node:path";
export const MANIFEST_FOLDER = path.join(process.cwd(), "manifests");
export const PROFILES_FOLDER = path.join(process.cwd(), "profiles");
export const ASSETS_FOLDER = path.join(process.cwd(), "assets");

View File

@ -0,0 +1,92 @@
import readline from "readline";
import chalk from "chalk";
export interface Command {
name: string;
description: string;
execute: (args: string[]) => void | Promise<void>;
}
class CommandHandler {
private commands: Map<string, Command>;
private rl: readline.Interface;
constructor() {
this.commands = new Map();
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: chalk.blue("> "),
});
this.registerCommand({
name: "help",
description: "Shows all available commands",
execute: this.handleHelp.bind(this),
});
this.registerCommand({
name: "exit",
description: "Exits the application",
execute: () => {
console.log(chalk.yellow("Goodbye!"));
this.rl.close();
process.exit(0);
},
});
}
public registerCommand(command: Command): void {
this.commands.set(command.name, command);
}
private handleHelp(): void {
console.log(chalk.green("\nAvailable commands:"));
this.commands.forEach((command) => {
console.log(chalk.cyan(`${command.name}: `) + command.description);
});
console.log();
}
private async executeCommand(input: string): Promise<void> {
const args = input.trim().split(/\s+/);
const commandName = args.shift()?.toLowerCase();
if (!commandName) {
return;
}
const command = this.commands.get(commandName);
if (!command) {
console.log(chalk.red(`Unknown command: ${commandName}`));
console.log(chalk.yellow('Type "help" to see available commands'));
return;
}
try {
await command.execute(args);
} catch (error) {
console.error(chalk.red("Error executing command:"), error);
}
}
public start(): void {
console.log(chalk.green("Welcome to the SkyMP LaunchServer CLI!"));
console.log(chalk.yellow('Type "help" to see available commands\n'));
this.rl.prompt();
this.rl.on("line", async (input) => {
await this.executeCommand(input.trim());
this.rl.prompt();
});
this.rl.on("close", () => {
console.log(chalk.yellow("Goodbye!"));
process.exit(0);
});
}
}
export const commandHandler = new CommandHandler();

View File

@ -0,0 +1,27 @@
import { Elysia } from "elysia";
import chalk from "chalk";
import staticPlugin from "@elysiajs/static";
import { helpers } from "./helpers.ts";
new Elysia()
.use(
staticPlugin({
prefix: "/api/assets",
assets: "assets",
}),
)
.group("/api", (group) => {
return group
.get("/profiles", async () => {
const allProfiles = await helpers.profile.getAll();
return allProfiles.filter((profile) => profile.enabled);
})
.get("/manifest/:id", async ({ params: { id } }) => {
return await helpers.manifest.get(id);
});
})
.listen(5454);
console.log(chalk.green("Server started on port 5454"));

View File

@ -0,0 +1,149 @@
import { exists, mkdir, readdir, readFile, stat } from "node:fs/promises";
import path from "node:path";
import * as crypto from "node:crypto";
import { ASSETS_FOLDER, MANIFEST_FOLDER, PROFILES_FOLDER } from "../consts";
import type { ManifestFile, ProfileFile } from "../commands/types";
export interface FileInfo {
path: string;
hash: string;
size: number;
}
export const helpers = {
manifest: {
init: async () => {
if (!(await exists(MANIFEST_FOLDER))) {
try {
await mkdir(MANIFEST_FOLDER, { recursive: true });
} catch (e) {
console.error(e);
}
}
},
isFolderExists: async () => {
if (await exists(MANIFEST_FOLDER)) {
return true;
}
await helpers.manifest.init();
return true;
},
isExists: async (id: string) => {
const manifestPath = path.join(MANIFEST_FOLDER, `${id}.json`);
return await exists(manifestPath);
},
save: async (id: string, data: string, resave: boolean) => {
if (resave) {
await Bun.file(path.join(MANIFEST_FOLDER, `${id}.json`)).delete();
}
await Bun.write(path.join(MANIFEST_FOLDER, `${id}.json`), data);
},
generate: async (dir: string): Promise<FileInfo[]> => {
const files: FileInfo[] = [];
async function scanDir(currentDir: string) {
const dirEntries = await readdir(currentDir, { withFileTypes: true });
for (const entry of dirEntries) {
const fullPath = path.join(currentDir, entry.name);
const relPath = path.relative(dir, fullPath);
if (entry.isDirectory()) {
await scanDir(fullPath);
} else if (entry.isFile()) {
const fileBuffer = await readFile(fullPath);
const hash = crypto
.createHash("sha256")
.update(fileBuffer)
.digest("hex");
const { size } = await stat(fullPath);
files.push({ path: relPath, hash, size });
}
}
}
await scanDir(dir);
return files;
},
get: async (id: string): Promise<ManifestFile[]> => {
const isProfileEnabled = await helpers.profile.enabled(id);
if (!isProfileEnabled) return [];
const manifestFilePath = path.join(MANIFEST_FOLDER, `${id}.json`);
const file = Bun.file(manifestFilePath);
return JSON.parse(await file.text());
},
},
profile: {
save: async (name: string, data: string) => {
if (!(await exists(PROFILES_FOLDER))) {
await mkdir(PROFILES_FOLDER, { recursive: true });
}
await Bun.write(path.join(PROFILES_FOLDER, `${name}.json`), data);
},
getAll: async (): Promise<ProfileFile[]> => {
const dirEntries = await readdir(PROFILES_FOLDER, {
withFileTypes: true,
});
const profiles = [];
for (const entry of dirEntries) {
const file = Bun.file(path.join(PROFILES_FOLDER, entry.name));
const data: ProfileFile = JSON.parse(await file.text());
profiles.push(data);
}
return profiles;
},
enabled: async (id: string): Promise<Boolean> => {
const profilePath = path.join(PROFILES_FOLDER, `${id}.json`);
if (!(await exists(profilePath))) return false;
const file = Bun.file(profilePath);
const data: ProfileFile = JSON.parse(await file.text());
return data.enabled;
},
},
asset: {
init: async (dir: string) => {
const targetPath = path.join(ASSETS_FOLDER, dir);
if (await exists(targetPath)) {
return;
}
try {
await mkdir(targetPath, { recursive: true });
} catch (e) {
console.error(e);
}
await Bun.write(
path.join(targetPath, "your-assets-here.txt"),
"Place your assets here",
);
},
},
system: {
init: async () => {
if (!(await exists(ASSETS_FOLDER))) {
await mkdir(ASSETS_FOLDER, { recursive: true });
}
if (!(await exists(PROFILES_FOLDER))) {
await mkdir(PROFILES_FOLDER, { recursive: true });
}
if (!(await exists(MANIFEST_FOLDER))) {
await mkdir(MANIFEST_FOLDER, { recursive: true });
}
},
},
};

View File

@ -0,0 +1,2 @@
export * from "./helpers.ts";
export * from "./commandHandler.ts";

View File

@ -0,0 +1,9 @@
import { helpers } from "./core";
import { commandHandler } from "./core";
import "./commands";
await helpers.system.init();
import "./core/elysia";
commandHandler.start();

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
// Enable latest features
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleDetection": "force",
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false
}
}