feat: create trpc router & service for apps

This commit is contained in:
Nicolas Meienberger 2023-02-02 07:40:49 +01:00 committed by Nicolas Meienberger
parent 3e67758d86
commit fa8f178433
20 changed files with 2245 additions and 100 deletions

View file

@ -124,10 +124,14 @@ services:
- tipi_main_network
volumes:
- ${PWD}/packages/dashboard/src:/dashboard/src
- ${PWD}/packages/dashboard/server.ts:/dashboard/server.ts
# - /dashboard/node_modules
# - /dashboard/.next
- ${PWD}/state:/runtipi/state
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
labels:
traefik.enable: true
# Web

View file

@ -121,7 +121,10 @@ services:
REDIS_HOST: ${REDIS_HOST}
volumes:
- ${PWD}/state:/runtipi/state
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
labels:
traefik.enable: true
# Web

View file

@ -126,7 +126,10 @@ services:
REDIS_HOST: ${REDIS_HOST}
volumes:
- ${PWD}/state:/runtipi/state
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
labels:
traefik.enable: true

View file

@ -122,7 +122,10 @@ services:
REDIS_HOST: ${REDIS_HOST}
volumes:
- ${PWD}/state:/runtipi/state
- ${PWD}/repos:/runtipi/repos:ro
- ${PWD}/apps:/runtipi/apps
- ${PWD}/logs:/app/logs
- ${STORAGE_PATH}:/app/storage
labels:
traefik.enable: true
# Web

View file

@ -3,3 +3,4 @@ POSTGRES_DBNAME=postgres
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_PORT=5433
APPS_REPO_ID=repo-id

View file

@ -1,47 +1,47 @@
/* eslint-disable no-console */
import path from 'path';
import pg from 'pg';
import { migrate } from '@runtipi/postgres-migrations';
import { Logger } from './src/server/core/Logger';
export const runPostgresMigrations = async () => {
console.log('Starting database migration');
export const runPostgresMigrations = async (dbName?: string) => {
Logger.info('Starting database migration');
const { POSTGRES_HOST, POSTGRES_DBNAME, POSTGRES_USERNAME, POSTGRES_PASSWORD, POSTGRES_PORT = 5432 } = process.env;
console.log('Connecting to database', POSTGRES_DBNAME, 'on', POSTGRES_HOST, 'as', POSTGRES_USERNAME, 'on port', POSTGRES_PORT);
Logger.info('Connecting to database', POSTGRES_DBNAME, 'on', POSTGRES_HOST, 'as', POSTGRES_USERNAME, 'on port', POSTGRES_PORT);
const client = new pg.Client({
user: POSTGRES_USERNAME,
host: POSTGRES_HOST,
database: POSTGRES_DBNAME,
database: dbName || POSTGRES_DBNAME,
password: POSTGRES_PASSWORD,
port: Number(POSTGRES_PORT),
});
await client.connect();
console.log('Client connected');
Logger.info('Client connected');
try {
const { rows } = await client.query('SELECT * FROM migrations');
// if rows contains a migration with name 'Initial1657299198975' (legacy typeorm) delete table migrations. As all migrations are idempotent we can safely delete the table and start over.
if (rows.find((row) => row.name === 'Initial1657299198975')) {
console.log('Found legacy migration. Deleting table migrations');
Logger.info('Found legacy migration. Deleting table migrations');
await client.query('DROP TABLE migrations');
}
} catch (e) {
console.log('Migrations table not found, creating it');
Logger.info('Migrations table not found, creating it');
}
console.log('Running migrations');
Logger.info('Running migrations');
try {
await migrate({ client }, path.join(__dirname, 'migrations'), { skipCreateMigrationTable: true });
} catch (e) {
console.log('Error running migrations. Dropping table migrations and trying again');
Logger.error('Error running migrations. Dropping table migrations and trying again');
await client.query('DROP TABLE migrations');
await migrate({ client }, path.join(__dirname, 'migrations'), { skipCreateMigrationTable: true });
}
console.log('Migration complete');
Logger.info('Migration complete');
await client.end();
};

View file

@ -1,9 +1,9 @@
import * as trpcNext from '@trpc/server/adapters/next';
import { createContext } from '../../../server/context';
import { appRouter } from '../../../server/routers/_app';
import { mainRouter } from '../../../server/routers/_app';
// export API handler
export default trpcNext.createNextApiHandler({
router: appRouter,
router: mainRouter,
createContext,
});

View file

@ -9,3 +9,29 @@ export const readJsonFile = (path: string): unknown | null => {
return null;
}
};
export const readFile = (path: string): string => {
try {
return fs.readFileSync(path).toString();
} catch {
return '';
}
};
export const readdirSync = (path: string): string[] => fs.readdirSync(path);
export const fileExists = (path: string): boolean => fs.existsSync(path);
export const writeFile = (path: string, data: string) => fs.writeFileSync(path, data);
export const createFolder = (path: string) => {
if (!fileExists(path)) {
fs.mkdirSync(path, { recursive: true });
}
};
export const deleteFolder = (path: string) => fs.rmSync(path, { recursive: true });
export const getSeed = () => {
const seed = readFile('/runtipi/state/seed');
return seed.toString();
};

View file

@ -1,14 +1,17 @@
import { inferRouterInputs, inferRouterOutputs } from '@trpc/server';
import { router } from '../trpc';
import { appRouter } from './app/app.router';
import { authRouter } from './auth/auth.router';
import { systemRouter } from './system/system.router';
export const appRouter = router({
export const mainRouter = router({
system: systemRouter,
auth: authRouter,
app: appRouter,
});
// export type definition of API
export type AppRouter = typeof appRouter;
export type AppRouter = typeof mainRouter;
export type RouterInput = inferRouterInputs<AppRouter>;
export type RouterOutput = inferRouterOutputs<AppRouter>;

View file

@ -0,0 +1,27 @@
import { z } from 'zod';
import { inferRouterOutputs } from '@trpc/server';
import { AppServiceClass } from '../../services/apps/apps.service';
import { router, protectedProcedure } from '../../trpc';
import { prisma } from '../../db/client';
export type AppRouterOutput = inferRouterOutputs<typeof appRouter>;
const AppService = new AppServiceClass(prisma);
const formSchema = z.object({}).catchall(z.any());
export const appRouter = router({
getApp: protectedProcedure.input(z.object({ id: z.string() })).query(({ input }) => AppService.getApp(input.id)),
startAllApp: protectedProcedure.mutation(AppService.startAllApps),
startApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.startApp(input.id)),
installApp: protectedProcedure
.input(z.object({ id: z.string(), form: formSchema, exposed: z.boolean().optional(), domain: z.string().optional() }))
.mutation(({ input }) => AppService.installApp(input.id, input.form, input.exposed, input.domain)),
updateAppConfig: protectedProcedure
.input(z.object({ id: z.string(), form: formSchema, exposed: z.boolean().optional(), domain: z.string().optional() }))
.mutation(({ input }) => AppService.updateAppConfig(input.id, input.form, input.exposed, input.domain)),
stopApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.stopApp(input.id)),
uninstallApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.uninstallApp(input.id)),
updateApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.updateApp(input.id)),
installedApps: protectedProcedure.query(AppService.installedApps),
listApps: protectedProcedure.query(() => AppServiceClass.listApps()),
});

View file

@ -0,0 +1,508 @@
import fs from 'fs-extra';
import { App, PrismaClient } from '@prisma/client';
import { faker } from '@faker-js/faker';
import { setConfig } from '../../core/TipiConfig';
import { AppInfo, checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from './apps.helpers';
import { createApp, createAppConfig } from '../../tests/apps.factory';
import { Logger } from '../../core/Logger';
import { getTestDbClient } from '../../../../tests/server/db-connection';
let db: PrismaClient;
const TEST_SUITE = 'appshelpers';
beforeAll(async () => {
db = await getTestDbClient(TEST_SUITE);
});
beforeEach(async () => {
jest.mock('fs-extra');
});
afterEach(async () => {
await db.app.deleteMany();
});
afterAll(async () => {
await db.app.deleteMany();
await db.$disconnect();
});
describe('checkAppRequirements', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({}, db);
app1 = app1create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(app1create.MockFiles);
});
it('should return appInfo if there are no particular requirement', async () => {
const result = checkAppRequirements(app1.id);
expect(result.id).toEqual(app1.id);
});
it('Should throw an error if app does not exist', async () => {
try {
checkAppRequirements('notexisting');
expect(true).toBe(false);
} catch (e) {
// @ts-expect-error - Mocking fs
expect(e.message).toEqual('App notexisting has invalid config.json file');
}
});
it('Should throw if architecture is not supported', async () => {
setConfig('architecture', 'arm64');
const { MockFiles, appInfo } = await createApp({ supportedArchitectures: ['arm'] }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
try {
checkAppRequirements(appInfo.id);
expect(true).toBe(false);
} catch (e) {
// @ts-expect-error - Test file
expect(e.message).toEqual(`App ${appInfo.id} is not supported on this architecture`);
}
});
});
describe('getEnvMap', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true }, db);
app1 = app1create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(app1create.MockFiles);
});
it('should return a map of env vars', async () => {
const envMap = getEnvMap(app1.id);
expect(envMap.get('TEST_FIELD')).toBe('test');
});
});
describe('Test: checkEnvFile', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true }, db);
app1 = app1create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(app1create.MockFiles);
});
it('Should not throw if all required fields are present', async () => {
checkEnvFile(app1.id);
});
it('Should throw if a required field is missing', () => {
const newAppEnv = 'APP_PORT=test\n';
fs.writeFileSync(`/app/storage/app-data/${app1.id}/app.env`, newAppEnv);
try {
checkEnvFile(app1.id);
expect(true).toBe(false);
} catch (e: unknown) {
if (e instanceof Error) {
expect(e).toBeDefined();
expect(e.message).toBe('New info needed. App config needs to be updated');
} else {
expect(true).toBe(false);
}
}
});
it('Should throw if config.json is incorrect', async () => {
// arrange
fs.writeFileSync(`/app/storage/app-data/${app1.id}/config.json`, 'invalid json');
const { appInfo } = await createApp({}, db);
// act
try {
checkEnvFile(appInfo.id);
expect(true).toBe(false);
} catch (e: unknown) {
if (e instanceof Error) {
expect(e).toBeDefined();
expect(e.message).toBe(`App ${appInfo.id} has invalid config.json file`);
} else {
expect(true).toBe(false);
}
}
});
});
describe('Test: generateEnvFile', () => {
let app1: AppInfo;
let appEntity1: App;
beforeEach(async () => {
const app1create = await createApp({ installed: true }, db);
app1 = app1create.appInfo;
appEntity1 = app1create.appEntity;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(app1create.MockFiles);
});
it('Should generate an env file', async () => {
const fakevalue = faker.random.alphaNumeric(10);
generateEnvFile(Object.assign(appEntity1, { config: { TEST_FIELD: fakevalue } }));
const envmap = getEnvMap(app1.id);
expect(envmap.get('TEST_FIELD')).toBe(fakevalue);
});
it('Should automatically generate value for random field', async () => {
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, randomField: true }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
generateEnvFile(appEntity);
const envmap = getEnvMap(appInfo.id);
expect(envmap.get('RANDOM_FIELD')).toBeDefined();
expect(envmap.get('RANDOM_FIELD')).toHaveLength(32);
});
it('Should not re-generate random field if it already exists', async () => {
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, randomField: true }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
const randomField = faker.random.alphaNumeric(32);
fs.writeFileSync(`/app/storage/app-data/${appInfo.id}/app.env`, `RANDOM_FIELD=${randomField}`);
generateEnvFile(appEntity);
const envmap = getEnvMap(appInfo.id);
expect(envmap.get('RANDOM_FIELD')).toBe(randomField);
});
it('Should throw an error if required field is not provided', async () => {
try {
generateEnvFile(Object.assign(appEntity1, { config: { TEST_FIELD: undefined } }));
expect(true).toBe(false);
} catch (e: unknown) {
if (e instanceof Error) {
expect(e).toBeDefined();
expect(e.message).toBe('Variable TEST_FIELD is required');
} else {
expect(true).toBe(false);
}
}
});
it('Should throw an error if app does not exist', async () => {
try {
generateEnvFile(Object.assign(appEntity1, { id: 'not-existing-app' }));
expect(true).toBe(false);
} catch (e: unknown) {
if (e instanceof Error) {
expect(e).toBeDefined();
expect(e.message).toBe('App not-existing-app has invalid config.json file');
} else {
expect(true).toBe(false);
}
}
});
it('Should add APP_EXPOSED to env file', async () => {
const domain = faker.internet.domainName();
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, exposed: true, domain }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
generateEnvFile(appEntity);
const envmap = getEnvMap(appInfo.id);
expect(envmap.get('APP_EXPOSED')).toBe('true');
expect(envmap.get('APP_DOMAIN')).toBe(domain);
});
it('Should not add APP_EXPOSED if domain is not provided', async () => {
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, exposed: true }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
generateEnvFile(appEntity);
const envmap = getEnvMap(appInfo.id);
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
});
it('Should not add APP_EXPOSED if app is not exposed', async () => {
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true, domain: faker.internet.domainName() }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
generateEnvFile(appEntity);
const envmap = getEnvMap(appInfo.id);
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
expect(envmap.get('APP_DOMAIN')).toBe(`localhost:${appInfo.port}`);
});
it('Should create app folder if it does not exist', async () => {
const { appEntity, appInfo, MockFiles } = await createApp({ installed: true }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
fs.rmSync(`/app/storage/app-data/${appInfo.id}`, { recursive: true });
generateEnvFile(appEntity);
expect(fs.existsSync(`/app/storage/app-data/${appInfo.id}`)).toBe(true);
});
});
describe('getAvailableApps', () => {
beforeEach(async () => {
const app1create = await createApp({ installed: true }, db);
const app2create = await createApp({}, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app1create.MockFiles, app2create.MockFiles));
});
it('Should return all available apps', async () => {
const availableApps = await getAvailableApps();
expect(availableApps.length).toBe(2);
});
it('Should not return apps with invalid config.json', async () => {
const { appInfo: app1, MockFiles: MockFiles1 } = await createApp({ installed: true }, db);
const { MockFiles: MockFiles2 } = await createApp({}, db);
MockFiles1[`/runtipi/repos/repo-id/apps/${app1.id}/config.json`] = 'invalid json';
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(MockFiles1, MockFiles2));
const availableApps = await getAvailableApps();
expect(availableApps.length).toBe(1);
});
});
describe('Test: getAppInfo', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: false }, db);
app1 = app1create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(app1create.MockFiles);
});
it('Should return app info', async () => {
const appInfo = getAppInfo(app1.id);
expect(appInfo?.id).toBe(app1.id);
});
it('Should take config.json locally if app is installed', async () => {
const { appInfo, MockFiles, appEntity } = await createApp({ installed: true }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
const newConfig = createAppConfig();
fs.writeFileSync(`/runtipi/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
const app = getAppInfo(appInfo.id, appEntity.status);
expect(app?.id).toEqual(newConfig.id);
});
it('Should take config.json from repo if app is not installed', async () => {
const { appInfo, MockFiles, appEntity } = await createApp({ installed: false }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
const newConfig = createAppConfig();
fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
const app = getAppInfo(appInfo.id, appEntity.status);
expect(app?.id).toEqual(newConfig.id);
});
it('Should return null if app is not available', async () => {
const { appInfo, MockFiles, appEntity } = await createApp({ installed: false }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
const newConfig = {
id: faker.random.alphaNumeric(32),
available: false,
};
fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
const app = getAppInfo(appInfo.id, appEntity.status);
expect(app).toBeNull();
});
it('Should throw if something goes wrong', async () => {
const log = jest.spyOn(Logger, 'error');
const spy = jest.spyOn(fs, 'existsSync').mockImplementation(() => {
throw new Error('Something went wrong');
});
const { appInfo, MockFiles, appEntity } = await createApp({ installed: false }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
const newConfig = {
id: faker.random.alphaNumeric(32),
available: false,
};
fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
try {
getAppInfo(appInfo.id, appEntity.status);
expect(true).toBe(false);
} catch (e: unknown) {
if (e instanceof Error) {
expect(e.message).toBe(`Error loading app: ${appInfo.id}`);
expect(log).toBeCalledWith(`Error loading app: ${appInfo.id}`);
} else {
expect(true).toBe(false);
}
}
spy.mockRestore();
log.mockRestore();
});
it('Should return null if app does not exist', async () => {
const app = getAppInfo(faker.random.word());
expect(app).toBeNull();
});
});
describe('getUpdateInfo', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true }, db);
app1 = app1create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(app1create.MockFiles);
});
it('Should return update info', async () => {
const updateInfo = await getUpdateInfo(app1.id, 1);
expect(updateInfo?.latest).toBe(app1.tipi_version);
expect(updateInfo?.current).toBe(1);
});
it('Should return null if app is not installed', async () => {
const updateInfo = await getUpdateInfo(faker.random.word(), 1);
expect(updateInfo).toBeNull();
});
it('Should return null if config.json is invalid', async () => {
const { appInfo, MockFiles } = await createApp({ installed: true }, db);
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'invalid json';
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
const updateInfo = await getUpdateInfo(appInfo.id, 1);
expect(updateInfo).toBeNull();
});
it('should return null if version is not provided', async () => {
const updateInfo = await getUpdateInfo(app1.id);
expect(updateInfo).toBe(null);
});
});
describe('Test: ensureAppFolder', () => {
beforeEach(() => {
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
};
// @ts-expect-error - Mocking fs
fs.__createMockFiles(mockFiles);
});
it('should copy the folder from repo', () => {
// Act
ensureAppFolder('test');
// Assert
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual(['test.yml']);
});
it('should not copy the folder if it already exists', () => {
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
'/runtipi/apps/test': ['docker-compose.yml'],
'/runtipi/apps/test/docker-compose.yml': 'test',
};
// @ts-expect-error - Mocking fs
fs.__createMockFiles(mockFiles);
// Act
ensureAppFolder('test');
// Assert
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual(['docker-compose.yml']);
});
it('Should overwrite the folder if clean up is true', () => {
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
'/runtipi/apps/test': ['docker-compose.yml'],
'/runtipi/apps/test/docker-compose.yml': 'test',
};
// @ts-expect-error - Mocking fs
fs.__createMockFiles(mockFiles);
// Act
ensureAppFolder('test', true);
// Assert
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual(['test.yml']);
});
it('Should delete folder if it exists but has no docker-compose.yml file', () => {
// Arrange
const randomFileName = `${faker.random.word()}.yml`;
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: [randomFileName],
'/runtipi/apps/test': ['test.yml'],
};
// @ts-expect-error - Mocking fs
fs.__createMockFiles(mockFiles);
// Act
ensureAppFolder('test');
// Assert
const files = fs.readdirSync('/runtipi/apps/test');
expect(files).toEqual([randomFileName]);
});
});

View file

@ -0,0 +1,339 @@
import crypto from 'crypto';
import fs from 'fs-extra';
import { z } from 'zod';
import { App } from '@prisma/client';
import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../../common/fs.helpers';
import { APP_CATEGORIES, FIELD_TYPES } from './apps.types';
import { getConfig } from '../../core/TipiConfig';
import { Logger } from '../../core/Logger';
import { notEmpty } from '../../common/typescript.helpers';
import { ARCHITECTURES } from '../../core/TipiConfig/TipiConfig';
const formFieldSchema = z.object({
type: z.nativeEnum(FIELD_TYPES),
label: z.string(),
placeholder: z.string().optional(),
max: z.number().optional(),
min: z.number().optional(),
hint: z.string().optional(),
required: z.boolean().optional().default(false),
env_variable: z.string(),
});
export const appInfoSchema = z.object({
id: z.string(),
available: z.boolean(),
port: z.number().min(1).max(65535),
name: z.string(),
description: z.string().optional().default(''),
version: z.string().optional().default('latest'),
tipi_version: z.number(),
short_desc: z.string(),
author: z.string(),
source: z.string(),
website: z.string().optional(),
categories: z.nativeEnum(APP_CATEGORIES).array(),
url_suffix: z.string().optional(),
form_fields: z.array(formFieldSchema).optional().default([]),
https: z.boolean().optional().default(false),
exposable: z.boolean().optional().default(false),
no_gui: z.boolean().optional().default(false),
supported_architectures: z.nativeEnum(ARCHITECTURES).array().optional(),
});
export type AppInfo = z.infer<typeof appInfoSchema>;
export type FormField = z.infer<typeof formFieldSchema>;
/**
* This function checks the requirements for the app with the provided name.
* It reads the config.json file for the app, parses it,
* and checks if the architecture of the current system is supported by the app.
* If the config.json file is invalid, it throws an error.
* If the architecture is not supported, it throws an error.
*
* @param {string} appName - The name of the app.
* @throws Will throw an error if the app has an invalid config.json file or if the current system architecture is not supported by the app.
* @returns {AppInfo} - parsed app config data
*/
export const checkAppRequirements = (appName: string) => {
const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (!parsedConfig.success) {
throw new Error(`App ${appName} has invalid config.json file`);
}
if (parsedConfig.data.supported_architectures && !parsedConfig.data.supported_architectures.includes(getConfig().architecture)) {
throw new Error(`App ${appName} is not supported on this architecture`);
}
return parsedConfig.data;
};
/**
* This function reads the env file for the app with the provided name and returns a Map containing the key-value pairs of the environment variables.
* It reads the file, splits it into individual environment variables, and stores them in a Map, with the environment variable name as the key and its value as the value.
*
* @param {string} appName - The name of the app.
* @returns {Map<string, string>} - A Map containing the key-value pairs of the environment variables.
*/
export const getEnvMap = (appName: string) => {
const envFile = readFile(`/app/storage/app-data/${appName}/app.env`).toString();
const envVars = envFile.split('\n');
const envVarsMap = new Map<string, string>();
envVars.forEach((envVar) => {
const [key, value] = envVar.split('=');
if (key && value) envVarsMap.set(key, value);
});
return envVarsMap;
};
/**
* This function checks if the env file for the app with the provided name is valid.
* It reads the config.json file for the app, parses it,
* and uses the app's form fields to check if all required fields are present in the env file.
* If the config.json file is invalid, it throws an error.
* If a required variable is missing in the env file, it throws an error.
*
* @param {string} appName - The name of the app.
* @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing in the env file.
*/
export const checkEnvFile = (appName: string) => {
const configFile = readJsonFile(`/runtipi/apps/${appName}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (!parsedConfig.success) {
throw new Error(`App ${appName} has invalid config.json file`);
}
const envMap = getEnvMap(appName);
parsedConfig.data.form_fields.forEach((field) => {
const envVar = field.env_variable;
const envVarValue = envMap.get(envVar);
if (!envVarValue && field.required) {
throw new Error('New info needed. App config needs to be updated');
}
});
};
/**
* This function generates a random string of the provided length by using the SHA-256 hash algorithm.
* It takes the provided name and a seed value, concatenates them, and uses them as input for the hash algorithm.
* It then returns a substring of the resulting hash of the provided length.
*
* @param {string} name - A name used as input for the hash algorithm.
* @param {number} length - The desired length of the random string.
* @returns {string} - A random string of the provided length.
*/
const getEntropy = (name: string, length: number) => {
const hash = crypto.createHash('sha256');
hash.update(name + getSeed());
return hash.digest('hex').substring(0, length);
};
/**
* This function takes an input of unknown type, checks if it is an object and not null,
* and returns it as a record of unknown values, if it is not an object or is null, returns an empty object.
*
* @param {unknown} json - The input of unknown type.
* @returns {Record<string, unknown>} - The input as a record of unknown values, or an empty object if the input is not an object or is null.
*/
const castAppConfig = (json: unknown): Record<string, unknown> => {
if (typeof json !== 'object' || json === null) {
return {};
}
return json as Record<string, unknown>;
};
/**
* This function generates an env file for the provided app.
* It reads the config.json file for the app, parses it,
* and uses the app's form fields and domain to generate the env file
* if the app is exposed and has a domain set, it adds the domain to the env file,
* otherwise, it adds the internal IP address to the env file
* It also creates the app-data folder for the app if it does not exist
*
* @param {App} app - The app for which the env file is generated.
* @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing.
*/
export const generateEnvFile = (app: App) => {
const configFile = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (!parsedConfig.success) {
throw new Error(`App ${app.id} has invalid config.json file`);
}
const baseEnvFile = readFile('/runtipi/.env').toString();
let envFile = `${baseEnvFile}\nAPP_PORT=${parsedConfig.data.port}\n`;
const envMap = getEnvMap(app.id);
parsedConfig.data.form_fields.forEach((field) => {
const formValue = castAppConfig(app.config)[field.env_variable];
const envVar = field.env_variable;
if (formValue) {
envFile += `${envVar}=${formValue}\n`;
} else if (field.type === 'random') {
if (envMap.has(envVar)) {
envFile += `${envVar}=${envMap.get(envVar)}\n`;
} else {
const length = field.min || 32;
const randomString = getEntropy(field.env_variable, length);
envFile += `${envVar}=${randomString}\n`;
}
} else if (field.required) {
throw new Error(`Variable ${field.env_variable} is required`);
}
});
if (app.exposed && app.domain) {
envFile += 'APP_EXPOSED=true\n';
envFile += `APP_DOMAIN=${app.domain}\n`;
envFile += 'APP_PROTOCOL=https\n';
} else {
envFile += `APP_DOMAIN=${getConfig().internalIp}:${parsedConfig.data.port}\n`;
}
// Create app-data folder if it doesn't exist
if (!fs.existsSync(`/app/storage/app-data/${app.id}`)) {
fs.mkdirSync(`/app/storage/app-data/${app.id}`, { recursive: true });
}
writeFile(`/app/storage/app-data/${app.id}/app.env`, envFile);
};
/**
This function reads the apps directory and skips certain system files, then reads the config.json and metadata/description.md files for each app,
parses the config file, filters out any apps that are not available and returns an array of app information.
If the config.json file is invalid, it logs an error message.
@returns {Promise<AppInfo[]>} - Returns a promise that resolves with an array of available apps' information.
*/
export const getAvailableApps = async () => {
const appsDir = readdirSync(`/runtipi/repos/${getConfig().appsRepoId}/apps`);
const skippedFiles = ['__tests__', 'docker-compose.common.yml', 'schema.json', '.DS_Store'];
const apps = appsDir
.map((app) => {
if (skippedFiles.includes(app)) return null;
const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (!parsedConfig.success) {
Logger.error(`App ${JSON.stringify(app)} has invalid config.json`);
} else if (parsedConfig.data.available) {
const description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${parsedConfig.data.id}/metadata/description.md`);
return { ...parsedConfig.data, description };
}
return null;
})
.filter(notEmpty);
return apps;
};
/**
* This function reads the config.json and metadata/description.md files for the app with the provided id,
* parses the config file and returns an object with app information.
* It checks if the app is installed or not and looks for the config.json file in the appropriate directory.
* If the config.json file is invalid, it returns null.
* If an error occurs during the process, it logs the error message and throws an error.
*
* @param {string} id - The app id.
* @param {AppStatus} [status] - The app status.
* @returns {AppInfo | null} - Returns an object with app information or null if the app is not found.
*/
export const getAppInfo = (id: string, status?: App['status']) => {
try {
// Check if app is installed
const installed = typeof status !== 'undefined' && status !== 'missing';
if (installed && fileExists(`/runtipi/apps/${id}/config.json`)) {
const configFile = readJsonFile(`/runtipi/apps/${id}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (parsedConfig.success && parsedConfig.data.available) {
const description = readFile(`/runtipi/apps/${id}/metadata/description.md`);
return { ...parsedConfig.data, description };
}
}
if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
const configFile = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
if (parsedConfig.success && parsedConfig.data.available) {
const description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/metadata/description.md`);
return { ...parsedConfig.data, description };
}
}
return null;
} catch (e) {
Logger.error(`Error loading app: ${id}`);
throw new Error(`Error loading app: ${id}`);
}
};
/**
* This function returns an object containing information about the updates available for the app with the provided id.
* It checks if the app is installed or not and looks for the config.json file in the appropriate directory.
* If the config.json file is invalid, it returns null.
* If the app is not found, it returns null.
*
* @param {string} id - The app id.
* @param {number} [version] - The current version of the app.
* @returns {Promise<{current: number, latest: number, dockerVersion: string} | null>} - Returns an object containing information about the updates available for the app or null if the app is not found or has an invalid config.json file.
*/
export const getUpdateInfo = async (id: string, version?: number) => {
const doesFileExist = fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}`);
if (!doesFileExist || !version) {
return null;
}
const repoConfig = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
const parsedConfig = appInfoSchema.safeParse(repoConfig);
if (parsedConfig.success) {
return {
current: version || 0,
latest: parsedConfig.data.tipi_version,
dockerVersion: parsedConfig.data.version,
};
}
return null;
};
/**
* This function ensures that the app folder for the app with the provided name exists.
* If the cleanup parameter is set to true, it deletes the app folder if it exists.
* If the app folder does not exist, it copies the app folder from the apps repository.
*
* @param {string} appName - The name of the app.
* @param {boolean} [cleanup=false] - A flag indicating whether to cleanup the app folder before ensuring its existence.
* @throws Will throw an error if the app folder cannot be copied from the repository
*/
export const ensureAppFolder = (appName: string, cleanup = false) => {
if (cleanup && fileExists(`/runtipi/apps/${appName}`)) {
deleteFolder(`/runtipi/apps/${appName}`);
}
if (!fileExists(`/runtipi/apps/${appName}/docker-compose.yml`)) {
if (fileExists(`/runtipi/apps/${appName}`)) {
deleteFolder(`/runtipi/apps/${appName}`);
}
// Copy from apps repo
fs.copySync(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}`, `/runtipi/apps/${appName}`);
}
};

View file

@ -0,0 +1,648 @@
import fs from 'fs-extra';
import { PrismaClient } from '@prisma/client';
import { AppServiceClass } from './apps.service';
import { EventDispatcher, EVENT_TYPES } from '../../core/EventDispatcher';
import { AppInfo, getEnvMap } from './apps.helpers';
import { createApp } from '../../tests/apps.factory';
import { APP_STATUS } from './apps.types';
import { setConfig } from '../../core/TipiConfig';
import { getTestDbClient } from '../../../../tests/server/db-connection';
let db: PrismaClient;
let AppsService: AppServiceClass;
const TEST_SUITE = 'appsservice';
beforeAll(async () => {
db = await getTestDbClient(TEST_SUITE);
AppsService = new AppServiceClass(db);
});
beforeEach(async () => {
jest.mock('fs-extra');
await db.app.deleteMany();
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
});
afterAll(async () => {
await db.app.deleteMany();
await db.$disconnect();
});
describe('Install app', () => {
let app1: AppInfo;
beforeEach(async () => {
const { MockFiles, appInfo } = await createApp({}, db);
app1 = appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
});
it('Should correctly generate env file for app', async () => {
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${app1.port}`);
});
it('Should add app in database', async () => {
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
const app = await db.app.findUnique({ where: { id: app1.id } });
expect(app).toBeDefined();
expect(app?.id).toBe(app1.id);
expect(app?.config).toStrictEqual({ TEST_FIELD: 'test' });
expect(app?.status).toBe(APP_STATUS.RUNNING);
});
it('Should start app if already installed', async () => {
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
expect(spy.mock.calls.length).toBe(2);
expect(spy.mock.calls[0]).toEqual([EVENT_TYPES.APP, ['install', app1.id]]);
expect(spy.mock.calls[1]).toEqual([EVENT_TYPES.APP, ['start', app1.id]]);
spy.mockRestore();
});
it('Should delete app if install script fails', async () => {
// Arrange
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow(`App ${app1.id} failed to install\nstdout: error`);
const app = await db?.app.findUnique({ where: { id: app1.id } });
expect(app).toBeNull();
});
it('Should throw if required form fields are missing', async () => {
await expect(AppsService.installApp(app1.id, {})).rejects.toThrowError('Variable TEST_FIELD is required');
});
it('Correctly generates a random value if the field has a "random" type', async () => {
const { appInfo, MockFiles } = await createApp({ randomField: true }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
await AppsService.installApp(appInfo.id, { TEST_FIELD: 'yolo' });
const envMap = getEnvMap(appInfo.id);
expect(envMap.get('RANDOM_FIELD')).toBeDefined();
expect(envMap.get('RANDOM_FIELD')).toHaveLength(32);
});
it('Should correctly copy app from repos to apps folder', async () => {
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
const appFolder = fs.readdirSync(`/runtipi/apps/${app1.id}`);
expect(appFolder).toBeDefined();
expect(appFolder.indexOf('docker-compose.yml')).toBeGreaterThanOrEqual(0);
});
it('Should cleanup any app folder existing before install', async () => {
const { MockFiles, appInfo } = await createApp({}, db);
app1 = appInfo;
MockFiles[`/runtipi/apps/${appInfo.id}/docker-compose.yml`] = 'test';
MockFiles[`/runtipi/apps/${appInfo.id}/test.yml`] = 'test';
MockFiles[`/runtipi/apps/${appInfo.id}`] = ['test.yml', 'docker-compose.yml'];
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
expect(fs.existsSync(`/runtipi/apps/${app1.id}/test.yml`)).toBe(true);
await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
expect(fs.existsSync(`/runtipi/apps/${app1.id}/test.yml`)).toBe(false);
expect(fs.existsSync(`/runtipi/apps/${app1.id}/docker-compose.yml`)).toBe(true);
});
it('Should throw if app is exposed and domain is not provided', async () => {
await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' }, true)).rejects.toThrowError('Domain is required if app is exposed');
});
it('Should throw if app is exposed and config does not allow it', async () => {
await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`App ${app1.id} is not exposable`);
});
it('Should throw if app is exposed and domain is not valid', async () => {
const { MockFiles, appInfo } = await createApp({ exposable: true }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('Domain test is not valid');
});
it('Should throw if app is exposed and domain is already used', async () => {
const app2 = await createApp({ exposable: true }, db);
const app3 = await createApp({ exposable: true }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles({ ...app2.MockFiles, ...app3.MockFiles });
await AppsService.installApp(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
await expect(AppsService.installApp(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`Domain test.com already in use by app ${app2.appInfo.id}`);
});
it('Should throw if architecure is not supported', async () => {
// arrange
setConfig('architecture', 'amd64');
const { MockFiles, appInfo } = await createApp({ supportedArchitectures: ['arm'] }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} is not supported on this architecture`);
});
it('Can install if architecture is supported', async () => {
setConfig('architecture', 'arm');
const { MockFiles, appInfo } = await createApp({ supportedArchitectures: ['arm', 'amd64'] }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
const app = await db.app.findUnique({ where: { id: appInfo.id } });
expect(app).toBeDefined();
});
it('Can install if no architecture is specified', async () => {
setConfig('architecture', 'arm');
const { MockFiles, appInfo } = await createApp({ supportedArchitectures: undefined }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
await AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' });
const app = await db.app.findUnique({ where: { id: appInfo.id } });
expect(app).toBeDefined();
});
it('Should throw if config.json is not valid', async () => {
// arrange
const { MockFiles, appInfo } = await createApp({}, db);
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'test';
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
// act & assert
await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} has invalid config.json file`);
});
it('Should throw if config.json is not valid after folder copy', async () => {
// arrange
jest.spyOn(fs, 'copySync').mockImplementationOnce(() => {});
const { MockFiles, appInfo } = await createApp({}, db);
MockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = 'test';
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
// act & assert
await expect(AppsService.installApp(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} has invalid config.json file`);
});
});
describe('Uninstall app', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true }, db);
app1 = app1create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
it('App should be installed by default', async () => {
// Act
const app = await db.app.findUnique({ where: { id: app1.id } });
// Assert
expect(app).toBeDefined();
expect(app?.id).toBe(app1.id);
expect(app?.status).toBe(APP_STATUS.RUNNING);
});
it('Should correctly remove app from database', async () => {
// Act
await AppsService.uninstallApp(app1.id);
const app = await db.app.findUnique({ where: { id: app1.id } });
// Assert
expect(app).toBeNull();
});
it('Should stop app if it is running', async () => {
// Arrange
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
// Act
await AppsService.uninstallApp(app1.id);
// Assert
expect(spy.mock.calls.length).toBe(2);
expect(spy.mock.calls[0]).toEqual([EVENT_TYPES.APP, ['stop', app1.id]]);
expect(spy.mock.calls[1]).toEqual([EVENT_TYPES.APP, ['uninstall', app1.id]]);
spy.mockRestore();
});
it('Should throw if app is not installed', async () => {
// Act & Assert
await expect(AppsService.uninstallApp('any')).rejects.toThrowError('App any not found');
});
it('Should throw if uninstall script fails', async () => {
// Arrange
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
await db.app.update({ where: { id: app1.id }, data: { status: 'updating' } });
// Act & Assert
await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to uninstall\nstdout: test`);
const app = await db.app.findUnique({ where: { id: app1.id } });
expect(app?.status).toBe(APP_STATUS.STOPPED);
});
});
describe('Start app', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true }, db);
app1 = app1create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
it('Should correctly dispatch event', async () => {
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
await AppsService.startApp(app1.id);
expect(spy.mock.lastCall).toEqual([EVENT_TYPES.APP, ['start', app1.id]]);
spy.mockRestore();
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.startApp('any')).rejects.toThrowError('App any not found');
});
it('Should restart if app is already running', async () => {
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
await AppsService.startApp(app1.id);
expect(spy.mock.calls.length).toBe(1);
await AppsService.startApp(app1.id);
expect(spy.mock.calls.length).toBe(2);
spy.mockRestore();
});
it('Regenerate env file', async () => {
fs.writeFileSync(`/app/storage/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000');
await AppsService.startApp(app1.id);
const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${app1.port}`);
});
it('Should throw if start script fails', async () => {
// Arrange
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
// Act & Assert
await expect(AppsService.startApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to start\nstdout: test`);
const app = await db.app.findUnique({ where: { id: app1.id } });
expect(app?.status).toBe(APP_STATUS.STOPPED);
});
});
describe('Stop app', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true }, db);
app1 = app1create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
it('Should correctly dispatch stop event', async () => {
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
await AppsService.stopApp(app1.id);
expect(spy.mock.lastCall).toEqual([EVENT_TYPES.APP, ['stop', app1.id]]);
});
it('Should throw if app is not installed', async () => {
await expect(AppsService.stopApp('any')).rejects.toThrowError('App any not found');
});
it('Should throw if stop script fails', async () => {
// Arrange
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
// Act & Assert
await expect(AppsService.stopApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to stop\nstdout: test`);
const app = await db.app.findUnique({ where: { id: app1.id } });
expect(app?.status).toBe(APP_STATUS.RUNNING);
});
});
describe('Update app config', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true }, db);
app1 = app1create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
it('Should correctly update app config', async () => {
await AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' });
const envFile = fs.readFileSync(`/app/storage/app-data/${app1.id}/app.env`).toString();
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${app1.port}`);
});
it('Should throw if required field is missing', async () => {
await expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: '' })).rejects.toThrowError('Variable TEST_FIELD is required');
});
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 found');
});
it('Should not recreate random field if already present in .env', async () => {
const { appInfo, MockFiles } = await createApp({ randomField: true, installed: true }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
const envFile = fs.readFileSync(`/app/storage/app-data/${appInfo.id}/app.env`).toString();
fs.writeFileSync(`/app/storage/app-data/${appInfo.id}/app.env`, `${envFile}\nRANDOM_FIELD=test`);
await AppsService.updateAppConfig(appInfo.id, { TEST_FIELD: 'test' });
const envMap = getEnvMap(appInfo.id);
expect(envMap.get('RANDOM_FIELD')).toBe('test');
});
it('Should throw if app is exposed and domain is not provided', () => expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true)).rejects.toThrowError('Domain is required'));
it('Should throw if app is exposed and domain is not valid', () =>
expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test')).rejects.toThrowError('Domain test is not valid'));
it('Should throw if app is exposed and config does not allow it', () =>
expect(AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`App ${app1.id} is not exposable`));
it('Should throw if app is exposed and domain is already used', async () => {
const app2 = await createApp({ exposable: true, installed: true }, db);
const app3 = await createApp({ exposable: true, installed: true }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app2.MockFiles, app3.MockFiles));
await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
await expect(AppsService.updateAppConfig(app3.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com')).rejects.toThrowError(`Domain test.com already in use by app ${app2.appInfo.id}`);
});
it('Should not throw if updating with same domain', async () => {
const app2 = await createApp({ exposable: true, installed: true }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app2.MockFiles));
await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
await AppsService.updateAppConfig(app2.appInfo.id, { TEST_FIELD: 'test' }, true, 'test.com');
});
it('Should throw if app has invalid config.json', async () => {
const { appInfo, MockFiles } = await createApp({ installed: true }, db);
MockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = 'invalid json';
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(MockFiles));
fs.writeFileSync(`/app/storage/app-data/${appInfo.id}/config.json`, 'test');
await expect(AppsService.updateAppConfig(appInfo.id, { TEST_FIELD: 'test' })).rejects.toThrowError(`App ${appInfo.id} has invalid config.json`);
});
});
describe('Get app config', () => {
let app1: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true }, db);
app1 = app1create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app1create.MockFiles));
});
it('Should correctly get app config', async () => {
const app = await AppsService.getApp(app1.id);
expect(app).toBeDefined();
expect(app.config).toStrictEqual({ TEST_FIELD: 'test' });
expect(app.id).toBe(app1.id);
expect(app.status).toBe(APP_STATUS.RUNNING);
});
it('Should return default values if app is not installed', async () => {
const { appInfo, MockFiles } = await createApp({ installed: false }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(MockFiles));
const appconfig = await AppsService.getApp(appInfo.id);
expect(appconfig).toBeDefined();
expect(appconfig.id).toBe(appInfo.id);
expect(appconfig.config).toStrictEqual({});
expect(appconfig.status).toBe(APP_STATUS.MISSING);
});
});
describe('List apps', () => {
let app1: AppInfo;
let app2: AppInfo;
beforeEach(async () => {
const app1create = await createApp({ installed: true }, db);
const app2create = await createApp({}, db);
app1 = app1create.appInfo;
app2 = app2create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app1create.MockFiles, app2create.MockFiles));
});
it('Should correctly list apps sorted by name', async () => {
const { apps } = await AppServiceClass.listApps();
const sortedApps = [app1, app2].sort((a, b) => a.name.localeCompare(b.name));
expect(apps).toBeDefined();
expect(apps.length).toBe(2);
expect(apps.length).toBe(2);
expect(apps[0]?.id).toBe(sortedApps[0]?.id);
expect(apps[1]?.id).toBe(sortedApps[1]?.id);
expect(apps[0]?.description).toBe('md desc');
});
it('Should not list apps that have supportedArchitectures and are not supported', async () => {
// Arrange
setConfig('architecture', 'arm64');
const app3 = await createApp({ supportedArchitectures: ['arm'] }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app3.MockFiles));
// Act
const { apps } = await AppServiceClass.listApps();
// Assert
expect(apps).toBeDefined();
expect(apps.length).toBe(0);
});
it('Should list apps that have supportedArchitectures and are supported', async () => {
// Arrange
setConfig('architecture', 'arm');
const app3 = await createApp({ supportedArchitectures: ['arm'] }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app3.MockFiles));
// Act
const { apps } = await AppServiceClass.listApps();
// Assert
expect(apps).toBeDefined();
expect(apps.length).toBe(1);
});
it('Should list apps that have no supportedArchitectures specified', async () => {
// Arrange
setConfig('architecture', 'arm');
const app3 = await createApp({ supportedArchitectures: undefined }, db);
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app3.MockFiles));
// Act
const { apps } = await AppServiceClass.listApps();
// Assert
expect(apps).toBeDefined();
expect(apps.length).toBe(1);
});
it('Should not list app with invalid config.json', async () => {
// Arrange
const { MockFiles: mockApp1, appInfo } = await createApp({}, db);
const { MockFiles: mockApp2 } = await createApp({}, db);
const MockFiles = Object.assign(mockApp1, mockApp2);
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = 'invalid json';
// @ts-expect-error - Mocking fs
fs.__createMockFiles(MockFiles);
// Act
const { apps } = await AppServiceClass.listApps();
// Assert
expect(apps).toBeDefined();
expect(apps.length).toBe(1);
});
});
describe.skip('Start all apps', () => {
it('Should correctly start all apps', async () => {
// arrange
const app1create = await createApp({ installed: true }, db);
const app2create = await createApp({ installed: true }, db);
const app1 = app1create.appInfo;
const app2 = app2create.appInfo;
const apps = [app1, app2].sort((a, b) => a.id.localeCompare(b.id));
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app1create.MockFiles, app2create.MockFiles));
await AppsService.startAllApps();
expect(spy.mock.calls.length).toBe(2);
const expectedCalls = apps.map((app) => [EVENT_TYPES.APP, ['start', app.id]]);
expect(spy.mock.calls).toEqual(expect.arrayContaining(expectedCalls));
});
it('Should not start apps which have not status RUNNING', async () => {
// arrange
const app1 = await createApp({ installed: true, status: 'running' }, db);
const app2 = await createApp({ installed: true, status: 'running' }, db);
const app3 = await createApp({ installed: true, status: 'stopped' }, db);
const spy = jest.spyOn(EventDispatcher, 'dispatchEventAsync');
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app1.MockFiles, app2.MockFiles, app3.MockFiles));
await AppsService.startAllApps();
const apps = await db.app.findMany();
expect(spy.mock.calls.length).toBe(2);
expect(apps.length).toBe(3);
});
it('Should put app status to STOPPED if start script fails', async () => {
// Arrange
await createApp({ installed: true }, db);
await createApp({ installed: true }, db);
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
// Act
await AppsService.startAllApps();
const apps = await db.app.findMany();
// Assert
expect(apps.length).toBe(2);
expect(apps[0]?.status).toBe(APP_STATUS.STOPPED);
expect(apps[1]?.status).toBe(APP_STATUS.STOPPED);
});
});
describe('Update app', () => {
it('Should correctly update app', async () => {
const app1create = await createApp({ installed: true }, db);
const app1 = app1create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app1create.MockFiles));
await db.app.update({ where: { id: app1.id }, data: { version: 0 } });
const app = await AppsService.updateApp(app1.id);
expect(app).toBeDefined();
expect(app.config).toStrictEqual({ TEST_FIELD: 'test' });
expect(app.version).toBe(app1.tipi_version);
expect(app.status).toBe(APP_STATUS.STOPPED);
});
it("Should throw if app doesn't exist", async () => {
await expect(AppsService.updateApp('test-app2')).rejects.toThrow('App test-app2 not found');
});
it('Should throw if update script fails', async () => {
// Arrange
const app1create = await createApp({ installed: true }, db);
const app1 = app1create.appInfo;
// @ts-expect-error - Mocking fs
fs.__createMockFiles(Object.assign(app1create.MockFiles));
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
await expect(AppsService.updateApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to update\nstdout: error`);
const app = await db.app.findUnique({ where: { id: app1.id } });
expect(app?.status).toBe(APP_STATUS.STOPPED);
});
});

View file

@ -0,0 +1,373 @@
import validator from 'validator';
import { App, PrismaClient } from '@prisma/client';
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, appInfoSchema, AppInfo, getAppInfo } from './apps.helpers';
import { getConfig } from '../../core/TipiConfig';
import { EventDispatcher } from '../../core/EventDispatcher';
import { Logger } from '../../core/Logger';
import { createFolder, readJsonFile } from '../../common/fs.helpers';
const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
const filterApp = (app: AppInfo): boolean => {
if (!app.supported_architectures) {
return true;
}
const arch = getConfig().architecture;
return app.supported_architectures.includes(arch);
};
const filterApps = (apps: AppInfo[]): AppInfo[] => apps.sort(sortApps).filter(filterApp);
export class AppServiceClass {
private prisma;
constructor(p: PrismaClient) {
this.prisma = p;
}
/**
* This function starts all apps that are in the 'running' status.
* It finds all the running apps and starts them by regenerating the env file, checking the env file and dispatching the start event.
* If the start event is successful, the app's status is updated to 'running', otherwise, it is updated to 'stopped'
* If there is an error while starting the app, it logs the error and updates the app's status to 'stopped'.
*
* @returns {Promise<void>} - A promise that resolves when all apps are started.
*/
public async startAllApps() {
const apps = await this.prisma.app.findMany({ where: { status: 'running' }, orderBy: { id: 'asc' } });
await Promise.all(
apps.map(async (app) => {
// Regenerate env file
try {
ensureAppFolder(app.id);
generateEnvFile(app);
checkEnvFile(app.id);
await this.prisma.app.update({ where: { id: app.id }, data: { status: 'starting' } });
EventDispatcher.dispatchEventAsync('app', ['start', app.id]).then(({ success }) => {
if (success) {
this.prisma.app.update({ where: { id: app.id }, data: { status: 'running' } }).then(() => {});
} else {
this.prisma.app.update({ where: { id: app.id }, data: { status: 'stopped' } }).then(() => {});
}
});
} catch (e) {
await this.prisma.app.update({ where: { id: app.id }, data: { status: 'stopped' } });
Logger.error(e);
}
}),
);
}
/**
* This function starts an app specified by its appName, regenerates its environment file and checks for missing requirements.
* It updates the app's status in the database to 'starting' and 'running' if the start process is successful, otherwise it updates the status to 'stopped'.
*
* @param {string} appName - The name of the app to start
* @returns {Promise<App | null>} - Returns a promise that resolves with the updated app information.
* @throws {Error} - If the app is not found or the start process fails.
*/
public startApp = async (appName: string) => {
let app = await this.prisma.app.findUnique({ where: { id: appName } });
if (!app) {
throw new Error(`App ${appName} not found`);
}
ensureAppFolder(appName);
// Regenerate env file
generateEnvFile(app);
checkEnvFile(appName);
await this.prisma.app.update({ where: { id: appName }, data: { status: 'starting' } });
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['start', app.id]);
if (success) {
await this.prisma.app.update({ where: { id: appName }, data: { status: 'running' } });
} else {
await this.prisma.app.update({ where: { id: appName }, data: { status: 'stopped' } });
throw new Error(`App ${appName} failed to start\nstdout: ${stdout}`);
}
app = await this.prisma.app.findUnique({ where: { id: appName } });
return app;
};
/**
* Installs an app and updates the status accordingly
*
* @param {string} id - The id of the app to be installed
* @param {Record<string, string>} form - The form data submitted by the user
* @param {boolean} [exposed] - A flag indicating if the app will be exposed to the internet
* @param {string} [domain] - The domain name to expose the app to the internet, required if exposed is true
* @returns {Promise<App | null>} Returns a promise that resolves to the installed app object
*/
public installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string) => {
let app = await this.prisma.app.findUnique({ where: { id } });
if (app) {
await this.startApp(id);
} else {
if (exposed && !domain) {
throw new Error('Domain is required if app is exposed');
}
if (domain && !validator.isFQDN(domain)) {
throw new Error(`Domain ${domain} is not valid`);
}
ensureAppFolder(id, true);
checkAppRequirements(id);
// Create app folder
createFolder(`/app/storage/app-data/${id}`);
const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
const parsedAppInfo = appInfoSchema.safeParse(appInfo);
if (!parsedAppInfo.success) {
throw new Error(`App ${id} has invalid config.json file`);
}
if (!parsedAppInfo.data.exposable && exposed) {
throw new Error(`App ${id} is not exposable`);
}
if (exposed) {
const appsWithSameDomain = await this.prisma.app.findMany({ where: { domain, exposed: true } });
if (appsWithSameDomain.length > 0) {
throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0]?.id}`);
}
}
app = await this.prisma.app.create({ data: { id, status: 'installing', config: form, version: parsedAppInfo.data.tipi_version, exposed: exposed || false, domain } });
if (app) {
// Create env file
generateEnvFile(app);
}
// Run script
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['install', id]);
if (!success) {
await this.prisma.app.delete({ where: { id } });
throw new Error(`App ${id} failed to install\nstdout: ${stdout}`);
}
}
app = await this.prisma.app.update({ where: { id }, data: { status: 'running' } });
return app;
};
/**
* Lists available apps
*
* @returns {Promise<{apps: Array<AppInfo>, total: number }>} An object containing list of apps and total number of apps
*/
public static listApps = async () => {
const apps = await getAvailableApps();
const filteredApps = filterApps(apps);
return { apps: filteredApps, total: apps.length };
};
/**
* Update the configuration of an app
*
* @param {string} id - The ID of the app to update.
* @param {object} form - The new configuration of the app.
* @param {boolean} [exposed=false] - If the app should be exposed or not.
* @param {string} [domain] - The domain for the app if exposed is true.
* @returns {Promise<App | null>} The updated app
*/
public updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string) => {
if (exposed && !domain) {
throw new Error('Domain is required if app is exposed');
}
if (domain && !validator.isFQDN(domain)) {
throw new Error(`Domain ${domain} is not valid`);
}
let app = await this.prisma.app.findUnique({ where: { id } });
if (!app) {
throw new Error(`App ${id} not found`);
}
const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
const parsedAppInfo = appInfoSchema.safeParse(appInfo);
if (!parsedAppInfo.success) {
throw new Error(`App ${id} has invalid config.json`);
}
if (!parsedAppInfo.data.exposable && exposed) {
throw new Error(`App ${id} is not exposable`);
}
if (exposed) {
const appsWithSameDomain = await this.prisma.app.findMany({ where: { domain, exposed: true, id: { not: id } } });
if (appsWithSameDomain.length > 0) {
throw new Error(`Domain ${domain} already in use by app ${appsWithSameDomain[0]?.id}`);
}
}
app = await this.prisma.app.update({ where: { id }, data: { config: form, exposed: exposed || false, domain } });
generateEnvFile(app);
return app;
};
/**
* Stops a running application by its id
*
* @param {string} id - The id of the application to stop
* @returns {Promise<App>} - The stopped application
* @throws {Error} - If the app cannot be found or if stopping the app failed
*/
public stopApp = async (id: string) => {
let app = await this.prisma.app.findUnique({ where: { id } });
if (!app) {
throw new Error(`App ${id} not found`);
}
ensureAppFolder(id);
generateEnvFile(app);
// Run script
await this.prisma.app.update({ where: { id }, data: { status: 'stopping' } });
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['stop', id]);
if (success) {
await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
} else {
await this.prisma.app.update({ where: { id }, data: { status: 'running' } });
throw new Error(`App ${id} failed to stop\nstdout: ${stdout}`);
}
app = await this.prisma.app.findUnique({ where: { id } });
return app;
};
/**
* Uninstalls an app by stopping it, running the app's `uninstall` script, and removing its data
*
* @param {string} id - The id of the app to uninstall
* @returns {Promise<{id: string, status: string, config: object}>} - An object containing the id of the uninstalled app, the status of the app ('missing'), and the config object
* @throws {Error} - If the app is not found or if the app's `uninstall` script fails
*/
public uninstallApp = async (id: string) => {
const app = await this.prisma.app.findUnique({ where: { id } });
if (!app) {
throw new Error(`App ${id} not found`);
}
if (app.status === 'running') {
await this.stopApp(id);
}
ensureAppFolder(id);
generateEnvFile(app);
await this.prisma.app.update({ where: { id }, data: { status: 'uninstalling' } });
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['uninstall', id]);
if (!success) {
await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
throw new Error(`App ${id} failed to uninstall\nstdout: ${stdout}`);
}
await this.prisma.app.delete({ where: { id } });
return { id, status: 'missing', config: {} };
};
/**
* Returns the app with the provided id. If the app is not found, it returns a default app object
*
* @param {string} id - The id of the app to retrieve
* @returns {Promise<App>} - The app object
*/
public getApp = async (id: string) => {
let app = await this.prisma.app.findUnique({ where: { id } });
const info = getAppInfo(id, app?.status);
const parsedInfo = appInfoSchema.safeParse(info);
if (parsedInfo.success) {
if (!app) {
app = { id, status: 'missing', config: {}, exposed: false, domain: '' } as App;
}
return { ...app, info: { ...parsedInfo.data } };
}
throw new Error(`App ${id} has invalid config.json`);
};
/**
* Updates an app with the specified ID
*
* @param {string} id - ID of the app to update
* @returns {Promise<App>} - An object representing the updated app
* @throws {Error} - If the app is not found or if the update process fails.
*/
public updateApp = async (id: string) => {
let app = await this.prisma.app.findUnique({ where: { id } });
if (!app) {
throw new Error(`App ${id} not found`);
}
ensureAppFolder(id);
generateEnvFile(app);
await this.prisma.app.update({ where: { id }, data: { status: 'updating' } });
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['update', id]);
if (success) {
const appInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
const parsedAppInfo = appInfoSchema.parse(appInfo);
await this.prisma.app.update({ where: { id }, data: { status: 'running', version: parsedAppInfo.tipi_version } });
} else {
await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);
}
app = await this.prisma.app.update({ where: { id }, data: { status: 'stopped' } });
return app;
};
/**
* Returns a list of all installed apps
*
* @returns {Promise<App[]>} - An array of app objects
* @throws {Error} - If the app is not found or if the update process fails.
*/
public installedApps = async () => {
const apps = await this.prisma.app.findMany();
return apps.map((app) => {
const info = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
const parsedInfo = appInfoSchema.safeParse(info);
if (parsedInfo.success) {
return { ...app, info: { ...parsedInfo.data } };
}
throw new Error(`App ${app.id} has invalid config.json`);
});
};
}

View file

@ -0,0 +1,45 @@
export const APP_STATUS = {
INSTALLING: 'installing',
MISSING: 'missing',
RUNNING: 'running',
STARTING: 'starting',
STOPPED: 'stopped',
STOPPING: 'stopping',
UNINSTALLING: 'uninstalling',
UPDATING: 'updating',
} as const;
export type AppStatus = typeof APP_STATUS[keyof typeof APP_STATUS];
export const APP_CATEGORIES = {
NETWORK: 'network',
MEDIA: 'media',
DEVELOPMENT: 'development',
AUTOMATION: 'automation',
SOCIAL: 'social',
UTILITIES: 'utilities',
PHOTOGRAPHY: 'photography',
SECURITY: 'security',
FEATURED: 'featured',
BOOKS: 'books',
DATA: 'data',
MUSIC: 'music',
FINANCE: 'finance',
GAMING: 'gaming',
} as const;
export type AppCategory = typeof APP_CATEGORIES[keyof typeof APP_CATEGORIES];
export const FIELD_TYPES = {
TEXT: 'text',
PASSWORD: 'password',
EMAIL: 'email',
NUMBER: 'number',
FQDN: 'fqdn',
IP: 'ip',
FQDNIP: 'fqdnip',
URL: 'url',
RANDOM: 'random',
} as const;
export type FieldType = typeof FIELD_TYPES[keyof typeof FIELD_TYPES];

View file

@ -0,0 +1,107 @@
import { faker } from '@faker-js/faker';
import { App, PrismaClient } from '@prisma/client';
import { Architecture } from '../core/TipiConfig/TipiConfig';
import { AppInfo, appInfoSchema } from '../services/apps/apps.helpers';
import { APP_CATEGORIES } from '../services/apps/apps.types';
interface IProps {
installed?: boolean;
status?: App['status'];
requiredPort?: number;
randomField?: boolean;
exposed?: boolean;
domain?: string;
exposable?: boolean;
supportedArchitectures?: Architecture[];
}
type CreateConfigParams = {
id?: string;
};
const createAppConfig = (props?: CreateConfigParams) =>
appInfoSchema.parse({
id: props?.id || faker.random.alphaNumeric(32),
available: true,
port: faker.datatype.number({ min: 30, max: 65535 }),
name: faker.random.alphaNumeric(32),
description: faker.random.alphaNumeric(32),
tipi_version: 1,
short_desc: faker.random.alphaNumeric(32),
author: faker.random.alphaNumeric(32),
source: faker.internet.url(),
categories: [APP_CATEGORIES.AUTOMATION],
});
const createApp = async (props: IProps, db?: PrismaClient) => {
const { installed = false, status = 'running', randomField = false, exposed = false, domain = '', exposable = false, supportedArchitectures } = props;
const categories = Object.values(APP_CATEGORIES);
const randomId = faker.random.alphaNumeric(32);
const appInfo: AppInfo = {
id: randomId,
port: faker.datatype.number({ min: 3000, max: 5000 }),
available: true,
form_fields: [
{
type: 'text',
label: faker.random.word(),
required: true,
env_variable: 'TEST_FIELD',
},
],
name: faker.random.word(),
description: faker.random.words(),
tipi_version: faker.datatype.number({ min: 1, max: 10 }),
short_desc: faker.random.words(),
author: faker.name.firstName(),
source: faker.internet.url(),
categories: [categories[faker.datatype.number({ min: 0, max: categories.length - 1 })]] as AppInfo['categories'],
exposable,
supported_architectures: supportedArchitectures,
version: String(faker.datatype.number({ min: 1, max: 10 })),
https: false,
no_gui: false,
};
if (randomField) {
appInfo.form_fields?.push({
required: false,
type: 'random',
label: faker.random.word(),
env_variable: 'RANDOM_FIELD',
});
}
const MockFiles: Record<string, string | string[]> = {};
MockFiles['/runtipi/.env'] = 'TEST=test';
MockFiles['/runtipi/repos/repo-id'] = '';
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo));
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
let appEntity: App = {} as App;
if (installed && db) {
appEntity = await db.app.create({
data: {
id: appInfo.id,
config: { TEST_FIELD: 'test' },
status,
exposed,
domain,
version: 1,
},
});
MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
MockFiles[`/runtipi/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
MockFiles[`/runtipi/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
}
return { appInfo, MockFiles, appEntity };
};
export { createApp, createAppConfig };

View file

@ -0,0 +1,30 @@
import { PrismaClient } from '@prisma/client';
import pg from 'pg';
import { runPostgresMigrations } from '../../run-migration';
import { getConfig } from '../../src/server/core/TipiConfig';
export const getTestDbClient = async (testsuite: string) => {
const pgClient = new pg.Client({
user: getConfig().postgresUsername,
host: getConfig().postgresHost,
database: getConfig().postgresDatabase,
password: getConfig().postgresPassword,
port: getConfig().postgresPort,
});
await pgClient.connect();
await pgClient.query(`DROP DATABASE IF EXISTS ${testsuite}`);
await pgClient.query(`CREATE DATABASE ${testsuite}`);
await pgClient.end();
await runPostgresMigrations(testsuite);
return new PrismaClient({
datasources: {
db: {
url: `postgresql://${getConfig().postgresUsername}:${getConfig().postgresPassword}@${getConfig().postgresHost}:${getConfig().postgresPort}/${testsuite}?connect_timeout=300`,
},
},
});
};

View file

@ -13,7 +13,7 @@ jest.mock('fs-extra');
jest.mock('child_process');
let db: DataSource | null = null;
const TEST_SUITE = 'appshelpers';
const TEST_SUITE = 'appshelperslegacy';
beforeAll(async () => {
db = await setupConnection(TEST_SUITE);

View file

@ -13,7 +13,7 @@ jest.mock('fs-extra');
jest.mock('child_process');
let db: DataSource | null = null;
const TEST_SUITE = 'appsservice';
const TEST_SUITE = 'appsservicelegacy';
beforeAll(async () => {
db = await setupConnection(TEST_SUITE);
@ -309,7 +309,7 @@ describe('Start app', () => {
});
it('Regenerate env file', async () => {
fs.writeFile(`/app/storage/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
fs.writeFileSync(`/app/storage/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000');
await AppsService.startApp(app1.id);

189
pnpm-lock.yaml generated
View file

@ -30,15 +30,15 @@ importers:
'@runtipi/postgres-migrations': ^5.3.0
'@tabler/core': 1.0.0-beta16
'@tabler/icons': ^1.109.0
'@tanstack/react-query': ^4.20.4
'@tanstack/react-query': ^4.24.4
'@testing-library/dom': ^8.19.0
'@testing-library/jest-dom': ^5.16.5
'@testing-library/react': ^13.4.0
'@testing-library/user-event': ^14.4.3
'@trpc/client': ^10.7.0
'@trpc/next': ^10.7.0
'@trpc/react-query': ^10.7.0
'@trpc/server': ^10.7.0
'@trpc/next': ^10.9.1
'@trpc/react-query': ^10.9.1
'@trpc/server': ^10.9.1
'@types/fs-extra': ^9.0.13
'@types/isomorphic-fetch': ^0.0.36
'@types/jest': ^29.2.4
@ -63,6 +63,7 @@ importers:
eslint-config-next: 13.1.1
eslint-plugin-import: ^2.25.3
eslint-plugin-jest: ^27.1.7
eslint-plugin-jsdoc: ^39.6.9
eslint-plugin-jsx-a11y: ^6.6.1
eslint-plugin-react: ^7.31.10
eslint-plugin-react-hooks: ^4.6.0
@ -73,9 +74,10 @@ importers:
jest: ^29.3.1
jest-environment-jsdom: ^29.3.1
jsonwebtoken: ^9.0.0
msw: ^0.49.2
msw: ^1.0.0
next: 13.1.1
next-router-mock: ^0.8.0
nodemon: ^2.0.15
pg: ^8.7.3
prisma: ^4.8.0
react: 18.2.0
@ -109,11 +111,11 @@ importers:
'@runtipi/postgres-migrations': 5.3.0
'@tabler/core': 1.0.0-beta16_biqbaboplfbrettd7655fr4n2y
'@tabler/icons': 1.116.1_biqbaboplfbrettd7655fr4n2y
'@tanstack/react-query': 4.20.4_biqbaboplfbrettd7655fr4n2y
'@trpc/client': 10.7.0_@trpc+server@10.7.0
'@trpc/next': 10.7.0_gmaturtqmtlrknaw65ek5pmv2i
'@trpc/react-query': 10.7.0_x4ie6nhblo2vtx2aafrgzlfqy4
'@trpc/server': 10.7.0
'@tanstack/react-query': 4.24.4_biqbaboplfbrettd7655fr4n2y
'@trpc/client': 10.7.0_@trpc+server@10.9.1
'@trpc/next': 10.9.1_3jqi55bejizrmk4tzcsm3yonw4
'@trpc/react-query': 10.9.1_hv4rsvckahicenzgmmmurigzzu
'@trpc/server': 10.9.1
argon2: 0.29.1
clsx: 1.1.1
fs-extra: 10.1.0
@ -176,13 +178,15 @@ importers:
eslint-config-next: 13.1.1_lzzuuodtsqwxnvqeq4g4likcqa
eslint-plugin-import: 2.26.0_smw3o7qjeokkcohbvp7rylsoqq
eslint-plugin-jest: 27.1.7_q2j57hara4akamrxhqbo5gmcq4
eslint-plugin-jsdoc: 39.6.9_eslint@8.30.0
eslint-plugin-jsx-a11y: 6.6.1_eslint@8.30.0
eslint-plugin-react: 7.31.11_eslint@8.30.0
eslint-plugin-react-hooks: 4.6.0_eslint@8.30.0
jest: 29.3.1_zfha7dvnw4nti6zkbsmhmn6xo4
jest-environment-jsdom: 29.3.1
msw: 0.49.2_typescript@4.9.4
msw: 1.0.0_typescript@4.9.4
next-router-mock: 0.8.0_next@13.1.1+react@18.2.0
nodemon: 2.0.16
prisma: 4.8.0
ts-jest: 29.0.3_iyz3vhhlowkpp2xbqliblzwv3y
ts-node: 10.9.1_awa2wsr5thmg3i7jqycphctjfq
@ -1555,6 +1559,15 @@ packages:
- typescript
dev: true
/@es-joy/jsdoccomment/0.36.1:
resolution: {integrity: sha512-922xqFsTpHs6D0BUiG4toiyPOMc8/jafnWKxz1KWgS4XzKPy2qXf1Pe6UFuNSCQqt6tOuhAWXBNuuyUhJmw9Vg==}
engines: {node: ^14 || ^16 || ^17 || ^18 || ^19}
dependencies:
comment-parser: 1.3.1
esquery: 1.4.0
jsdoc-type-pratt-parser: 3.1.0
dev: true
/@esbuild/android-arm/0.16.8:
resolution: {integrity: sha512-r/qxYWkC3gY+Uq24wZacAUevGGb6d7d8VpyO8R0HGg31LXVi+eUr8XxHLCcmVzAjRjlZsZfzPelGpAKP/DafKg==}
engines: {node: '>=12'}
@ -3307,12 +3320,12 @@ packages:
react-dom: 18.2.0_react@18.2.0
dev: false
/@tanstack/query-core/4.20.4:
resolution: {integrity: sha512-lhLtGVNJDsJ/DyZXrLzekDEywQqRVykgBqTmkv0La32a/RleILXy6JMLBb7UmS3QCatg/F/0N9/5b0i5j6IKcA==}
/@tanstack/query-core/4.24.4:
resolution: {integrity: sha512-9dqjv9eeB6VHN7lD3cLo16ZAjfjCsdXetSAD5+VyKqLUvcKTL0CklGQRJu+bWzdrS69R6Ea4UZo8obHYZnG6aA==}
dev: false
/@tanstack/react-query/4.20.4_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-SJRxx13k/csb9lXAJfycgVA1N/yU/h3bvRNWP0+aHMfMjmbyX82FdoAcckDBbOdEyAupvb0byelNHNeypCFSyA==}
/@tanstack/react-query/4.24.4_biqbaboplfbrettd7655fr4n2y:
resolution: {integrity: sha512-RpaS/3T/a3pHuZJbIAzAYRu+1nkp+/enr9hfRXDS/mojwx567UiMksoqW4wUFWlwIvWTXyhot2nbIipTKEg55Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
@ -3323,7 +3336,7 @@ packages:
react-native:
optional: true
dependencies:
'@tanstack/query-core': 4.20.4
'@tanstack/query-core': 4.24.4
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
use-sync-external-store: 1.2.0_react@18.2.0
@ -3386,53 +3399,53 @@ packages:
engines: {node: '>= 10'}
dev: true
/@trpc/client/10.7.0_@trpc+server@10.7.0:
/@trpc/client/10.7.0_@trpc+server@10.9.1:
resolution: {integrity: sha512-xCPc2hp37REJst6TXMkJvdc85CHvxpVY6YVdZIjl7uCYoQcrstwy8AWi7ElB3x7e19Cweke+9CAaUXKwAE1AKQ==}
peerDependencies:
'@trpc/server': 10.7.0
dependencies:
'@trpc/server': 10.7.0
'@trpc/server': 10.9.1
dev: false
/@trpc/next/10.7.0_gmaturtqmtlrknaw65ek5pmv2i:
resolution: {integrity: sha512-ZLY4J9IgEHN2op0UM14NCwCDyodRUw8Pmxgnpr62+UMIhDll6uggb2an8Op87l9EeYdKhKe2VavDWphI8vFGgw==}
/@trpc/next/10.9.1_3jqi55bejizrmk4tzcsm3yonw4:
resolution: {integrity: sha512-4eYS+2B+TtYpymZBtyPPZG5ZWMEfKvZ1T6Q5sWGGFidBHW3FBjMiSpNmigrFfQpidiAEtygaPyBeJvM2DQtMCw==}
peerDependencies:
'@tanstack/react-query': ^4.3.8
'@trpc/client': 10.7.0
'@trpc/react-query': ^10.0.0-proxy-beta.21
'@trpc/server': 10.7.0
'@trpc/client': 10.9.1
'@trpc/react-query': ^10.8.0
'@trpc/server': 10.9.1
next: '*'
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
'@tanstack/react-query': 4.20.4_biqbaboplfbrettd7655fr4n2y
'@trpc/client': 10.7.0_@trpc+server@10.7.0
'@trpc/react-query': 10.7.0_x4ie6nhblo2vtx2aafrgzlfqy4
'@trpc/server': 10.7.0
'@tanstack/react-query': 4.24.4_biqbaboplfbrettd7655fr4n2y
'@trpc/client': 10.7.0_@trpc+server@10.9.1
'@trpc/react-query': 10.9.1_hv4rsvckahicenzgmmmurigzzu
'@trpc/server': 10.9.1
next: 13.1.1_7nrowiyds4jpk2wpzkb7237oey
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
react-ssr-prepass: 1.5.0_react@18.2.0
dev: false
/@trpc/react-query/10.7.0_x4ie6nhblo2vtx2aafrgzlfqy4:
resolution: {integrity: sha512-dSSmYid6NWocU32QMsH6qWEqnW4mbp7hX01hxsBfAA2mGYD8b4Kgc9ripw4MpPNbsJElJFyWDHfb/UHnhywWbA==}
/@trpc/react-query/10.9.1_hv4rsvckahicenzgmmmurigzzu:
resolution: {integrity: sha512-R6TvyJkvxc0i7qXNHlb+1aUnsJz5YuxhZDzDUJl9akNlvxlmZFm3R9gEMEjDAXIYAUHjbmucl9FKKN6GjCTBTA==}
peerDependencies:
'@tanstack/react-query': ^4.3.8
'@trpc/client': 10.7.0
'@trpc/server': 10.7.0
'@trpc/client': 10.9.1
'@trpc/server': 10.9.1
react: '>=16.8.0'
react-dom: '>=16.8.0'
dependencies:
'@tanstack/react-query': 4.20.4_biqbaboplfbrettd7655fr4n2y
'@trpc/client': 10.7.0_@trpc+server@10.7.0
'@trpc/server': 10.7.0
'@tanstack/react-query': 4.24.4_biqbaboplfbrettd7655fr4n2y
'@trpc/client': 10.7.0_@trpc+server@10.9.1
'@trpc/server': 10.9.1
react: 18.2.0
react-dom: 18.2.0_react@18.2.0
dev: false
/@trpc/server/10.7.0:
resolution: {integrity: sha512-kFhlqhzZt5PwVRRDci8oJKMMFGjjq9189stT6bvS9xuuLjRzzwhMShRi99sm/jqhdjWmlXyO3Ncx0rnghUvH+w==}
/@trpc/server/10.9.1:
resolution: {integrity: sha512-cUbj8KphlH0IHuoVUhkZrN1E+FdfRpagglqhblyj8VPJKkjzTjb6D8MYHlXhiX7Tdmtwy2w0ev39HBkzlM0thg==}
dev: false
/@tsconfig/node10/1.0.9:
@ -3500,12 +3513,12 @@ packages:
resolution: {integrity: sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==}
dependencies:
'@types/connect': 3.4.35
'@types/node': 17.0.31
'@types/node': 18.11.18
/@types/connect/3.4.35:
resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
dependencies:
'@types/node': 17.0.31
'@types/node': 18.11.18
/@types/cookie/0.4.1:
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
@ -3529,26 +3542,18 @@ packages:
resolution: {integrity: sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==}
dev: false
/@types/express-serve-static-core/4.17.28:
resolution: {integrity: sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==}
dependencies:
'@types/node': 17.0.31
'@types/qs': 6.9.7
'@types/range-parser': 1.2.4
/@types/express-serve-static-core/4.17.29:
resolution: {integrity: sha512-uMd++6dMKS32EOuw1Uli3e3BPgdLIXmezcfHv7N4c1s3gkhikBplORPpMq3fuWkxncZN1reb16d5n8yhQ80x7Q==}
dependencies:
'@types/node': 17.0.31
'@types/node': 18.11.18
'@types/qs': 6.9.7
'@types/range-parser': 1.2.4
dev: false
/@types/express/4.17.13:
resolution: {integrity: sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==}
dependencies:
'@types/body-parser': 1.19.2
'@types/express-serve-static-core': 4.17.28
'@types/express-serve-static-core': 4.17.29
'@types/qs': 6.9.7
'@types/serve-static': 1.13.10
@ -3698,7 +3703,6 @@ packages:
/@types/node/18.11.18:
resolution: {integrity: sha512-DHQpWGjyQKSHj3ebjFI/wRKcqQcdR+MoFBygntYOZytCqNfkd2ZC4ARDJ2DQqhjH5p85Nnd3jhUJIXrszFX/JA==}
dev: true
/@types/normalize-package-data/2.4.1:
resolution: {integrity: sha512-Gj7cI7z+98M282Tqmp2K5EIsoouUEzbBJhQQzDE3jSIRk6r9gsz0oUokqIUR4u1R3dMHo0pDHM7sNOHyhulypw==}
@ -3763,7 +3767,7 @@ packages:
resolution: {integrity: sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==}
dependencies:
'@types/mime': 1.3.2
'@types/node': 17.0.31
'@types/node': 18.11.18
/@types/set-cookie-parser/2.4.2:
resolution: {integrity: sha512-fBZgytwhYAUkj/jC/FAV4RQ5EerRup1YQsXQCh8rZfiHkc4UahC192oH0smGwsXol3cL3A5oETuAHeQHmhXM4w==}
@ -4511,7 +4515,7 @@ packages:
dev: true
/array-flatten/1.1.1:
resolution: {integrity: sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=}
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
dev: false
/array-ify/1.0.0:
@ -4525,7 +4529,7 @@ packages:
call-bind: 1.0.2
define-properties: 1.1.4
es-abstract: 1.20.0
get-intrinsic: 1.1.1
get-intrinsic: 1.1.3
is-string: 1.0.7
dev: true
@ -4966,7 +4970,7 @@ packages:
resolution: {integrity: sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA==}
dependencies:
function-bind: 1.1.1
get-intrinsic: 1.1.1
get-intrinsic: 1.1.3
/callsites/3.1.0:
resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==}
@ -5324,6 +5328,11 @@ packages:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
dev: false
/comment-parser/1.3.1:
resolution: {integrity: sha512-B52sN2VNghyq5ofvUsqZjmk6YkihBX5vMSChmSK9v4ShjKf3Vk5Xcmgpw4o+iIgtrnM/u5FiMpz9VKb8lpBveA==}
engines: {node: '>= 12.0.0'}
dev: true
/commitizen/4.2.5:
resolution: {integrity: sha512-9sXju8Qrz1B4Tw7kC5KhnvwYQN88qs2zbiB8oyMsnXZyJ24PPGiNM3nHr73d32dnE3i8VJEXddBFIbOgYSEXtQ==}
engines: {node: '>= 12'}
@ -5462,7 +5471,7 @@ packages:
dev: true
/cookie-signature/1.0.6:
resolution: {integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw=}
resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==}
dev: false
/cookie/0.4.2:
@ -5947,7 +5956,7 @@ packages:
safe-buffer: 5.2.1
/ee-first/1.1.1:
resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=}
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
dev: false
/electron-to-chromium/1.4.136:
@ -5981,7 +5990,7 @@ packages:
dev: false
/encodeurl/1.0.2:
resolution: {integrity: sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k=}
resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==}
engines: {node: '>= 0.8'}
dev: false
@ -6016,7 +6025,7 @@ packages:
es-to-primitive: 1.2.1
function-bind: 1.1.1
function.prototype.name: 1.1.5
get-intrinsic: 1.1.1
get-intrinsic: 1.1.3
get-symbol-description: 1.0.0
has: 1.0.3
has-property-descriptors: 1.0.0
@ -6028,7 +6037,7 @@ packages:
is-shared-array-buffer: 1.0.2
is-string: 1.0.7
is-weakref: 1.0.2
object-inspect: 1.12.0
object-inspect: 1.12.2
object-keys: 1.1.1
object.assign: 4.1.2
regexp.prototype.flags: 1.4.3
@ -6135,7 +6144,7 @@ packages:
dev: true
/escape-html/1.0.3:
resolution: {integrity: sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=}
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
dev: false
/escape-string-regexp/1.0.5:
@ -6506,6 +6515,24 @@ packages:
- typescript
dev: true
/eslint-plugin-jsdoc/39.6.9_eslint@8.30.0:
resolution: {integrity: sha512-GrESRfjT1jOaK1LC2DldMuh+Ajmex9OnRIVSKMXJ1t7lNDB+J2MA11afLPXfkTKWJpplxZohjo8prT8s5LOPgQ==}
engines: {node: ^14 || ^16 || ^17 || ^18 || ^19}
peerDependencies:
eslint: ^7.0.0 || ^8.0.0
dependencies:
'@es-joy/jsdoccomment': 0.36.1
comment-parser: 1.3.1
debug: 4.3.4
escape-string-regexp: 4.0.0
eslint: 8.30.0
esquery: 1.4.0
semver: 7.3.8
spdx-expression-parse: 3.0.1
transitivePeerDependencies:
- supports-color
dev: true
/eslint-plugin-jsx-a11y/6.6.1_eslint@8.30.0:
resolution: {integrity: sha512-sXgFVNHiWffBq23uiS/JaP6eVR622DqwB4yTzKvGZGcPq6/yZ3WmOZfuBks/vHWo9GaFOqC2ZK4i6+C35knx7Q==}
engines: {node: '>=4.0'}
@ -6781,7 +6808,7 @@ packages:
dev: true
/etag/1.8.1:
resolution: {integrity: sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=}
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
dev: false
@ -7132,7 +7159,7 @@ packages:
dev: false
/fresh/0.5.2:
resolution: {integrity: sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac=}
resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
engines: {node: '>= 0.6'}
dev: false
@ -7225,13 +7252,6 @@ packages:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
/get-intrinsic/1.1.1:
resolution: {integrity: sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==}
dependencies:
function-bind: 1.1.1
has: 1.0.3
has-symbols: 1.0.3
/get-intrinsic/1.1.3:
resolution: {integrity: sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A==}
dependencies:
@ -7757,7 +7777,7 @@ packages:
dev: true
/import-lazy/2.1.0:
resolution: {integrity: sha1-BWmOPUXIjo1+nZLLBYTnfwlvPkM=}
resolution: {integrity: sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A==}
engines: {node: '>=4'}
dev: true
@ -9170,6 +9190,11 @@ packages:
dependencies:
argparse: 2.0.1
/jsdoc-type-pratt-parser/3.1.0:
resolution: {integrity: sha512-MgtD0ZiCDk9B+eI73BextfRrVQl0oyzRG8B2BjORts6jbunj4ScKPcyXGTbB6eXL4y9TzxCm6hyeLq/2ASzNdw==}
engines: {node: '>=12.0.0'}
dev: true
/jsdom/20.0.3:
resolution: {integrity: sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==}
engines: {node: '>=14'}
@ -9824,7 +9849,7 @@ packages:
dev: false
/media-typer/0.3.0:
resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=}
resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
engines: {node: '>= 0.6'}
dev: false
@ -9850,7 +9875,7 @@ packages:
dev: true
/merge-descriptors/1.0.1:
resolution: {integrity: sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E=}
resolution: {integrity: sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==}
dev: false
/merge-stream/2.0.0:
@ -9879,7 +9904,7 @@ packages:
dev: true
/methods/1.1.2:
resolution: {integrity: sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=}
resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
engines: {node: '>= 0.6'}
dev: false
@ -10333,8 +10358,8 @@ packages:
/ms/2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
/msw/0.49.2_typescript@4.9.4:
resolution: {integrity: sha512-70/E10f+POE2UmMw16v8PnKatpZplpkUwVRLBqiIdimpgaC3le7y2yOq9JmOrL15jpwWM5wAcPTOj0f550LI3g==}
/msw/1.0.0_typescript@4.9.4:
resolution: {integrity: sha512-8QVa1RAN/Nzbn/tKmtimJ+b2M1QZOMdETQW7/1TmBOZ4w+wJojfxuh1Hj5J4FYdBgZWW/TK4CABUOlOM4OjTOA==}
engines: {node: '>=14'}
hasBin: true
requiresBuild: true
@ -10360,7 +10385,7 @@ packages:
node-fetch: 2.6.7
outvariant: 1.3.0
path-to-regexp: 6.2.1
strict-event-emitter: 0.2.8
strict-event-emitter: 0.4.4
type-fest: 2.19.0
typescript: 4.9.4
yargs: 17.4.1
@ -10607,10 +10632,6 @@ packages:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
/object-inspect/1.12.0:
resolution: {integrity: sha512-Ho2z80bVIvJloH+YzRmpZVQe87+qASmBUKZDWgx9cu+KDrX2ZDH/3tMy+gXbZETVGs2M8YdxObOh7XAtim9Y0g==}
dev: true
/object-inspect/1.12.2:
resolution: {integrity: sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ==}
@ -10994,7 +11015,7 @@ packages:
dev: true
/path-to-regexp/0.1.7:
resolution: {integrity: sha1-32BBeABfUi8V60SQ5yR6G/qmf4w=}
resolution: {integrity: sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==}
dev: false
/path-to-regexp/6.2.1:
@ -12057,6 +12078,10 @@ packages:
events: 3.3.0
dev: true
/strict-event-emitter/0.4.4:
resolution: {integrity: sha512-rTCFXHBxs2/XvNc7InSkSwUkwyQ0T9eop/Qvm0atNUXpBxjwsJ5yb7Ih/tgHbjPdeCcB4aCP8K4tuo7hNKssNg==}
dev: true
/string-env-interpolation/1.0.1:
resolution: {integrity: sha512-78lwMoCcn0nNu8LszbP1UA7g55OeE4v7rCeWnM5B453rnNr4aq+5it3FEYtZrSEiMvHZOZ9Jlqb0OD0M2VInqg==}
dev: true
@ -13015,7 +13040,7 @@ packages:
is-yarn-global: 0.3.0
latest-version: 5.1.0
pupa: 2.1.1
semver: 7.3.7
semver: 7.3.8
semver-diff: 3.1.1
xdg-basedir: 4.0.0
dev: true
@ -13087,7 +13112,7 @@ packages:
dev: true
/utils-merge/1.0.1:
resolution: {integrity: sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM=}
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
dev: false