diff --git a/appveyor.yml b/appveyor.yml index 45c6765..f12d50b 100644 --- a/appveyor.yml +++ b/appveyor.yml @@ -26,3 +26,4 @@ test_script: chmod -R 777 /appveyor/projects npm install npm test + npm test:integration diff --git a/cli.mjs b/cli.mjs new file mode 100644 index 0000000..99d71ce --- /dev/null +++ b/cli.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node + +// Get arguments +const [,, ...args] = process.argv + +import os from 'os' +import fs from 'fs/promises' +import Util from './core/util.mjs' +import Core from './core/core.mjs' +import path from 'path' + +if (!args.length) PrintHelp() +if (args[0] !== 'checkconfig' && args[0] !== 'install' && args[0] !== 'uninstall') PrintHelp + +let configFile = 'config.json' +if (args[1]) { + configFile = args[1] +} + +const basicRunnerTemplate = ` +import fs from 'fs' +import { runner } from 'service-core' + +runner(import.meta.url, '${configFile}', 'db.json') +.then( + function(core) { + core.log.info('core is running') + }, + function(err) { + runner.log.error(err, 'Error starting runner') + process.exit(1) + } +) +` + +const util = new Util(import.meta.url) + +function PrintHelp() { + console.log('') + console.log('Usage: sccli [config.json]') + console.log('') + console.log('') + console.log(' checkconfig : Test local config.json for errors') + console.log(' install : Install local config.json as service') + console.log(' uninstall : Uninstall local config.json as a service') + console.log('') + process.exit(1) +} + +if (args[0] === 'checkconfig') { + fs.readFile(configFile) + .then(async function(content) { + let config = JSON.parse(content) + util.verifyConfig(config) + + let names = util.getAppNames(config) + + if (!names.length) { + return Promise.reject(new Error('No application names were found')) + } + + for (let name of names) { + let provConstructor = Core.providers.get(config[name].provider) + let provider = new provConstructor(config[name]) + await provider.checkConfig(config[name]) + } + + console.log(`${configFile} is a valid config with ${names.length} application${names.length > 1 ? 's' : ''}:`) + for (let name of names) { + console.log(` * ${name} (${config[name].provider}${config[name].url ? ': ' + config[name].url : ' provider'})`) + } + }) + .catch(function(err) { + console.log('Error checking config:', err) + process.exit(2) + }) + .then(function() { + process.exit(0) + }) +} else if (args[0] === 'install') { + if(os.platform() === 'win32') { + const runner = path.join(process.cwd(), './runner.mjs') + + fs.stat(runner).catch(function() { + return fs.writeFile(runner, basicRunnerTemplate) + }).then(function() { + return Promise.all([ + fs.readFile(configFile), + import('node-windows'), + ]) + }).then(function([content, nodewindows]) { + let config = JSON.parse(content) + const Service = nodewindows.Service + + let serviceConfig = { + name: config.title || config.name, + description: config.description, + script: runner, + env: { + name: 'NODE_ENV', + value: 'production', + }, + wait: 0, + grow: .5, + maxRestarts: 10 + //, workingDirectory: '...' + //, allowServiceLogon: true + } + + console.log('Service', serviceConfig) + // Create a new service object + let svc = new Service(serviceConfig); + + svc.on('install',function(){ + svc.start(); + process.exit(0) + }); + + svc.on('alreadyinstalled',function(){ + svc.start(); + process.exit(0) + }); + + svc.install(); + }) + } else { + console.log('non windows install targets are currently unsupported') + process.exit(2) + } +} else if (args[0] === 'uninstall') { + if(os.platform() === 'win32') { + const runner = path.join(process.cwd(), './runner.mjs') + + fs.stat(runner).catch(function() { + return fs.writeFile(runner, basicRunnerTemplate) + }).then(function() { + return Promise.all([ + fs.readFile(configFile), + import('node-windows'), + ]) + }).then(function([content, nodewindows]) { + let config = JSON.parse(content) + const Service = nodewindows.Service + + let serviceConfig = { + name: config.title || config.name, + description: config.description, + script: runner, + env: { + name: 'NODE_ENV', + value: 'production', + }, + wait: 0, + grow: .5, + maxRestarts: 10 + //, workingDirectory: '...' + //, allowServiceLogon: true + } + + console.log('Service', serviceConfig) + // Create a new service object + let svc = new Service(serviceConfig); + + svc.on('uninstall',function(){ + console.log('Uninstall complete.'); + console.log('The service exists: ',svc.exists); + process.exit(0) + }); + + svc.uninstall(); + }) + } else { + console.log('non windows install targets are currently unsupported') + process.exit(2) + } +} diff --git a/config.json b/config.json deleted file mode 100644 index 75d951b..0000000 --- a/config.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "name": "service-core", - "githubAuthToken": null, - "serviceName": "Service-Core Node", - "description": "NodeJS Test Service", - "port": 4270, - "managePort": 4271, - "devPort": 4269, - "appRepository": "thething/sc-helloworld", - "manageRepository": "TheThing/sc-manager" -} \ No newline at end of file diff --git a/core/application.mjs b/core/application.mjs index 017d922..80d46a6 100644 --- a/core/application.mjs +++ b/core/application.mjs @@ -32,7 +32,7 @@ export default class Application extends EventEmitter { this.fresh = true // Apply defaults to config - this.config.updateEvery = this.config.updateEvery || 180 + this.config.updateEvery = this.config.updateEvery != null ? this.config.updateEvery : 180 this.config.startWaitUntilFail = this.config.startWaitUntilFail || (60 * 1000) this.config.heartbeatTimeout = this.config.heartbeatTimeout || (3 * 1000) this.config.heartbeatAttempts = this.config.heartbeatAttempts || 5 @@ -49,6 +49,7 @@ export default class Application extends EventEmitter { startAutoupdater() { if (this.provider.static) return + if (this.config.updateEvery === 0) return let timer = this.setInterval(() => { this.update().then( diff --git a/core/cli.mjs b/core/cli.mjs new file mode 100644 index 0000000..e69de29 diff --git a/core/core.mjs b/core/core.mjs index 99d36a0..3587cca 100644 --- a/core/core.mjs +++ b/core/core.mjs @@ -22,9 +22,8 @@ export default class Core { Core.providers.set(name, provider) } - constructor(db, util, log, restart) { + constructor(db, util, log) { // some sanity checks - if (typeof(restart) !== 'function') throw new Error('restart parameter was not a function') if (!log || typeof(log) !== 'object') throw new Error('log parameter was invalid') if (typeof(log.event) !== 'object') throw new Error('log parameter was invalid') if (typeof(log.info) !== 'function' @@ -40,9 +39,9 @@ export default class Core { this.db = db this.util = util this.log = log - this.restart = restart this.applications = [] this.applicationMap = new Map() + this._applicationFatalCrash = null } getApplication(name) { @@ -114,6 +113,12 @@ export default class Core { } } + criticalError(application, version, errorCode) { + application.ctx.log.fatal(`Critical error ${errorCode} running ${version.version}`) + version.stable = -2 + this.db.writeSync() + } + async runApplication(application) { let name = application.name let found = false @@ -128,6 +133,9 @@ export default class Core { if (version.stable < 0 && !application.fresh) continue await application.closeServer() + this._applicationFatalCrash = this.criticalError.bind(this, application, version) + process.once('exit', this._applicationFatalCrash) + try { application.ctx.log.info(`Attempting to run version ${version.version}`) await application.runVersion(version.version) @@ -136,9 +144,15 @@ export default class Core { await this.db.write() break } catch(err) { - version.stable = Math.min(version.stable, 0) - 1 + if (application.fresh) { + version.stable = -2 + } else { + version.stable = Math.min(version.stable, 0) - 1 + } await this.db.write() application.ctx.log.error(err, `Error starting ${version.version}: ${err.message}`) + } finally { + process.off('exit', this._applicationFatalCrash) } } diff --git a/core/core_old.mjs b/core/core_old.mjs deleted file mode 100644 index ba9961d..0000000 --- a/core/core_old.mjs +++ /dev/null @@ -1,499 +0,0 @@ -import fs from 'fs' -import { EventEmitter } from 'events' -import { request } from './client.mjs' -import HttpServer from './http.mjs' - -const fsp = fs.promises - -export default class Core extends EventEmitter{ - constructor(util, config, db, log, closeCb) { - super() - process.stdin.resume() - this.http = new HttpServer() - this.util = util - this.config = config - this.db = db - this.log = log - this._close = closeCb - this._activeCrashHandler = null - this.appRunning = false - this.manageRunning = false - this.monitoring = false - this._appUpdating = { - fresh: true, - updating: false, - starting: false, - logs: '', - } - this._manageUpdating = { - fresh: true, - updating: false, - starting: false, - logs: '', - } - - this.db.set('core.manageActive', null) - .set('core.appActive', null) - .write().then() - } - - startMonitor() { - if (this.monitoring) return - this.log.info('[Scheduler] Automatic updater has been turned on. Will check for updates every 3 hours') - let updating = false - - this.monitoring = setInterval(async () => { - if (updating) return - updating = true - this.log.info('[Scheduler] Starting automatic check for latest version of app and manage') - - try { - await this.installLatestVersion('app') - await this.installLatestVersion('manage') - } catch(err) { - this.log.error(err, 'Error checking for latest versions') - this.log.event.error('Error checking for latest versions: ' + err.message) - updating = false - return - } - - try { - if (this.hasNewVersionAvailable('app') || !this.appRunning) { - await this.tryStartProgram('app') - } - } catch(err) { - this.log.error(err, 'Unknown error occured attempting to app') - this.log.event.error('Unknown error starting app: ' + err.message) - } - try { - if (this.hasNewVersionAvailable('manage') || !this.manageRunning) { - await this.tryStartProgram('manage') - } - } catch(err) { - this.log.error(err, 'Unknown error occured attempting to start manage') - this.log.event.error('Unknown error starting manage: ' + err.message) - } - updating = false - }, 1000 * 60 * 60 * 3) // every 3 hours - } - - restart() { - this._close() - } - - status() { - return { - app: this.appRunning, - manage: this.manageRunning, - appUpdating: this._appUpdating.updating, - manageUpdating: this._manageUpdating.updating, - appStarting: this._appUpdating.starting, - manageStarting: this._manageUpdating.starting, - } - } - - async getLatestVersion(active, name) { - // Example: 'https://api.github.com/repos/thething/sc-helloworld/releases' - this.logActive(name, active, `Updater: Fetching release info from: https://api.github.com/repos/${this.config[name + 'Repository']}/releases\n`) - - let result = await request(this.config, `https://api.github.com/repos/${this.config[name + 'Repository']}/releases`) - - let items = result.body.filter(function(item) { - if (!item.assets.length) return false - for (let i = 0; i < item.assets.length; i++) { - if (item.assets[i].name.endsWith('-sc.zip')) return true - } - }) - - if (items && items.length) { - for (let x = 0; x < items.length; x++) { - let item = items[x] - for (let i = 0; i < item.assets.length; i++) { - if (item.assets[i].name.endsWith('-sc.zip')) { - if (this.db.get('core.' + name + 'LatestInstalled').value() === item.name) { - this.logActive(name, active, `Updater: Latest version already installed, exiting early\n`) - return null - } - this.logActive(name, active, `Updater: Found version ${item.name} with file ${item.assets[i].name}\n`) - - await this.db.set(`core.${name}LatestVersion`, item.name) - .write() - this.emit('dbupdated', {}) - - return { - name: item.name, - filename: item.assets[i].name, - url: item.assets[i].browser_download_url, - description: item.body, - } - } - } - } - } else { - return null - } - } - - logActive(name, active, logline, doNotPrint = false) { - if (!doNotPrint) { - this.log.info(`[${name}] ` + logline.replace(/\n/g, '')) - } - active.logs += logline - this.emit(name + 'log', active) - } - - getProgramLogs(name) { - if (name === 'app' && this._appUpdating.logs) { - return this._appUpdating.logs - } else if (name === 'manage' && this._manageUpdating.logs) { - return this._manageUpdating.logs - } - - let latestInstalled = this.db.get('core.' + name + 'LatestInstalled').value() - let latestVersion = this.db.get('core.' + name + 'LatestVersion').value() - if (latestVersion) { - let value = this.db.get(`core_${name}History`).getById(latestVersion).value() - if (value) return value.logs - } - if (latestInstalled) { - let value = this.db.get(`core_${name}History`).getById(latestInstalled).value() - if (value) return value.logs - } - return '< no logs found >' - } - - async installVersion(name, active, version) { - if (fs.existsSync(this.util.getPathFromRoot(`./${name}/` + version.name))) { - await this.util.runCommand('rmdir', ['/S', '/Q', `"${this.util.getPathFromRoot(`./${name}/` + version.name)}"`]) - } - if (!fs.existsSync(this.util.getPathFromRoot(`./${name}/`))) { - await fsp.mkdir(this.util.getPathFromRoot(`./${name}/`)) - } - try { - await fsp.mkdir(this.util.getPathFromRoot(`./${name}/` + version.name)) - } catch(err) { - if (err.code !== 'EEXIST') { - throw err - } - } - // await fsp.mkdir(this.util.getPathFromRoot(`./${name}/` + version.name + '/node_modules')) - this.logActive(name, active, `Installer: Downloading ${version.name} (${version.url}) to ${version.name + '/' + version.name + '.zip'}\n`) - let filePath = this.util.getPathFromRoot(`./${name}/` + version.name + '/' + version.name + '.zip') - await request(this.config, version.url, filePath) - this.logActive(name, active, `Installer: Downloading finished, starting extraction\n`) - await this.util.runCommand( - '"C:\\Program Files\\7-Zip\\7z.exe"', - ['x', `"${filePath}"`], - this.util.getPathFromRoot(`./${name}/` + version.name + '/'), - this.logActive.bind(this, name, active) - ) - - if (!fs.existsSync(this.util.getPathFromRoot(`./${name}/` + version.name + '/index.mjs'))) { - this.logActive(name, active, `\nInstaller: ERROR: Missing index.mjs in the folder, exiting\n`) - throw new Error(`Missing index.mjs in ${this.util.getPathFromRoot(`./${name}/` + version.name + '/index.mjs')}`) - } - - this.logActive(name, active, `\nInstaller: Starting npm install\n`) - - await this.util.runCommand( - 'npm.cmd', - ['install', '--production', '--no-optional', '--no-package-lock', '--no-audit'], - this.util.getPathFromRoot(`./${name}/` + version.name + '/'), - this.logActive.bind(this, name, active) - ) - - await this.db.set(`core.${name}LatestInstalled`, version.name) - .write() - this.emit('dbupdated', {}) - - this.logActive(name, active, `\nInstaller: Successfully installed ${version.name}\n`) - } - - getActive(name) { - if (name === 'app') { - return this._appUpdating - } else if (name === 'manage') { - return this._manageUpdating - } else { - throw new Error('Invalid name: ' + name) - } - } - - async startModule(module, port) { - let out = await module.start(this.config, this.db, this.log, this, this.http, port) - if (out && out.then) { - await out - } - if (!this.http.getCurrentServer()) { - this.log.warn('Module did not call http.createServer') - } - } - - hasNewVersionAvailable(name) { - let newestVersion = this.db.get(`core.${name}LatestInstalled`).value() - if (!newestVersion) return false - - let history = this.db.get(`core_${name}History`).getById(newestVersion).value() - if (history.installed && history.stable === 0) { - return true - } - return false - } - - async tryStartProgram(name) { - let active = this.getActive(name) - - if (this[name + 'Running'] && !this.hasNewVersionAvailable(name)) { - this.log.event.warn('Attempting to start ' + name + ' which is already running') - this.log.warn('Attempting to start ' + name + ' which is already running') - this.logActive(name, active, `Runner: Attempting to start it but it is already running\n`, true) - return - } - active.starting = true - - if (this[name + 'Running']) { - let success = await this.http.closeServer(name) - if (!success) { - if (process.env.NODE_ENV === 'production') { - this.logActive(name, active, `Runner: Found new version but server could not be shut down, restarting service core\n`) - await new Promise(() => { - this.log.event.warn('Found new version of ' + name + ' but server could not be shut down gracefully, restarting...', null, () => { - process.exit(100) - }) - }) - } else { - this.logActive(name, active, `Runner: Found new version but server could not be shut down\n`) - return - } - } - this[name + 'Running'] = false - this.emit('statusupdated', {}) - } - - let history = this.db.get(`core_${name}History`) - .filter('installed') - .orderBy('installed', 'desc') - .value() - this.logActive(name, active, `Runner: Finding available version\n`) - - for (let i = 0; i < history.length; i++) { - if ((history[i].stable === -1 && !active.fresh) - || (history[i].stable < -1)) { - this.logActive(name, active, `Runner: Skipping version ${history[i].name} due to marked as unstable\n`) - continue - } - - await this.db.set(`core.${name}Active`, history[i].name) - .write() - this.emit('dbupdated', {}) - - let running = await this.tryStartProgramVersion(name, active, history[i].name) - if (running) { - history[i].stable = 1 - } else { - if (active.fresh || history[i].stable === -1) { - history[i].stable = -2 - } else { - history[i].stable = -1 - } - await this.db.set(`core.${name}Active`, null) - .write() - this.emit('dbupdated', {}) - } - active.fresh = false - - await this.db.get(`core_${name}History`).updateById(history[i].id, history[i].stable).write() - if (history[i].stable > 0) break - } - - if (!this.db.get(`core.${name}Active`).value()) { - this.logActive(name, active, `Runner: Could not find any available stable version of ${name}\n`) - this.log.error('Unable to start ' + name) - this.log.event.error('Unable to start ' + name) - } - - active.starting = false - } - - programCrashed(name, version, active, oldStable) { - let newStable = -2 - console.log('EXITING:', oldStable, active) - if (oldStable === 0 && !active.fresh) { - newStable = -1 - } - let temp = this.db.get(`core_${name}History`).getById(version).set('stable', newStable ) - temp.value() // Trigger update on __wrapped__ - fs.writeFileSync(this.db.adapterFilePath, JSON.stringify(temp.__wrapped__, null, 2)) - } - - async tryStartProgramVersion(name, active, version) { - if (!version) return false - this.logActive(name, active, `Runner: Attempting to start ${version}\n`) - let indexPath = this.util.getUrlFromRoot(`./${name}/` + version + '/index.mjs') - let module - - try { - this.logActive(name, active, `Runner: Loading ${indexPath}\n`) - module = await import(indexPath) - } catch (err) { - this.logActive(name, active, `Runner: Error importing module\n`, true) - this.logActive(name, active, `${err.stack}\n`, true) - this.log.error(err, `Failed to load ${indexPath}`) - return false - } - - let checkTimeout = null - let oldStable = this.db.get(`core_${name}History`).getById(version).value().stable - this._activeCrashHandler = this.programCrashed.bind(this, name, version, active, oldStable) - process.once('exit', this._activeCrashHandler) - try { - let port = name === 'app' ? this.config.port : this.config.managePort - await new Promise((res, rej) => { - checkTimeout = setTimeout(function() { - rej(new Error('Program took longer than 60 seconds to resolve promise')) - }, 60 * 1000) - - this.logActive(name, active, `Runner: Starting module\n`) - - try { - this.http.setContext(name) - this.startModule(module, port) - .then(res, rej) - } catch (err) { - rej(err) - } - }) - clearTimeout(checkTimeout) - - await this.checkProgramRunning(name, active, port) - process.off('exit', this._activeCrashHandler) - } catch (err) { - clearTimeout(checkTimeout) - process.off('exit', this._activeCrashHandler) - await this.http.closeServer(name) - - this.logActive(name, active, `Runner: Error starting\n`, true) - this.logActive(name, active, `${err.stack}\n`, true) - this.log.error(err, `Failed to start ${name}`) - return false - } - this._activeCrashHandler = null - - this.logActive(name, active, `Runner: Successfully started version ${version}\n`) - await this.db.set(`core.${name}Active`, version) - .write() - - if (name === 'app') { - this.appRunning = true - } else { - this.manageRunning = true - } - this.emit('statusupdated', {}) - - this.logActive(name, active, `Runner: Module is running successfully\n`) - - return true - } - - async checkProgramRunning(name, active, port) { - this.logActive(name, active, `Checker: Testing out module port ${port}\n`) - let start = new Date() - let error = null - let success = false - - while (new Date() - start < 10 * 1000) { - try { - let check = await request(this.config, `http://localhost:${port}`, null, 0, true) - success = true - break - } catch(err) { - this.logActive(name, active, `Checker: ${err.message}, retrying in 3 seconds\n`) - error = err - await new Promise(function(res) { setTimeout(res, 3000)}) - } - } - if (success) return true - throw error || new Error('Checking server failed') - } - - async installLatestVersion(name) { - if (!this.config[name + 'Repository']) { - if (name === 'app') { - this.log.error(name + ' Repository was missing from config') - this.log.event.error(name + ' Repository was missing from config') - } else { - this.log.warn(name + ' Repository was missing from config') - this.log.event.warn(name + ' Repository was missing from config') - } - return - } - - let active = this.getActive(name) - let oldLogs = active.logs || '' - if (oldLogs) { - oldLogs += '\n' - } - active.logs = '' - active.updating = true - - this.emit('statusupdated', {}) - this.logActive(name, active, `Installer: Checking for updates at time: ${new Date().toISOString().replace('T', ' ').split('.')[0]}\n`) - - let version = null - let installed = false - let found = false - try { - version = await this.getLatestVersion(active, name) - if (version) { - let core = this.db.get('core').value() - let fromDb = this.db.get(`core_${name}History`).getById(version.name).value() - if (!fromDb || !fromDb.installed) { - let oldVersion = core[name + 'Current'] || '' - this.logActive(name, active, `Installer: Updating from ${oldVersion} to ${version.name}\n`) - await this.installVersion(name, active, version) - this.logActive(name, active, `Installer: Finished: ${new Date().toISOString().replace('T', ' ').split('.')[0]}\n`) - installed = new Date() - } else { - found = true - this.logActive(name, active, `Installer: Version ${version.name} already installed\n`) - } - } - } catch(err) { - this.logActive(name, active, '\n', true) - this.logActive(name, active, `Installer: Exception occured while updating ${name}\n`, true) - this.logActive(name, active, err.stack, true) - this.log.error('Error while updating ' + name, err) - } - active.updating = false - if (version && !found) { - await this.db.get(`core_${name}History`).upsert({ - id: version.name, - name: version.name, - filename: version.filename, - url: version.url, - description: version.description, - logs: active.logs, - stable: 0, - installed: installed && installed.toISOString(), - }).write() - } - active.logs = oldLogs + active.logs - this.emit(name + 'log', active) - this.emit('statusupdated', {}) - } - - async start(name) { - var version = this.db.get('core.' + name + 'LatestInstalled').value() - if (version) { - await this.tryStartProgram(name) - } - - await this.installLatestVersion(name) - - if (version !== this.db.get('core.' + name + 'LatestInstalled').value()) { - if (!this[name + 'Running'] || this.hasNewVersionAvailable(name)) { - await this.tryStartProgram(name) - } - } - } -} diff --git a/core/db.mjs b/core/db.mjs index 71059c7..cfa23e5 100644 --- a/core/db.mjs +++ b/core/db.mjs @@ -1,5 +1,6 @@ import { setTimeout } from 'timers/promises' import { Low, JSONFile, Memory } from 'lowdb' +import fs from 'fs' import { defaults, isObject } from './defaults.mjs' export default function GetDB(config, log, orgFilename = 'db.json') { @@ -12,7 +13,7 @@ export default function GetDB(config, log, orgFilename = 'db.json') { const db = new Low(adapter) db.id = 'id' - + db.filename = fullpath db.config = config db.createId = function(collection) { @@ -110,6 +111,14 @@ export default function GetDB(config, log, orgFilename = 'db.json') { }) } + db.writeSync = function() { + try { + fs.writeFileSync(db.filename, JSON.stringify(db.data)) + } catch(err) { + db.log.error(err, `Error during writeSync to ${db.filename}`) + } + } + return db.read() .then(function() { if (!isObject(db.data)) { diff --git a/core/lib.mjs b/core/lib.mjs new file mode 100644 index 0000000..f1a5094 --- /dev/null +++ b/core/lib.mjs @@ -0,0 +1,40 @@ +import Util from './util.mjs' +import getLog from './log.mjs' +import GetDB from './db.mjs' +import StaticProvider from './providers/static.mjs' +import Core from './core.mjs' + +export default class ServiceCore { + constructor(name, root_import_meta_url, dbfilename = 'db.json') { + if (!root_import_meta_url) { + throw new Error('ServiceCore must be called with the full string from "import.meta.url" from a file residing in the root directory') + } + this._root_import_meta_url = root_import_meta_url + this.util = new Util(this._root_import_meta_url) + this.dbfilename = dbname + this.log = getLog(name) + this.name = name + this.config = {} + this.db = null + this.core = null + this.app = null + } + + async init(module = null) { + this.db = await GetDB(this.config, this.log, this.dbfilename) + this.core = new Core(this.db, this.util, this.log) + + let provider = new StaticProvider() + this.app = new Application({ + db: this.db, + util: this.util, + log: this.log, + core: this.core, + }, provider, this.name) + this.app.registerModule(module) + } + + run() { + return this.app.runVersion('static') + } +} diff --git a/core/runner.mjs b/core/runner.mjs index 1f1b601..2533570 100644 --- a/core/runner.mjs +++ b/core/runner.mjs @@ -26,7 +26,7 @@ export async function runner(root_import_meta_url, configname = 'config.json', d runner.log = log const db = await GetDB(config, log, util.getPathFromRoot('./' + dbname)) - const core = new Core(db, util, log, function() {}) + const core = new Core(db, util, log) await core.init() await core.run() diff --git a/core/util.mjs b/core/util.mjs index 00c16ff..4e92625 100644 --- a/core/util.mjs +++ b/core/util.mjs @@ -188,7 +188,9 @@ export default class Util { processor._kill = processor.kill processor.kill = function() { if(os.platform() === 'win32'){ - execSync('taskkill /pid ' + processor.pid + ' /T /F') + try { + execSync('taskkill /pid ' + processor.pid + ' /T /F') + } catch {} }else{ processor.kill(); } diff --git a/exampleconfig.json b/exampleconfig.json new file mode 100644 index 0000000..c81ca21 --- /dev/null +++ b/exampleconfig.json @@ -0,0 +1,24 @@ +{ + "name": "service-core", + "title": "ExampleService", + "description": "Example config service running helloworld", + "helloworld": { + "provider": "git", + "url": "https://git.nfp.is/api/v1/repos/thething/sc-helloworld/releases", + "token": null, + "port": 8888, + "https": false, + "updateEvery": 180, + "startWaitUntilFail": 60000, + "heartbeatTimeout": 3000, + "heartbeatAttempts": 5, + "heartbeatAttemptsWait": 2000, + "heartbeatPath": "/", + "log": [ + { + "level": "info", + "stream": "process.stdout" + } + ] + } +} \ No newline at end of file diff --git a/index.mjs b/index.mjs new file mode 100644 index 0000000..4643cc3 --- /dev/null +++ b/index.mjs @@ -0,0 +1,21 @@ +import bunyan from 'bunyan-lite' +import { runner } from './core/runner.mjs' +import { ServiceCore } from './core/lib.mjs' +import Core from './core/core.mjs' +import Application from './core/application.mjs' +import Util from './core/util.mjs' +import HttpServer from './core/http.mjs' +import { request } from './core/client.mjs' +import getLog from './core/log.mjs' + +export { + bunyan, + runner, + ServiceCore, + Core, + Application, + Util, + HttpServer, + request, + getLog, +} diff --git a/install.bat b/install.bat deleted file mode 100644 index acfc9a5..0000000 --- a/install.bat +++ /dev/null @@ -1,2 +0,0 @@ -node service\install.mjs -PAUSE \ No newline at end of file diff --git a/lib.mjs b/lib.mjs deleted file mode 100644 index b7f43e5..0000000 --- a/lib.mjs +++ /dev/null @@ -1,52 +0,0 @@ -import Util from './core/util.mjs' -import { readFileSync } from 'fs' -import getLog from './core/log.mjs' -import lowdb from './core/db.mjs' -import Core from './core/core.mjs' - -export default class ServiceCore { - constructor(name, root_import_meta_url) { - if (!root_import_meta_url) { - throw new Error('ServiceCore must be called with the full string from "import.meta.url" from a file residing in the root directory') - } - this._root_import_meta_url = root_import_meta_url - this.util = new Util(this._root_import_meta_url) - this.log = getLog(name) - this.db = null - this.config = null - this.core = null - } - - close(err) { - if (err) { - this.log.fatal(err, 'App recorded a fatal error') - process.exit(4) - } - this.log.warn('App asked to be restarted') - process.exit(0) - } - - async init(module = null) { - try { - this.config = JSON.parse(readFileSync(this.util.getPathFromRoot('./config.json'))) - } catch (err) { - throw new Error('Unable to read config.json from root directory: ' + err) - } - - try { - this.db = await lowdb(this.util, this.log) - } catch (err) { - throw new Error('Unable to read initialise lowdb: ' + err) - } - - this.core = new Core(this.util, this.config, this.db, this.log, (err) => this.close(err)) - - if (module) { - return this.startModule(module) - } - } - - startModule(module) { - return this.core.startModule(module, this.config.devPort) - } -} diff --git a/npminstall.bat b/npminstall.bat deleted file mode 100644 index 328bd9e..0000000 --- a/npminstall.bat +++ /dev/null @@ -1,2 +0,0 @@ -npm install -PAUSE \ No newline at end of file diff --git a/package.json b/package.json index 28e00c6..1289f4f 100644 --- a/package.json +++ b/package.json @@ -36,10 +36,12 @@ }, "homepage": "https://github.com/TheThing/service-core#readme", "files": [ - "lib.mjs", + "index.mjs", + "cli.mjs", "package.json", "README.md", - "core" + "core", + "bin" ], "dependencies": { "bunyan-lite": "^1.0.1", diff --git a/runner.mjs b/runner.mjs deleted file mode 100644 index 58adab7..0000000 --- a/runner.mjs +++ /dev/null @@ -1,69 +0,0 @@ -import { readFileSync } from 'fs' -import getLog from './core/log.mjs' -import lowdb from './core/db.mjs' -import Core from './core/core.mjs' -import Util from './core/util.mjs' - -let config - -try { - config = JSON.parse(readFileSync('./config.json')) -} catch (err) { - let logger = getLog('critical-error') - logger.fatal('Error opening config file') - logger.fatal('Make sure it is valid JSON') - logger.fatal(err) - logger.event.error('Unable to start, error in config.json: ' + err.message) - process.exit(10) -} - -const log = getLog(config.name) - -const close = function(err) { - if (err) { - log.fatal(err, 'App recorded a fatal error') - log.event.error('App recorded a fatal error: ' + err.message, null, function() { - process.exit(4) - }) - return - } - log.warn('App asked to be restarted') - log.event.warn('App requested to be restarted', null, function() { - process.exit(0) - }) -} - -const util = new Util(import.meta.url) - -lowdb(util, log).then(async function(db) { - let core = new Core(util, config, db, log, close) - let errors = 0 - try { - await core.start('app') - } catch (err) { - log.event.error('Unable to start app: ' + err.message) - log.error(err, 'Unable to start app') - errors++ - } - try { - await core.start('manage') - } catch (err) { - log.event.error('Unable to start manage: ' + err.message) - log.error(err, 'Unable to start manage') - errors++ - } - core.startMonitor() - if (errors === 2 || (!core.appRunning && !core.manageRunning)) { - throw new Error('Neither manage or app were started, exiting.') - } -}, function(err) { - log.fatal(err, 'Critical error opening database') - log.event.error('Critical error opening database: ' + err.message, null, function() { - process.exit(2) - }) -}).catch(function(err) { - log.fatal(err, 'Unknown error occured opening app') - log.event.error('Unknown error occured opening app: ' + err.message, null, function() { - process.exit(3) - }) -}) diff --git a/service/install.mjs b/service/install.mjs deleted file mode 100644 index 99eca09..0000000 --- a/service/install.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import svc from './service.mjs' - -svc.on('install',function(){ - svc.start(); -}); - -svc.on('alreadyinstalled',function(){ - svc.start(); -}); - -svc.install(); \ No newline at end of file diff --git a/service/service.mjs b/service/service.mjs deleted file mode 100644 index 3ba6f80..0000000 --- a/service/service.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import path from 'path' -import { readFileSync } from 'fs' -import { fileURLToPath } from 'url' -import nodewindows from 'node-windows' - -function getPathFromRoot(add) { - const __dirname = path.dirname(fileURLToPath(import.meta.url)); - return path.join(__dirname,'../', add) -} - -let config = JSON.parse(readFileSync(getPathFromRoot('./config.json'))) - -const Service = nodewindows.Service - -let serviceConfig = { - name: config.serviceName, - description: config.description, - script: getPathFromRoot('./runner.mjs'), - env: { - name: 'NODE_ENV', - value: 'production', - }, - wait: 0, - grow: .5, - maxRestarts: 10 - //, workingDirectory: '...' - //, allowServiceLogon: true -} - -console.log('Service', serviceConfig) - -// Create a new service object -let svc = new Service(serviceConfig); - -export default svc diff --git a/service/uninstall.mjs b/service/uninstall.mjs deleted file mode 100644 index 9729d22..0000000 --- a/service/uninstall.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import svc from './service.mjs' - -// Listen for the "install" event, which indicates the -// process is available as a service. -svc.on('uninstall',function(){ - console.log('Uninstall complete.'); - console.log('The service exists: ',svc.exists); -}); - -svc.uninstall(); diff --git a/test/application.test.mjs b/test/application.test.mjs index 3611bcb..b52cb78 100644 --- a/test/application.test.mjs +++ b/test/application.test.mjs @@ -70,6 +70,24 @@ t.describe('constructor()', function() { assert.strictEqual(typeof(app.http.closeServer), 'function') }) + + + t.test('should not default updateEvery if its zero', function() { + const assertTest = { a: 1, updateEvery: 0 } + const assertName = 'test' + ctx.db.config = { + test: assertTest, + app: { b: 2}, + manage: { c: 3 }, + } + + let app = new Application(ctx, {}, assertName) + assert.notStrictEqual(app.config, assertTest) + assert.strictEqual(app.config.a, assertTest.a) + assert.strictEqual(app.config.updateEvery, assertTest.updateEvery) + assert.strictEqual(app.config.updateEvery, 0) + }) + t.test('should create http instance correctly', function() { ctx.db.config = { testapp: { a: 1, https: true }, @@ -105,6 +123,15 @@ t.timeout(250).describe('#startAutoupdater()', function() { app.startAutoupdater() }) + + t.test('should do nothing if updateEvery is zero', async function() { + const stubInterval = stub() + stubInterval.throws(new Error('should not be seen')) + ctx.db.config.test = { updateEvery: 0 } + let app = new Application(ctx, { }, 'test', { setInterval: stubInterval }) + + app.startAutoupdater() + }) t.test('should call setInterval correctly', function() { const assertTimeMinutes = 1440 diff --git a/test/core.test.integration.mjs b/test/core.test.integration.mjs index c6bfbf9..8612960 100644 --- a/test/core.test.integration.mjs +++ b/test/core.test.integration.mjs @@ -6,31 +6,30 @@ import { request } from '../core/client.mjs' import { setTimeout } from 'timers/promises' import { prettyPrintMessage } from './helpers.mjs' import { pipeline } from 'stream' +import getLog from '../core/log.mjs' const util = new Util(import.meta.url) const port = 61412 -t.timeout(5000).describe('', function() { +t.timeout(10000).describe('', function() { let server = null let prefix = `http://localhost:${port}/` let files = [] let logs = [] let versions = [] - let requests = [] let processor + let integrationLog = getLog('test.integration', []) t.before(function(cb) { server = http.createServer(function(req, res) { req.on('error', function(err) { - console.log('error', err) + integrationLog.error(err, 'error') }) res.on('error', function(err) { - console.log('error', err) + integrationLog.error(err, 'error') }) - requests.push(req.url) - - console.log(req.url) + integrationLog.info('[SERVER] got request ' + req.url) if (req.url === '/releases') { res.statusCode = 200 @@ -78,7 +77,7 @@ t.timeout(5000).describe('', function() { return fs.rm(file, { force: true, recursive: true }) })) .then(function() { - if (processor) { + if (processor && !processor.exitCode) { processor.kill() } }) @@ -105,6 +104,21 @@ t.timeout(5000).describe('', function() { const version_3_crashing = ` export function start(http, port, ctx) { + process.exit(1) + } + ` + + const version_4_stable = ` + export function start(http, port, ctx) { + const server = http.createServer(function (req, res) { + res.writeHead(200); + res.end(JSON.stringify({ version: 'v4' })) + }) + + return server.listenAsync(port, '0.0.0.0') + .then(() => { + ctx.log.info({ port: port, listening: true }, \`Server is listening on \${port} serving v4\`) + }) } ` @@ -141,6 +155,10 @@ t.timeout(5000).describe('', function() { } } + integrationLog.on('newlog', function(record) { + prettyPrintMessage(JSON.stringify(record)) + }) + let logWaitIndex = 0 function hasLogLine(regMatch) { if (logs.length > logWaitIndex) { @@ -241,9 +259,12 @@ t.timeout(5000).describe('', function() { assert.strictEqual(checkListening.body.version, 'v1') while (!hasLogLine(/core is running/)) { + catchupLog() await setTimeout(10) } + catchupLog() + let db = JSON.parse(await fs.readFile(util.getPathFromRoot('./db.json'))) assert.strictEqual(db.core.testapp.active, assertNameVersion1) assert.strictEqual(db.core.testapp.versions.length, 1) @@ -257,18 +278,11 @@ t.timeout(5000).describe('', function() { const assertNameVersion2 = 'v2_nolisten' file(`./testapp/${assertNameVersion2}`) versions.splice(0, 0, [assertNameVersion2, 'no listen version', 'v2-sc.7z']) - requests.splice(0, requests.length) - assert.strictEqual(requests.length, 0) // wait a second for it to trigger an update await setTimeout(500) - while (requests.length < 2) { - catchupLog() - await setTimeout(25) - } - while (!hasLogLine(/Error starting v2_nolisten/)) { catchupLog() await setTimeout(10) @@ -326,8 +340,8 @@ t.timeout(5000).describe('', function() { assert.ok(findInLogs(/Error starting v2_nolisten/)) processor.kill() - await waitUntilClosed() + await waitUntilClosed() processor = startRunner() listening = await waitUntilListening() @@ -350,10 +364,85 @@ t.timeout(5000).describe('', function() { assert.notOk(findInLogs(/Attempting to run version v2_nolisten/)) assert.notOk(findInLogs(/Error starting v2_nolisten/)) + // Create our third version + await fs.writeFile(index, version_3_crashing) + await util.runCommand(util.get7zipExecutable(), ['a', file('./v3-sc.7z'), index], util.getPathFromRoot('./testapp')) + + const assertNameVersion3 = 'v3_crash' + file(`./testapp/${assertNameVersion3}`) + versions.splice(0, 0, [assertNameVersion3, 'crash version', 'v3-sc.7z']) + + // wait a second for it to trigger an update + + await setTimeout(500) + + while (!hasLogLine(/Attempting to run version v3_crash/)) { + catchupLog() + await setTimeout(10) + } + while (processor.exitCode == null) { catchupLog() await setTimeout(10) } + + db = JSON.parse(await fs.readFile(util.getPathFromRoot('./db.json'))) + assert.strictEqual(db.core.testapp.active, assertNameVersion3) + assert.strictEqual(db.core.testapp.versions.length, 3) + assert.strictEqual(db.core.testapp.versions[0].stable, -2) + assert.strictEqual(db.core.testapp.versions[1].stable, -2) + assert.strictEqual(db.core.testapp.versions[2].stable, 1) + catchupLog() + + // Should recover afterwards + await waitUntilClosed() + processor = startRunner() + + listening = await waitUntilListening() + + assert.ok(listening) + + checkListening = await request({}, `http://localhost:${listening.port}/`) + assert.strictEqual(checkListening.body.version, 'v1') + + while (!hasLogLine(/core is running/)) { + await setTimeout(10) + } + + // Create our fourth version + await fs.writeFile(index, version_4_stable) + await util.runCommand(util.get7zipExecutable(), ['a', file('./v4-sc.7z'), index], util.getPathFromRoot('./testapp')) + + const assertNameVersion4 = 'v4_stable' + file(`./testapp/${assertNameVersion4}`) + versions.splice(0, 0, [assertNameVersion4, 'no listen version', 'v4-sc.7z']) + + // wait a second for it to trigger an update + + await setTimeout(500) + + while (!hasLogLine(/Attempting to run version v4_stable/)) { + catchupLog() + await setTimeout(10) + } + + while (!hasLogLine(/Server is listening on 31313 serving v4/)) { + catchupLog() + await setTimeout(10) + } + + catchupLog() + + checkListening = await request({}, `http://localhost:${listening.port}/`) + assert.strictEqual(checkListening.body.version, 'v4') + + await setTimeout(10) + + db = JSON.parse(await fs.readFile(util.getPathFromRoot('./db.json'))) + assert.strictEqual(db.core.testapp.active, assertNameVersion4) + assert.strictEqual(db.core.testapp.versions.length, 4) + assert.strictEqual(db.core.testapp.versions[0].stable, 1) + assert.strictEqual(db.core.testapp.versions[1].stable, -2) }) }) diff --git a/test/core.test.mjs b/test/core.test.mjs index 0f2ceed..0a3c271 100644 --- a/test/core.test.mjs +++ b/test/core.test.mjs @@ -126,27 +126,6 @@ t.describe('Core.addProvider()', function() { }) t.describe('#constructor()', function() { - t.test('should throw if close is not a function', function() { - let tests = [ - [1, 'number'], - [0, 'false number'], - ['asdf', 'string'], - ['', 'false string'], - [[], 'array'], - [{}, 'object'], - ] - - tests.forEach(function(check) { - assert.throws(function() { - new Core(db, util, log, check[0]) - }, function(err) { - assert.match(err.message, /restart/i) - assert.match(err.message, /function/i) - return true - }, `throw if restart is ${check[1]}`) - }) - }) - t.test('should throw if util is not util', function() { let tests = [ [1, 'number'], @@ -160,7 +139,7 @@ t.describe('#constructor()', function() { tests.forEach(function(check) { assert.throws(function() { - new Core(db, check[0], log, function() {}) + new Core(db, check[0], log) }, function(err) { assert.match(err.message, /util/i) assert.match(err.message, /instance/i) @@ -182,7 +161,7 @@ t.describe('#constructor()', function() { tests.forEach(function(check) { assert.throws(function() { - new Core(check[0], util, log, function() {}) + new Core(check[0], util, log) }, function(err) { assert.match(err.message, /db/i) assert.match(err.message, /instance/i) @@ -230,13 +209,11 @@ t.describe('#constructor()', function() { t.test('should accept log, util and close function', function() { const assertLog = log - const assertClose = function() {} - let core = new Core(db, util, assertLog, assertClose) + let core = new Core(db, util, assertLog) assert.strictEqual(core.db, db) assert.strictEqual(core.util, util) assert.strictEqual(core.log, assertLog) - assert.strictEqual(core.restart, assertClose) assert.deepStrictEqual(core.applications, []) assert.ok(core.applicationMap) }) @@ -247,7 +224,7 @@ t.describe('#getApplication()', function() { t.test('should return application based on the name', function() { const assertName = 'Yami no Naka' const assertApplication = { a: 1 } - let core = new Core(db, util, log, function() {}) + let core = new Core(db, util, log) core.applicationMap.set(assertName, assertApplication) assert.strictEqual(core.getApplication(assertName), assertApplication) }) @@ -268,7 +245,7 @@ t.describe('#init()', function() { t.beforeEach(function() { log.error.reset() - core = new Core(db, util, log, function() {}) + core = new Core(db, util, log) core.util = fakeUtil = { verifyConfig: stub(), getAppNames: stub().returns([]), @@ -453,7 +430,7 @@ t.describe('#run()', function() { versions: [] }, } - core = new Core(db, util, log, function() {}) + core = new Core(db, util, log) core.runApplication = stubRunApplication = stub().resolves() db.write = stubWrite = stub().resolves() log.info.reset() @@ -642,7 +619,7 @@ t.describe('#runApplication()', function() { versions: [] } } - core = new Core(db, util, log, function() {}) + core = new Core(db, util, log) db.write = stubWrite = stub().resolves() log.info.reset() log.warn.reset() @@ -770,7 +747,6 @@ t.describe('#runApplication()', function() { assert.strict(testApp.runVersion.firstCall[0], '32') }) - t.test('should skip version with stable of -1 if fresh is false', async function() { const assertError = new Error('Daikichi to Rin') testApp.runVersion.rejects(assertError) @@ -841,7 +817,7 @@ t.describe('#runApplication()', function() { assert.match(testApp.ctx.log.error.secondCall[1], new RegExp('31')) assert.match(testApp.ctx.log.error.secondCall[1], new RegExp(assertError.message)) - assert.strictEqual(db.data.core[testAppName].versions[0].stable, -1) + assert.strictEqual(db.data.core[testAppName].versions[0].stable, -2) assert.strictEqual(db.data.core[testAppName].versions[1].stable, -2) assert.ok(stubWrite.callCount, 2) @@ -1003,3 +979,88 @@ t.describe('#runApplication()', function() { assert.ok(stubWrite.called) }) }) + +t.describe('#criticalError()', function() { + let core + let testApp + let testAppName + let stubWrite + + t.beforeEach(function() { + testAppName = 'nano.RIPE' + core = new Core(db, util, log) + db.writeSync = stubWrite = stub() + log.info.reset() + log.warn.reset() + log.error.reset() + testApp = { + name: testAppName, + fresh: false, + ctx: { + log: { + info: stub(), + warn: stub(), + error: stub(), + fatal: stub(), + }, + }, + closeServer: stub(), + runVersion: stub(), + } + }) + + t.test('should log to fatal', function() { + const assertVersion = { + version: 'Dai kirai! Aishiteru', + stable: 0, + } + assert.notOk(testApp.ctx.log.fatal.called) + core.criticalError(testApp, assertVersion) + assert.ok(testApp.ctx.log.fatal.called) + assert.match(testApp.ctx.log.fatal.firstCall[0], /critical/i) + assert.match(testApp.ctx.log.fatal.firstCall[0], new RegExp(assertVersion.version)) + }) + + t.test('should always change to stable -2 regardless of fresh', function() { + const assertVersion = { + version: 'Dai kirai! Aishiteru', + stable: 0, + } + testApp.fresh = false + + assertVersion.stable = 5 + core.criticalError(testApp, assertVersion) + assert.strictEqual(assertVersion.stable, -2) + + assertVersion.stable = -1 + core.criticalError(testApp, assertVersion) + assert.strictEqual(assertVersion.stable, -2) + + testApp.fresh = true + + assertVersion.stable = 5 + core.criticalError(testApp, assertVersion) + assert.strictEqual(assertVersion.stable, -2) + + assertVersion.stable = -1 + core.criticalError(testApp, assertVersion) + assert.strictEqual(assertVersion.stable, -2) + }) + + t.test('should call db.writeSync afterwards', function() { + let checkStable = 0 + const assertVersion = { + version: 'Dai kirai! Aishiteru', + stable: 0, + } + + stubWrite.returnWith(function() { + checkStable = assertVersion.stable + }) + + assert.notOk(stubWrite.called) + core.criticalError(testApp, assertVersion) + assert.ok(stubWrite.called) + assert.strictEqual(checkStable, -2) + }) +}) \ No newline at end of file diff --git a/test/db.test.mjs b/test/db.test.mjs index 8db534f..4064746 100644 --- a/test/db.test.mjs +++ b/test/db.test.mjs @@ -1,5 +1,6 @@ import { Eltro as t, assert, stub } from 'eltro' import fs from 'fs/promises' +import fsSync from 'fs' import lowdb from '../core/db.mjs' import Util from '../core/util.mjs' @@ -36,6 +37,35 @@ t.test('Should auto create file with some defaults', async function() { assert.ok(db.data.core.version) assert.notOk(db.data.core.app) assert.notOk(db.data.core.manager) + assert.strictEqual(db.filename, util.getPathFromRoot('./db_test.json')) +}) + +t.test('#writeSync() Should support syncronous write', function() { + const filename = util.getPathFromRoot('./db_test.json') + const assertValue = 'Yume no Naka no Watashi no Yume' + + return lowdb({}, logger, filename) + .then(function(db) { + db.data.songtest = assertValue + + db.writeSync() + + let content = JSON.parse(fsSync.readFileSync(filename)) + assert.strictEqual(content.songtest, assertValue) + }) +}) + +t.test('#writeSync() Should not throw', async function() { + let db = await lowdb({}, logger, util.getPathFromRoot('./db_test.json')) + + db.filename = util.getPathFromRoot('../test') + + assert.notOk(db.log.error.called) + db.writeSync() + assert.ok(db.log.error.called) + assert.match(db.log.error.firstCall[0].message, /directory/i) + assert.match(db.log.error.firstCall[1], /writ/) + assert.match(db.log.error.firstCall[1], new RegExp(db.filename.replace(/\\/g, '\\\\'))) }) t.test('Should support in-memory db', async function() { diff --git a/uninstall.bat b/uninstall.bat deleted file mode 100644 index 581acec..0000000 --- a/uninstall.bat +++ /dev/null @@ -1,5 +0,0 @@ -@setlocal enableextensions -@cd /d "%~dp0" - -node service\uninstall.mjs -PAUSE \ No newline at end of file