Updated Build Script (#14)

Total rewrite of the build script. There were some issues with the original I built, so I spent some time rewriting it into this. I've been testing it with my mods for a while now, and I believe I've ironed out all of the issues. I wanted to replace the old script before 3.7 dropped.

Notable Changes:
- Dropped the inline ignore array for a `.buildignore` file. The syntax of this file now exactly matches that of a `.gitignore` file.
- Dropped the `bestzip` package for `archiver`.
- Dropped all custom functions that handled file and directory ignoring for the `Ignore` package.
- Includes the `Winston` package for sexy logging.
- Changed any function that touches a file to run asynchronously.
- Changed the build process to use an OS temporary directory instead of creating one in the project directory.
- Added a verbose option to display which files ended up being copied and which were ignored for testing the `.buildignore` file.
- Changed the packaged mod to include the folder structure in which it must be installed: `/user/mods/mod-name-here`.
- Updated some README files to add some more information.

Closes issue #13

Co-authored-by: Refringe <brownelltyler@gmail.com>
Reviewed-on: #14
Co-authored-by: Refringe <refringe@noreply.dev.sp-tarkov.com>
Co-committed-by: Refringe <refringe@noreply.dev.sp-tarkov.com>
This commit is contained in:
Refringe 2023-09-21 14:40:11 +00:00 committed by chomp
parent 9932613f6b
commit c0d3a6a357
116 changed files with 10323 additions and 3576 deletions

View File

@ -1,10 +1,11 @@
# Mod examples for 3.7.0
# Mod examples for v3.7.0
A collection of example mods that perform typical actions in SPT
# Setup
Download and put each folder in user/mods
# Mod upgrade guide
Dive into a specific mod folder and follow the instructions in the `README.md` file.
# Mod Upgrade Guide
Read [Here](https://hub.sp-tarkov.com/doc/entry/51-modding-in-2-4-0/)

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -8,17 +8,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -8,7 +8,7 @@
"downlevelIteration": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"resolveJsonModule": true,
"resolveJsonModule": true,
"outDir": "tmp",
"baseUrl": ".",
"paths": {

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,81 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"max-len": [
"warn",
{
"code":100
}
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,81 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"max-len": [
"warn",
{
"code":100
}
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -10,17 +10,20 @@
},
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,81 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"max-len": [
"warn",
{
"code":100
}
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

View File

@ -1,64 +1,66 @@
# Welcome to the SPT-AKI Modding Project
This project was created to automate most parts of building and setting up an environment.
This project is designed to streamline the initial setup process for building and creating mods in the SPT-AKI environment. Follow this guide to set up your environment efficiently.
## **NodeJS:**
## **Table of Contents**
- [NodeJS Setup](#nodejs-setup)
- [IDE Setup](#ide-setup)
- [Workspace Configuration](#workspace-configuration)
- [Environment Setup](#environment-setup)
- [Essential Concepts](#essential-concepts)
- [Coding Guidelines](#coding-guidelines)
- [Distribution Guidelines](#distribution-guidelines)
The first step would be to install nodejs on your pc, the version you NEED is **16.17.1**
## **NodeJS Setup**
That version is the one that has been used to test the mod templates and build scripts.
Before you begin, ensure to install NodeJS version `v16.17.1`, which has been tested thoroughly with our mod templates and build scripts. Download it from the [official NodeJS website](https://nodejs.org/).
It can be downloaded from here: https://nodejs.org/dist/v16.17.1/node-v16.17.1-x64.msi
After installation, it's advised to reboot your system.
A system reboot may be needed after install.
## **IDE Setup**
## **IDE:**
For this project, you can work with either [VSCodium](https://vscodium.com/) or [VSCode](https://code.visualstudio.com/). However, we strongly recommend using VSCode, as all development and testing have been carried out using this IDE, ensuring a smoother experience and compatibility with the project setups. Either way, we have a prepared a workspace file to assist you in setting up your environment.
The second step is having an IDE ready. We've setup a VSCodium workspace file to help with this.
## **Workspace Configuration**
You CAN use Visual Studio Code if you so desire, just keep in mind that our devs tests on the mod files was done using VSCode.
With NodeJS and your chosen IDE ready, initiate the `mod.code-workspace` file using your IDE:
You can get VSCode here: https://code.visualstudio.com/
> File -> Open Workspace from File...
## **Workspace:**
Upon project loading, consider installing recommended plugins like the ESLint plugin.
Once you have NodeJS and VSCode/VSCodium ready, open the mod.code-workspace file with VSCode (File->Open Workspace from File...).
## **Environment Setup**
Once the project loads you may be recommended to install the ESLint plugin. This is HIGHLY recommended.
An automated task is available to configure your environment for Typescript utilization:
## **Environment Setup:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: install
There is a task that will automatically setup your environment to use typescript.
Note: Preserve the `node_modules` folder as it contains necessary dependencies for Typescript and other functionalities.
To run it, you just need to go to:
## **Essential Concepts**
> Terminal->Run Task...->Show All Tasks...->npm: install
Prioritize understanding Dependency Injection and Inversion of Control, the architectural principles SPT-AKI adopts. Comprehensive guidelines will be available on the hub upon release.
After running this task, your environment will be ready to start coding.
Some resources to get you started:
- [A quick intro to Dependency Injection](https://www.freecodecamp.org/news/a-quick-intro-to-dependency-injection-what-it-is-and-when-to-use-it-7578c84fa88f/)
- [Understanding Inversion of Control (IoC) Principle](https://medium.com/@amitkma/understanding-inversion-of-control-ioc-principle-163b1dc97454)
DO NOT remove the node_modules folder, this is an auto generated directory that has the required dependencies to be able to use typescript and more.
## **Coding Guidelines**
## **IMPORTANT:**
Focus your mod development around the `mod.ts` file. In the `package.json` file, only alter these properties: `"name"`, `"version"`, `"license"`, `"author"`, and `"akiVersion"`.
Before starting to work on your mod, we suggest you read about Dependency Injection and Inversion of Control as this is the adopted architecture SPT-AKI has adopted.
New to Typescript? Find comprehensive documentation on the [official website](https://www.typescriptlang.org/docs/).
It will be difficult to understand some of the problems you may be having if you dont understand the basics of it.
## **Distribution Guidelines**
A guide explaining all the essentials will be available on the hub on release for you to read about.
Automated tasks are set up to bundle all necessary files for your mod to function in SPT-AKI:
## **Coding:**
> Terminal -> Run Task... -> Show All Tasks... -> npm: build
All your work should be centered around the mod.ts file as an entry point.
You can ONLY change the following properties from the package.json file: `"name"`, `"version"`, `"license"`: `"MIT"`, `"author"`, `"akiVersion"`.
The ZIP output, located in the `dist` directory, contains all required files. Ensure all files are included and modify the `.buildignore` file as needed. This ZIP file is your uploadable asset for the hub.
If you have never used typescript before, you can read about it here: https://www.typescriptlang.org/docs/
## **Conclusion**
## **Distributing your mod:**
With this setup, you're ready to begin modding with SPT-AKI. If you run into any trouble be sure to check out the [modding documentation on the hub](https://hub.sp-tarkov.com/doc/lexicon/66-modding/). If you really get stuck feel free to join us in the [#mods-development](https://discord.com/channels/875684761291599922/875803116409323562) official Discord channel.
The project has been set up with an automatic task that will copy and zip ALL required files for your mod to work on SPT-AKI.
To run this task you just need to go to:
> Terminal->Run Task...->Show All Tasks...->npm: build:zip
The output will be a mod.zip file that will appear on the root of the project.
Always verify that all files were included into the zip file.
Build something awesome!

View File

@ -0,0 +1,383 @@
#!/usr/bin/env node
/**
* Build Script
*
* This script automates the build process for server-side SPT mod projects, facilitating the creation of distributable
* mod packages. It performs a series of operations as outlined below:
* - Loads the .buildignore file, which is used to list files that should be ignored during the build process.
* - Loads the package.json to get project details so a descriptive name can be created for the mod package.
* - Creates a distribution directory and a temporary working directory.
* - Copies files to the temporary directory while respecting the .buildignore rules.
* - Creates a zip archive of the project files.
* - Moves the zip file to the root of the distribution directory.
* - Cleans up the temporary directory.
*
* It's typical that this script be customized to suit the needs of each project. For example, the script can be updated
* to perform additional operations, such as moving the mod package to a specific location or uploading it to a server.
* This script is intended to be a starting point for developers to build upon.
*
* Usage:
* - Run this script using npm: `npm run build`
* - Use `npm run buildinfo` for detailed logging.
*
* Note:
* - Ensure that all necessary Node.js modules are installed before running the script: `npm install`
* - The script reads configurations from the `package.json` and `.buildignore` files; ensure they are correctly set up.
*
* @author Refringe
* @version v1.0.0
*/
import fs from "fs-extra";
import os from "os";
import path from "path";
import { fileURLToPath } from "url";
import { dirname } from "path";
import ignore from "ignore";
import archiver from "archiver";
import winston from "winston";
// Get the command line arguments to determine whether to use verbose logging.
const args = process.argv.slice(2);
const verbose = args.includes("--verbose") || args.includes("-v");
// Configure the Winston logger to use colours.
const logColors = {
error: "red",
warn: "yellow",
info: "grey",
success: "green",
};
winston.addColors(logColors);
// Create a logger instance to log build progress. Configure the logger levels to allow for different levels of logging
// based on the verbosity flag, and set the console transport to log messages of the appropriate level.
const logger = winston.createLogger({
levels: {
error: 0,
warn: 1,
success: 2,
info: 3,
},
format: winston.format.combine(
winston.format.colorize(),
winston.format.printf(info => {
return `${info.level}: ${info.message}`;
})
),
transports: [
new winston.transports.Console({
level: verbose ? "info" : "success",
}),
],
});
/**
* The main function orchestrates the build process for creating a distributable mod package. It leverages a series of
* helper functions to perform various tasks such as loading configuration files, setting up directories, copying files
* according to `.buildignore` rules, and creating a ZIP archive of the project files.
*
* Utilizes the Winston logger to provide information on the build status at different stages of the process.
*
* @returns {void}
*/
async function main() {
// Get the current directory where the script is being executed
const currentDir = getCurrentDirectory();
// Defining at this scope because we need to use it in the finally block.
let projectDir;
try {
// Load the .buildignore file to set up an ignore handler for the build process.
const buildIgnorePatterns = await loadBuildIgnoreFile(currentDir);
// Load the package.json file to get project details.
const packageJson = await loadPackageJson(currentDir);
// Create a descriptive name for the mod package.
const projectName = createProjectName(packageJson);
logger.log("success", `Project name created: ${projectName}`);
// Remove the old distribution directory and create a fresh one.
const distDir = await removeOldDistDirectory(currentDir);
logger.log("info", "Distribution directory successfully cleaned.");
// Create a temporary working directory to perform the build operations.
projectDir = await createTemporaryDirectoryWithProjectName(projectName);
logger.log("success", "Temporary working directory successfully created.");
logger.log("info", projectDir);
// Copy files to the temporary directory while respecting the .buildignore rules.
logger.log("info", "Beginning copy operation using .buildignore file...");
await copyFiles(currentDir, projectDir, buildIgnorePatterns);
logger.log("success", "Files successfully copied to temporary directory.");
// Create a zip archive of the project files.
logger.log("info", "Beginning folder compression...");
const zipFilePath = path.join(path.dirname(projectDir), `${projectName}.zip`);
await createZipFile(projectDir, zipFilePath, "user/mods/" + projectName);
logger.log("success", "Archive successfully created.");
logger.log("info", zipFilePath);
// Move the zip file inside of the project directory, within the temporary working directory.
const zipFileInProjectDir = path.join(projectDir, `${projectName}.zip`);
await fs.move(zipFilePath, zipFileInProjectDir);
logger.log("success", "Archive successfully moved.");
logger.log("info", zipFileInProjectDir);
// Move the temporary directory into the distribution directory.
await fs.move(projectDir, distDir);
logger.log("success", "Temporary directory successfully moved into project distribution directory.");
// Log the success message. Write out the path to the mod package.
logger.log("success", "------------------------------------");
logger.log("success", "Build script completed successfully!");
logger.log("success", "Your mod package has been created in the 'dist' directory:");
logger.log("success", `/${path.relative(process.cwd(), path.join(distDir, `${projectName}.zip`))}`);
logger.log("success", "------------------------------------");
if (!verbose) {
logger.log("success", "To see a detailed build log, use `npm run buildinfo`.");
logger.log("success", "------------------------------------");
}
} catch (err) {
// If any of the file operations fail, log the error.
logger.log("error", "An error occurred: " + err);
} finally {
// Clean up the temporary directory, even if the build fails.
if (projectDir) {
try {
await fs.promises.rm(projectDir, { force: true, recursive: true });
logger.log("info", "Cleaned temporary directory.");
} catch (err) {
logger.log("error", "Failed to clean temporary directory: " + err);
}
}
}
}
/**
* Retrieves the current working directory where the script is being executed. This directory is used as a reference
* point for various file operations throughout the build process, ensuring that paths are resolved correctly regardless
* of the location from which the script is invoked.
*
* @returns {string} The absolute path of the current working directory.
*/
function getCurrentDirectory() {
return dirname(fileURLToPath(import.meta.url));
}
/**
* Loads the `.buildignore` file and sets up an ignore handler using the `ignore` module. The `.buildignore` file
* contains a list of patterns describing files and directories that should be ignored during the build process. The
* ignore handler created by this method is used to filter files and directories when copying them to the temporary
* directory, ensuring that only necessary files are included in the final mod package.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<ignore>} A promise that resolves to an ignore handler.
*/
async function loadBuildIgnoreFile(currentDir) {
const buildIgnorePath = path.join(currentDir, ".buildignore");
try {
// Attempt to read the contents of the .buildignore file asynchronously.
const fileContent = await fs.promises.readFile(buildIgnorePath, "utf-8");
// Return a new ignore instance and add the rules from the .buildignore file (split by newlines).
return ignore().add(fileContent.split("\n"));
} catch (err) {
logger.log("warn", "Failed to read .buildignore file. No files or directories will be ignored.");
// Return an empty ignore instance, ensuring the build process can continue.
return ignore();
}
}
/**
* Loads the `package.json` file and returns its content as a JSON object. The `package.json` file contains important
* project details such as the name and version, which are used in later stages of the build process to create a
* descriptive name for the mod package. The method reads the file from the current working directory, ensuring that it
* accurately reflects the current state of the project.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<Object>} A promise that resolves to a JSON object containing the contents of the `package.json`.
*/
async function loadPackageJson(currentDir) {
const packageJsonPath = path.join(currentDir, "package.json");
// Read the contents of the package.json file asynchronously as a UTF-8 string.
const packageJsonContent = await fs.promises.readFile(packageJsonPath, "utf-8");
return JSON.parse(packageJsonContent);
}
/**
* Constructs a descriptive name for the mod package using details from the `package.json` file. The name is created by
* concatenating the project name, version, and a timestamp, resulting in a unique and descriptive file name for each
* build. This name is used as the base name for the temporary working directory and the final ZIP archive, helping to
* identify different versions of the mod package easily.
*
* @param {Object} packageJson - A JSON object containing the contents of the `package.json` file.
* @returns {string} A string representing the constructed project name.
*/
function createProjectName(packageJson) {
// Remove any non-alphanumeric characters from the author and name.
const author = packageJson.author.replace(/\W/g, "");
const name = packageJson.name.replace(/\W/g, "");
const version = packageJson.version;
// Ensure the name is lowercase, as per the package.json specification.
return `${author}-${name}-${version}`.toLowerCase();
}
/**
* Defines the location of the distribution directory where the final mod package will be stored and deletes any
* existing distribution directory to ensure a clean slate for the build process.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @returns {Promise<string>} A promise that resolves to the absolute path to the distribution directory.
*/
async function removeOldDistDirectory(projectDir) {
const distPath = path.join(projectDir, "dist");
await fs.remove(distPath);
return distPath;
}
/**
* Creates a temporary working directory using the project name. This directory serves as a staging area where project
* files are gathered before being archived into the final mod package. The method constructs a unique directory path
* by appending the project name to a base temporary directory path, ensuring that each build has its own isolated
* working space. This approach facilitates clean and organized build processes, avoiding potential conflicts with other
* builds.
*
* @param {string} currentDirectory - The absolute path of the current working directory.
* @param {string} projectName - The constructed project name, used to create a unique path for the temporary directory.
* @returns {Promise<string>} A promise that resolves to the absolute path of the newly created temporary directory.
*/
async function createTemporaryDirectoryWithProjectName(projectName) {
// Create a new directory in the system's temporary folder to hold the project files.
const tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "spt-mod-build-"));
// Create a subdirectory within the temporary directory using the project name for this specific build.
const projectDir = path.join(tempDir, projectName);
await fs.ensureDir(projectDir);
return projectDir;
}
/**
* Copies the project files to the temporary directory while respecting the rules defined in the `.buildignore` file.
* The method is recursive, iterating over all files and directories in the source directory and using the ignore
* handler to filter out files and directories that match the patterns defined in the `.buildignore` file. This ensures
* that only the necessary files are included in the final mod package, adhering to the specifications defined by the
* developer in the `.buildignore` file.
*
* The copy operations are delayed and executed in parallel to improve efficiency and reduce the build time. This is
* achieved by creating an array of copy promises and awaiting them all at the end of the function.
*
* @param {string} sourceDirectory - The absolute path of the current working directory.
* @param {string} destinationDirectory - The absolute path of the temporary directory where the files will be copied.
* @param {Ignore} ignoreHandler - The ignore handler created from the `.buildignore` file.
* @returns {Promise<void>} A promise that resolves when all copy operations are completed successfully.
*/
async function copyFiles(srcDir, destDir, ignoreHandler) {
try {
// Read the contents of the source directory to get a list of entries (files and directories).
const entries = await fs.promises.readdir(srcDir, { withFileTypes: true });
// Initialize an array to hold the promises returned by recursive calls to copyFiles and copyFile operations.
const copyOperations = [];
for (const entry of entries) {
// Define the source and destination paths for each entry.
const srcPath = path.join(srcDir, entry.name);
const destPath = path.join(destDir, entry.name);
// Get the relative path of the source file to check against the ignore handler.
const relativePath = path.relative(process.cwd(), srcPath);
// If the ignore handler dictates that this file should be ignored, skip to the next iteration.
if (ignoreHandler.ignores(relativePath)) {
logger.log("info", `Ignored: /${path.relative(process.cwd(), srcPath)}`);
continue;
}
if (entry.isDirectory()) {
// If the entry is a directory, create the corresponding temporary directory and make a recursive call
// to copyFiles to handle copying the contents of the directory.
await fs.ensureDir(destPath);
copyOperations.push(copyFiles(srcPath, destPath, ignoreHandler));
} else {
// If the entry is a file, add a copyFile operation to the copyOperations array and log the event when
// the operation is successful.
copyOperations.push(
fs.copy(srcPath, destPath).then(() => {
logger.log("info", `Copied: /${path.relative(process.cwd(), srcPath)}`);
})
);
}
}
// Await all copy operations to ensure all files and directories are copied before exiting the function.
await Promise.all(copyOperations);
} catch (err) {
// Log an error message if any error occurs during the copy process.
logger.log("error", "Error copying files: " + err);
}
}
/**
* Creates a ZIP archive of the project files located in the temporary directory. The method uses the `archiver` module
* to create a ZIP file, which includes all the files that have been copied to the temporary directory during the build
* process. The ZIP file is named using the project name, helping to identify the contents of the archive easily.
*
* @param {string} directoryPath - The absolute path of the temporary directory containing the project files.
* @param {string} projectName - The constructed project name, used to name the ZIP file.
* @returns {Promise<string>} A promise that resolves to the absolute path of the created ZIP file.
*/
async function createZipFile(directoryToZip, zipFilePath, containerDirName) {
return new Promise((resolve, reject) => {
// Create a write stream to the specified ZIP file path.
const output = fs.createWriteStream(zipFilePath);
// Create a new archiver instance with ZIP format and maximum compression level.
const archive = archiver("zip", {
zlib: { level: 9 },
});
// Set up an event listener for the 'close' event to resolve the promise when the archiver has finalized.
output.on("close", function () {
logger.log("info", "Archiver has finalized. The output and the file descriptor have closed.");
resolve();
});
// Set up an event listener for the 'warning' event to handle warnings appropriately, logging them or rejecting
// the promise based on the error code.
archive.on("warning", function (err) {
if (err.code === "ENOENT") {
logger.log("warn", `Archiver issued a warning: ${err.code} - ${err.message}`);
} else {
reject(err);
}
});
// Set up an event listener for the 'error' event to reject the promise if any error occurs during archiving.
archive.on("error", function (err) {
reject(err);
});
// Pipe archive data to the file.
archive.pipe(output);
// Add the directory to the archive, under the provided directory name.
archive.directory(directoryToZip, containerDirName);
// Finalize the archive, indicating that no more files will be added and triggering the 'close' event once all
// data has been written.
archive.finalize();
});
}
// Engage!
main();

View File

@ -7,17 +7,20 @@
"akiVersion": "~3.7",
"scripts": {
"setup": "npm i",
"build": "node ./packageBuild.ts"
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.4.5",
"@typescript-eslint/eslint-plugin": "6.2.0",
"@typescript-eslint/parser": "6.2.0",
"bestzip": "2.2.1",
"archiver": "^6.0",
"eslint": "8.46.0",
"fs-extra": "11.1.1",
"glob": "10.3.3",
"fs-extra": "^11.1",
"ignore": "^5.2",
"os": "^0.1",
"tsyringe": "4.8.0",
"typescript": "5.1.6"
"typescript": "5.1.6",
"winston": "^3.9"
}
}
}

View File

@ -1,75 +0,0 @@
#!/usr/bin/env node
// This is a simple script used to build a mod package. The script will copy necessary files to the build directory
// and compress the build directory into a zip file that can be easily shared.
const fs = require("fs-extra");
const glob = require("glob");
const zip = require('bestzip');
const path = require("path");
const minimatch = require('minimatch');
// Load the package.json file to get some information about the package so we can name things appropriately. This is
// atypical, and you would never do this in a production environment, but this script is only used for development so
// it's fine in this case. Some of these values are stored in environment variables, but those differ between node
// versions; the 'author' value is not available after node v14.
const { author, name:packageName, version } = require("./package.json");
// Generate the name of the package, stripping out all non-alphanumeric characters in the 'author' and 'name'.
const modName = `${author.replace(/[^a-z0-9]/gi, "")}-${packageName.replace(/[^a-z0-9]/gi, "")}-${version}`;
console.log(`Generated package name: ${modName}`);
// Delete the old build directory and compressed package file.
fs.rmSync(`${__dirname}/dist`, { force: true, recursive: true });
console.log("Previous build files deleted.");
// Generate a list of files that should not be copied over into the distribution directory. This is a blacklist to ensure
// we always copy over additional files and directories that authors may have added to their project. This may need to be
// expanded upon by the mod author to allow for node modules that are used within the mod; example commented out below.
const ignoreList = [
"node_modules/",
// "node_modules/!(weighted|glob)", // Instead of excluding the entire node_modules directory, allow two node modules.
"src/**/*.js",
"types/",
".git/",
".gitea/",
".eslintignore",
".eslintrc.json",
".gitignore",
".DS_Store",
"packageBuild.ts",
"mod.code-workspace",
"package-lock.json",
"tsconfig.json"
];
const exclude = glob.sync(`{${ignoreList.join(",")}}`, { realpath: true, dot: true });
fs.copySync(__dirname, path.normalize(`${__dirname}/../~${modName}`), {filter: (src) =>
{
const relativePath = path.relative(__dirname, src);
const shouldExclude = exclude.some((pattern) => minimatch(relativePath, pattern));
console.log(`${relativePath} - Excluded: ${shouldExclude}`);
return !shouldExclude;
},});
fs.moveSync(path.normalize(`${__dirname}/../~${modName}`), path.normalize(`${__dirname}/${modName}`), { overwrite: true });
fs.copySync(path.normalize(`${__dirname}/${modName}`), path.normalize(`${__dirname}/dist`));
console.log("Build files copied.");
// Compress the files for easy distribution. The compressed file is saved into the dist directory. When uncompressed we
// need to be sure that it includes a directory that the user can easily copy into their game mods directory.
zip({
source: modName,
destination: `dist/${modName}.zip`,
cwd: __dirname
}).catch(function(err)
{
console.error("A bestzip error has occurred: ", err.stack);
}).then(function()
{
console.log(`Compressed mod package to: /dist/${modName}.zip`);
// Now that we're done with the compression we can delete the temporary build directory.
fs.rmSync(`${__dirname}/${modName}`, { force: true, recursive: true });
console.log("Build successful! your zip file has been created and is ready to be uploaded to hub.sp-tarkov.com/files/");
});

View File

@ -0,0 +1,20 @@
/.buildignore
/.DS_Store
/.editorconfig
/.eslintignore
/.eslintrc.json
/.git
/.github
/.gitignore
/.gitlab
/.nvmrc
/.prettierrc
/.vscode
/build.mjs
/dist
/images
/mod.code-workspace
/node_modules
/package-lock.json
/tsconfig.json
/types

View File

@ -1,75 +1,98 @@
{
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
"root": true,
"parser": "@typescript-eslint/parser",
"plugins": [
"@typescript-eslint"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": ["PascalCase"]
},
{
"selector": "objectLiteralProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": ["PascalCase", "camelCase"],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": ["UPPER_CASE"]
}
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/eslint-recommended",
"plugin:@typescript-eslint/recommended"
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
"rules": {
"@typescript-eslint/no-explicit-any": 0,
"@typescript-eslint/no-unused-vars": 1,
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/no-namespace": 0,
"@typescript-eslint/comma-dangle": 1,
"@typescript-eslint/func-call-spacing": 2,
"@typescript-eslint/quotes": 1,
"@typescript-eslint/brace-style": [
"warn",
"allman"
],
"@typescript-eslint/naming-convention": [
"warn",
{
"selector": "default",
"format": [
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeLike",
"format": [
"PascalCase"
]
},
{
"selector": "objectLiteralProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "typeProperty",
"format": [
"PascalCase",
"camelCase"
],
"leadingUnderscore": "allow"
},
{
"selector": "enumMember",
"format": [
"UPPER_CASE"
]
}
],
"@typescript-eslint/indent": [
"warn",
4
],
"@typescript-eslint/no-unused-expressions": [
"warn",
{
"allowShortCircuit": false,
"allowTernary": false
}
],
"@typescript-eslint/keyword-spacing": [
"warn",
{
"before": true,
"after": true
}
],
"@typescript-eslint/explicit-module-boundary-types": [
"warn",
{
"allowArgumentsExplicitlyTypedAsAny": true
}
]
},
"overrides": [
{
"files": [
"*.mjs",
"*.ts"
],
"env": {
"node": true
}
}
]
}
}

Some files were not shown because too many files have changed in this diff Show More