commit b6ec8de3ba5f3562283aaf9cf253fa8287539b1e Author: Oleksandr Honcharov <0976053529@ukr.net> Date: Mon Feb 10 13:54:47 2025 +0200 server init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8a1e950 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules +bun.lock + +.idea \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..ab77aee --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "inquirer": "^12.3.3" + } +} \ No newline at end of file diff --git a/packages/server/.gitignore b/packages/server/.gitignore new file mode 100644 index 0000000..d7cb8e6 --- /dev/null +++ b/packages/server/.gitignore @@ -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 diff --git a/packages/server/.prettierignore b/packages/server/.prettierignore new file mode 100644 index 0000000..2893c78 --- /dev/null +++ b/packages/server/.prettierignore @@ -0,0 +1,6 @@ +# Ignore artifacts: +assets +dist +manifests +node_modules +profiles diff --git a/packages/server/.prettierrc b/packages/server/.prettierrc new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/packages/server/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/packages/server/README.md b/packages/server/README.md new file mode 100644 index 0000000..f0d2208 --- /dev/null +++ b/packages/server/README.md @@ -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. diff --git a/packages/server/package.json b/packages/server/package.json new file mode 100644 index 0000000..206908f --- /dev/null +++ b/packages/server/package.json @@ -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" + } +} diff --git a/packages/server/src/commands/changeProfileEnabled.ts b/packages/server/src/commands/changeProfileEnabled.ts new file mode 100644 index 0000000..aa2d831 --- /dev/null +++ b/packages/server/src/commands/changeProfileEnabled.ts @@ -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] ", + 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); diff --git a/packages/server/src/commands/createProfile.ts b/packages/server/src/commands/createProfile.ts new file mode 100644 index 0000000..018bb9a --- /dev/null +++ b/packages/server/src/commands/createProfile.ts @@ -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 ", + 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); diff --git a/packages/server/src/commands/index.ts b/packages/server/src/commands/index.ts new file mode 100644 index 0000000..d8ff7b6 --- /dev/null +++ b/packages/server/src/commands/index.ts @@ -0,0 +1,3 @@ +import "./createProfile"; +import "./syncup"; +import "./changeProfileEnabled"; diff --git a/packages/server/src/commands/sync.ts b/packages/server/src/commands/sync.ts new file mode 100644 index 0000000..daf7480 --- /dev/null +++ b/packages/server/src/commands/sync.ts @@ -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); diff --git a/packages/server/src/commands/syncup.ts b/packages/server/src/commands/syncup.ts new file mode 100644 index 0000000..6aea402 --- /dev/null +++ b/packages/server/src/commands/syncup.ts @@ -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); diff --git a/packages/server/src/commands/types/index.ts b/packages/server/src/commands/types/index.ts new file mode 100644 index 0000000..a641acb --- /dev/null +++ b/packages/server/src/commands/types/index.ts @@ -0,0 +1,11 @@ +export interface ProfileFile { + id: string; + name: string; + enabled: boolean; + assetsFolder: string; +} + +export interface ManifestFile { + path: string; + hash: string; +} diff --git a/packages/server/src/consts/index.ts b/packages/server/src/consts/index.ts new file mode 100644 index 0000000..621258c --- /dev/null +++ b/packages/server/src/consts/index.ts @@ -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"); diff --git a/packages/server/src/core/commandHandler.ts b/packages/server/src/core/commandHandler.ts new file mode 100644 index 0000000..b2e131a --- /dev/null +++ b/packages/server/src/core/commandHandler.ts @@ -0,0 +1,92 @@ +import readline from "readline"; +import chalk from "chalk"; + +export interface Command { + name: string; + description: string; + execute: (args: string[]) => void | Promise; +} + +class CommandHandler { + private commands: Map; + 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 { + 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(); diff --git a/packages/server/src/core/elysia.ts b/packages/server/src/core/elysia.ts new file mode 100644 index 0000000..8dd87b5 --- /dev/null +++ b/packages/server/src/core/elysia.ts @@ -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")); diff --git a/packages/server/src/core/helpers.ts b/packages/server/src/core/helpers.ts new file mode 100644 index 0000000..0f34742 --- /dev/null +++ b/packages/server/src/core/helpers.ts @@ -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 => { + 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 => { + 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 => { + 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 => { + 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 }); + } + }, + }, +}; diff --git a/packages/server/src/core/index.ts b/packages/server/src/core/index.ts new file mode 100644 index 0000000..19cf63d --- /dev/null +++ b/packages/server/src/core/index.ts @@ -0,0 +1,2 @@ +export * from "./helpers.ts"; +export * from "./commandHandler.ts"; diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts new file mode 100644 index 0000000..6159608 --- /dev/null +++ b/packages/server/src/index.ts @@ -0,0 +1,9 @@ +import { helpers } from "./core"; +import { commandHandler } from "./core"; +import "./commands"; + +await helpers.system.init(); + +import "./core/elysia"; + +commandHandler.start(); diff --git a/packages/server/tsconfig.json b/packages/server/tsconfig.json new file mode 100644 index 0000000..24f809d --- /dev/null +++ b/packages/server/tsconfig.json @@ -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 + } +}