adaptorex
Version:
Connect all your live interactive storytelling devices and software
1,441 lines (1,339 loc) • 39.5 kB
JavaScript
/**
* creates framework for starting, creating, loading, editing sessions.
*
* Uses minimist to parse optional arguments
*
* Express API server with json parse.
*
* @requires mongo
* @requires ne
* @requires game
* @requires file
* @requires plugin_management
*
* @requires express
* @requires http
* @requires socket.io
* @requires path
* @requires readline
* @requires os
*
* @requires minimist
*
* @module index
* @copyright Lasse Marburg 2021
* @license MIT
*/
require("./adaptor.js")
const mongo = require("./mongo.js")
const ne = require("./ne.js")
const game = require("./game.js")
const file = require("./file.js")
const auth = require("./authentication.js")
const plugin_management = require("./plugin_management.js")
const readline = require("readline")
const os = require("os")
const { MongoError } = require("mongodb")
var argv = require("minimist")(process.argv.slice(2))
const semver = require("semver")
const errors = require("./errors")
const opener = require("opener")
const cors = require("cors")
const path = require("path")
const express = require("express")
var app = express()
var server = require("http").createServer(app)
io = require("socket.io")(server, {
cors: {
origin: isWhitelisted,
methods: ["GET", "POST"],
credentials: true
}
})
// socket.io will not handle BigInt (used by telegram plugin for IDs) well, since serialization fails without the lines below (see also: https://github.com/GoogleChromeLabs/jsbi/issues/30)
BigInt.prototype["toJSON"] = function () {
return this.toString()
}
/** @type {express.Router} */
var api = express.Router()
Object.assign(adaptor, errors)
/**
* global logging Object with coloring and log file. Use log instead of console.log whenever possible.
*/
global.log = new adaptor.Log()
/** list of game Objects */
var games = {}
/**
* @type {plugin_management.PluginManager}
* manages plugin install and provision
* */
var plugin_manager
/** Database module depends on selection in config */
var database
/** openAPI document object */
let openapi
/** asyncAPI document object */
let asyncapi
/**
* @typedef {Object} Config
* @property {string} host - The host URL for the server
* @property {number} port - The port number for the server
* @property {string} data - Path to the data directory
* @property {Object} database - Database configuration
* @property {string} database.type - Type of database (e.g., "nedb", "mongodb")
* @property {string} [database.url] - URL for MongoDB connection
* @property {string} level - Logging level
* @property {string} authentication - Authentication method ("disabled", "basic", etc.)
* @property {boolean} headless - Whether to run in headless mode
* @property {boolean} [open] - Whether to automatically open the editor in browser
* @property {string} [url] - Public URL for the server
* @property {Array<string>} [whitelist] - List of allowed origins for CORS
* @property {Object} [login] - Login credentials configuration
* @property {string} [login.user] - Username for webview login
* @property {string} [login.password] - Password for webview login
* @property {Array<string>} [plugins] - List of plugins to load
* @property {string} [minNpmPluginVersion] - Minimum required version for npm plugins
* @property {Object} [games] - Configuration for loaded games
*/
/**
* initiate adaptor based on config
*
* - create login security middleware.
* - create var data folder for game data
* - listen for file changes in plugin development folder
* - provide static route for example website
* - connect to Database
* - instantiate games that are listed in config
*
* @param {Config} config - Contains parameters that customize the setup and load existing games
*/
function init(config) {
let log_socket = io.of(/\/log\/\w+$/)
log.createSocket(log_socket)
Object.assign(adaptor, config)
if (config.authentication == "basic") {
adaptor.auth = new auth.BasicAuth(config.users)
} else if (config.authentication == "disabled") {
adaptor.auth = new auth.NoAuth({
login: "Anonymosaurus",
password: "",
role: "admin"
})
} else {
log.warn(0, "No authentication method specified")
adaptor.auth = new auth.NoAuth({
login: "Anonymosaurus",
password: "",
role: "admin"
})
}
log_socket.use(adaptor.auth.authenticateSocket.bind(adaptor.auth))
plugin_manager = new plugin_management.PluginManager(config)
file.watchDir(plugin_manager.development_dir, (e, changed_file) => {
if (e !== "change") return
const parts = changed_file.split(path.sep)
const dev_index = parts.indexOf("development")
const plugin_dir = parts[dev_index + 1]
const plugin_name = plugin_manager.findPluginNameByDirectory(plugin_dir)
log.debug(0, `Change in plugins development folder: ${e} ${changed_file}.`)
reloadPlugin(plugin_name).catch((error) => {
log.error(0, error)
if (error.cause) log.error(0, error.cause)
})
})
adaptor.loadPlugin = plugin_manager.loadPlugin.bind(plugin_manager)
adaptor.getAvailablePlugins =
plugin_manager.getAvailablePlugins.bind(plugin_manager)
adaptor.now = function () {
return new Date(Date.now())
}
adaptor.userInfo = () => {
return os.userInfo()
}
if (!adaptor.python) {
if (process.platform == "win32") {
adaptor.python = "python"
} else {
adaptor.python = "python3"
}
}
adaptor.io = io
file.mkdir(path.join(adaptor.data, "games"))
app.use(cors({ credentials: true, origin: isWhitelisted }))
app.use(express.json())
server.listen(config.port)
server.on("error", (e) => {
if (e.code === "EADDRINUSE") {
log.error(
"http server",
"Port " +
config.port +
" is already in use. Close any other adaptor instances or other server that uses the same port and restart."
)
process.exit(0)
}
})
if (!file.exists(path.join(__dirname, "/public/index.html"))) {
config.headless = true
}
adaptor.webhooks.push({
name: "local",
title: "Local",
url: config.host + ":" + config.port
})
let ips = adaptor.getIPs()
for (let ip in ips) {
let ip_name = ip
.replace(/[^a-zA-Z0-9 ]/g, "")
.replace(" ", "_")
.toLowerCase()
adaptor.webhooks.push({
name: ip_name,
title: ip,
url: `http://${ips[ip]}:${config.port}`
})
}
if (config.url) {
adaptor.webhooks.push({ name: "remote", title: "Remote", url: config.url })
}
server.on("listening", (res) => {
log.info(0, "Server is listening")
})
let homesocket = io.of("/")
homesocket.on("connection", client)
// Init Database
if (config.hasOwnProperty("database")) {
if (config.database["type"] == "mongodb") {
database = mongo
} else if (config.database["type"] == "nedb") {
database = ne
} else {
log.error(
0,
"Not a valid database type: " +
config.database["type"] +
"\n. Options: mongodb, nedb "
)
process.exit(0)
}
} else {
database = ne
}
database
.start(config.database)
.then((result) => {
return setLatestVersion()
})
.then((result) => {
return adaptor.getAvailablePlugins()
})
.then((result) => {
log.debug(0, `Found ${result.length} available plugins`)
return adaptor.dereference(
path.join(__dirname, "/doc/api/public/openapi.yaml")
)
})
.then((result) => {
openapi = result
adaptor.openapi = openapi
return adaptor.dereference(
path.join(__dirname, "/doc/api/public/asyncapi.yaml")
)
})
.then((result) => {
asyncapi = result
adaptor.asyncapi = asyncapi
let load_game_promises = []
if (config.hasOwnProperty("games")) {
for (let game in config.games) {
load_game_promises.push(loadGame(game, config.games[game]))
}
return Promise.all(load_game_promises)
}
return
})
.then((result) => {
if (semver.gt(adaptor.info.latestVersion, adaptor.info.version)) {
adaptor.info.updateAvailable = true
log.info(
0,
`adaptor:ex server up and running. Startup time was ${process.uptime()} sec.`
)
log.info(
0,
`✨ Update available. ${adaptor.info.version} → ${adaptor.info.latestVersion} (https://docs.adaptorex.org/install)`
)
} else {
log.info(
0,
`adaptor:ex server version ${adaptor.info.version} up and running. Startup time was ${process.uptime()} sec.`
)
}
let issues = []
let errors = []
if (Array.isArray(result)) {
result.forEach((res) => {
if (res.issues.length) {
issues = issues.concat(res.issues)
}
if (res.errors.length) {
errors = errors.concat(res.errors)
}
})
}
if (issues.length) {
log.warn(
0,
`Experienced ${issues.length} issue(s) on startup. See previous error messages.`
)
}
if (errors.length) {
log.error(
0,
`Experienced ${errors.length} unexpected error(s) on startup. See previous error messages.`
)
}
if (config.headless) {
log.info(0, "Access adaptor:ex API at one of the following urls")
logWebhooks("/api")
} else {
log.info(
0,
"Open adaptor:ex editor with one of the following links or type: open"
)
logWebhooks()
}
if (config.open) {
openEditor()
}
rl.prompt()
})
.catch((error) => {
log.error(0, error)
if (error.cause) {
error.message = error.message + "\n" + error.cause.message
log.error(0, error.cause)
}
process.exit(0)
})
// add API routing
api.use(adaptor.auth.authenticate.bind(adaptor.auth))
api.use((req, res, next) => {
log.trace(
"API",
`${req.method} request to ${req.originalUrl} from ${req.user.login}`
)
next()
})
api.get("/info", (req, res) => {
res.send(adaptor.info)
})
api.get("/config", (req, res) => {
res.send(config)
})
api.get("/me", (req, res) => {
try {
let user = getMe(req, config)
res.send(user)
} catch (error) {
errorResponseHandling(error, res)
}
})
api.get("/plugins", (req, res) => {
plugin_manager
.getAvailablePlugins()
.then((result) => {
res.send(result)
})
.catch((err) => {
errorResponseHandling(err, res)
})
})
api.get("/games", (req, res) => {
getGames()
.then((result) => {
res.send(result)
})
.catch((err) => {
log.error("API", err)
res
.status(500)
.send({ name: err.name, message: err.message, stack: err.stack })
})
})
api.post("/game", (req, res) => {
if (typeof req.body.name === "string") {
if (req.body.template) {
var data = { template: req.body.template }
} else {
var data = undefined
}
loadGame(req.body.name, data)
.then(function (result) {
res.status(201).send(result)
})
.catch(function (err) {
log.error("API", err)
if (err.cause) {
log.error("API", err.cause)
err.message = err.message + "\n" + err.cause.message
}
res
.status(500)
.send({ name: err.name, message: err.message, stack: err.stack })
})
} else {
log.error("API", "couldn't load game. Name property missing.")
res
.status(400)
.send({ name: "InvalidError", message: "Name property missing." })
}
})
api.post("/game/:game/setup", (req, res) => {
if (games[req.params.game]) {
games[req.params.game]
.editSetup(req.body)
.then(function (result) {
res.status(200).send(result)
})
.catch(function (err) {
log.error("API", err)
res
.status(500)
.send({ name: err.name, message: err.message, stack: err.stack })
})
} else {
res.status(404).send({
name: "NotFoundError",
message: `game ${req.params.game} was not found`
})
}
})
api.post("/game/:game/_event", (req, res) => {
if (games[req.params.game]) {
if (!req.body.name) {
log.error("API", "couldn't dispatch game event. Name property missing.")
res
.status(400)
.send({ name: "InvalidError", message: "name property missing." })
return
}
try {
games[req.params.game].event.emit(req.body.name, req.body.payload)
log.debug(
req.params.game,
`dispatch game event '${req.body.name}' via API`
)
res.status(200).send({})
} catch (error) {
log.error("API", err)
res
.status(500)
.send({ name: err.name, message: err.message, stack: err.stack })
}
} else {
res.status(404).send({
name: "NotFoundError",
message: `game ${req.params.game} was not found`
})
}
})
api.get("/game/:game", (req, res) => {
if (games[req.params.game]) {
games[req.params.game]
.getElements()
.then((result) => {
res.send(result)
})
.catch((err) => {
log.error("API", err)
res
.status(500)
.send({ name: err.name, message: err.message, stack: err.stack })
})
} else {
res.status(404).send({
name: "NotFoundError",
message: `game ${req.params.game} was not found`
})
}
})
api.get("/game/:game/collections", (req, res) => {
if (games[req.params.game]) {
games[req.params.game]
.getDataCollectionSchemas()
.then((result) => {
res.send(result)
})
.catch((err) => {
log.error("API", err)
res
.status(500)
.send({ name: err.name, message: err.message, stack: err.stack })
})
} else {
res.status(404).send({
name: "NotFoundError",
message: `game ${req.params.game} was not found`
})
}
})
api.get("/game/:game/media", (req, res) => {
if (games[req.params.game]) {
Promise.resolve(games[req.params.game].getFiles())
.then((result) => {
res.send(result)
})
.catch((err) => {
log.error("API", err)
res
.status(500)
.send({ name: err.name, message: err.message, stack: err.stack })
})
} else {
res.status(404).send({
name: "NotFoundError",
message: `game ${req.params.game} was not found`
})
}
})
api.delete("/game/:game", (req, res) => {
if (typeof req.params.game === "string") {
deleteGame(req.params.game)
.then((result) => {
if (result) {
res.status(204).send()
} else {
res.status(404).send({
name: "NotFoundError",
message: `game ${req.params.game} was not found`
})
}
})
.catch((err) => {
log.error("API", err)
res
.status(500)
.send({ name: err.name, message: err.message, stack: err.stack })
})
} else {
log.error("API", "couldn't delete game. Name property missing.")
res
.status(400)
.send({ name: "ValidationError", message: "Name property missing." })
}
})
api.get("/game/:game/actions", (req, res) => {
if (games[req.params.game]) {
games[req.params.game]
.getActions(req.query)
.then((result) => {
res.send(result)
})
.catch((err) => {
log.error("API", err)
res
.status(500)
.send({ name: err.name, message: err.message, stack: err.stack })
})
} else {
res.status(404).send({
name: "NotFoundError",
message: `game ${req.params.game} was not found`
})
}
})
api.get("/game/:game/level_schema", (req, res) => {
if (games[req.params.game]) {
games[req.params.game]
.getLevelSchema()
.then((result) => {
res.send(result)
})
.catch((err) => {
log.error("API", err)
res
.status(500)
.send({ name: err.name, message: err.message, stack: err.stack })
})
} else {
res.status(404).send({
name: "NotFoundError",
message: `game ${req.params.game} was not found`
})
}
})
app.use("/api", api)
api.use((err, req, res, next) => {
if (err instanceof SyntaxError) {
log.error("API", `${req.method} request to ${req.originalUrl} failed`)
log.error("API", err.message)
return res.status(400).send({ name: err.name, message: err.message })
}
log.error("API", err.message)
next(err)
})
if (!config.headless) {
var client_app = express.Router()
client_app.use("/", express.static(path.join(__dirname, "/public")))
// Host static webpage for local client build
client_app.get("/", function (req, res) {
res.sendFile(path.join(__dirname, "public", "index.html"))
})
client_app.get("/game/*", function (req, res) {
res.sendFile(path.join(__dirname, "public", "index.html"))
})
app.use("/", client_app)
}
}
/**
* Fetches the latest version of the adaptorex package from the npm registry
* and updates the adaptor.info.latestVersion property.
*
* @async
* @function setLatestVersion
* @returns {Promise<void>} A promise that resolves when the version check is complete
*/
async function setLatestVersion() {
try {
const result = await fetch(`https://registry.npmjs.org/adaptorex`)
if (!result.ok) {
log.info(
0,
`Could not fetch update info from npm. Registry returned ${result.status}: ${result.statusText}`
)
}
const pckg_data = await result.json()
adaptor.info.latestVersion = pckg_data["dist-tags"].latest
} catch (error) {
log.info(0, `Failed to fetch update info from npm. ${error.message}`)
}
}
function logWebhooks(append_path) {
for (let webhook of adaptor.webhooks) {
log.info(0, `${webhook.title}:\t${webhook.url}${append_path || ""}`)
}
}
/**
* check whitelist to see if cross origin access is allowed for origin url
*
* @param {*} origin
* @param {*} callback
*/
function isWhitelisted(origin, callback) {
if (typeof origin === "undefined") {
callback(null, true)
} else if (origin.indexOf("//localhost") !== -1) {
callback(null, true)
} else if (config["whitelist"]) {
var originIsWhitelisted = config.whitelist.indexOf(origin) !== -1
if (!originIsWhitelisted) {
log.warn(
0,
`Rejected request from remote ${origin} because it is not in whitelist.`
)
}
callback(null, originIsWhitelisted)
} else {
callback(null, true)
}
}
function client(socket) {
log.debug(0, "client connected on " + socket.handshake.headers.host)
}
/**
* Make error specific API response for AdaptorErrors. Otherwise, respond with 500 internal server error.
*
* @param {Error} err - error object that was caught
* @param {Object} res - request response object
* @returns undefined
*/
function errorResponseHandling(err, res) {
log.error("API", err)
if (err.cause) {
err.message = err.message + "\n" + err.cause.message
log.error("API", err.cause.message)
}
if (err instanceof adaptor.NotFoundError) {
return res.status(404).send({ name: err.name, message: err.message })
} else if (err instanceof adaptor.AdaptorError) {
return res.status(400).send({ name: err.name, message: err.message })
} else if (
(err instanceof MongoError && err.code == 11000) ||
err.errorType == "uniqueViolated"
) {
return res
.status(400)
.send({ name: "DuplicateError", message: err.message })
}
res
.status(500)
.send({ name: err.name, message: err.message, stack: err.stack })
}
/**
* Open editor client in default web browser
*/
async function openEditor() {
if (!config.headless) {
log.info(
0,
`Opening adaptor:ex editor at ${config.host}:${config.port} in default web browser`
)
opener(`${config.host}:${config.port}`)
} else {
log.warn(
0,
`Can not auto open editor when running in headless mode. Omit the --headless option and make sure you have a built of the adaptor:ex client in public directory.`
)
}
}
function getMe(req, config) {
let username
if (req.user && req.user.login) {
username = req.user.login
}
const user = adaptor.auth.getUser(config, username)
return user
}
/**
* get preview data for all loaded games
*
* @returns {Promise} list of game preview Objects `{name:"gamename",setup:{}}`
*/
async function getGames() {
let returnmsg = []
for (let game in games) {
let game_setup = await games[game].getSetup()
returnmsg.push({ name: game, setup: game_setup })
}
return returnmsg
}
/**
* - create Game Object with provided Database Object
* - resume if there is an existing database
* else build default game
* - open route to Game editing Website
* - note game in config file
*
* @param {string} name - name of game to be created or continued
* @param {Object} [game_data] - game specific config parameters or template name if its a new game
*/
function loadGame(name, game_data) {
if (!game_data) {
game_data = {}
}
return new Promise(function (resolve, reject) {
let name_convert_spaces = name.replace(/ /g, "%20")
var game_db = new database.Database({ name: name_convert_spaces })
let load_result
game_db
.connect()
.then((result) => {
let client = adaptor.io.of(`/game/${name_convert_spaces}`)
client.use(adaptor.auth.authenticateSocket.bind(adaptor.auth))
if (config.hasOwnProperty("url")) {
game_data["files_url"] =
config.url + "/" + name_convert_spaces + "/files"
game_data["url"] = config.url + "/" + name_convert_spaces
} else {
game_data["files_url"] =
config.host +
":" +
config.port +
"/" +
name_convert_spaces +
"/files"
game_data["url"] =
config.host + ":" + config.port + "/" + name_convert_spaces
}
game_data["data"] = path.join(
adaptor.data,
"games",
name_convert_spaces
)
game_data["files"] = path.join(
adaptor.data,
"games",
name_convert_spaces,
"files"
)
game_data["public"] = path.join(
adaptor.data,
"games",
name_convert_spaces,
"public"
)
file.mkdir(game_data.data)
file.mkdir(path.join(game_data.data, "functions"))
file.mkdir(game_data.files)
file.mkdir(game_data.public)
game_data["name"] = name
let game_app = express.Router()
app.use(`/${name_convert_spaces}`, game_app)
// Host Public game files
game_app.use(
"/files",
express.static(game_data.public, {
maxAge: 7200000
})
)
let game_api = express.Router()
api.use(`/game/${name_convert_spaces}`, game_api)
let newgame = new game.Game(game_data, game_app, game_api, client)
games[name] = newgame
if (result == "created") {
if (game_data.hasOwnProperty("template")) {
return newgame.build(game_db, game_data.template)
} else {
return newgame.build(game_db, "basic")
}
} else if (result == "connected") {
return newgame.resume(game_db)
} else {
throw new Error(name + " could neither be resumed or created.")
}
})
.then((result) => {
load_result = result
if (config_file) {
if (!config.hasOwnProperty("games")) {
config["games"] = {}
}
config.games[name] = game_data
return file.saveJSON(config, config_file)
}
return
})
.then((result) => {
log.info(0, load_result.status + " game " + name)
return resolve(load_result)
})
.catch((error) => {
let err = new Error(
`Error while loading game ${name}. To skip loading the game, remove it from config file.`,
{ cause: error }
)
return reject(err)
})
})
}
/**
* remove game but keep its data in database
*/
async function unloadGame(name) {
if (games.hasOwnProperty(name)) {
if (config_file) {
log.info("unload", config.games[name])
if (config.games.hasOwnProperty(name)) {
delete config.games[name]
await file.saveJSON(config, config_file)
}
}
await games[name].unload()
delete games[name]
log.info("game", name + " unloaded")
return true
} else {
log.error("unload", "No such Game '" + name + "'")
return false
}
}
/**
* unload and remove game and its Database
* does not create backup! Handle with care
*/
async function deleteGame(name) {
if (games.hasOwnProperty(name)) {
await games[name].db.clone("archive_" + name)
await games[name].delete()
await unloadGame(name)
log.info("delete", name + " was successfully deleted")
return true
} else {
log.error("delete", "No such Game '" + name + "'")
return false
}
}
/**
* Reload a plugin by removing its module and reloading it in all games.
*
* Allows to update a plugin without having to restart the server.
*
* @param {string} plugin_name - name of the plugin to reload
*/
async function reloadPlugin(plugin_name) {
await plugin_manager.removePluginModule(plugin_name)
await Promise.all(
Object.values(games).map((game) => game.topics.plugin.refresh(plugin_name))
)
}
function setConfig(filename) {
config_file = filename
try {
config = require(path.resolve(filename))
log.info(0, `Load config file ${filename}`)
} catch (e) {
if (e.code !== "MODULE_NOT_FOUND") {
log.error(0, e)
}
log.info(0, "Config file missing. Will create " + filename)
file.mkdir(path.dirname(filename))
}
}
/**
* Configure login credentials for the webview based on commandline options.
*
* @param {Object} argv - Command line arguments object
* @param {string} [argv.login] - Username for webview login (use "off" to disable login)
* @param {string} [argv.password] - Password for webview login
* @param {string} [argv.pw] - Alternative password parameter
*/
function loginConfig() {
if (!config.hasOwnProperty("login")) {
config["login"] = {}
log.info(0, "Create Webview login credentials")
} else {
log.info(0, "Change Webview login credentials")
}
if (argv.hasOwnProperty("login")) {
if (argv.login == "off") {
delete config.login["user"]
delete config.login["password"]
log.info(
0,
"Webview login deactivated. Reactivate by setting login and password."
)
return
}
config.login["user"] = argv.login
}
if (argv.hasOwnProperty("password")) {
config.login["password"] = argv.password
}
if (argv.hasOwnProperty("pw")) {
config.login["password"] = argv.pw
}
}
/**
* set url for database access (only mongodb)
* -d mongodb -m mongodb://<username>:<password>@localhost:<port>?replicaSet=adaptor-repl
* with authentication enabled use:
* -d mongodb -m mongodb://<username>:<password>@localhost:<port>/admin?replicaSet=adaptor-repl
*/
function databaseConfig(db_type) {
if (!config.hasOwnProperty("database")) {
config["database"] = { type: db_type }
} else {
config.database["type"] = db_type
}
if (argv.hasOwnProperty("m")) {
config.database["url"] = argv.m
} else if (argv.hasOwnProperty("mongodburl")) {
config.database["url"] = argv.mongodburl
} else if (argv.hasOwnProperty("mongourl")) {
config.database["url"] = argv.mongourl
}
}
/**
* give all games and plugins the chance to tidy up if necessary then exit node process
*
*/
function quit() {
let promises = []
for (let g in games) {
promises.push(games[g].quit())
}
Promise.all(promises)
.then((result) => {
log.info("", "Auf bald!")
process.exit(0)
})
.catch((err) => {
log.error("", err)
process.exit(0)
})
}
/********************************************************************************************
* load config and check options
*
*********************************************************************************************/
/** path to and name of config file. If file is not found config will be created based on options
* @default
*/
var config_file = "./adaptorex/config.json"
/** content of config file.
* @default
*/
var config = {
host: "http://localhost",
port: 8081,
data: "./adaptorex",
database: { type: "nedb" },
level: "info",
authentication: "disabled"
}
/**
* If run as package, set config file and data path to OS specific app directory and always open editor in browser.
*/
if (process.pkg) {
const homeDir = os.homedir()
/**
* path to the adaptorex app directory
* Defaults to OS specific home dir
*/
var appDir = path.join(homeDir, "adaptorex")
switch (os.platform()) {
case "win32": // Windows
appDir = path.join(
process.env.LOCALAPPDATA || path.join(homeDir, "AppData", "Local"),
"adaptorex"
)
break
case "darwin": // macOS
appDir = path.join(homeDir, "Library", "Application Support", "adaptorex")
break
case "linux": // Linux
appDir = path.join(homeDir, ".local", "share", "adaptorex")
break
}
config_file = `${appDir}/config.json`
config["data"] = appDir
config["open"] = true
}
if (argv.hasOwnProperty("c")) {
setConfig(argv.c)
} else if (argv.hasOwnProperty("config")) {
setConfig(argv.config)
} else {
setConfig(config_file)
}
config["headless"] = false
for (let arg in argv) {
if (arg == "headless") {
config["headless"] = true
} else if (arg == "open" || arg == "o") {
config["open"] = true
} else if (argv[arg] == true) {
log.warn(0, "parameters missing for option '" + arg + "'")
} else {
switch (arg) {
case "level":
case "log":
config["level"] = argv.level || argv.log
break
case "l":
config["level"] = argv.l
break
case "p":
config.port = argv.p
break
case "port":
config.port = argv.port
break
case "host":
config["host"] = argv.host
break
case "h":
config["host"] = argv.h
break
case "u":
config["url"] = argv.u
break
case "url":
config["url"] = argv.url
break
case "a":
config["authentication"] = argv.a
break
case "auth":
config["authentication"] = argv.auth
break
case "f":
config["data"] = argv.f
break
case "data":
config["data"] = argv.data
break
case "files":
config["data"] = argv.files
break
case "plugins":
config["plugins"] = [argv.plugins]
break
case "v":
config["minNpmPluginVersion"] = argv.v
break
case "min-npm-plugin-version":
config["minNpmPluginVersion"] = argv["min-npm-plugin-version"]
break
case "d":
databaseConfig(argv.d)
break
case "db":
databaseConfig(argv.db)
break
case "database":
databaseConfig(argv.database)
break
case "python":
config["python"] = argv.python
case "login":
loginConfig()
break
case "password":
loginConfig()
break
case "pw":
loginConfig()
break
case "_":
break
}
}
}
file
.saveJSON(config, config_file)
.then((result) => {
log.info(0, `Data directory is at ${config.data}`)
file.mkdir(config.data)
return log.init(config.level, path.join(config.data, "/log"))
})
.then((result) => {
init(config)
})
.catch((error) => {
log.error("config", error)
})
/** global object that provides some useful input functions */
global.Input = {}
/**
* Confirmation function that prompts the user for confirmation before executing a callback function.
* @param {string} action - The action being performed.
* @param {string} element - The element being affected.
* @param {function} callback - The function to be executed if the user confirms.
* @returns {Promise} - Resolves when the user confirms or rejects the action.
*/
Input.confirmation = function (action, element, callback) {
return new Promise((resolve, reject) => {
rl.question(
"are you sure you want to " + action + " " + element + "? (y/n) ",
(answ) => {
if (answ == "y" || answ == "yes") {
callback(element)
.then((res) => resolve(res))
.catch((error) => {
log.error(0, error.message)
})
} else {
resolve(`cancelled ${action} ${element}`)
}
rl.prompt()
}
)
})
}
/**
* Takes a date as argument and calculates the time that has passed since and returns it in a human readable way
* @param {Date} date - The date to calculate the time difference from
* @returns {string} - A human readable string representing the time that has passed since the given date
*/
Input.since = function (date) {
if (!date) {
return "never"
}
const units = [
{ name: "year", seconds: 31536000 },
{ name: "month", seconds: 2628000 },
{ name: "day", seconds: 86400 },
{ name: "hour", seconds: 3600 },
{ name: "minute", seconds: 60 }
]
let timePassed = (Date.now() - date) / 1000
for (const unit of units) {
const value = Math.floor(timePassed / unit.seconds)
if (value > 0) {
return value + " " + unit.name + (value > 1 ? "s " : " ") + "ago"
}
}
return "just now"
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: "> "
})
rl.on("line", (input) => {
if (!input) {
return
}
/*
* split input by space character but ignore spaces in quotes (" or ')
*
* regex for only double quotes: /(?:[^\s"]+|"[^"]*")+/g
* source:
* https://stackoverflow.com/questions/16261635/javascript-split-string-by-space-but-ignore-space-in-quotes-notice-not-to-spli/16261693#16261693
*
* remove quotes consequentially
*/
input = input.match(/(?:[^\s"']+|['"][^'"]*["'])+/g)
for (var i = 0; i < input.length; i++) {
input[i] = input[i].replace(/["']+/g, "")
}
switch (input[0]) {
case "quit":
quit()
break
case "games":
for (let game in games) {
log.info(0, game)
}
case "del":
case "delete":
if (input.length > 1) {
Input.confirmation("delete", input[1], deleteGame)
}
break
case "open":
case "editor":
case "browser":
openEditor()
break
case "load":
if (input.length == 2) {
loadGame(input[1]).catch((error) => {
log.error(0, error)
if (error.cause) log.error(0, error.cause)
})
} else if (input.length > 2) {
loadGame(input[1], { template: input[2] }).catch((error) => {
log.error(0, error)
if (error.cause) log.error(0, error.cause)
})
}
break
case "unload":
if (input.length > 1) {
unloadGame(input[1])
}
break
case "plugins":
if (input.length <= 2) {
plugin_manager.listPlugins().catch((error) => {
log.error(0, error)
if (error.cause) log.error(0, error.cause)
})
break
} else if (input.length <= 4) {
switch (input[1]) {
case "install":
plugin_manager
.findAvailablePlugin("name", input[2])
.then((plugin) => {
if (!plugin) {
log.error(0, `Plugin "${input[2]}" not found`)
return
}
return plugin_manager.installPlugin(plugin, input[3])
})
.then(() => {
log.info(0, "Install done")
})
.catch((error) => {
log.error(0, error)
if (error.cause) log.error(0, error.cause)
})
break
case "uninstall":
Input.confirmation(
"uninstall",
input[2],
plugin_manager.uninstallPlugin.bind(plugin_manager)
)
break
case "reload":
reloadPlugin(input[2])
.then(() => {
log.info(0, `Reload plugin ${input[2]} finished`)
})
.catch((error) => {
log.error(0, error)
if (error.cause) log.error(0, error.cause)
})
break
}
}
break
case "log":
if (input.length == 2) {
log.setLevel(input[1])
config["level"] = input[1]
file.saveJSON(config, config_file)
break
}
log.info(
0,
"Please provide a log level: trace, debug, info, warn, error, silent"
)
break
case "urls":
logWebhooks()
break
case "api":
logWebhooks("/api")
break
case "whitelist":
if (input.length == 3) {
if (input[1] == "add") {
if (!config["whitelist"]) {
config["whitelist"] = []
}
config["whitelist"].push(input[2])
} else if (input[1] == "remove") {
if (config["whitelist"] && config["whitelist"].includes(input[2])) {
config["whitelist"] = config["whitelist"].filter(
(w) => w !== input[2]
)
} else {
log.error(
0,
`Can not remove ${input[2]}. Not found in config.whitelist`
)
break
}
}
file.saveJSON(config, config_file)
break
} else if (input.length == 2) {
if (input[1] == "any") {
delete config["whitelist"]
} else if (input[1] == "none") {
config["whitelist"] = []
}
file.saveJSON(config, config_file)
break
} else if (input.length == 1) {
log.info(0, config["whitelist"])
break
}
log.info(0, "wrong number of arguments for whitelist command")
break
default:
if (games.hasOwnProperty(input[0])) {
let game = input.shift()
games[game].command(input)
} else {
if (Object.keys(games).length == 1) {
for (let game in games) {
games[game].command(input)
}
} else {
log.info("", "No such game or command '" + input[0] + "'")
}
}
break
}
rl.prompt()
}).on("close", () => {
quit()
})