Merge develop
This commit is contained in:
parent
c73308e08c
commit
96555d884b
14 changed files with 1697 additions and 4605 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,3 +1,4 @@
|
|||
.pnpm-debug.log
|
||||
.env
|
||||
.env*
|
||||
node_modules/
|
||||
|
|
|
@ -30,12 +30,15 @@
|
|||
"zustand": "^3.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.0.0",
|
||||
"@types/js-cookie": "^3.0.2",
|
||||
"@types/node": "17.0.31",
|
||||
"@types/react": "18.0.8",
|
||||
"@types/react-dom": "18.0.3",
|
||||
"@types/validator": "^13.7.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
||||
"@typescript-eslint/parser": "^5.0.0",
|
||||
"eslint-plugin-import": "^2.25.3",
|
||||
"autoprefixer": "^10.4.4",
|
||||
"eslint": "8.12.0",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.cjs
|
||||
*.cjs
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
module.exports = {
|
||||
env: { node: true },
|
||||
env: { node: true, jest: true },
|
||||
extends: ['airbnb-typescript', 'eslint:recommended', 'plugin:import/typescript'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
|
|
7
packages/system-api/jest.config.cjs
Normal file
7
packages/system-api/jest.config.cjs
Normal file
|
@ -0,0 +1,7 @@
|
|||
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
testMatch: ['**/__tests__/**/*.test.ts'],
|
||||
setupFiles: ['dotenv/config'],
|
||||
};
|
|
@ -2,12 +2,15 @@
|
|||
"name": "system-api",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/server.ts",
|
||||
"exports": "./dist/server.js",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=14.16"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rimraf dist",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"test": "jest",
|
||||
"build-prod": "esbuild --bundle src/server.ts --outdir=dist --allow-overwrite --sourcemap --platform=node --minify --analyze=verbose --external:./node_modules/* --format=esm",
|
||||
"build:watch": "esbuild --bundle src/server.ts --outdir=dist --allow-overwrite --sourcemap --platform=node --external:./node_modules/* --format=esm --watch",
|
||||
"start:dev": "NODE_ENV=development nodemon --trace-deprecation --trace-warnings --watch dist dist/server.js",
|
||||
|
@ -24,7 +27,7 @@
|
|||
"dotenv": "^16.0.0",
|
||||
"express": "^4.17.3",
|
||||
"helmet": "^5.0.2",
|
||||
"internal-ip": "^7.0.0",
|
||||
"internal-ip": "^6.0.0",
|
||||
"jsonwebtoken": "^8.5.1",
|
||||
"node-port-scanner": "^3.0.1",
|
||||
"p-iteration": "^1.1.8",
|
||||
|
@ -41,23 +44,25 @@
|
|||
"@types/cookie-parser": "^1.4.3",
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/jest": "^27.5.0",
|
||||
"@types/jsonwebtoken": "^8.5.8",
|
||||
"@types/passport": "^1.0.7",
|
||||
"@types/passport-http-bearer": "^1.0.37",
|
||||
"@types/tcp-port-used": "^1.0.1",
|
||||
"@types/validator": "^13.7.2",
|
||||
"@typescript-eslint/eslint-plugin": "^5.18.0",
|
||||
"@typescript-eslint/parser": "^5.22.0",
|
||||
"concurrently": "^7.1.0",
|
||||
"esbuild": "^0.14.32",
|
||||
"eslint": "^8.13.0",
|
||||
"eslint-config-airbnb-typescript": "^17.0.0",
|
||||
"eslint-config-hardcore": "^24.5.0",
|
||||
"eslint-config-prettier": "^8.5.0",
|
||||
"eslint-config-react": "^1.1.7",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-prettier": "^4.0.0",
|
||||
"eslint-plugin-react": "^7.29.4",
|
||||
"eslint-plugin-unicorn": "^42.0.0",
|
||||
"jest": "^28.1.0",
|
||||
"nodemon": "^2.0.15",
|
||||
"prettier": "2.6.2"
|
||||
"prettier": "2.6.2",
|
||||
"ts-jest": "^28.0.2",
|
||||
"typescript": "4.6.4"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,11 @@ interface IConfig {
|
|||
CLIENT_URLS: string[];
|
||||
}
|
||||
|
||||
dotenv.config();
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
dotenv.config({ path: '.env.test' });
|
||||
} else {
|
||||
dotenv.config();
|
||||
}
|
||||
|
||||
const { NODE_ENV = 'development', ROOT_FOLDER = '', JWT_SECRET = '', INTERNAL_IP = '' } = process.env;
|
||||
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
import AppsService from '../apps.service';
|
||||
|
||||
describe('Install app', () => {
|
||||
it('Should throw when app is not available', () => {
|
||||
expect(AppsService.installApp('not-available', {})).rejects.toThrow('App not-available not available');
|
||||
});
|
||||
});
|
|
@ -1,34 +1,7 @@
|
|||
import { NextFunction, Request, Response } from 'express';
|
||||
import si from 'systeminformation';
|
||||
import { appNames } from '../../config/apps';
|
||||
import AppsService from './apps.service';
|
||||
import { AppConfig } from '../../config/types';
|
||||
import { createFolder, fileExists, readJsonFile, writeFile, readFile } from '../fs/fs.helpers';
|
||||
import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, getInitalFormValues, runAppScript } from './apps.helpers';
|
||||
|
||||
type AppsState = { installed: string };
|
||||
|
||||
const getStateFile = (): AppsState => {
|
||||
return readJsonFile('/state/apps.json');
|
||||
};
|
||||
|
||||
const generateEnvFile = (appName: string, form: Record<string, string>) => {
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
|
||||
const baseEnvFile = readFile('/.env').toString();
|
||||
let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
|
||||
|
||||
Object.keys(configFile.form_fields).forEach((key) => {
|
||||
const value = form[key];
|
||||
|
||||
if (value) {
|
||||
const envVar = configFile.form_fields[key].env_variable;
|
||||
envFile += `${envVar}=${value}\n`;
|
||||
} else if (configFile.form_fields[key].required) {
|
||||
throw new Error(`Variable ${key} is required`);
|
||||
}
|
||||
});
|
||||
|
||||
writeFile(`/app-data/${appName}/app.env`, envFile);
|
||||
};
|
||||
import { getInitalFormValues } from './apps.helpers';
|
||||
|
||||
const uninstallApp = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
|
@ -38,11 +11,7 @@ const uninstallApp = async (req: Request, res: Response, next: NextFunction) =>
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
checkAppExists(appName);
|
||||
ensureAppState(appName, false);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['uninstall', appName]);
|
||||
await AppsService.uninstallApp(appName);
|
||||
|
||||
res.status(200).json({ message: 'App uninstalled successfully' });
|
||||
} catch (e) {
|
||||
|
@ -58,9 +27,7 @@ const stopApp = async (req: Request, res: Response, next: NextFunction) => {
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
checkAppExists(appName);
|
||||
// Run script
|
||||
await runAppScript(['stop', appName]);
|
||||
await AppsService.stopApp(appName);
|
||||
|
||||
res.status(200).json({ message: 'App stopped successfully' });
|
||||
} catch (e) {
|
||||
|
@ -77,8 +44,7 @@ const updateAppConfig = async (req: Request, res: Response, next: NextFunction)
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
checkAppExists(appName);
|
||||
generateEnvFile(appName, form);
|
||||
AppsService.updateAppConfig(appName, form);
|
||||
|
||||
res.status(200).json({ message: 'App updated successfully' });
|
||||
} catch (e) {
|
||||
|
@ -94,15 +60,9 @@ const getAppInfo = async (req: Request, res: Response<AppConfig>, next: NextFunc
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
const dockerContainers = await si.dockerContainers();
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${id}/config.json`);
|
||||
const appInfo = await AppsService.getAppInfo(id);
|
||||
|
||||
const state = getStateFile();
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
configFile.installed = installed.includes(id);
|
||||
configFile.status = (dockerContainers.find((container) => container.name === `${id}`)?.state as 'running') || 'stopped';
|
||||
|
||||
res.status(200).json(configFile);
|
||||
res.status(200).json(appInfo);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
|
@ -110,25 +70,7 @@ const getAppInfo = async (req: Request, res: Response<AppConfig>, next: NextFunc
|
|||
|
||||
const listApps = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const apps = appNames
|
||||
.map((app) => {
|
||||
try {
|
||||
return readJsonFile(`/apps/${app}/config.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const dockerContainers = await si.dockerContainers();
|
||||
|
||||
const state = getStateFile();
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
|
||||
apps.forEach((app) => {
|
||||
app.installed = installed.includes(app.id);
|
||||
app.status = dockerContainers.find((container) => container.name === `${app.id}`)?.state || 'stopped';
|
||||
});
|
||||
const apps = await AppsService.listApps();
|
||||
|
||||
res.status(200).json(apps);
|
||||
} catch (e) {
|
||||
|
@ -138,23 +80,13 @@ const listApps = async (req: Request, res: Response, next: NextFunction) => {
|
|||
|
||||
const startApp = async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const { id: appName } = req.params;
|
||||
const { id } = req.params;
|
||||
|
||||
if (!appName) {
|
||||
if (!id) {
|
||||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
checkAppExists(appName);
|
||||
checkEnvFile(appName);
|
||||
|
||||
// Regenerate env file
|
||||
const form = getInitalFormValues(appName);
|
||||
generateEnvFile(appName, form);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['start', appName]);
|
||||
|
||||
ensureAppState(appName, true);
|
||||
await AppsService.startApp(id);
|
||||
|
||||
res.status(200).json({ message: 'App started successfully' });
|
||||
} catch (e) {
|
||||
|
@ -171,35 +103,7 @@ const installApp = async (req: Request, res: Response, next: NextFunction) => {
|
|||
throw new Error('App name is required');
|
||||
}
|
||||
|
||||
const appIsAvailable = appNames.includes(id);
|
||||
|
||||
if (!appIsAvailable) {
|
||||
throw new Error(`App ${id} not available`);
|
||||
}
|
||||
|
||||
const appExists = fileExists(`/app-data/${id}`);
|
||||
|
||||
if (appExists) {
|
||||
await startApp(req, res, next);
|
||||
} else {
|
||||
const appIsValid = await checkAppRequirements(id);
|
||||
|
||||
if (!appIsValid) {
|
||||
throw new Error(`App ${id} requirements not met`);
|
||||
}
|
||||
|
||||
// Create app folder
|
||||
createFolder(`/app-data/${id}`);
|
||||
|
||||
// Create env file
|
||||
generateEnvFile(id, form);
|
||||
ensureAppState(id, true);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['install', id]);
|
||||
|
||||
res.status(200).json({ message: 'App installed successfully' });
|
||||
}
|
||||
await AppsService.installApp(id, form);
|
||||
} catch (e) {
|
||||
next(e);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,9 @@ import portUsed from 'tcp-port-used';
|
|||
import p from 'p-iteration';
|
||||
import { AppConfig } from '../../config/types';
|
||||
import { fileExists, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
|
||||
import { internalIpV4 } from 'internal-ip';
|
||||
import InternalIp from 'internal-ip';
|
||||
|
||||
type AppsState = { installed: string };
|
||||
|
||||
export const checkAppRequirements = async (appName: string) => {
|
||||
let valid = true;
|
||||
|
@ -10,7 +12,7 @@ export const checkAppRequirements = async (appName: string) => {
|
|||
|
||||
if (configFile.requirements?.ports) {
|
||||
await p.forEachSeries(configFile.requirements.ports, async (port: number) => {
|
||||
const ip = await internalIpV4();
|
||||
const ip = await InternalIp.v4();
|
||||
const used = await portUsed.check(port, ip);
|
||||
|
||||
if (used) valid = false;
|
||||
|
@ -99,3 +101,26 @@ export const ensureAppState = (appName: string, installed: boolean) => {
|
|||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const generateEnvFile = (appName: string, form: Record<string, string>) => {
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${appName}/config.json`);
|
||||
const baseEnvFile = readFile('/.env').toString();
|
||||
let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
|
||||
|
||||
Object.keys(configFile.form_fields).forEach((key) => {
|
||||
const value = form[key];
|
||||
|
||||
if (value) {
|
||||
const envVar = configFile.form_fields[key].env_variable;
|
||||
envFile += `${envVar}=${value}\n`;
|
||||
} else if (configFile.form_fields[key].required) {
|
||||
throw new Error(`Variable ${key} is required`);
|
||||
}
|
||||
});
|
||||
|
||||
writeFile(`/app-data/${appName}/app.env`, envFile);
|
||||
};
|
||||
|
||||
export const getStateFile = (): AppsState => {
|
||||
return readJsonFile('/state/apps.json');
|
||||
};
|
||||
|
|
106
packages/system-api/src/modules/apps/apps.service.ts
Normal file
106
packages/system-api/src/modules/apps/apps.service.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
import si from 'systeminformation';
|
||||
import { appNames } from '../../config/apps';
|
||||
import { AppConfig } from '../../config/types';
|
||||
import { createFolder, fileExists, readJsonFile } from '../fs/fs.helpers';
|
||||
import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, generateEnvFile, getInitalFormValues, getStateFile, runAppScript } from './apps.helpers';
|
||||
|
||||
const startApp = async (appName: string): Promise<void> => {
|
||||
checkAppExists(appName);
|
||||
checkEnvFile(appName);
|
||||
|
||||
// Regenerate env file
|
||||
const form = getInitalFormValues(appName);
|
||||
generateEnvFile(appName, form);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['start', appName]);
|
||||
|
||||
ensureAppState(appName, true);
|
||||
};
|
||||
|
||||
const installApp = async (id: string, form: Record<string, string>): Promise<void> => {
|
||||
const appIsAvailable = appNames.includes(id);
|
||||
|
||||
if (!appIsAvailable) {
|
||||
throw new Error(`App ${id} not available`);
|
||||
}
|
||||
|
||||
const appExists = fileExists(`/app-data/${id}`);
|
||||
|
||||
if (appExists) {
|
||||
await startApp(id);
|
||||
} else {
|
||||
const appIsValid = await checkAppRequirements(id);
|
||||
|
||||
if (!appIsValid) {
|
||||
throw new Error(`App ${id} requirements not met`);
|
||||
}
|
||||
|
||||
// Create app folder
|
||||
createFolder(`/app-data/${id}`);
|
||||
|
||||
// Create env file
|
||||
generateEnvFile(id, form);
|
||||
ensureAppState(id, true);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['install', id]);
|
||||
}
|
||||
};
|
||||
|
||||
const listApps = async (): Promise<AppConfig[]> => {
|
||||
const apps: AppConfig[] = appNames
|
||||
.map((app) => {
|
||||
try {
|
||||
return readJsonFile(`/apps/${app}/config.json`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const dockerContainers = await si.dockerContainers();
|
||||
|
||||
const state = getStateFile();
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
|
||||
apps.forEach((app) => {
|
||||
app.installed = installed.includes(app.id);
|
||||
app.status = (dockerContainers.find((container) => container.name === `${app.id}`)?.state as 'running') || 'stopped';
|
||||
});
|
||||
|
||||
return apps;
|
||||
};
|
||||
|
||||
const getAppInfo = async (id: string): Promise<AppConfig> => {
|
||||
const dockerContainers = await si.dockerContainers();
|
||||
const configFile: AppConfig = readJsonFile(`/apps/${id}/config.json`);
|
||||
|
||||
const state = getStateFile();
|
||||
const installed: string[] = state.installed.split(' ').filter(Boolean);
|
||||
configFile.installed = installed.includes(id);
|
||||
configFile.status = (dockerContainers.find((container) => container.name === `${id}`)?.state as 'running') || 'stopped';
|
||||
|
||||
return configFile;
|
||||
};
|
||||
|
||||
const updateAppConfig = async (id: string, form: Record<string, string>): Promise<void> => {
|
||||
checkAppExists(id);
|
||||
generateEnvFile(id, form);
|
||||
};
|
||||
|
||||
const stopApp = async (id: string): Promise<void> => {
|
||||
checkAppExists(id);
|
||||
// Run script
|
||||
await runAppScript(['stop', id]);
|
||||
};
|
||||
|
||||
const uninstallApp = async (id: string): Promise<void> => {
|
||||
checkAppExists(id);
|
||||
ensureAppState(id, false);
|
||||
|
||||
// Run script
|
||||
await runAppScript(['uninstall', id]);
|
||||
};
|
||||
|
||||
export default { installApp, startApp, listApps, getAppInfo, updateAppConfig, stopApp, uninstallApp };
|
|
@ -1,7 +1,7 @@
|
|||
import { Request, Response } from 'express';
|
||||
import publicIp from 'public-ip';
|
||||
import portScanner from 'node-port-scanner';
|
||||
import { internalIpV4 } from 'internal-ip';
|
||||
import internalIp from 'internal-ip';
|
||||
|
||||
const isPortOpen = async (req: Request, res: Response<boolean>) => {
|
||||
const { port } = req.params;
|
||||
|
@ -14,7 +14,7 @@ const isPortOpen = async (req: Request, res: Response<boolean>) => {
|
|||
};
|
||||
|
||||
const getInternalIp = async (req: Request, res: Response<string>) => {
|
||||
const ip = await internalIpV4();
|
||||
const ip = await internalIp.v4();
|
||||
|
||||
res.status(200).send(ip);
|
||||
};
|
||||
|
|
|
@ -8,13 +8,13 @@
|
|||
"forceConsistentCasingInFileNames": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"isolatedModules": false,
|
||||
"jsx": "preserve",
|
||||
"incremental": true
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs"],
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.cjs", "jest.config.cjs"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
|
5988
pnpm-lock.yaml
5988
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue