Merge pull request #15 from meienberger/tests/jest-setup

Tests/jest setup
This commit is contained in:
Nicolas Meienberger 2022-05-11 19:35:55 +00:00 committed by GitHub
commit fa0a7d0764
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 2158 additions and 4635 deletions

48
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,48 @@
name: Tipi CI
on:
push:
env:
ROOT_FOLDER: /test
jobs:
cache-and-install:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 16
- uses: pnpm/action-setup@v2.0.1
name: Install pnpm
id: pnpm-install
with:
version: 7
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
run: |
echo "::set-output name=pnpm_cache_dir::$(pnpm store path)"
- uses: actions/cache@v3
name: Setup pnpm cache
with:
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Run tests
run: pnpm -r lint
- name: Run tests
run: pnpm -r test

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
.pnpm-debug.log
.env
.env*
node_modules/

View file

@ -1,5 +1,6 @@
{
"name": "File Browser",
"available": true,
"port": 8096,
"id": "filebrowser",
"description": "Reliable and Performant File Management Desktop Sync and File Sharing",

View file

@ -1,5 +1,6 @@
{
"name": "FreshRSS",
"available": true,
"port": 8086,
"id": "freshrss",
"description": "FreshRSS is a self-hosted RSS feed aggregator like Leed or Kriss Feed.\nIt is lightweight, easy to work with, powerful, and customizable.\n\nIt is a multi-user application with an anonymous reading mode. It supports custom tags. There is an API for (mobile) clients, and a Command-Line Interface.\n\nThanks to the WebSub standard (formerly PubSubHubbub), FreshRSS is able to receive instant push notifications from compatible sources, such as Mastodon, Friendica, WordPress, Blogger, FeedBurner, etc.\n\nFreshRSS natively supports basic Web scraping, based on XPath, for Web sites not providing any RSS / Atom feed.\n\nFinally, it supports extensions for further tuning.",

View file

@ -1,5 +1,6 @@
{
"name": "Invidious",
"available": true,
"port": 8095,
"id": "invidious",
"description": "",

View file

@ -1,5 +1,6 @@
{
"name": "Jackett",
"available": true,
"port": 8097,
"id": "jackett",
"description": "Jackett works as a proxy server: it translates queries from apps (Sonarr, Radarr, SickRage, CouchPotato, Mylar3, Lidarr, DuckieTV, qBittorrent, Nefarious etc.) into tracker-site-specific http queries, parses the html or json response, and then sends results back to the requesting software. This allows for getting recent uploads (like RSS) and performing searches.",
@ -7,7 +8,5 @@
"author": "",
"source": "https://github.com/Jackett/Jackett",
"image": "https://avatars.githubusercontent.com/u/15383019?s=200&v=4",
"form_fields": {
}
"form_fields": {}
}

View file

@ -1,5 +1,6 @@
{
"name": "Jellyfin",
"available": true,
"port": 8091,
"id": "jellyfin",
"description": "",

View file

@ -1,5 +1,6 @@
{
"name": "Joplin Server",
"available": true,
"port": 8099,
"id": "joplin",
"description": "",

View file

@ -1,5 +1,6 @@
{
"name": "n8n",
"available": true,
"port": 8094,
"id": "n8n",
"description": "n8n is an extendable workflow automation tool. With a fair-code distribution model, n8n will always have visible source code, be available to self-host, and allow you to add your own custom functions, logic and apps. n8n's node-based approach makes it highly versatile, enabling you to connect anything to everything.",

View file

@ -1,5 +1,6 @@
{
"name": "Nextcloud",
"available": true,
"port": 8083,
"id": "nextcloud",
"description": "Nextcloud is a self-hosted, open source, and fully-featured cloud storage solution for your personal files, office documents, and photos.",

View file

@ -1,23 +1,24 @@
{
"name": "PiHole",
"port": 8081,
"requirements": {
"ports": [53]
},
"id": "pihole",
"description": "",
"short_desc": "",
"author": "",
"source": "",
"image": "https://avatars.githubusercontent.com/u/16827203?s=200&v=4",
"form_fields": {
"password": {
"type": "password",
"label": "Password",
"max": 50,
"min": 3,
"required": true,
"env_variable": "APP_PASSWORD"
}
"name": "PiHole",
"available": true,
"port": 8081,
"requirements": {
"ports": [53]
},
"id": "pihole",
"description": "",
"short_desc": "",
"author": "",
"source": "",
"image": "https://avatars.githubusercontent.com/u/16827203?s=200&v=4",
"form_fields": {
"password": {
"type": "password",
"label": "Password",
"max": 50,
"min": 3,
"required": true,
"env_variable": "APP_PASSWORD"
}
}
}
}

View file

@ -1,5 +1,6 @@
{
"name": "Radarr",
"available": true,
"port": 8088,
"id": "radarr",
"description": "",

View file

@ -1,5 +1,6 @@
{
"name": "Sonarr",
"available": true,
"port": 8098,
"id": "sonarr",
"description": "",
@ -7,7 +8,5 @@
"author": "",
"source": "",
"image": "https://avatars.githubusercontent.com/u/1082903?s=200&v=4",
"form_fields": {
}
"form_fields": {}
}

View file

@ -1,5 +1,6 @@
{
"name": "Syncthing",
"available": true,
"port": 8090,
"id": "syncthing",
"description": "Syncthing is a peer-to-peer continuous file synchronization program. It synchronizes files between two or more computers in real time, safely protected from prying eyes. Your data is your data alone and you deserve to choose where it is stored, whether it is shared with some third party, and how it's transmitted over the internet.\n\nInstall the Syncthing app on your Umbrel and pair it with the Syncthing app on your phone or computer for a self hosted peer-to-peer backup solution.",

View file

@ -1,5 +1,6 @@
{
"name": "Tailscale",
"available": true,
"port": 8093,
"id": "tailscale",
"description": "",

View file

@ -1,5 +1,6 @@
{
"name": "Transmission",
"available": true,
"port": 8089,
"requirements": {
"ports": [51413]

View file

@ -1,5 +1,6 @@
{
"name": "Wireguard",
"available": true,
"port": 8082,
"requirements": {
"ports": [51820]

View file

@ -1,4 +0,0 @@
# Script to clean up the setup
./scripts/stop.sh
sudo rm -rf app-data/**

View file

@ -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",

View file

@ -1,3 +1,3 @@
node_modules/
dist/
*.cjs
*.cjs

View file

@ -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: {

View file

@ -0,0 +1,9 @@
const childProcess: { execFile: typeof execFile } = jest.genMockFromModule('child_process');
const execFile = (path: string, args: string[], thing: any, callback: Function) => {
callback();
};
childProcess.execFile = execFile;
module.exports = childProcess;

View file

@ -0,0 +1,86 @@
import path from 'path';
const fs: {
__createMockFiles: typeof createMockFiles;
readFileSync: typeof readFileSync;
existsSync: typeof existsSync;
writeFileSync: typeof writeFileSync;
mkdirSync: typeof mkdirSync;
rmSync: typeof rmSync;
readdirSync: typeof readdirSync;
copyFileSync: typeof copyFileSync;
} = jest.genMockFromModule('fs');
let mockFiles = Object.create(null);
const createMockFiles = (newMockFiles: Record<string, string>) => {
mockFiles = Object.create(null);
// Create folder tree
for (const file in newMockFiles) {
const dir = path.dirname(file);
if (!mockFiles[dir]) {
mockFiles[dir] = [];
}
mockFiles[dir].push(path.basename(file));
mockFiles[file] = newMockFiles[file];
}
};
const readFileSync = (p: string) => {
return mockFiles[p];
};
const existsSync = (p: string) => {
return mockFiles[p] !== undefined;
};
const writeFileSync = (p: string, data: any) => {
mockFiles[p] = data;
};
const mkdirSync = (p: string) => {
mockFiles[p] = Object.create(null);
};
const rmSync = (p: string, options: { recursive: boolean }) => {
if (options.recursive) {
delete mockFiles[p];
} else {
delete mockFiles[p][Object.keys(mockFiles[p])[0]];
}
};
const readdirSync = (p: string) => {
const files: string[] = [];
const depth = p.split('/').length;
Object.keys(mockFiles).forEach((file) => {
if (file.startsWith(p)) {
const fileDepth = file.split('/').length;
if (fileDepth === depth + 1) {
files.push(file.split('/').pop() || '');
}
}
});
return files;
};
const copyFileSync = (source: string, destination: string) => {
mockFiles[destination] = mockFiles[source];
};
fs.readdirSync = readdirSync;
fs.existsSync = existsSync;
fs.readFileSync = readFileSync;
fs.writeFileSync = writeFileSync;
fs.mkdirSync = mkdirSync;
fs.rmSync = rmSync;
fs.copyFileSync = copyFileSync;
fs.__createMockFiles = createMockFiles;
module.exports = fs;

View file

@ -0,0 +1,7 @@
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
setupFiles: ['<rootDir>/tests/dotenv-config.ts'],
};

View file

@ -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",
@ -33,7 +36,8 @@
"passport-http-bearer": "^1.0.1",
"public-ip": "^5.0.0",
"systeminformation": "^5.11.9",
"tcp-port-used": "^1.0.2"
"tcp-port-used": "^1.0.2",
"mock-fs": "^5.1.2"
},
"devDependencies": {
"@types/bcrypt": "^5.0.0",
@ -41,23 +45,26 @@
"@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/mock-fs": "^4.13.1",
"@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"
}
}

View file

@ -19,6 +19,7 @@ export type Maybe<T> = T | null | undefined;
export interface AppConfig {
id: string;
available: boolean;
port: number;
name: string;
requirements?: {

View file

@ -0,0 +1,258 @@
import AppsService from '../apps.service';
import fs from 'fs';
import config from '../../../config';
import { AppConfig, FieldTypes } from '../../../config/types';
import childProcess from 'child_process';
jest.mock('fs');
jest.mock('child_process');
beforeEach(() => {
jest.resetModules();
jest.resetAllMocks();
});
const testApp: Partial<AppConfig> = {
id: 'test-app',
port: 3000,
available: true,
form_fields: {
test: {
type: FieldTypes.text,
label: 'Test field',
required: true,
env_variable: 'TEST_FIELD',
},
test2: {
type: FieldTypes.text,
label: 'Test field 2',
required: false,
env_variable: 'TEST_FIELD_2',
},
},
};
const testApp2: Partial<AppConfig> = {
available: true,
id: 'test-app2',
};
const MOCK_FILE_EMPTY = {
[`${config.ROOT_FOLDER}/apps/test-app/config.json`]: JSON.stringify(testApp),
[`${config.ROOT_FOLDER}/.env`]: 'TEST=test',
[`${config.ROOT_FOLDER}/state/apps.json`]: '{"installed": ""}',
};
const MOCK_FILE_INSTALLED = {
[`${config.ROOT_FOLDER}/apps/test-app/config.json`]: JSON.stringify(testApp),
[`${config.ROOT_FOLDER}/apps/test-app2/config.json`]: JSON.stringify(testApp2),
[`${config.ROOT_FOLDER}/.env`]: 'TEST=test',
[`${config.ROOT_FOLDER}/state/apps.json`]: '{"installed": "test-app"}',
[`${config.ROOT_FOLDER}/app-data/test-app`]: '',
[`${config.ROOT_FOLDER}/app-data/test-app/app.env`]: 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test',
};
describe('Install app', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_EMPTY);
});
it('Should correctly generate env file for app', async () => {
await AppsService.installApp('test-app', { test: 'test' });
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test');
});
it('Should add app to state file', async () => {
await AppsService.installApp('test-app', { test: 'test' });
const stateFile = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/apps.json`).toString());
expect(stateFile.installed).toBe(' test-app');
});
it('Should correctly run app script', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.installApp('test-app', { test: 'test' });
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', 'test-app'], {}, expect.any(Function)]);
spy.mockRestore();
});
it('Should start app if already installed', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.installApp('test-app', { test: 'test' });
await AppsService.installApp('test-app', { test: 'test' });
expect(spy.mock.calls.length).toBe(2);
expect(spy.mock.calls[0]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', 'test-app'], {}, expect.any(Function)]);
expect(spy.mock.calls[1]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', 'test-app'], {}, expect.any(Function)]);
spy.mockRestore();
});
it('Should throw if required form fields are missing', async () => {
await expect(AppsService.installApp('test-app', {})).rejects.toThrowError('Variable test is required');
});
});
describe('Uninstall app', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_INSTALLED);
});
it('Should correctly remove app from state file', async () => {
await AppsService.uninstallApp('test-app');
const stateFile = JSON.parse(fs.readFileSync(`${config.ROOT_FOLDER}/state/apps.json`).toString());
expect(stateFile.installed).toBe('');
});
it('Should correctly run app script', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.uninstallApp('test-app');
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['uninstall', 'test-app'], {}, expect.any(Function)]);
spy.mockRestore();
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.uninstallApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
});
});
describe('Start app', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_INSTALLED);
});
it('Should correctly run app script', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.startApp('test-app');
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', 'test-app'], {}, expect.any(Function)]);
spy.mockRestore();
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.startApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
});
it('Should restart if app is already running', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.startApp('test-app');
expect(spy.mock.calls.length).toBe(1);
await AppsService.startApp('test-app');
expect(spy.mock.calls.length).toBe(2);
spy.mockRestore();
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.startApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
});
it('Regenerate env file', async () => {
fs.writeFile(`${config.ROOT_FOLDER}/app-data/test-app/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
await AppsService.startApp('test-app');
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test');
});
});
describe('Stop app', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_INSTALLED);
});
it('Should correctly run app script', async () => {
const spy = jest.spyOn(childProcess, 'execFile');
await AppsService.stopApp('test-app');
expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['stop', 'test-app'], {}, expect.any(Function)]);
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.stopApp('test-app-2')).rejects.toThrowError('App test-app-2 not installed');
});
});
describe('Update app config', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_INSTALLED);
});
it('Should correctly update app config', async () => {
await AppsService.updateAppConfig('test-app', { test: 'test', test2: 'test2' });
const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/test-app/app.env`).toString();
expect(envFile.trim()).toBe('TEST=test\nAPP_PORT=3000\nTEST_FIELD=test\nTEST_FIELD_2=test2');
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.updateAppConfig('test-app-2', { test: 'test' })).rejects.toThrowError('App test-app-2 not installed');
});
it('Should throw if required form fields are missing', async () => {
await expect(AppsService.updateAppConfig('test-app', {})).rejects.toThrowError('Variable test is required');
});
});
describe('Get app config', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_INSTALLED);
});
it('Should correctly get app config', async () => {
const appconfig = await AppsService.getAppInfo('test-app');
expect(appconfig).toEqual({ ...testApp, installed: true, status: 'stopped' });
});
it('Should have installed false if app is not installed', async () => {
const appconfig = await AppsService.getAppInfo('test-app2');
expect(appconfig).toEqual({ ...testApp2, installed: false, status: 'stopped' });
});
});
describe('List apps', () => {
beforeEach(() => {
// @ts-ignore
fs.__createMockFiles(MOCK_FILE_INSTALLED);
});
it('Should correctly list apps', async () => {
const apps = await AppsService.listApps();
expect(apps).toEqual([
{ ...testApp, installed: true, status: 'stopped' },
{ ...testApp2, installed: false, status: 'stopped' },
]);
expect(apps.length).toBe(2);
expect(apps[0].id).toBe('test-app');
expect(apps[1].id).toBe('test-app2');
});
});

View file

@ -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);
}

View file

@ -1,8 +1,10 @@
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 { fileExists, readdirSync, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
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;
@ -94,8 +96,49 @@ export const ensureAppState = (appName: string, installed: boolean) => {
}
} else {
if (state.installed.indexOf(appName) !== -1) {
state.installed = state.installed.replace(` ${appName}`, '');
state.installed = state.installed.replace(`${appName}`, '');
writeFile('/state/apps.json', JSON.stringify(state));
}
}
};
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');
};
export const getAvailableApps = (): string[] => {
const apps: string[] = [];
const appsDir = readdirSync('/apps');
appsDir.forEach((app) => {
if (fileExists(`/apps/${app}/config.json`)) {
const configFile: AppConfig = readJsonFile(`/apps/${app}/config.json`);
if (configFile.available) {
apps.push(app);
}
}
});
return apps;
};

View file

@ -0,0 +1,101 @@
import si from 'systeminformation';
import { AppConfig } from '../../config/types';
import { createFolder, fileExists, readJsonFile } from '../fs/fs.helpers';
import { checkAppExists, checkAppRequirements, checkEnvFile, ensureAppState, generateEnvFile, getAvailableApps, 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 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]);
}
return Promise.resolve();
};
const listApps = async (): Promise<AppConfig[]> => {
const apps: AppConfig[] = getAvailableApps()
.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 };

View file

@ -11,6 +11,8 @@ export const readJsonFile = (path: string): any => {
export const readFile = (path: string): string => fs.readFileSync(getAbsolutePath(path)).toString();
export const readdirSync = (path: string): string[] => fs.readdirSync(getAbsolutePath(path));
export const fileExists = (path: string): boolean => fs.existsSync(getAbsolutePath(path));
export const writeFile = (path: string, data: any) => fs.writeFileSync(getAbsolutePath(path), data);

View file

@ -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);
};

View file

@ -0,0 +1,3 @@
import * as dotenv from 'dotenv';
dotenv.config({ path: '.env.test' });

View file

@ -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"]
}

5995
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff