refactor: import from packages/shared and remove duplicate code
This commit is contained in:
parent
da9fa0d72a
commit
a15a4f602a
35 changed files with 57 additions and 622 deletions
|
@ -13,9 +13,11 @@ FROM builder_base AS builder
|
|||
WORKDIR /app
|
||||
|
||||
COPY ./pnpm-lock.yaml ./
|
||||
COPY ./pnpm-workspace.yaml ./
|
||||
RUN pnpm fetch --no-scripts
|
||||
|
||||
COPY ./package*.json ./
|
||||
COPY ./packages ./packages
|
||||
|
||||
RUN pnpm install -r --prefer-offline
|
||||
COPY ./src ./src
|
||||
|
|
|
@ -11,6 +11,7 @@ COPY ./pnpm-lock.yaml ./
|
|||
RUN pnpm fetch --ignore-scripts
|
||||
|
||||
COPY ./package*.json ./
|
||||
COPY ./packages ./packages
|
||||
|
||||
RUN pnpm install -r --prefer-offline
|
||||
|
||||
|
|
|
@ -18,7 +18,7 @@ const onRebuild = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const included = ['express', 'pg', '@runtipi/postgres-migrations', 'connect-redis', 'express-session', 'drizzle-orm'];
|
||||
const included = ['express', 'pg', '@runtipi/postgres-migrations', 'connect-redis', 'express-session', 'drizzle-orm', '@runtipi/shared'];
|
||||
const excluded = ['pg-native', '*required-server-files.json'];
|
||||
const external = Object.keys(pkg.dependencies || {}).filter((dep) => !included.includes(dep));
|
||||
external.push(...excluded);
|
||||
|
|
|
@ -3,6 +3,7 @@ const nextConfig = {
|
|||
swcMinify: true,
|
||||
output: 'standalone',
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@runtipi/shared'],
|
||||
serverRuntimeConfig: {
|
||||
INTERNAL_IP: process.env.INTERNAL_IP,
|
||||
TIPI_VERSION: process.env.TIPI_VERSION,
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"watch": ["src/server"],
|
||||
"watch": ["src/server", "packages/shared"],
|
||||
"exec": "node ./esbuild.js dev",
|
||||
"ext": "js ts"
|
||||
}
|
||||
|
|
|
@ -4,11 +4,11 @@ import { IconDownload } from '@tabler/icons-react';
|
|||
import { Tooltip } from 'react-tooltip';
|
||||
import type { AppStatus as AppStatusEnum } from '@/server/db/schema';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { AppInfo } from '@runtipi/shared';
|
||||
import { AppStatus } from '../AppStatus';
|
||||
import { AppLogo } from '../AppLogo/AppLogo';
|
||||
import { limitText } from '../../modules/AppStore/helpers/table.helpers';
|
||||
import styles from './AppTile.module.scss';
|
||||
import { AppInfo } from '../../core/types';
|
||||
|
||||
type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
|
||||
|
||||
|
|
|
@ -16,7 +16,7 @@ import {
|
|||
IconTool,
|
||||
IconUsers,
|
||||
} from '@tabler/icons-react';
|
||||
import { AppCategory } from './types';
|
||||
import { AppCategory } from '@runtipi/shared';
|
||||
|
||||
type AppCategoryEntry = {
|
||||
id: AppCategory;
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
import * as Router from '../../server/routers/_app';
|
||||
|
||||
export type { FormField, AppInfo } from '@/server/services/apps/apps.helpers';
|
||||
export type { AppCategory } from '@/server/services/apps/apps.types';
|
||||
|
||||
export type App = Omit<Router.RouterOutput['app']['getApp'], 'info'>;
|
||||
export type AppWithInfo = Router.RouterOutput['app']['getApp'];
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { faker } from '@faker-js/faker';
|
||||
import type { AppStatus } from '@/server/db/schema';
|
||||
import { APP_CATEGORIES } from '../../../server/services/apps/apps.types';
|
||||
import { App, AppCategory, AppInfo, AppWithInfo } from '../../core/types';
|
||||
import { AppInfo, AppCategory, APP_CATEGORIES } from '@runtipi/shared';
|
||||
import { App, AppWithInfo } from '../../core/types';
|
||||
|
||||
const randomCategory = (): AppCategory[] => {
|
||||
const categories = Object.values(APP_CATEGORIES);
|
||||
|
|
|
@ -2,8 +2,8 @@ import clsx from 'clsx';
|
|||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppCategory } from '@runtipi/shared';
|
||||
import { AppLogo } from '../../../../components/AppLogo/AppLogo';
|
||||
import { AppCategory } from '../../../../core/types';
|
||||
import { colorSchemeForCategory, limitText } from '../../helpers/table.helpers';
|
||||
import styles from './AppStoreTile.module.scss';
|
||||
|
||||
|
|
|
@ -2,8 +2,8 @@ import React from 'react';
|
|||
import Select, { SingleValue, OptionProps, ControlProps, components } from 'react-select';
|
||||
import { Icon } from '@tabler/icons-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppCategory } from '@runtipi/shared';
|
||||
import { APP_CATEGORIES } from '../../../../core/constants';
|
||||
import { AppCategory } from '../../../../core/types';
|
||||
import { useUIStore } from '../../../../state/uiStore';
|
||||
|
||||
const { Option, Control } = components;
|
||||
|
|
|
@ -3,33 +3,33 @@ import { limitText, sortTable } from '../table.helpers';
|
|||
import { AppTableData } from '../table.types';
|
||||
|
||||
describe('sortTable function', () => {
|
||||
const app = createAppConfig({ name: 'a', categories: ['social'] });
|
||||
const app2 = createAppConfig({ name: 'b', categories: ['network', 'automation'] });
|
||||
const app3 = createAppConfig({ name: 'c', categories: ['network'] });
|
||||
const app = createAppConfig({ id: 'a', name: 'a', categories: ['social'] });
|
||||
const app2 = createAppConfig({ id: 'b', name: 'B', categories: ['network', 'automation'] });
|
||||
const app3 = createAppConfig({ id: 'c', name: 'c', categories: ['network'] });
|
||||
|
||||
// Randomize the order of the apps
|
||||
const data: AppTableData = [app3, app, app2];
|
||||
|
||||
it('should sort by name in ascending order', () => {
|
||||
const sortedData = sortTable({ data, direction: 'asc', col: 'name', search: '' });
|
||||
const sortedData = sortTable({ data, direction: 'asc', col: 'id', search: '' });
|
||||
|
||||
expect(sortedData).toEqual([app, app2, app3]);
|
||||
});
|
||||
|
||||
it('should sort by name in descending order', () => {
|
||||
const sortedData = sortTable({ data, direction: 'desc', col: 'name', search: '' });
|
||||
const sortedData = sortTable({ data, direction: 'desc', col: 'id', search: '' });
|
||||
|
||||
expect(sortedData).toEqual([app3, app2, app]);
|
||||
});
|
||||
|
||||
it('should filter by search term', () => {
|
||||
const sortedData = sortTable({ data, direction: 'asc', col: 'name', search: 'b' });
|
||||
const sortedData = sortTable({ data, direction: 'asc', col: 'id', search: 'b' });
|
||||
|
||||
expect(sortedData).toEqual([app2]);
|
||||
});
|
||||
|
||||
it('should filter by category', () => {
|
||||
const sortedData = sortTable({ data, direction: 'asc', col: 'name', search: '', category: 'automation' });
|
||||
const sortedData = sortTable({ data, direction: 'asc', col: 'id', search: '', category: 'automation' });
|
||||
|
||||
expect(sortedData).toEqual([app2]);
|
||||
});
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AppCategory, AppInfo } from '../../../core/types';
|
||||
import { AppCategory, AppInfo } from '@runtipi/shared';
|
||||
import { AppTableData } from './table.types';
|
||||
|
||||
type SortParams = {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { AppInfo } from '../../../core/types';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
|
||||
export type SortableColumns = keyof Pick<AppInfo, 'id'>;
|
||||
export type SortDirection = 'asc' | 'desc';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { create } from 'zustand';
|
||||
import { AppCategory } from '../../../core/types';
|
||||
import { AppCategory } from '@runtipi/shared';
|
||||
import { SortableColumns } from '../helpers/table.types';
|
||||
|
||||
type Store = {
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { AppActions } from './AppActions';
|
||||
import { cleanup, fireEvent, render, screen, waitFor, userEvent } from '../../../../../../tests/test-utils';
|
||||
import { AppInfo } from '../../../../core/types';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
|
|
|
@ -2,9 +2,9 @@ import { IconExternalLink } from '@tabler/icons-react';
|
|||
import React from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { DataGrid, DataGridItem } from '../../../components/ui/DataGrid';
|
||||
import Markdown from '../../../components/Markdown/Markdown';
|
||||
import { AppInfo } from '../../../core/types';
|
||||
|
||||
interface IProps {
|
||||
info: AppInfo;
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { fromPartial } from '@total-typescript/shoehorn';
|
||||
import { FormField } from '@runtipi/shared';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
|
||||
import { FormField } from '../../../../core/types';
|
||||
import { InstallForm } from './InstallForm';
|
||||
|
||||
describe('Test: InstallForm', () => {
|
||||
|
|
|
@ -5,11 +5,11 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||
import { Tooltip } from 'react-tooltip';
|
||||
import clsx from 'clsx';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { type FormField, type AppInfo } from '@runtipi/shared';
|
||||
import { Button } from '../../../../components/ui/Button';
|
||||
import { Switch } from '../../../../components/ui/Switch';
|
||||
import { Input } from '../../../../components/ui/Input';
|
||||
import { validateAppConfig } from '../../utils/validators';
|
||||
import { type FormField, type AppInfo } from '../../../../core/types';
|
||||
|
||||
interface IProps {
|
||||
formFields: FormField[];
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { InstallModal } from './InstallModal';
|
||||
import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
|
||||
import { AppInfo } from '../../../../core/types';
|
||||
|
||||
describe('InstallModal', () => {
|
||||
const app = {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { InstallForm } from '../InstallForm';
|
||||
import { AppInfo } from '../../../../core/types';
|
||||
import { FormValues } from '../InstallForm/InstallForm';
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { AppInfo } from '../../../core/types';
|
||||
|
||||
interface IProps {
|
||||
info: AppInfo;
|
||||
|
|
|
@ -2,8 +2,8 @@ import { IconAlertTriangle } from '@tabler/icons-react';
|
|||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { Button } from '../../../components/ui/Button';
|
||||
import { AppInfo } from '../../../core/types';
|
||||
|
||||
interface IProps {
|
||||
info: AppInfo;
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { Button } from '../../../../components/ui/Button';
|
||||
import { AppInfo } from '../../../../core/types';
|
||||
|
||||
interface IProps {
|
||||
newVersion: string;
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { AppInfo } from '@runtipi/shared';
|
||||
import { InstallForm } from './InstallForm';
|
||||
import { AppInfo } from '../../../core/types';
|
||||
import { FormValues } from './InstallForm/InstallForm';
|
||||
|
||||
interface IProps {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { FormField } from '../../../../core/types';
|
||||
import { FormField } from '@runtipi/shared';
|
||||
import { validateAppConfig, validateField } from './validators';
|
||||
|
||||
describe('Test: validateField', () => {
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import validator from 'validator';
|
||||
import { useUIStore } from '@/client/state/uiStore';
|
||||
import type { FormField } from '../../../../core/types';
|
||||
import type { FormField } from '@runtipi/shared';
|
||||
|
||||
export const validateField = (field: FormField, value: string | undefined | boolean): string | undefined => {
|
||||
const { translator } = useUIStore.getState();
|
||||
|
|
|
@ -57,7 +57,7 @@ export const ResetPasswordContainer: React.FC<Props> = ({ isRequested }) => {
|
|||
<h2 className="h2 text-center mb-3">{t('auth.reset-password.title')}</h2>
|
||||
<p>{t('auth.reset-password.instructions')}</p>
|
||||
<pre>
|
||||
<code>./scripts/reset-password.sh</code>
|
||||
<code>./runtipi-cli reset-password</code>
|
||||
</pre>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -22,6 +22,6 @@ describe('Test: Dashboard', () => {
|
|||
},
|
||||
};
|
||||
|
||||
render(<DashboardContainer data={data} />);
|
||||
render(<DashboardContainer data={data} isLoading={false} />);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,37 +5,6 @@ import { readJsonFile } from '../../common/fs.helpers';
|
|||
|
||||
jest.mock('fs-extra');
|
||||
|
||||
jest.mock('next/config', () =>
|
||||
jest.fn(() => ({
|
||||
serverRuntimeConfig: {
|
||||
DNS_IP: '1.1.1.1',
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line
|
||||
import nextConfig from 'next/config';
|
||||
|
||||
describe('Test: process.env', () => {
|
||||
it('should return config from .env', () => {
|
||||
const config = new TipiConfig().getConfig();
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config.dnsIp).toBe('1.1.1.1');
|
||||
});
|
||||
|
||||
it('should throw an error if there are invalid values', () => {
|
||||
// @ts-expect-error - We are mocking next/config
|
||||
nextConfig.mockImplementationOnce(() => ({
|
||||
serverRuntimeConfig: {
|
||||
DNS_IP: 'invalid',
|
||||
},
|
||||
}));
|
||||
|
||||
expect(() => new TipiConfig().getConfig()).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: getConfig', () => {
|
||||
it('It should return config from .env', () => {
|
||||
// arrange
|
||||
|
|
|
@ -1,58 +1,10 @@
|
|||
import { z } from 'zod';
|
||||
import { envSchema, settingsSchema } from '@runtipi/shared';
|
||||
import fs from 'fs-extra';
|
||||
import nextConfig from 'next/config';
|
||||
import { readJsonFile } from '../../common/fs.helpers';
|
||||
import { Logger } from '../Logger';
|
||||
|
||||
export const ARCHITECTURES = {
|
||||
ARM: 'arm',
|
||||
ARM64: 'arm64',
|
||||
AMD64: 'amd64',
|
||||
} as const;
|
||||
export type Architecture = (typeof ARCHITECTURES)[keyof typeof ARCHITECTURES];
|
||||
|
||||
const configSchema = z.object({
|
||||
NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
|
||||
REDIS_HOST: z.string(),
|
||||
status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]),
|
||||
architecture: z.nativeEnum(ARCHITECTURES),
|
||||
dnsIp: z.string().ip().trim(),
|
||||
rootFolder: z.string(),
|
||||
internalIp: z.string(),
|
||||
version: z.string(),
|
||||
jwtSecret: z.string(),
|
||||
appsRepoId: z.string(),
|
||||
appsRepoUrl: z.string().url().trim(),
|
||||
domain: z.string().trim(),
|
||||
localDomain: z.string().trim(),
|
||||
storagePath: z
|
||||
.string()
|
||||
.trim()
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
if (!value) return undefined;
|
||||
return value?.replace(/\s/g, '');
|
||||
}),
|
||||
postgresHost: z.string(),
|
||||
postgresDatabase: z.string(),
|
||||
postgresUsername: z.string(),
|
||||
postgresPassword: z.string(),
|
||||
postgresPort: z.number(),
|
||||
demoMode: z
|
||||
.string()
|
||||
.or(z.boolean())
|
||||
.optional()
|
||||
.transform((value) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
return value === 'true';
|
||||
}),
|
||||
});
|
||||
|
||||
export const settingsSchema = configSchema
|
||||
.partial()
|
||||
.pick({ dnsIp: true, internalIp: true, appsRepoUrl: true, domain: true, storagePath: true, localDomain: true })
|
||||
.and(z.object({ port: z.number(), sslPort: z.number(), listenIp: z.string().ip().trim() }).partial());
|
||||
|
||||
type TipiSettingsType = z.infer<typeof settingsSchema>;
|
||||
|
||||
const formatErrors = (errors: { fieldErrors: Record<string, string[]> }) =>
|
||||
|
@ -64,11 +16,11 @@ const formatErrors = (errors: { fieldErrors: Record<string, string[]> }) =>
|
|||
export class TipiConfig {
|
||||
private static instance: TipiConfig;
|
||||
|
||||
private config: z.infer<typeof configSchema>;
|
||||
private config: z.infer<typeof envSchema>;
|
||||
|
||||
constructor() {
|
||||
const conf = { ...process.env, ...nextConfig()?.serverRuntimeConfig };
|
||||
const envConfig: z.infer<typeof configSchema> = {
|
||||
const envConfig: z.infer<typeof envSchema> = {
|
||||
postgresHost: conf.POSTGRES_HOST,
|
||||
postgresDatabase: conf.POSTGRES_DBNAME,
|
||||
postgresUsername: conf.POSTGRES_USERNAME,
|
||||
|
@ -92,10 +44,10 @@ export class TipiConfig {
|
|||
};
|
||||
|
||||
const fileConfig = readJsonFile('/runtipi/state/settings.json') || {};
|
||||
const parsedFileConfig = configSchema.partial().safeParse(fileConfig);
|
||||
const parsedFileConfig = envSchema.partial().safeParse(fileConfig);
|
||||
|
||||
if (parsedFileConfig.success) {
|
||||
const parsedConfig = configSchema.safeParse({ ...envConfig, ...parsedFileConfig.data });
|
||||
const parsedConfig = envSchema.safeParse({ ...envConfig, ...parsedFileConfig.data });
|
||||
if (parsedConfig.success) {
|
||||
this.config = parsedConfig.data;
|
||||
} else {
|
||||
|
@ -133,18 +85,18 @@ export class TipiConfig {
|
|||
return this.config;
|
||||
}
|
||||
|
||||
public async setConfig<T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile = false) {
|
||||
const newConf: z.infer<typeof configSchema> = { ...this.getConfig() };
|
||||
public async setConfig<T extends keyof typeof envSchema.shape>(key: T, value: z.infer<typeof envSchema>[T], writeFile = false) {
|
||||
const newConf: z.infer<typeof envSchema> = { ...this.getConfig() };
|
||||
newConf[key] = value;
|
||||
|
||||
this.config = configSchema.parse(newConf);
|
||||
this.config = envSchema.parse(newConf);
|
||||
|
||||
if (writeFile) {
|
||||
const currentJsonConf = readJsonFile('/runtipi/state/settings.json') || {};
|
||||
const parsedConf = configSchema.partial().parse(currentJsonConf);
|
||||
const parsedConf = envSchema.partial().parse(currentJsonConf);
|
||||
|
||||
parsedConf[key] = value;
|
||||
const parsed = configSchema.partial().parse(parsedConf);
|
||||
const parsed = envSchema.partial().parse(parsedConf);
|
||||
|
||||
await fs.promises.writeFile('/runtipi/state/settings.json', JSON.stringify(parsed));
|
||||
}
|
||||
|
@ -155,7 +107,7 @@ export class TipiConfig {
|
|||
throw new Error('Cannot update settings in demo mode');
|
||||
}
|
||||
|
||||
const newConf: z.infer<typeof configSchema> = { ...this.getConfig() };
|
||||
const newConf: z.infer<typeof envSchema> = { ...this.getConfig() };
|
||||
const parsed = settingsSchema.safeParse(settings);
|
||||
|
||||
if (!parsed.success) {
|
||||
|
@ -165,11 +117,11 @@ export class TipiConfig {
|
|||
|
||||
await fs.promises.writeFile('/runtipi/state/settings.json', JSON.stringify(parsed.data));
|
||||
|
||||
this.config = configSchema.parse({ ...newConf, ...parsed.data });
|
||||
this.config = envSchema.parse({ ...newConf, ...parsed.data });
|
||||
}
|
||||
}
|
||||
|
||||
export const setConfig = <T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile = false) => {
|
||||
export const setConfig = <T extends keyof typeof envSchema.shape>(key: T, value: z.infer<typeof envSchema>[T], writeFile = false) => {
|
||||
return TipiConfig.getInstance().setConfig(key, value, writeFile);
|
||||
};
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { inferRouterOutputs } from '@trpc/server';
|
||||
import { settingsSchema } from '@runtipi/shared';
|
||||
import { router, protectedProcedure, publicProcedure } from '../../trpc';
|
||||
import { SystemServiceClass } from '../../services/system';
|
||||
import { settingsSchema } from '../../core/TipiConfig/TipiConfig';
|
||||
import * as TipiConfig from '../../core/TipiConfig';
|
||||
|
||||
export type SystemRouterOutput = inferRouterOutputs<typeof systemRouter>;
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import fs from 'fs-extra';
|
||||
import { fromAny, fromPartial } from '@total-typescript/shoehorn';
|
||||
import { fromAny } from '@total-typescript/shoehorn';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
|
||||
import { getAppEnvMap } from '@/server/utils/env-generation';
|
||||
import { appInfoSchema } from '@runtipi/shared';
|
||||
import { setConfig } from '../../core/TipiConfig';
|
||||
import { appInfoSchema, checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getUpdateInfo } from './apps.helpers';
|
||||
import { checkAppRequirements, checkEnvFile, getAppInfo, getAvailableApps, getUpdateInfo } from './apps.helpers';
|
||||
import { createAppConfig, insertApp } from '../../tests/apps.factory';
|
||||
|
||||
let db: TestDatabase;
|
||||
|
@ -120,166 +120,6 @@ describe('Test: appInfoSchema', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Test: generateEnvFile()', () => {
|
||||
it('Should generate an env file', async () => {
|
||||
// arrange
|
||||
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST_FIELD', type: 'text', label: 'test', required: true }] });
|
||||
const app = await insertApp({}, appConfig, db);
|
||||
const fakevalue = faker.string.alphanumeric(10);
|
||||
|
||||
// act
|
||||
await generateEnvFile(Object.assign(app, { config: { TEST_FIELD: fakevalue } }));
|
||||
const envmap = await getAppEnvMap(app.id);
|
||||
|
||||
// assert
|
||||
expect(envmap.get('TEST_FIELD')).toBe(fakevalue);
|
||||
});
|
||||
|
||||
it('Should automatically generate value for random field', async () => {
|
||||
// arrange
|
||||
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'RANDOM_FIELD', type: 'random', label: 'test', min: 32, max: 32, required: true }] });
|
||||
const app = await insertApp({}, appConfig, db);
|
||||
|
||||
// act
|
||||
await generateEnvFile(app);
|
||||
const envmap = await getAppEnvMap(app.id);
|
||||
|
||||
// assert
|
||||
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 () => {
|
||||
// arrange
|
||||
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'RANDOM_FIELD', type: 'random', label: 'test', min: 32, max: 32, required: true }] });
|
||||
const app = await insertApp({}, appConfig, db);
|
||||
const randomField = faker.string.alphanumeric(32);
|
||||
fs.writeFileSync(`/app/storage/app-data/${app.id}/app.env`, `RANDOM_FIELD=${randomField}`);
|
||||
|
||||
// act
|
||||
await generateEnvFile(app);
|
||||
const envmap = await getAppEnvMap(app.id);
|
||||
|
||||
// assert
|
||||
expect(envmap.get('RANDOM_FIELD')).toBe(randomField);
|
||||
});
|
||||
|
||||
it('Should throw an error if required field is not provided', async () => {
|
||||
// arrange
|
||||
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST_FIELD', type: 'text', label: 'test', required: true }] });
|
||||
const app = await insertApp({}, appConfig, db);
|
||||
|
||||
// act & assert
|
||||
await expect(generateEnvFile(Object.assign(app, { config: { TEST_FIELD: undefined } }))).rejects.toThrowError('Variable test is required');
|
||||
});
|
||||
|
||||
it('Should throw an error if app does not exist', async () => {
|
||||
// act & assert
|
||||
await expect(generateEnvFile(fromPartial({ id: 'not-existing-app' }))).rejects.toThrowError('App not-existing-app has invalid config.json file');
|
||||
});
|
||||
|
||||
it('Should add APP_EXPOSED to env file if domain is provided and app is exposed', async () => {
|
||||
// arrange
|
||||
const domain = faker.internet.domainName();
|
||||
const appConfig = createAppConfig();
|
||||
const app = await insertApp({ domain, exposed: true }, appConfig, db);
|
||||
|
||||
// act
|
||||
await generateEnvFile(app);
|
||||
const envmap = await getAppEnvMap(app.id);
|
||||
|
||||
// assert
|
||||
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 () => {
|
||||
// arrange
|
||||
const appConfig = createAppConfig();
|
||||
const app = await insertApp({ exposed: true }, appConfig, db);
|
||||
|
||||
// act
|
||||
await generateEnvFile(app);
|
||||
const envmap = await getAppEnvMap(app.id);
|
||||
|
||||
// assert
|
||||
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('Should not add APP_EXPOSED if app is not exposed', async () => {
|
||||
// arrange
|
||||
const appConfig = createAppConfig();
|
||||
const app = await insertApp({ exposed: false, domain: faker.internet.domainName() }, appConfig, db);
|
||||
|
||||
// act
|
||||
await generateEnvFile(app);
|
||||
const envmap = await getAppEnvMap(app.id);
|
||||
|
||||
// assert
|
||||
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
|
||||
expect(envmap.get('APP_DOMAIN')).toBe(`localhost:${appConfig.port}`);
|
||||
});
|
||||
|
||||
it('Should create app folder if it does not exist', async () => {
|
||||
// arrange
|
||||
const appConfig = createAppConfig();
|
||||
const app = await insertApp({}, appConfig, db);
|
||||
fs.rmSync(`/app/storage/app-data/${app.id}`, { recursive: true });
|
||||
|
||||
// act
|
||||
await generateEnvFile(app);
|
||||
|
||||
// assert
|
||||
expect(fs.existsSync(`/app/storage/app-data/${app.id}`)).toBe(true);
|
||||
});
|
||||
|
||||
it('should generate vapid private and public keys if config has generate_vapid_keys set to true', async () => {
|
||||
// arrange
|
||||
const appConfig = createAppConfig({ generate_vapid_keys: true });
|
||||
const app = await insertApp({}, appConfig, db);
|
||||
|
||||
// act
|
||||
await generateEnvFile(app);
|
||||
const envmap = await getAppEnvMap(app.id);
|
||||
|
||||
// assert
|
||||
expect(envmap.get('VAPID_PRIVATE_KEY')).toBeDefined();
|
||||
expect(envmap.get('VAPID_PUBLIC_KEY')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not generate vapid private and public keys if config has generate_vapid_keys set to false', async () => {
|
||||
// arrange
|
||||
const appConfig = createAppConfig({ generate_vapid_keys: false });
|
||||
const app = await insertApp({}, appConfig, db);
|
||||
|
||||
// act
|
||||
await generateEnvFile(app);
|
||||
const envmap = await getAppEnvMap(app.id);
|
||||
|
||||
// assert
|
||||
expect(envmap.get('VAPID_PRIVATE_KEY')).toBeUndefined();
|
||||
expect(envmap.get('VAPID_PUBLIC_KEY')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not re-generate vapid private and public keys if they already exist', async () => {
|
||||
// arrange
|
||||
const appConfig = createAppConfig({ generate_vapid_keys: true });
|
||||
const app = await insertApp({}, appConfig, db);
|
||||
|
||||
const vapidPrivateKey = faker.string.alphanumeric(32);
|
||||
const vapidPublicKey = faker.string.alphanumeric(32);
|
||||
|
||||
// act
|
||||
fs.writeFileSync(`/app/storage/app-data/${app.id}/app.env`, `VAPID_PRIVATE_KEY=${vapidPrivateKey}\nVAPID_PUBLIC_KEY=${vapidPublicKey}`);
|
||||
await generateEnvFile(app);
|
||||
const envmap = await getAppEnvMap(app.id);
|
||||
|
||||
// assert
|
||||
expect(envmap.get('VAPID_PRIVATE_KEY')).toBe(vapidPrivateKey);
|
||||
expect(envmap.get('VAPID_PUBLIC_KEY')).toBe(vapidPublicKey);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: getAvailableApps()', () => {
|
||||
it('Should return all available apps', async () => {
|
||||
// arrange
|
||||
|
@ -359,18 +199,6 @@ describe('Test: getAppInfo()', () => {
|
|||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('Should throw if something goes wrong', async () => {
|
||||
// arrange
|
||||
jest.spyOn(fs, 'existsSync').mockImplementationOnce(() => {
|
||||
throw new Error('Something went wrong');
|
||||
});
|
||||
const appConfig = createAppConfig();
|
||||
const app = await insertApp({ status: 'missing' }, appConfig, db);
|
||||
|
||||
// act & assert
|
||||
expect(() => getAppInfo(app.id, app.status)).toThrowError(`Error loading app: ${app.id}`);
|
||||
});
|
||||
|
||||
it('Should return null if app does not exist', async () => {
|
||||
// arrange
|
||||
const app = getAppInfo(faker.lorem.word());
|
||||
|
@ -413,63 +241,3 @@ describe('Test: getUpdateInfo()', () => {
|
|||
expect(updateInfo).toEqual({ latestVersion: 0, latestDockerVersion: '0.0.0' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test: ensureAppFolder()', () => {
|
||||
it('should copy the folder from repo', async () => {
|
||||
// arrange
|
||||
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
|
||||
await fs.promises.writeFile('/runtipi/repos/repo-id/apps/test/test.yml', 'test');
|
||||
// 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', async () => {
|
||||
// arrange
|
||||
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
|
||||
await fs.promises.writeFile('/runtipi/repos/repo-id/apps/test/test.yml', 'test');
|
||||
await fs.promises.mkdir('/runtipi/apps/test', { recursive: true });
|
||||
await fs.promises.writeFile('/runtipi/apps/test/docker-compose.yml', 'test');
|
||||
|
||||
// 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', async () => {
|
||||
// arrange
|
||||
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
|
||||
await fs.promises.writeFile('/runtipi/repos/repo-id/apps/test/test.yml', 'test');
|
||||
await fs.promises.mkdir('/runtipi/apps/test', { recursive: true });
|
||||
await fs.promises.writeFile('/runtipi/apps/test/docker-compose.yml', 'test');
|
||||
|
||||
// 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', async () => {
|
||||
// arrange
|
||||
const randomFileName = `${faker.lorem.word()}.yml`;
|
||||
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
|
||||
await fs.promises.writeFile(`/runtipi/repos/repo-id/apps/test/${randomFileName}`, 'test');
|
||||
await fs.promises.mkdir('/runtipi/apps/test', { recursive: true });
|
||||
await fs.promises.writeFile('/runtipi/apps/test/test.yml', 'test');
|
||||
|
||||
// act
|
||||
ensureAppFolder('test');
|
||||
|
||||
// assert
|
||||
const files = fs.readdirSync('/runtipi/apps/test');
|
||||
expect(files).toEqual([randomFileName]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,61 +1,11 @@
|
|||
import crypto from 'crypto';
|
||||
import fs from 'fs-extra';
|
||||
import { z } from 'zod';
|
||||
import { App } from '@/server/db/schema';
|
||||
import { envMapToString, envStringToMap, generateVapidKeys, getAppEnvMap } from '@/server/utils/env-generation';
|
||||
import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile } from '../../common/fs.helpers';
|
||||
import { APP_CATEGORIES, FIELD_TYPES } from './apps.types';
|
||||
import { getAppEnvMap } from '@/server/utils/env-generation';
|
||||
import { appInfoSchema } from '@runtipi/shared';
|
||||
import { fileExists, readdirSync, readFile, readJsonFile } from '../../common/fs.helpers';
|
||||
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).catch(() => FIELD_TYPES.TEXT),
|
||||
label: z.string(),
|
||||
placeholder: z.string().optional(),
|
||||
max: z.number().optional(),
|
||||
min: z.number().optional(),
|
||||
hint: z.string().optional(),
|
||||
options: z.object({ label: z.string(), value: z.string() }).array().optional(),
|
||||
required: z.boolean().optional().default(false),
|
||||
default: z.union([z.boolean(), z.string()]).optional(),
|
||||
regex: z.string().optional(),
|
||||
pattern_error: z.string().optional(),
|
||||
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(),
|
||||
force_expose: z.boolean().optional().default(false),
|
||||
generate_vapid_keys: z.boolean().optional().default(false),
|
||||
categories: z
|
||||
.nativeEnum(APP_CATEGORIES)
|
||||
.array()
|
||||
.catch((ctx) => {
|
||||
Logger.warn(`Invalid categories "${JSON.stringify(ctx.input)}" defaulting to utilities`);
|
||||
return [APP_CATEGORIES.UTILITIES];
|
||||
}),
|
||||
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.
|
||||
|
@ -120,186 +70,6 @@ export const checkEnvFile = async (appName: string) => {
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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 = async (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();
|
||||
const envMap = envStringToMap(baseEnvFile);
|
||||
|
||||
// Default always present env variables
|
||||
envMap.set('APP_PORT', String(parsedConfig.data.port));
|
||||
envMap.set('APP_ID', app.id);
|
||||
|
||||
const existingEnvMap = await getAppEnvMap(app.id);
|
||||
|
||||
if (parsedConfig.data.generate_vapid_keys) {
|
||||
if (existingEnvMap.has('VAPID_PUBLIC_KEY') && existingEnvMap.has('VAPID_PRIVATE_KEY')) {
|
||||
envMap.set('VAPID_PUBLIC_KEY', existingEnvMap.get('VAPID_PUBLIC_KEY') as string);
|
||||
envMap.set('VAPID_PRIVATE_KEY', existingEnvMap.get('VAPID_PRIVATE_KEY') as string);
|
||||
} else {
|
||||
const vapidKeys = generateVapidKeys();
|
||||
envMap.set('VAPID_PUBLIC_KEY', vapidKeys.publicKey);
|
||||
envMap.set('VAPID_PRIVATE_KEY', vapidKeys.privateKey);
|
||||
}
|
||||
}
|
||||
|
||||
parsedConfig.data.form_fields.forEach((field) => {
|
||||
const formValue = castAppConfig(app.config)[field.env_variable];
|
||||
const envVar = field.env_variable;
|
||||
|
||||
if (formValue || typeof formValue === 'boolean') {
|
||||
envMap.set(envVar, String(formValue));
|
||||
} else if (field.type === 'random') {
|
||||
if (existingEnvMap.has(envVar)) {
|
||||
envMap.set(envVar, existingEnvMap.get(envVar) as string);
|
||||
} else {
|
||||
const length = field.min || 32;
|
||||
const randomString = getEntropy(field.env_variable, length);
|
||||
|
||||
envMap.set(envVar, randomString);
|
||||
}
|
||||
} else if (field.required) {
|
||||
throw new Error(`Variable ${field.label || field.env_variable} is required`);
|
||||
}
|
||||
});
|
||||
|
||||
if (app.exposed && app.domain) {
|
||||
envMap.set('APP_EXPOSED', 'true');
|
||||
envMap.set('APP_DOMAIN', app.domain);
|
||||
envMap.set('APP_PROTOCOL', 'https');
|
||||
envMap.set('APP_HOST', app.domain);
|
||||
} else {
|
||||
envMap.set('APP_DOMAIN', `${getConfig().internalIp}:${parsedConfig.data.port}`);
|
||||
envMap.set('APP_HOST', getConfig().internalIp);
|
||||
}
|
||||
|
||||
// Create app-data folder if it doesn't exist
|
||||
const appDataDirectoryExists = await fs.promises.stat(`/app/storage/app-data/${app.id}`).catch(() => false);
|
||||
if (!appDataDirectoryExists) {
|
||||
await fs.promises.mkdir(`/app/storage/app-data/${app.id}`, { recursive: true });
|
||||
}
|
||||
|
||||
await fs.promises.writeFile(`/app/storage/app-data/${app.id}/app.env`, envMapToString(envMap));
|
||||
};
|
||||
|
||||
/**
|
||||
* Given a template and a map of variables, this function replaces all instances of the variables in the template with their values.
|
||||
*
|
||||
* @param {string} template - The template to be rendered.
|
||||
* @param {Map<string, string>} envMap - The map of variables and their values.
|
||||
*/
|
||||
const renderTemplate = (template: string, envMap: Map<string, string>) => {
|
||||
let renderedTemplate = template;
|
||||
|
||||
envMap.forEach((value, key) => {
|
||||
renderedTemplate = renderedTemplate.replace(new RegExp(`{{${key}}}`, 'g'), value);
|
||||
});
|
||||
|
||||
return renderedTemplate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Given an app, this function copies the app's data directory to the app-data folder.
|
||||
* If a file with an extension of .template is found, it will be copied as a file without the .template extension and the template variables will be replaced
|
||||
* by the values in the app's env file.
|
||||
*
|
||||
* @param {string} id - The id of the app.
|
||||
*/
|
||||
export const copyDataDir = async (id: string) => {
|
||||
const envMap = await getAppEnvMap(id);
|
||||
|
||||
const appDataDirExists = (await fs.promises.lstat(`/runtipi/apps/${id}/data`).catch(() => false)) as fs.Stats;
|
||||
if (!appDataDirExists || !appDataDirExists.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataDir = await fs.promises.readdir(`/runtipi/apps/${id}/data`);
|
||||
|
||||
const processFile = async (file: string) => {
|
||||
if (file.endsWith('.template')) {
|
||||
const template = await fs.promises.readFile(`/runtipi/apps/${id}/data/${file}`, 'utf-8');
|
||||
const renderedTemplate = renderTemplate(template, envMap);
|
||||
|
||||
await fs.promises.writeFile(`/app/storage/app-data/${id}/data/${file.replace('.template', '')}`, renderedTemplate);
|
||||
} else {
|
||||
await fs.promises.copyFile(`/runtipi/apps/${id}/data/${file}`, `/app/storage/app-data/${id}/data/${file}`);
|
||||
}
|
||||
};
|
||||
|
||||
const processDir = async (path: string) => {
|
||||
await fs.promises.mkdir(`/app/storage/app-data/${id}/data/${path}`, { recursive: true });
|
||||
const files = await fs.promises.readdir(`/runtipi/apps/${id}/data/${path}`);
|
||||
|
||||
await Promise.all(
|
||||
files.map(async (file) => {
|
||||
const fullPath = `/runtipi/apps/${id}/data/${path}/${file}`;
|
||||
|
||||
if ((await fs.promises.lstat(fullPath)).isDirectory()) {
|
||||
await processDir(`${path}/${file}`);
|
||||
} else {
|
||||
await processFile(`${path}/${file}`);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
await Promise.all(
|
||||
dataDir.map(async (file) => {
|
||||
const fullPath = `/runtipi/apps/${id}/data/${file}`;
|
||||
|
||||
if ((await fs.promises.lstat(fullPath)).isDirectory()) {
|
||||
await processDir(file);
|
||||
} else {
|
||||
await processFile(file);
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
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.
|
||||
|
@ -394,26 +164,3 @@ export const getAppInfo = (id: string, status?: App['status']) => {
|
|||
throw new Error(`Error loading app: ${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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] - 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): void => {
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import fs from 'fs-extra';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Architecture } from '../core/TipiConfig/TipiConfig';
|
||||
import { AppInfo, appInfoSchema } from '../services/apps/apps.helpers';
|
||||
import { APP_CATEGORIES } from '../services/apps/apps.types';
|
||||
import { AppInfo, Architecture, appInfoSchema, APP_CATEGORIES } from '@runtipi/shared';
|
||||
import { TestDatabase } from './test-utils';
|
||||
import { appTable, AppStatus, App, NewApp } from '../db/schema';
|
||||
|
||||
|
|
Loading…
Reference in a new issue