Преглед изворни кода

Merge pull request #223 from meienberger/release/0.7.0

Release/0.7.0
Nicolas Meienberger пре 2 година
родитељ
комит
7aabf0de7b
89 измењених фајлова са 2791 додато и 1293 уклоњено
  1. 1 1
      .github/workflows/ci.yml
  2. 7 22
      .gitignore
  3. 7 5
      Dockerfile
  4. 2 4
      Dockerfile.dev
  5. 17 0
      README.md
  6. 9 4
      docker-compose.dev.yml
  7. 9 6
      docker-compose.rc.yml
  8. 10 7
      docker-compose.yml
  9. 0 0
      media/data/music/.gitkeep
  10. 4 3
      package.json
  11. 1 5
      packages/dashboard/next.config.js
  12. 1 5
      packages/dashboard/package.json
  13. 2 2
      packages/dashboard/src/components/AppLogo/AppLogo.tsx
  14. 0 1
      packages/dashboard/src/components/Layout/Layout.tsx
  15. 8 0
      packages/dashboard/src/components/Layout/SideMenu.tsx
  16. 14 0
      packages/dashboard/src/components/StatusScreens/RestartingScreen.tsx
  17. 50 0
      packages/dashboard/src/components/StatusScreens/StatusWrapper.tsx
  18. 14 0
      packages/dashboard/src/components/StatusScreens/UpdatingScreen.tsx
  19. 0 34
      packages/dashboard/src/core/api.ts
  20. 0 11
      packages/dashboard/src/core/fetcher.ts
  21. 1 6
      packages/dashboard/src/core/helpers/url-helpers.ts
  22. 72 2
      packages/dashboard/src/generated/graphql.tsx
  23. 3 0
      packages/dashboard/src/graphql/mutations/restart.graphql
  24. 3 0
      packages/dashboard/src/graphql/mutations/update.graphql
  25. 8 10
      packages/dashboard/src/hooks/useCachedRessources.ts
  26. 3 3
      packages/dashboard/src/modules/Apps/containers/AppDetails.tsx
  27. 8 4
      packages/dashboard/src/pages/_app.tsx
  28. 1 1
      packages/dashboard/src/pages/api/getenv.tsx
  29. 93 10
      packages/dashboard/src/pages/settings.tsx
  30. 0 74
      packages/dashboard/src/state/authStore.ts
  31. 11 1
      packages/dashboard/src/state/systemStore.ts
  32. 2 1
      packages/dashboard/tsconfig.json
  33. 3 0
      packages/system-api/.eslintrc.cjs
  34. 3 0
      packages/system-api/.gitignore
  35. 11 0
      packages/system-api/__mocks__/fs-extra.ts
  36. 11 0
      packages/system-api/__mocks__/node-cron.ts
  37. 7 1
      packages/system-api/jest.config.cjs
  38. 5 13
      packages/system-api/package.json
  39. 0 58
      packages/system-api/src/config/config.ts
  40. 0 1
      packages/system-api/src/config/index.ts
  41. 7 7
      packages/system-api/src/config/logger/logger.ts
  42. 2 2
      packages/system-api/src/constants/constants.ts
  43. 227 0
      packages/system-api/src/core/config/EventDispatcher.ts
  44. 124 0
      packages/system-api/src/core/config/TipiConfig.ts
  45. 199 0
      packages/system-api/src/core/config/__tests__/EventDispatcher.test.ts
  46. 99 0
      packages/system-api/src/core/config/__tests__/TipiConfig.test.ts
  47. 37 0
      packages/system-api/src/core/jobs/__tests__/jobs.test.ts
  48. 10 5
      packages/system-api/src/core/jobs/jobs.ts
  49. 2 2
      packages/system-api/src/core/middlewares/sessionMiddleware.ts
  50. 9 8
      packages/system-api/src/core/updates/__tests__/v040.test.ts
  51. 6 5
      packages/system-api/src/core/updates/recover-migrations.ts
  52. 8 9
      packages/system-api/src/core/updates/v040.ts
  53. 0 29
      packages/system-api/src/helpers/repo-helpers.ts
  54. 9 10
      packages/system-api/src/modules/apps/__tests__/apps.factory.ts
  55. 89 29
      packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts
  56. 2 0
      packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts
  57. 64 85
      packages/system-api/src/modules/apps/__tests__/apps.service.test.ts
  58. 28 37
      packages/system-api/src/modules/apps/apps.helpers.ts
  59. 92 37
      packages/system-api/src/modules/apps/apps.service.ts
  60. 61 43
      packages/system-api/src/modules/fs/__tests__/fs.helpers.test.ts
  61. 19 22
      packages/system-api/src/modules/fs/fs.helpers.ts
  62. 242 0
      packages/system-api/src/modules/system/__tests__/system.resolver.test.ts
  63. 213 0
      packages/system-api/src/modules/system/__tests__/system.service.test.ts
  64. 12 0
      packages/system-api/src/modules/system/system.controller.ts
  65. 13 1
      packages/system-api/src/modules/system/system.resolver.ts
  66. 82 21
      packages/system-api/src/modules/system/system.service.ts
  67. 30 9
      packages/system-api/src/server.ts
  68. 2 1
      packages/system-api/src/test/connection.ts
  69. 6 0
      packages/system-api/src/test/jest-setup.ts
  70. 4 0
      packages/system-api/src/test/mutations/index.ts
  71. 3 0
      packages/system-api/src/test/mutations/restart.graphql
  72. 3 0
      packages/system-api/src/test/mutations/update.graphql
  73. 4 0
      packages/system-api/src/test/queries/index.ts
  74. 17 0
      packages/system-api/src/test/queries/systemInfo.graphql
  75. 6 0
      packages/system-api/src/test/queries/version.graphql
  76. 39 282
      pnpm-lock.yaml
  77. 95 99
      scripts/app.sh
  78. 90 0
      scripts/common.sh
  79. 84 50
      scripts/configure.sh
  80. 3 0
      scripts/deploy/release-rc.sh
  81. 31 36
      scripts/git.sh
  82. 11 0
      scripts/start-dev.sh
  83. 118 138
      scripts/start.sh
  84. 10 20
      scripts/stop.sh
  85. 15 6
      scripts/system-info.sh
  86. 33 0
      scripts/system.sh
  87. 3 3
      scripts/unsafe-cleanup.sh
  88. 125 0
      scripts/watcher.sh
  89. 2 2
      templates/env-sample

+ 1 - 1
.github/workflows/ci.yml

@@ -3,7 +3,7 @@ on:
   push:
   push:
 
 
 env:
 env:
-  ROOT_FOLDER: /test
+  ROOT_FOLDER: /runtipi
   JWT_SECRET: "secret"
   JWT_SECRET: "secret"
   ROOT_FOLDER_HOST: /tipi
   ROOT_FOLDER_HOST: /tipi
   APPS_REPO_ID: repo-id
   APPS_REPO_ID: repo-id

+ 7 - 22
.gitignore

@@ -1,11 +1,15 @@
+*.swo
+*.swp
+
+.DS_Store
+
+logs
 .pnpm-debug.log
 .pnpm-debug.log
 .env*
 .env*
 github.secrets
 github.secrets
 node_modules/
 node_modules/
 app-data/*
 app-data/*
 data/postgres
 data/postgres
-traefik/ssl/*
-!traefik/ssl/.gitkeep
 !app-data/.gitkeep
 !app-data/.gitkeep
 repos/*
 repos/*
 !repos/.gitkeep
 !repos/.gitkeep
@@ -14,25 +18,6 @@ apps/*
 traefik/shared
 traefik/shared
 media
 media
 
 
-scripts/pacapt
-
 state/*
 state/*
 !state/.gitkeep
 !state/.gitkeep
-
-media/data/movies/*
-media/data/tv/*
-media/data/books/spoken/*
-media/data/books/ebooks/*
-!media/data/movies/.gitkeep
-!media/data/tv/.gitkeep
-!media/data/books/metadata.db
-!media/data/books/ebooks/.gitkeep
-!media/data/books/spoken/.gitkeep
-
-media/torrents/complete/*
-!media/torrents/complete/.gitkeep
-media/torrents/incomplete/*
-!media/torrents/incomplete/.gitkeep
-media/torrents/watch/*
-!media/torrents/watch/.gitkeep
-packages/dashboard/package-lock.json
+media

+ 7 - 5
Dockerfile

@@ -23,21 +23,23 @@ FROM alpine:3.16.0 as app
 
 
 WORKDIR /
 WORKDIR /
 
 
-# Install dependencies
-RUN apk --no-cache add docker-compose nodejs npm bash g++ make git
+# # Install dependencies
+RUN apk --no-cache add nodejs npm
+RUN apk --no-cache add g++
+RUN apk --no-cache add make
+RUN apk --no-cache add python3
 
 
 RUN npm install node-gyp -g
 RUN npm install node-gyp -g
 
 
 WORKDIR /api
 WORKDIR /api
 COPY ./packages/system-api/package*.json /api/
 COPY ./packages/system-api/package*.json /api/
-RUN npm install --production
+RUN npm install --omit=dev
 
 
 WORKDIR /dashboard
 WORKDIR /dashboard
 COPY ./packages/dashboard/package*.json /dashboard/
 COPY ./packages/dashboard/package*.json /dashboard/
-RUN npm install --production
+RUN npm install --omit=dev
 
 
 COPY --from=build /api/dist /api/dist
 COPY --from=build /api/dist /api/dist
-COPY ./packages/system-api /api
 
 
 COPY --from=build /dashboard/.next /dashboard/.next
 COPY --from=build /dashboard/.next /dashboard/.next
 COPY ./packages/dashboard /dashboard
 COPY ./packages/dashboard /dashboard

+ 2 - 4
Dockerfile.dev

@@ -1,10 +1,8 @@
-FROM alpine:3.16.0 as app
+FROM node:18-alpine3.16
 
 
 WORKDIR /
 WORKDIR /
 
 
-# Install docker
-RUN apk --no-cache add docker-compose nodejs npm bash g++ make git
-
+RUN apk --no-cache add g++ make
 RUN npm install node-gyp -g
 RUN npm install node-gyp -g
 
 
 WORKDIR /api
 WORKDIR /api

+ 17 - 0
README.md

@@ -94,6 +94,21 @@ To stop Tipi, run the stop script.
 sudo ./scripts/stop.sh
 sudo ./scripts/stop.sh
 ```
 ```
 
 
+### Custom settings
+You can change the default settings by creating a `settings.json` file. The file should be located in the `state` directory. This file will make your changes persist across restarts. Example file:
+
+```json
+{
+  "dnsIp": "9.9.9.9", // DNS IP address
+  "domain": "mydomain.com", // Domain name to link to the dashboard
+  "port": 7000, // Change default http port 80
+  "sslPort": 7001, // Change default ssl port 443
+  "listenIp": "192.168.1.1", // Change default listen ip (advanced)
+  "storagePath": "/mnt/usb", // Change default storage path of app data
+}
+
+```
+
 ## Linking a domain to your dashboard
 ## Linking a domain to your dashboard
 If you want to link a domain to your dashboard, you can do so by providing the `--domain` option in the start script.
 If you want to link a domain to your dashboard, you can do so by providing the `--domain` option in the start script.
 
 
@@ -101,6 +116,8 @@ If you want to link a domain to your dashboard, you can do so by providing the `
 sudo ./scripts/start.sh --domain mydomain.com
 sudo ./scripts/start.sh --domain mydomain.com
 ```
 ```
 
 
+You can also specify it in the `settings.json` file as shown in the previous section.
+
 A Let's Encrypt certificate will be generated and installed automatically. Make sure to have ports 80 and 443 open on your firewall and that your domain has an **A** record pointing to your server IP.
 A Let's Encrypt certificate will be generated and installed automatically. Make sure to have ports 80 and 443 open on your firewall and that your domain has an **A** record pointing to your server IP.
 
 
 ## ❤️ Contributing
 ## ❤️ Contributing

+ 9 - 4
docker-compose.dev.yml

@@ -51,16 +51,18 @@ services:
     ports:
     ports:
       - 3001:3001
       - 3001:3001
     volumes:
     volumes:
-      ## Docker sock
-      - /var/run/docker.sock:/var/run/docker.sock:ro
-      - ${PWD}:/tipi
+      - ${PWD}/repos:/runtipi/repos:ro
+      - ${PWD}/apps:/runtipi/apps
+      - ${PWD}/state:/runtipi/state
       - ${PWD}/packages/system-api/src:/api/src
       - ${PWD}/packages/system-api/src:/api/src
+      - ${STORAGE_PATH}:/app/storage
+      - ${PWD}/logs:/app/logs
+      - ${PWD}/.env.dev:/runtipi/.env
       # - /api/node_modules
       # - /api/node_modules
     environment:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       INTERNAL_IP: ${INTERNAL_IP}
       TIPI_VERSION: ${TIPI_VERSION}
       TIPI_VERSION: ${TIPI_VERSION}
       JWT_SECRET: ${JWT_SECRET}
       JWT_SECRET: ${JWT_SECRET}
-      ROOT_FOLDER_HOST: ${ROOT_FOLDER_HOST}
       NGINX_PORT: ${NGINX_PORT}
       NGINX_PORT: ${NGINX_PORT}
       POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
       POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
       POSTGRES_USERNAME: tipi
       POSTGRES_USERNAME: tipi
@@ -88,6 +90,9 @@ services:
       dockerfile: Dockerfile.dev
       dockerfile: Dockerfile.dev
     command: /bin/sh -c "cd /dashboard && npm run dev"
     command: /bin/sh -c "cd /dashboard && npm run dev"
     container_name: dashboard
     container_name: dashboard
+    depends_on:
+      api:
+        condition: service_started
     ports:
     ports:
       - 3000:3000
       - 3000:3000
     networks:
     networks:

+ 9 - 6
docker-compose.rc.yml

@@ -44,14 +44,16 @@ services:
       tipi-db:
       tipi-db:
         condition: service_healthy
         condition: service_healthy
     volumes:
     volumes:
-      ## Docker sock
-      - /var/run/docker.sock:/var/run/docker.sock:ro
-      - ${PWD}:/tipi
+      - ${PWD}/repos:/runtipi/repos:ro
+      - ${PWD}/apps:/runtipi/apps
+      - ${PWD}/state:/runtipi/state
+      - ${PWD}/logs:/app/logs
+      - ${STORAGE_PATH}:/app/storage
+      - ${PWD}/.env:/runtipi/.env:ro
     environment:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       INTERNAL_IP: ${INTERNAL_IP}
       TIPI_VERSION: ${TIPI_VERSION}
       TIPI_VERSION: ${TIPI_VERSION}
       JWT_SECRET: ${JWT_SECRET}
       JWT_SECRET: ${JWT_SECRET}
-      ROOT_FOLDER_HOST: ${ROOT_FOLDER_HOST}
       NGINX_PORT: ${NGINX_PORT}
       NGINX_PORT: ${NGINX_PORT}
       POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
       POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
       POSTGRES_USERNAME: tipi
       POSTGRES_USERNAME: tipi
@@ -61,8 +63,6 @@ services:
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_URL: ${APPS_REPO_URL}
       APPS_REPO_URL: ${APPS_REPO_URL}
       DOMAIN: ${DOMAIN}
       DOMAIN: ${DOMAIN}
-    dns:
-      - ${DNS_IP}
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
     labels:
     labels:
@@ -89,6 +89,9 @@ services:
     container_name: dashboard
     container_name: dashboard
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
+    depends_on:
+      api:
+        condition: service_started
     environment:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       INTERNAL_IP: ${INTERNAL_IP}
       NODE_ENV: production
       NODE_ENV: production

+ 10 - 7
docker-compose.yml

@@ -1,4 +1,4 @@
-version: "3.9"
+version: "3.7"
 
 
 services:
 services:
   reverse-proxy:
   reverse-proxy:
@@ -44,14 +44,16 @@ services:
       tipi-db:
       tipi-db:
         condition: service_healthy
         condition: service_healthy
     volumes:
     volumes:
-      ## Docker sock
-      - /var/run/docker.sock:/var/run/docker.sock:ro
-      - ${PWD}:/tipi
+      - ${PWD}/repos:/runtipi/repos:ro
+      - ${PWD}/apps:/runtipi/apps
+      - ${PWD}/state:/runtipi/state
+      - ${PWD}/logs:/app/logs
+      - ${STORAGE_PATH}:/app/storage
+      - ${PWD}/.env:/runtipi/.env:ro
     environment:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       INTERNAL_IP: ${INTERNAL_IP}
       TIPI_VERSION: ${TIPI_VERSION}
       TIPI_VERSION: ${TIPI_VERSION}
       JWT_SECRET: ${JWT_SECRET}
       JWT_SECRET: ${JWT_SECRET}
-      ROOT_FOLDER_HOST: ${ROOT_FOLDER_HOST}
       NGINX_PORT: ${NGINX_PORT}
       NGINX_PORT: ${NGINX_PORT}
       POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
       POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
       POSTGRES_USERNAME: tipi
       POSTGRES_USERNAME: tipi
@@ -61,8 +63,6 @@ services:
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_ID: ${APPS_REPO_ID}
       APPS_REPO_URL: ${APPS_REPO_URL}
       APPS_REPO_URL: ${APPS_REPO_URL}
       DOMAIN: ${DOMAIN}
       DOMAIN: ${DOMAIN}
-    dns:
-      - ${DNS_IP}
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
     labels:
     labels:
@@ -90,6 +90,9 @@ services:
     container_name: dashboard
     container_name: dashboard
     networks:
     networks:
       - tipi_main_network
       - tipi_main_network
+    depends_on:
+      api:
+        condition: service_started
     environment:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       INTERNAL_IP: ${INTERNAL_IP}
       NODE_ENV: production
       NODE_ENV: production

+ 0 - 0
media/data/music/.gitkeep


+ 4 - 3
package.json

@@ -1,17 +1,18 @@
 {
 {
   "name": "runtipi",
   "name": "runtipi",
-  "version": "0.6.1",
+  "version": "0.7.0",
   "description": "A homeserver for everyone",
   "description": "A homeserver for everyone",
   "scripts": {
   "scripts": {
     "prepare": "husky install",
     "prepare": "husky install",
     "commit": "git-cz",
     "commit": "git-cz",
     "act:test-install": "act --container-architecture linux/amd64 -j test-install",
     "act:test-install": "act --container-architecture linux/amd64 -j test-install",
     "act:docker": "act --container-architecture linux/amd64 --secret-file github.secrets -j build-images",
     "act:docker": "act --container-architecture linux/amd64 --secret-file github.secrets -j build-images",
-    "start:dev": "docker-compose -f docker-compose.dev.yml --env-file .env.dev up --build",
+    "start:dev": "sudo ./scripts/start-dev.sh",
     "start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build",
     "start:rc": "docker-compose -f docker-compose.rc.yml --env-file .env up --build",
     "start:prod": "docker-compose --env-file .env up --build",
     "start:prod": "docker-compose --env-file .env up --build",
     "start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres",
     "start:pg": "docker run --name test-db -p 5433:5432 -d --rm -e POSTGRES_PASSWORD=postgres postgres",
-    "version": "echo $npm_package_version"
+    "version": "echo $npm_package_version",
+    "release:rc": "./scripts/deploy/release-rc.sh"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@commitlint/cli": "^17.0.3",
     "@commitlint/cli": "^17.0.3",

+ 1 - 5
packages/dashboard/next.config.js

@@ -1,5 +1,5 @@
 /** @type {import('next').NextConfig} */
 /** @type {import('next').NextConfig} */
-const { INTERNAL_IP, DOMAIN } = process.env;
+const { INTERNAL_IP, DOMAIN, NGINX_PORT } = process.env;
 
 
 const nextConfig = {
 const nextConfig = {
   webpackDevMiddleware: (config) => {
   webpackDevMiddleware: (config) => {
@@ -10,10 +10,6 @@ const nextConfig = {
     return config;
     return config;
   },
   },
   reactStrictMode: true,
   reactStrictMode: true,
-  env: {
-    INTERNAL_IP: INTERNAL_IP,
-    NEXT_PUBLIC_DOMAIN: DOMAIN,
-  },
   basePath: '/dashboard',
   basePath: '/dashboard',
 };
 };
 
 

+ 1 - 5
packages/dashboard/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "dashboard",
   "name": "dashboard",
-  "version": "0.6.1",
+  "version": "0.7.0",
   "private": true,
   "private": true,
   "scripts": {
   "scripts": {
     "test": "jest --colors",
     "test": "jest --colors",
@@ -17,14 +17,11 @@
     "@emotion/react": "^11",
     "@emotion/react": "^11",
     "@emotion/styled": "^11",
     "@emotion/styled": "^11",
     "@fontsource/open-sans": "^4.5.8",
     "@fontsource/open-sans": "^4.5.8",
-    "axios": "^0.26.1",
     "clsx": "^1.1.1",
     "clsx": "^1.1.1",
     "final-form": "^4.20.6",
     "final-form": "^4.20.6",
     "framer-motion": "^6",
     "framer-motion": "^6",
     "graphql": "^15.8.0",
     "graphql": "^15.8.0",
     "graphql-tag": "^2.12.6",
     "graphql-tag": "^2.12.6",
-    "immer": "^9.0.12",
-    "js-cookie": "^3.0.1",
     "next": "12.1.6",
     "next": "12.1.6",
     "react": "18.1.0",
     "react": "18.1.0",
     "react-dom": "18.1.0",
     "react-dom": "18.1.0",
@@ -36,7 +33,6 @@
     "remark-gfm": "^3.0.1",
     "remark-gfm": "^3.0.1",
     "remark-mdx": "^2.1.1",
     "remark-mdx": "^2.1.1",
     "swr": "^1.3.0",
     "swr": "^1.3.0",
-    "systeminformation": "^5.11.9",
     "tslib": "^2.4.0",
     "tslib": "^2.4.0",
     "validator": "^13.7.0",
     "validator": "^13.7.0",
     "zustand": "^3.7.2"
     "zustand": "^3.7.2"

+ 2 - 2
packages/dashboard/src/components/AppLogo/AppLogo.tsx

@@ -1,8 +1,8 @@
 import React from 'react';
 import React from 'react';
-import { useSytemStore } from '../../state/systemStore';
+import { useSystemStore } from '../../state/systemStore';
 
 
 const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
 const AppLogo: React.FC<{ id: string; size?: number; className?: string; alt?: string }> = ({ id, size = 80, className = '', alt = '' }) => {
-  const { baseUrl } = useSytemStore();
+  const { baseUrl } = useSystemStore();
   const logoUrl = `${baseUrl}/apps/${id}/metadata/logo.jpg`;
   const logoUrl = `${baseUrl}/apps/${id}/metadata/logo.jpg`;
 
 
   return (
   return (

+ 0 - 1
packages/dashboard/src/components/Layout/Layout.tsx

@@ -6,7 +6,6 @@ import { FiChevronRight } from 'react-icons/fi';
 import Header from './Header';
 import Header from './Header';
 import Menu from './SideMenu';
 import Menu from './SideMenu';
 import MenuDrawer from './MenuDrawer';
 import MenuDrawer from './MenuDrawer';
-// import UpdateBanner from './UpdateBanner';
 
 
 interface IProps {
 interface IProps {
   loading?: boolean;
   loading?: boolean;

+ 8 - 0
packages/dashboard/src/components/Layout/SideMenu.tsx

@@ -10,6 +10,7 @@ import { useRouter } from 'next/router';
 import { IconType } from 'react-icons';
 import { IconType } from 'react-icons';
 import { useLogoutMutation, useVersionQuery } from '../../generated/graphql';
 import { useLogoutMutation, useVersionQuery } from '../../generated/graphql';
 import { getUrl } from '../../core/helpers/url-helpers';
 import { getUrl } from '../../core/helpers/url-helpers';
+import { BsHeart } from 'react-icons/bs';
 
 
 const SideMenu: React.FC = () => {
 const SideMenu: React.FC = () => {
   const router = useRouter();
   const router = useRouter();
@@ -57,6 +58,12 @@ const SideMenu: React.FC = () => {
       <Flex flex="1" />
       <Flex flex="1" />
       <List>
       <List>
         <div className="mx-3">
         <div className="mx-3">
+          <a href="https://github.com/meienberger/runtipi?sponsor=1" target="_blank" rel="noreferrer">
+            <ListItem className="cursor-pointer hover:font-bold flex items-center mb-4">
+              <BsHeart size={20} className="mr-3" />
+              <p className="flex-1 mb-1 text-md">Donate</p>
+            </ListItem>
+          </a>
           <ListItem onClick={() => logout()} className="cursor-pointer hover:font-bold flex items-center mb-5">
           <ListItem onClick={() => logout()} className="cursor-pointer hover:font-bold flex items-center mb-5">
             <FiLogOut size={20} className="mr-3" />
             <FiLogOut size={20} className="mr-3" />
             <p className="flex-1">Log out</p>
             <p className="flex-1">Log out</p>
@@ -68,6 +75,7 @@ const SideMenu: React.FC = () => {
           </ListItem>
           </ListItem>
         </div>
         </div>
       </List>
       </List>
+
       <div className="pb-1 text-center text-sm text-gray-400 mt-5">Tipi version {Package.version}</div>
       <div className="pb-1 text-center text-sm text-gray-400 mt-5">Tipi version {Package.version}</div>
       {!isLatest && (
       {!isLatest && (
         <Badge className="self-center mt-1" colorScheme="green">
         <Badge className="self-center mt-1" colorScheme="green">

+ 14 - 0
packages/dashboard/src/components/StatusScreens/RestartingScreen.tsx

@@ -0,0 +1,14 @@
+import { Flex, Spinner, Text } from '@chakra-ui/react';
+import React from 'react';
+
+const RestartingScreen = () => {
+  return (
+    <Flex height="100vh" direction="column" alignItems="center" justifyContent="center">
+      <Text fontSize="2xl">Your system is restarting...</Text>
+      <Text color="gray.500">Please do not refresh this page</Text>
+      <Spinner size="lg" className="mt-5" />
+    </Flex>
+  );
+};
+
+export default RestartingScreen;

+ 50 - 0
packages/dashboard/src/components/StatusScreens/StatusWrapper.tsx

@@ -0,0 +1,50 @@
+import { SlideFade } from '@chakra-ui/react';
+import React, { useEffect, useState } from 'react';
+import useSWR from 'swr';
+import { SystemStatus, useSystemStore } from '../../state/systemStore';
+import RestartingScreen from './RestartingScreen';
+import UpdatingScreen from './UpdatingScreen';
+
+interface IProps {
+  children: React.ReactNode;
+}
+
+const fetcher = (url: string) => fetch(url).then((res) => res.json());
+
+const StatusWrapper: React.FC<IProps> = ({ children }) => {
+  const [s, setS] = useState<SystemStatus>(SystemStatus.RUNNING);
+  const { baseUrl } = useSystemStore();
+  const { data } = useSWR(`${baseUrl}/status`, fetcher, { refreshInterval: 1000 });
+
+  useEffect(() => {
+    if (data?.status === SystemStatus.RUNNING) {
+      setS(SystemStatus.RUNNING);
+    }
+    if (data?.status === SystemStatus.RESTARTING) {
+      setS(SystemStatus.RESTARTING);
+    }
+    if (data?.status === SystemStatus.UPDATING) {
+      setS(SystemStatus.UPDATING);
+    }
+  }, [data?.status]);
+
+  if (s === SystemStatus.RESTARTING) {
+    return (
+      <SlideFade in>
+        <RestartingScreen />
+      </SlideFade>
+    );
+  }
+
+  if (s === SystemStatus.UPDATING) {
+    return (
+      <SlideFade in>
+        <UpdatingScreen />
+      </SlideFade>
+    );
+  }
+
+  return <>{children}</>;
+};
+
+export default StatusWrapper;

+ 14 - 0
packages/dashboard/src/components/StatusScreens/UpdatingScreen.tsx

@@ -0,0 +1,14 @@
+import { Text, Flex, Spinner } from '@chakra-ui/react';
+import React from 'react';
+
+const UpdatingScreen = () => {
+  return (
+    <Flex height="100vh" direction="column" alignItems="center" justifyContent="center">
+      <Text fontSize="2xl">Your system is updating...</Text>
+      <Text color="gray.500">Please do not refresh this page</Text>
+      <Spinner size="lg" className="mt-5" />
+    </Flex>
+  );
+};
+
+export default UpdatingScreen;

+ 0 - 34
packages/dashboard/src/core/api.ts

@@ -1,34 +0,0 @@
-import axios, { Method } from 'axios';
-import { useSytemStore } from '../state/systemStore';
-
-interface IFetchParams {
-  endpoint: string;
-  method?: Method;
-  params?: JSON;
-  data?: Record<string, unknown>;
-}
-
-const api = async <T = unknown>(fetchParams: IFetchParams): Promise<T> => {
-  const { endpoint, method = 'GET', params, data } = fetchParams;
-
-  const { getState } = useSytemStore;
-  const BASE_URL = getState().baseUrl;
-
-  const response = await axios.request<T & { error?: string }>({
-    method,
-    params,
-    data,
-    url: `${BASE_URL}${endpoint}`,
-    withCredentials: true,
-  });
-
-  if (response.data.error) {
-    throw new Error(response.data.error);
-  }
-
-  if (response.data) return response.data;
-
-  throw new Error(`Network request error. status : ${response.status}`);
-};
-
-export default { fetch: api };

+ 0 - 11
packages/dashboard/src/core/fetcher.ts

@@ -1,11 +0,0 @@
-import { BareFetcher } from 'swr';
-import axios from 'axios';
-import { useSytemStore } from '../state/systemStore';
-
-const fetcher: BareFetcher<any> = (url: string) => {
-  const { baseUrl } = useSytemStore.getState();
-
-  return axios.get(url, { baseURL: baseUrl, withCredentials: true }).then((res) => res.data);
-};
-
-export default fetcher;

+ 1 - 6
packages/dashboard/src/core/helpers/url-helpers.ts

@@ -1,10 +1,5 @@
 export const getUrl = (url: string) => {
 export const getUrl = (url: string) => {
-  const domain = process.env.NEXT_PUBLIC_DOMAIN;
-  let prefix = '';
-
-  if (domain !== 'tipi.localhost') {
-    prefix = 'dashboard';
-  }
+  let prefix = 'dashboard';
 
 
   return `/${prefix}/${url}`;
   return `/${prefix}/${url}`;
 };
 };

+ 72 - 2
packages/dashboard/src/generated/graphql.tsx

@@ -23,7 +23,7 @@ export type App = {
   __typename?: 'App';
   __typename?: 'App';
   config: Scalars['JSONObject'];
   config: Scalars['JSONObject'];
   createdAt: Scalars['DateTime'];
   createdAt: Scalars['DateTime'];
-  domain: Scalars['String'];
+  domain?: Maybe<Scalars['String']>;
   exposed: Scalars['Boolean'];
   exposed: Scalars['Boolean'];
   id: Scalars['String'];
   id: Scalars['String'];
   info?: Maybe<AppInfo>;
   info?: Maybe<AppInfo>;
@@ -137,9 +137,11 @@ export type Mutation = {
   login: UserResponse;
   login: UserResponse;
   logout: Scalars['Boolean'];
   logout: Scalars['Boolean'];
   register: UserResponse;
   register: UserResponse;
+  restart: Scalars['Boolean'];
   startApp: App;
   startApp: App;
   stopApp: App;
   stopApp: App;
   uninstallApp: App;
   uninstallApp: App;
+  update: Scalars['Boolean'];
   updateApp: App;
   updateApp: App;
   updateAppConfig: App;
   updateAppConfig: App;
 };
 };
@@ -251,6 +253,10 @@ export type RegisterMutationVariables = Exact<{
 
 
 export type RegisterMutation = { __typename?: 'Mutation'; register: { __typename?: 'UserResponse'; user?: { __typename?: 'User'; id: string } | null } };
 export type RegisterMutation = { __typename?: 'Mutation'; register: { __typename?: 'UserResponse'; user?: { __typename?: 'User'; id: string } | null } };
 
 
+export type RestartMutationVariables = Exact<{ [key: string]: never }>;
+
+export type RestartMutation = { __typename?: 'Mutation'; restart: boolean };
+
 export type StartAppMutationVariables = Exact<{
 export type StartAppMutationVariables = Exact<{
   id: Scalars['String'];
   id: Scalars['String'];
 }>;
 }>;
@@ -269,6 +275,10 @@ export type UninstallAppMutationVariables = Exact<{
 
 
 export type UninstallAppMutation = { __typename?: 'Mutation'; uninstallApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 export type UninstallAppMutation = { __typename?: 'Mutation'; uninstallApp: { __typename: 'App'; id: string; status: AppStatusEnum } };
 
 
+export type UpdateMutationVariables = Exact<{ [key: string]: never }>;
+
+export type UpdateMutation = { __typename?: 'Mutation'; update: boolean };
+
 export type UpdateAppMutationVariables = Exact<{
 export type UpdateAppMutationVariables = Exact<{
   id: Scalars['String'];
   id: Scalars['String'];
 }>;
 }>;
@@ -294,7 +304,7 @@ export type GetAppQuery = {
     config: any;
     config: any;
     version?: number | null;
     version?: number | null;
     exposed: boolean;
     exposed: boolean;
-    domain: string;
+    domain?: string | null;
     updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
     updateInfo?: { __typename?: 'UpdateInfo'; current: number; latest: number; dockerVersion?: string | null } | null;
     info?: {
     info?: {
       __typename?: 'AppInfo';
       __typename?: 'AppInfo';
@@ -523,6 +533,36 @@ export function useRegisterMutation(baseOptions?: Apollo.MutationHookOptions<Reg
 export type RegisterMutationHookResult = ReturnType<typeof useRegisterMutation>;
 export type RegisterMutationHookResult = ReturnType<typeof useRegisterMutation>;
 export type RegisterMutationResult = Apollo.MutationResult<RegisterMutation>;
 export type RegisterMutationResult = Apollo.MutationResult<RegisterMutation>;
 export type RegisterMutationOptions = Apollo.BaseMutationOptions<RegisterMutation, RegisterMutationVariables>;
 export type RegisterMutationOptions = Apollo.BaseMutationOptions<RegisterMutation, RegisterMutationVariables>;
+export const RestartDocument = gql`
+  mutation Restart {
+    restart
+  }
+`;
+export type RestartMutationFn = Apollo.MutationFunction<RestartMutation, RestartMutationVariables>;
+
+/**
+ * __useRestartMutation__
+ *
+ * To run a mutation, you first call `useRestartMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useRestartMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [restartMutation, { data, loading, error }] = useRestartMutation({
+ *   variables: {
+ *   },
+ * });
+ */
+export function useRestartMutation(baseOptions?: Apollo.MutationHookOptions<RestartMutation, RestartMutationVariables>) {
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<RestartMutation, RestartMutationVariables>(RestartDocument, options);
+}
+export type RestartMutationHookResult = ReturnType<typeof useRestartMutation>;
+export type RestartMutationResult = Apollo.MutationResult<RestartMutation>;
+export type RestartMutationOptions = Apollo.BaseMutationOptions<RestartMutation, RestartMutationVariables>;
 export const StartAppDocument = gql`
 export const StartAppDocument = gql`
   mutation StartApp($id: String!) {
   mutation StartApp($id: String!) {
     startApp(id: $id) {
     startApp(id: $id) {
@@ -628,6 +668,36 @@ export function useUninstallAppMutation(baseOptions?: Apollo.MutationHookOptions
 export type UninstallAppMutationHookResult = ReturnType<typeof useUninstallAppMutation>;
 export type UninstallAppMutationHookResult = ReturnType<typeof useUninstallAppMutation>;
 export type UninstallAppMutationResult = Apollo.MutationResult<UninstallAppMutation>;
 export type UninstallAppMutationResult = Apollo.MutationResult<UninstallAppMutation>;
 export type UninstallAppMutationOptions = Apollo.BaseMutationOptions<UninstallAppMutation, UninstallAppMutationVariables>;
 export type UninstallAppMutationOptions = Apollo.BaseMutationOptions<UninstallAppMutation, UninstallAppMutationVariables>;
+export const UpdateDocument = gql`
+  mutation Update {
+    update
+  }
+`;
+export type UpdateMutationFn = Apollo.MutationFunction<UpdateMutation, UpdateMutationVariables>;
+
+/**
+ * __useUpdateMutation__
+ *
+ * To run a mutation, you first call `useUpdateMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useUpdateMutation` returns a tuple that includes:
+ * - A mutate function that you can call at any time to execute the mutation
+ * - An object with fields that represent the current status of the mutation's execution
+ *
+ * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2;
+ *
+ * @example
+ * const [updateMutation, { data, loading, error }] = useUpdateMutation({
+ *   variables: {
+ *   },
+ * });
+ */
+export function useUpdateMutation(baseOptions?: Apollo.MutationHookOptions<UpdateMutation, UpdateMutationVariables>) {
+  const options = { ...defaultOptions, ...baseOptions };
+  return Apollo.useMutation<UpdateMutation, UpdateMutationVariables>(UpdateDocument, options);
+}
+export type UpdateMutationHookResult = ReturnType<typeof useUpdateMutation>;
+export type UpdateMutationResult = Apollo.MutationResult<UpdateMutation>;
+export type UpdateMutationOptions = Apollo.BaseMutationOptions<UpdateMutation, UpdateMutationVariables>;
 export const UpdateAppDocument = gql`
 export const UpdateAppDocument = gql`
   mutation UpdateApp($id: String!) {
   mutation UpdateApp($id: String!) {
     updateApp(id: $id) {
     updateApp(id: $id) {

+ 3 - 0
packages/dashboard/src/graphql/mutations/restart.graphql

@@ -0,0 +1,3 @@
+mutation Restart {
+  restart
+}

+ 3 - 0
packages/dashboard/src/graphql/mutations/update.graphql

@@ -0,0 +1,3 @@
+mutation Update {
+  update
+}

+ 8 - 10
packages/dashboard/src/hooks/useCachedRessources.ts

@@ -1,9 +1,8 @@
 import { useEffect, useState } from 'react';
 import { useEffect, useState } from 'react';
 import { ApolloClient } from '@apollo/client';
 import { ApolloClient } from '@apollo/client';
-import axios from 'axios';
-import useSWR, { BareFetcher } from 'swr';
 import { createApolloClient } from '../core/apollo/client';
 import { createApolloClient } from '../core/apollo/client';
-import { useSytemStore } from '../state/systemStore';
+import { useSystemStore } from '../state/systemStore';
+import useSWR, { Fetcher } from 'swr';
 import { getUrl } from '../core/helpers/url-helpers';
 import { getUrl } from '../core/helpers/url-helpers';
 
 
 interface IReturnProps {
 interface IReturnProps {
@@ -11,13 +10,11 @@ interface IReturnProps {
   isLoadingComplete?: boolean;
   isLoadingComplete?: boolean;
 }
 }
 
 
-const fetcher: BareFetcher<any> = (url: string) => {
-  return axios.get(getUrl(url)).then((res) => res.data);
-};
+const fetcher: Fetcher<{ ip: string; domain: string; port: string }, string> = (...args) => fetch(...args).then((res) => res.json());
 
 
 export default function useCachedResources(): IReturnProps {
 export default function useCachedResources(): IReturnProps {
-  const { data } = useSWR<{ ip: string; domain: string; port: string }>('api/ip', fetcher);
-  const { baseUrl, setBaseUrl, setInternalIp, setDomain } = useSytemStore();
+  const { data } = useSWR(getUrl('api/getenv'), fetcher);
+  const { baseUrl, setBaseUrl, setInternalIp, setDomain } = useSystemStore();
   const [isLoadingComplete, setLoadingComplete] = useState(false);
   const [isLoadingComplete, setLoadingComplete] = useState(false);
   const [client, setClient] = useState<ApolloClient<unknown>>();
   const [client, setClient] = useState<ApolloClient<unknown>>();
 
 
@@ -28,7 +25,7 @@ export default function useCachedResources(): IReturnProps {
       setClient(restoredClient);
       setClient(restoredClient);
     } catch (error) {
     } catch (error) {
       // We might want to provide this error information to an error reporting service
       // We might want to provide this error information to an error reporting service
-      console.warn(error);
+      console.error(error);
     } finally {
     } finally {
       setLoadingComplete(true);
       setLoadingComplete(true);
     }
     }
@@ -36,6 +33,7 @@ export default function useCachedResources(): IReturnProps {
 
 
   useEffect(() => {
   useEffect(() => {
     const { ip, domain, port } = data || {};
     const { ip, domain, port } = data || {};
+
     if (ip && !baseUrl) {
     if (ip && !baseUrl) {
       setInternalIp(ip);
       setInternalIp(ip);
       setDomain(domain);
       setDomain(domain);
@@ -50,7 +48,7 @@ export default function useCachedResources(): IReturnProps {
         setBaseUrl(`https://${domain}/api`);
         setBaseUrl(`https://${domain}/api`);
       }
       }
     }
     }
-  }, [baseUrl, setBaseUrl, data, setInternalIp, setDomain]);
+  }, [baseUrl, setBaseUrl, setInternalIp, setDomain, data]);
 
 
   useEffect(() => {
   useEffect(() => {
     if (baseUrl) {
     if (baseUrl) {

+ 3 - 3
packages/dashboard/src/modules/Apps/containers/AppDetails.tsx

@@ -1,7 +1,7 @@
 import { SlideFade, Flex, Divider, useDisclosure, useToast } from '@chakra-ui/react';
 import { SlideFade, Flex, Divider, useDisclosure, useToast } from '@chakra-ui/react';
 import React from 'react';
 import React from 'react';
 import { FiExternalLink } from 'react-icons/fi';
 import { FiExternalLink } from 'react-icons/fi';
-import { useSytemStore } from '../../../state/systemStore';
+import { useSystemStore } from '../../../state/systemStore';
 import AppActions from '../components/AppActions';
 import AppActions from '../components/AppActions';
 import InstallModal from '../components/InstallModal';
 import InstallModal from '../components/InstallModal';
 import StopModal from '../components/StopModal';
 import StopModal from '../components/StopModal';
@@ -48,7 +48,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
 
 
   const updateAvailable = Number(app?.updateInfo?.current || 0) < Number(app?.updateInfo?.latest);
   const updateAvailable = Number(app?.updateInfo?.current || 0) < Number(app?.updateInfo?.latest);
 
 
-  const { internalIp } = useSytemStore();
+  const { internalIp } = useSystemStore();
 
 
   const handleError = (error: unknown) => {
   const handleError = (error: unknown) => {
     if (error instanceof Error) {
     if (error instanceof Error) {
@@ -207,7 +207,7 @@ const AppDetails: React.FC<IProps> = ({ app, info }) => {
           app={info}
           app={info}
           config={app?.config}
           config={app?.config}
           exposed={app?.exposed}
           exposed={app?.exposed}
-          domain={app?.domain}
+          domain={app?.domain || ''}
         />
         />
         <UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} newVersion={newVersion} />
         <UpdateModal onConfirm={handleUpdateSubmit} isOpen={updateDisclosure.isOpen} onClose={updateDisclosure.onClose} app={info} newVersion={newVersion} />
       </div>
       </div>

+ 8 - 4
packages/dashboard/src/pages/_app.tsx

@@ -8,12 +8,14 @@ import AuthWrapper from '../modules/Auth/containers/AuthWrapper';
 import { ApolloProvider } from '@apollo/client';
 import { ApolloProvider } from '@apollo/client';
 import useCachedResources from '../hooks/useCachedRessources';
 import useCachedResources from '../hooks/useCachedRessources';
 import Head from 'next/head';
 import Head from 'next/head';
+import StatusWrapper from '../components/StatusScreens/StatusWrapper';
+import LoadingScreen from '../components/LoadingScreen';
 
 
 function MyApp({ Component, pageProps }: AppProps) {
 function MyApp({ Component, pageProps }: AppProps) {
   const { client } = useCachedResources();
   const { client } = useCachedResources();
 
 
   if (!client) {
   if (!client) {
-    return null;
+    return <LoadingScreen />;
   }
   }
 
 
   return (
   return (
@@ -22,9 +24,11 @@ function MyApp({ Component, pageProps }: AppProps) {
         <Head>
         <Head>
           <title>Tipi</title>
           <title>Tipi</title>
         </Head>
         </Head>
-        <AuthWrapper>
-          <Component {...pageProps} />
-        </AuthWrapper>
+        <StatusWrapper>
+          <AuthWrapper>
+            <Component {...pageProps} />
+          </AuthWrapper>
+        </StatusWrapper>
       </ChakraProvider>
       </ChakraProvider>
     </ApolloProvider>
     </ApolloProvider>
   );
   );

+ 1 - 1
packages/dashboard/src/pages/api/ip.tsx → packages/dashboard/src/pages/api/getenv.tsx

@@ -1,4 +1,4 @@
-export default function ip(_: any, res: any) {
+export default function getEnv(_: any, res: any) {
   const { INTERNAL_IP } = process.env;
   const { INTERNAL_IP } = process.env;
   const { NGINX_PORT } = process.env;
   const { NGINX_PORT } = process.env;
   const { DOMAIN } = process.env;
   const { DOMAIN } = process.env;

+ 93 - 10
packages/dashboard/src/pages/settings.tsx

@@ -1,13 +1,34 @@
 import type { NextPage } from 'next';
 import type { NextPage } from 'next';
-import { Text } from '@chakra-ui/react';
+import { AlertDialog, AlertDialogBody, AlertDialogContent, AlertDialogFooter, AlertDialogHeader, AlertDialogOverlay, Button, Text, useDisclosure, useToast } from '@chakra-ui/react';
 import Layout from '../components/Layout';
 import Layout from '../components/Layout';
-import { useVersionQuery } from '../generated/graphql';
+import { useLogoutMutation, useRestartMutation, useUpdateMutation, useVersionQuery } from '../generated/graphql';
+import { useRef, useState } from 'react';
 
 
 const Settings: NextPage = () => {
 const Settings: NextPage = () => {
-  const { data, loading } = useVersionQuery();
+  const toast = useToast();
+  const restartDisclosure = useDisclosure();
+  const updateDisclosure = useDisclosure();
+  const cancelRef = useRef<any>();
+  const [loading, setLoading] = useState(false);
+  const { data } = useVersionQuery();
 
 
+  const [restart] = useRestartMutation();
+  const [update] = useUpdateMutation();
+  const [logout] = useLogoutMutation({ refetchQueries: ['Me'] });
   const isLatest = data?.version.latest === data?.version.current;
   const isLatest = data?.version.latest === data?.version.current;
 
 
+  const handleError = (error: unknown) => {
+    if (error instanceof Error) {
+      toast({
+        title: 'Error',
+        description: error.message,
+        status: 'error',
+        position: 'top',
+        isClosable: true,
+      });
+    }
+  };
+
   const renderUpdate = () => {
   const renderUpdate = () => {
     if (isLatest) {
     if (isLatest) {
       return (
       return (
@@ -18,22 +39,84 @@ const Settings: NextPage = () => {
     }
     }
 
 
     return (
     return (
-      <Text fontSize="md">
-        You are not using the latest version of Tipi. There is a new version ({data?.version.latest}) available. Visit{' '}
-        <a className="text-blue-600" target="_blank" rel="noreferrer" href={`https://github.com/meienberger/runtipi/releases/v${data?.version.latest}`}>
-          Github
-        </a>{' '}
-        for update instructions.
-      </Text>
+      <>
+        <Text fontSize="md">New version available</Text>
+        <Button onClick={updateDisclosure.onOpen} className="mr-2" colorScheme="green">
+          Update to {data?.version.latest}
+        </Button>
+      </>
     );
     );
   };
   };
 
 
+  const handleRestart = async () => {
+    setLoading(true);
+    try {
+      restart();
+      logout();
+    } catch (error) {
+      handleError(error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
+  const handleUpdate = async () => {
+    setLoading(true);
+    try {
+      update();
+      logout();
+    } catch (error) {
+      handleError(error);
+    } finally {
+      setLoading(false);
+    }
+  };
+
   return (
   return (
     <Layout loading={!data?.version && loading}>
     <Layout loading={!data?.version && loading}>
       <Text fontSize="3xl" className="font-bold">
       <Text fontSize="3xl" className="font-bold">
         Settings
         Settings
       </Text>
       </Text>
       {renderUpdate()}
       {renderUpdate()}
+      <Button onClick={restartDisclosure.onOpen} colorScheme="gray">
+        Restart
+      </Button>
+      <AlertDialog isOpen={restartDisclosure.isOpen} leastDestructiveRef={cancelRef} onClose={restartDisclosure.onClose}>
+        <AlertDialogOverlay>
+          <AlertDialogContent>
+            <AlertDialogHeader fontSize="lg" fontWeight="bold">
+              Restart Tipi
+            </AlertDialogHeader>
+            <AlertDialogBody>Would you like to restart your Tipi server?</AlertDialogBody>
+            <AlertDialogFooter>
+              <Button colorScheme="gray" ref={cancelRef} onClick={restartDisclosure.onClose}>
+                Cancel
+              </Button>
+              <Button colorScheme="red" isLoading={loading} onClick={handleRestart} ml={3}>
+                Restart
+              </Button>
+            </AlertDialogFooter>
+          </AlertDialogContent>
+        </AlertDialogOverlay>
+      </AlertDialog>
+      <AlertDialog isOpen={updateDisclosure.isOpen} leastDestructiveRef={cancelRef} onClose={updateDisclosure.onClose}>
+        <AlertDialogOverlay>
+          <AlertDialogContent>
+            <AlertDialogHeader fontSize="lg" fontWeight="bold">
+              Update Tipi
+            </AlertDialogHeader>
+            <AlertDialogBody>Would you like to update Tipi to the latest version?</AlertDialogBody>
+            <AlertDialogFooter>
+              <Button colorScheme="gray" ref={cancelRef} onClick={updateDisclosure.onClose}>
+                Cancel
+              </Button>
+              <Button colorScheme="green" isLoading={loading} onClick={handleUpdate} ml={3}>
+                Update
+              </Button>
+            </AlertDialogFooter>
+          </AlertDialogContent>
+        </AlertDialogOverlay>
+      </AlertDialog>
     </Layout>
     </Layout>
   );
   );
 };
 };

+ 0 - 74
packages/dashboard/src/state/authStore.ts

@@ -1,74 +0,0 @@
-import create from 'zustand';
-import Cookies from 'js-cookie';
-import api from '../core/api';
-import { IUser } from '../core/types';
-
-type AppsStore = {
-  user: IUser | null;
-  configured: boolean;
-  me: () => Promise<void>;
-  login: (email: string, password: string) => Promise<void>;
-  register: (email: string, password: string) => Promise<void>;
-  logout: () => void;
-  fetchConfigured: () => Promise<void>;
-  loading: boolean;
-};
-
-export const useAuthStore = create<AppsStore>((set) => ({
-  user: null,
-  configured: false,
-  loading: false,
-  me: async () => {
-    try {
-      set({ loading: true });
-      const response = await api.fetch<{ user: IUser | null }>({ endpoint: '/auth/me' });
-
-      set({ user: response.user, loading: false });
-    } catch (error) {
-      set({ loading: false, user: null });
-    }
-  },
-  login: async (email: string, password: string) => {
-    set({ loading: true });
-
-    try {
-      const response = await api.fetch<{ user: IUser }>({
-        endpoint: '/auth/login',
-        method: 'post',
-        data: { email, password },
-      });
-      set({ user: response.user, loading: false });
-    } catch (e) {
-      set({ loading: false });
-      throw e;
-    }
-  },
-  logout: async () => {
-    Cookies.remove('tipi_token');
-
-    set({ user: null, loading: false });
-  },
-  register: async (email: string, password: string) => {
-    set({ loading: true });
-
-    try {
-      const response = await api.fetch<{ user: IUser }>({
-        endpoint: '/auth/register',
-        method: 'post',
-        data: { email, password },
-      });
-      set({ user: response.user, loading: false });
-    } catch (e) {
-      set({ loading: false });
-      throw e;
-    }
-  },
-  fetchConfigured: async () => {
-    try {
-      const response = await api.fetch<{ configured: boolean }>({ endpoint: '/auth/configured' });
-      set({ configured: response.configured });
-    } catch (e) {
-      set({ configured: false });
-    }
-  },
-}));

+ 11 - 1
packages/dashboard/src/state/systemStore.ts

@@ -1,19 +1,29 @@
 import create from 'zustand';
 import create from 'zustand';
 
 
+export enum SystemStatus {
+  RUNNING = 'RUNNING',
+  RESTARTING = 'RESTARTING',
+  UPDATING = 'UPDATING',
+}
+
 type Store = {
 type Store = {
   baseUrl: string;
   baseUrl: string;
   internalIp: string;
   internalIp: string;
   domain: string;
   domain: string;
+  status: SystemStatus;
   setDomain: (domain?: string) => void;
   setDomain: (domain?: string) => void;
   setBaseUrl: (url: string) => void;
   setBaseUrl: (url: string) => void;
   setInternalIp: (ip: string) => void;
   setInternalIp: (ip: string) => void;
+  setStatus: (status: SystemStatus) => void;
 };
 };
 
 
-export const useSytemStore = create<Store>((set) => ({
+export const useSystemStore = create<Store>((set) => ({
   baseUrl: '',
   baseUrl: '',
   internalIp: '',
   internalIp: '',
   domain: '',
   domain: '',
+  status: SystemStatus.RUNNING,
   setDomain: (domain?: string) => set((state) => ({ ...state, domain: domain || '' })),
   setDomain: (domain?: string) => set((state) => ({ ...state, domain: domain || '' })),
   setBaseUrl: (url: string) => set((state) => ({ ...state, baseUrl: url })),
   setBaseUrl: (url: string) => set((state) => ({ ...state, baseUrl: url })),
   setInternalIp: (ip: string) => set((state) => ({ ...state, internalIp: ip })),
   setInternalIp: (ip: string) => set((state) => ({ ...state, internalIp: ip })),
+  setStatus: (status: SystemStatus) => set((state) => ({ ...state, status })),
 }));
 }));

+ 2 - 1
packages/dashboard/tsconfig.json

@@ -14,7 +14,8 @@
     "isolatedModules": true,
     "isolatedModules": true,
     "jsx": "preserve",
     "jsx": "preserve",
     "incremental": true,
     "incremental": true,
-    "strictNullChecks": true
+    "strictNullChecks": true,
+    "allowSyntheticDefaultImports": true
   },
   },
   "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
   "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
   "exclude": ["node_modules"]
   "exclude": ["node_modules"]

+ 3 - 0
packages/system-api/.eslintrc.cjs

@@ -19,4 +19,7 @@ module.exports = {
     'no-unused-vars': [1, { argsIgnorePattern: '^_' }],
     'no-unused-vars': [1, { argsIgnorePattern: '^_' }],
     '@typescript-eslint/no-unused-vars': [1, { argsIgnorePattern: '^_' }],
     '@typescript-eslint/no-unused-vars': [1, { argsIgnorePattern: '^_' }],
   },
   },
+  globals: {
+    NodeJS: true,
+  },
 };
 };

+ 3 - 0
packages/system-api/.gitignore

@@ -1,7 +1,10 @@
 node_modules/
 node_modules/
 dist/
 dist/
 
 
+.DS_Store
+
 # testing
 # testing
 coverage/
 coverage/
 logs/
 logs/
 sessions/
 sessions/
+.vscode

+ 11 - 0
packages/system-api/__mocks__/fs-extra.ts

@@ -11,6 +11,7 @@ const fs: {
   copyFileSync: typeof copyFileSync;
   copyFileSync: typeof copyFileSync;
   copySync: typeof copyFileSync;
   copySync: typeof copyFileSync;
   createFileSync: typeof createFileSync;
   createFileSync: typeof createFileSync;
+  unlinkSync: typeof unlinkSync;
 } = jest.genMockFromModule('fs-extra');
 } = jest.genMockFromModule('fs-extra');
 
 
 let mockFiles = Object.create(null);
 let mockFiles = Object.create(null);
@@ -97,6 +98,16 @@ const resetAllMocks = () => {
   mockFiles = Object.create(null);
   mockFiles = Object.create(null);
 };
 };
 
 
+const unlinkSync = (p: string) => {
+  if (mockFiles[p] instanceof Array) {
+    mockFiles[p].forEach((file: string) => {
+      delete mockFiles[path.join(p, file)];
+    });
+  }
+  delete mockFiles[p];
+};
+
+fs.unlinkSync = unlinkSync;
 fs.readdirSync = readdirSync;
 fs.readdirSync = readdirSync;
 fs.existsSync = existsSync;
 fs.existsSync = existsSync;
 fs.readFileSync = readFileSync;
 fs.readFileSync = readFileSync;

+ 11 - 0
packages/system-api/__mocks__/node-cron.ts

@@ -0,0 +1,11 @@
+const cron: {
+  schedule: typeof schedule;
+} = jest.genMockFromModule('node-cron');
+
+const schedule = (scd: string, cb: () => void) => {
+  cb();
+};
+
+cron.schedule = schedule;
+
+module.exports = cron;

+ 7 - 1
packages/system-api/jest.config.cjs

@@ -7,9 +7,15 @@ module.exports = {
   setupFiles: ['<rootDir>/src/test/dotenv-config.ts'],
   setupFiles: ['<rootDir>/src/test/dotenv-config.ts'],
   setupFilesAfterEnv: ['<rootDir>/src/test/jest-setup.ts'],
   setupFilesAfterEnv: ['<rootDir>/src/test/jest-setup.ts'],
   collectCoverage: true,
   collectCoverage: true,
-  collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/migrations/**/*.{ts,tsx}', '!**/config/**/*.{ts,tsx}', '!**/__tests__/**'],
+  collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/migrations/**/*.{ts,tsx}', '!**/src/config/**/*.{ts,tsx}', '!**/__tests__/**'],
   passWithNoTests: true,
   passWithNoTests: true,
   transform: {
   transform: {
     '^.+\\.graphql$': 'graphql-import-node/jest',
     '^.+\\.graphql$': 'graphql-import-node/jest',
   },
   },
+  globals: {
+    // NODE_ENV: 'test',
+    'ts-jest': {
+      isolatedModules: true,
+    },
+  },
 };
 };

+ 5 - 13
packages/system-api/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "system-api",
   "name": "system-api",
-  "version": "0.6.1",
+  "version": "0.7.0",
   "description": "",
   "description": "",
   "exports": "./dist/server.js",
   "exports": "./dist/server.js",
   "type": "module",
   "type": "module",
@@ -30,8 +30,6 @@
     "argon2": "^0.29.1",
     "argon2": "^0.29.1",
     "axios": "^0.26.1",
     "axios": "^0.26.1",
     "class-validator": "^0.13.2",
     "class-validator": "^0.13.2",
-    "compression": "^1.7.4",
-    "cookie-parser": "^1.4.6",
     "cors": "^2.8.5",
     "cors": "^2.8.5",
     "dotenv": "^16.0.0",
     "dotenv": "^16.0.0",
     "express": "^4.17.3",
     "express": "^4.17.3",
@@ -41,39 +39,33 @@
     "graphql-type-json": "^0.3.2",
     "graphql-type-json": "^0.3.2",
     "http": "0.0.1-security",
     "http": "0.0.1-security",
     "internal-ip": "^6.0.0",
     "internal-ip": "^6.0.0",
-    "jsonwebtoken": "^8.5.1",
-    "mock-fs": "^5.1.2",
     "node-cache": "^5.1.2",
     "node-cache": "^5.1.2",
     "node-cron": "^3.0.1",
     "node-cron": "^3.0.1",
     "node-port-scanner": "^3.0.1",
     "node-port-scanner": "^3.0.1",
-    "p-iteration": "^1.1.8",
     "pg": "^8.7.3",
     "pg": "^8.7.3",
-    "public-ip": "^5.0.0",
     "reflect-metadata": "^0.1.13",
     "reflect-metadata": "^0.1.13",
+    "semver": "^7.3.7",
     "session-file-store": "^1.5.0",
     "session-file-store": "^1.5.0",
-    "systeminformation": "^5.11.9",
     "tcp-port-used": "^1.0.2",
     "tcp-port-used": "^1.0.2",
     "type-graphql": "^1.1.1",
     "type-graphql": "^1.1.1",
     "typeorm": "^0.3.6",
     "typeorm": "^0.3.6",
     "validator": "^13.7.0",
     "validator": "^13.7.0",
-    "winston": "^3.7.2"
+    "winston": "^3.7.2",
+    "zod": "^3.19.1"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@faker-js/faker": "^7.3.0",
     "@faker-js/faker": "^7.3.0",
     "@swc/cli": "^0.1.57",
     "@swc/cli": "^0.1.57",
     "@swc/core": "^1.2.210",
     "@swc/core": "^1.2.210",
-    "@types/compression": "^1.7.2",
-    "@types/cookie-parser": "^1.4.3",
     "@types/cors": "^2.8.12",
     "@types/cors": "^2.8.12",
     "@types/express": "^4.17.13",
     "@types/express": "^4.17.13",
     "@types/express-session": "^1.17.4",
     "@types/express-session": "^1.17.4",
     "@types/fs-extra": "^9.0.13",
     "@types/fs-extra": "^9.0.13",
     "@types/jest": "^27.5.0",
     "@types/jest": "^27.5.0",
-    "@types/jsonwebtoken": "^8.5.8",
-    "@types/mock-fs": "^4.13.1",
     "@types/node": "17.0.31",
     "@types/node": "17.0.31",
     "@types/node-cron": "^3.0.2",
     "@types/node-cron": "^3.0.2",
     "@types/pg": "^8.6.5",
     "@types/pg": "^8.6.5",
+    "@types/semver": "^7.3.12",
     "@types/session-file-store": "^1.2.2",
     "@types/session-file-store": "^1.2.2",
     "@types/tcp-port-used": "^1.0.1",
     "@types/tcp-port-used": "^1.0.1",
     "@types/validator": "^13.7.2",
     "@types/validator": "^13.7.2",

+ 0 - 58
packages/system-api/src/config/config.ts

@@ -1,58 +0,0 @@
-import * as dotenv from 'dotenv';
-
-interface IConfig {
-  logs: {
-    LOGS_FOLDER: string;
-    LOGS_APP: string;
-    LOGS_ERROR: string;
-  };
-  NODE_ENV: string;
-  ROOT_FOLDER: string;
-  JWT_SECRET: string;
-  CLIENT_URLS: string[];
-  VERSION: string;
-  ROOT_FOLDER_HOST: string;
-  APPS_REPO_ID: string;
-  APPS_REPO_URL: string;
-  INTERNAL_IP: string;
-}
-
-if (process.env.NODE_ENV !== 'production') {
-  dotenv.config({ path: '.env.dev' });
-} else {
-  dotenv.config({ path: '.env' });
-}
-
-const {
-  LOGS_FOLDER = 'logs',
-  LOGS_APP = 'app.log',
-  LOGS_ERROR = 'error.log',
-  NODE_ENV = 'development',
-  JWT_SECRET = '',
-  INTERNAL_IP = '',
-  TIPI_VERSION = '',
-  ROOT_FOLDER_HOST = '',
-  NGINX_PORT = '80',
-  APPS_REPO_ID = '',
-  APPS_REPO_URL = '',
-  DOMAIN = '',
-} = process.env;
-
-const config: IConfig = {
-  logs: {
-    LOGS_FOLDER,
-    LOGS_APP,
-    LOGS_ERROR,
-  },
-  NODE_ENV,
-  ROOT_FOLDER: '/tipi',
-  JWT_SECRET,
-  CLIENT_URLS: ['http://localhost:3000', `http://${INTERNAL_IP}`, `http://${INTERNAL_IP}:${NGINX_PORT}`, `http://${INTERNAL_IP}:3000`, `https://${DOMAIN}`],
-  VERSION: TIPI_VERSION,
-  ROOT_FOLDER_HOST,
-  APPS_REPO_ID,
-  APPS_REPO_URL,
-  INTERNAL_IP,
-};
-
-export default config;

+ 0 - 1
packages/system-api/src/config/index.ts

@@ -1 +0,0 @@
-export { default } from './config';

+ 7 - 7
packages/system-api/src/config/logger/logger.ts

@@ -1,13 +1,13 @@
 import fs from 'fs-extra';
 import fs from 'fs-extra';
 import path from 'path';
 import path from 'path';
 import { createLogger, format, transports } from 'winston';
 import { createLogger, format, transports } from 'winston';
-import config from '..';
+import { getConfig } from '../../core/config/TipiConfig';
 
 
 const { align, printf, timestamp, combine, colorize } = format;
 const { align, printf, timestamp, combine, colorize } = format;
 
 
 // Create the logs directory if it does not exist
 // Create the logs directory if it does not exist
-if (!fs.existsSync(config.logs.LOGS_FOLDER)) {
-  fs.mkdirSync(config.logs.LOGS_FOLDER);
+if (!fs.existsSync(getConfig().logs.LOGS_FOLDER)) {
+  fs.mkdirSync(getConfig().logs.LOGS_FOLDER);
 }
 }
 
 
 /**
 /**
@@ -36,14 +36,14 @@ const Logger = createLogger({
     // - Write all logs error (and below) to `error.log`.
     // - Write all logs error (and below) to `error.log`.
     //
     //
     new transports.File({
     new transports.File({
-      filename: path.join(config.logs.LOGS_FOLDER, config.logs.LOGS_ERROR),
+      filename: path.join(getConfig().logs.LOGS_FOLDER, getConfig().logs.LOGS_ERROR),
       level: 'error',
       level: 'error',
     }),
     }),
     new transports.File({
     new transports.File({
-      filename: path.join(config.logs.LOGS_FOLDER, config.logs.LOGS_APP),
+      filename: path.join(getConfig().logs.LOGS_FOLDER, getConfig().logs.LOGS_APP),
     }),
     }),
   ],
   ],
-  exceptionHandlers: [new transports.File({ filename: path.join(config.logs.LOGS_FOLDER, config.logs.LOGS_ERROR) })],
+  exceptionHandlers: [new transports.File({ filename: path.join(getConfig().logs.LOGS_FOLDER, getConfig().logs.LOGS_ERROR) })],
 });
 });
 
 
 //
 //
@@ -59,4 +59,4 @@ const LoggerDev = createLogger({
   ],
   ],
 });
 });
 
 
-export default config.NODE_ENV === 'production' ? Logger : LoggerDev;
+export default process.env.NODE_ENV === 'production' ? Logger : LoggerDev;

+ 2 - 2
packages/system-api/src/constants/constants.ts

@@ -1,5 +1,5 @@
-import config from '../config';
+import { getConfig } from '../core/config/TipiConfig';
 
 
 export const APP_DATA_FOLDER = 'app-data';
 export const APP_DATA_FOLDER = 'app-data';
 export const APPS_FOLDER = 'apps';
 export const APPS_FOLDER = 'apps';
-export const isProd = config.NODE_ENV === 'production';
+export const isProd = getConfig().NODE_ENV === 'production';

+ 227 - 0
packages/system-api/src/core/config/EventDispatcher.ts

@@ -0,0 +1,227 @@
+import fs from 'fs-extra';
+import logger from '../../config/logger/logger';
+
+export enum EventTypes {
+  // System events
+  RESTART = 'restart',
+  UPDATE = 'update',
+  CLONE_REPO = 'clone_repo',
+  UPDATE_REPO = 'update_repo',
+  APP = 'app',
+  SYSTEM_INFO = 'system_info',
+}
+
+type SystemEvent = {
+  id: string;
+  type: EventTypes;
+  args: string[];
+  creationDate: Date;
+};
+
+type EventStatusTypes = 'running' | 'success' | 'error' | 'waiting';
+
+const WATCH_FILE = '/runtipi/state/events';
+
+// File state example:
+// restart 1631231231231 running "arg1 arg2"
+class EventDispatcher {
+  private static instance: EventDispatcher | null;
+
+  private queue: SystemEvent[] = [];
+
+  private lock: SystemEvent | null = null;
+
+  private interval: NodeJS.Timer;
+
+  private intervals: NodeJS.Timer[] = [];
+
+  constructor() {
+    const timer = this.pollQueue();
+    this.interval = timer;
+  }
+
+  public static getInstance(): EventDispatcher {
+    if (!EventDispatcher.instance) {
+      EventDispatcher.instance = new EventDispatcher();
+    }
+    return EventDispatcher.instance;
+  }
+
+  /**
+   * Generate a random task id
+   * @returns - Random id
+   */
+  private generateId() {
+    return Math.random().toString(36).substring(2, 9);
+  }
+
+  /**
+   * Collect lock status and clean queue if event is done
+   */
+  private collectLockStatusAndClean() {
+    if (!this.lock) {
+      return;
+    }
+
+    const status = this.getEventStatus(this.lock.id);
+
+    if (status === 'running' || status === 'waiting') {
+      return;
+    }
+
+    this.clearEvent(this.lock, status);
+    this.lock = null;
+  }
+
+  /**
+   * Poll queue and run events
+   */
+  private pollQueue() {
+    logger.info('EventDispatcher: Polling queue...');
+
+    if (!this.interval) {
+      const id = setInterval(() => {
+        this.runEvent();
+        this.collectLockStatusAndClean();
+      }, 1000);
+      this.intervals.push(id);
+      return id;
+    }
+
+    return this.interval;
+  }
+
+  /**
+   * Run event from the queue if there is no lock
+   */
+  private async runEvent() {
+    if (this.lock) {
+      return;
+    }
+
+    const event = this.queue[0];
+    if (!event) {
+      return;
+    }
+
+    this.lock = event;
+
+    // Write event to state file
+    const args = event.args.join(' ');
+    const line = `${event.type} ${event.id} waiting ${args}`;
+    fs.writeFileSync(WATCH_FILE, `${line}`);
+  }
+
+  /**
+   * Check event status
+   * @param id - Event id
+   * @returns - Event status
+   */
+  private getEventStatus(id: string): EventStatusTypes {
+    const event = this.queue.find((e) => e.id === id);
+
+    if (!event) {
+      return 'success';
+    }
+
+    // if event was created more than 3 minutes ago, it's an error
+    if (new Date().getTime() - event.creationDate.getTime() > 5 * 60 * 1000) {
+      return 'error';
+    }
+
+    const file = fs.readFileSync(WATCH_FILE, 'utf8');
+    const lines = file?.split('\n') || [];
+    const line = lines.find((l) => l.startsWith(`${event.type} ${event.id}`));
+
+    if (!line) {
+      return 'waiting';
+    }
+
+    const status = line.split(' ')[2] as EventStatusTypes;
+
+    return status;
+  }
+
+  /**
+   * Dispatch an event to the queue
+   * @param type - Event type
+   * @param args - Event arguments
+   * @returns - Event object
+   */
+  public dispatchEvent(type: EventTypes, args?: string[]): SystemEvent {
+    const event: SystemEvent = {
+      id: this.generateId(),
+      type,
+      args: args || [],
+      creationDate: new Date(),
+    };
+
+    this.queue.push(event);
+
+    return event;
+  }
+
+  /**
+   * Clear event from queue
+   * @param id - Event id
+   */
+  private clearEvent(event: SystemEvent, status: EventStatusTypes = 'success') {
+    this.queue = this.queue.filter((e) => e.id !== event.id);
+    if (fs.existsSync(`/app/logs/${event.id}.log`)) {
+      const log = fs.readFileSync(`/app/logs/${event.id}.log`, 'utf8');
+      if (log && status === 'error') {
+        logger.error(`EventDispatcher: ${event.type} ${event.id} failed with error: ${log}`);
+      } else if (log) {
+        logger.info(`EventDispatcher: ${event.type} ${event.id} finished with message: ${log}`);
+      }
+      fs.unlinkSync(`/app/logs/${event.id}.log`);
+    }
+    fs.writeFileSync(WATCH_FILE, '');
+  }
+
+  /**
+   * Dispatch an event to the queue and wait for it to finish
+   * @param type - Event type
+   * @param args - Event arguments
+   * @returns - Promise that resolves when the event is done
+   */
+  public async dispatchEventAsync(type: EventTypes, args?: string[]): Promise<{ success: boolean; stdout?: string }> {
+    const event = this.dispatchEvent(type, args);
+
+    return new Promise((resolve) => {
+      const interval = setInterval(() => {
+        this.intervals.push(interval);
+        const status = this.getEventStatus(event.id);
+
+        let log = '';
+        if (fs.existsSync(`/app/logs/${event.id}.log`)) {
+          log = fs.readFileSync(`/app/logs/${event.id}.log`, 'utf8');
+        }
+
+        if (status === 'success') {
+          clearInterval(interval);
+          resolve({ success: true, stdout: log });
+        } else if (status === 'error') {
+          clearInterval(interval);
+          resolve({ success: false, stdout: log });
+        }
+      }, 100);
+    });
+  }
+
+  public clearInterval() {
+    clearInterval(this.interval);
+    this.intervals.forEach((i) => clearInterval(i));
+  }
+
+  public clear() {
+    this.queue = [];
+    this.lock = null;
+    EventDispatcher.instance = null;
+    fs.writeFileSync(WATCH_FILE, '');
+  }
+}
+
+export const eventDispatcher = EventDispatcher.getInstance();
+
+export default EventDispatcher;

+ 124 - 0
packages/system-api/src/core/config/TipiConfig.ts

@@ -0,0 +1,124 @@
+import { z } from 'zod';
+import * as dotenv from 'dotenv';
+import fs from 'fs-extra';
+import { readJsonFile } from '../../modules/fs/fs.helpers';
+
+if (process.env.NODE_ENV !== 'production') {
+  dotenv.config({ path: '.env.dev' });
+} else {
+  dotenv.config({ path: '.env' });
+}
+const {
+  LOGS_FOLDER = '/app/logs',
+  LOGS_APP = 'app.log',
+  LOGS_ERROR = 'error.log',
+  NODE_ENV = 'development',
+  JWT_SECRET = '',
+  INTERNAL_IP = '',
+  TIPI_VERSION = '',
+  NGINX_PORT = '80',
+  APPS_REPO_ID = '',
+  APPS_REPO_URL = '',
+  DOMAIN = '',
+  STORAGE_PATH = '/runtipi',
+} = process.env;
+
+const configSchema = z.object({
+  NODE_ENV: z.union([z.literal('development'), z.literal('production'), z.literal('test')]),
+  status: z.union([z.literal('RUNNING'), z.literal('UPDATING'), z.literal('RESTARTING')]),
+  logs: z.object({
+    LOGS_FOLDER: z.string(),
+    LOGS_APP: z.string(),
+    LOGS_ERROR: z.string(),
+  }),
+  dnsIp: z.string(),
+  rootFolder: z.string(),
+  internalIp: z.string(),
+  version: z.string(),
+  jwtSecret: z.string(),
+  clientUrls: z.array(z.string()),
+  appsRepoId: z.string(),
+  appsRepoUrl: z.string(),
+  domain: z.string(),
+  storagePath: z.string(),
+});
+
+class Config {
+  private static instance: Config;
+
+  private config: z.infer<typeof configSchema>;
+
+  constructor() {
+    const envConfig: z.infer<typeof configSchema> = {
+      logs: {
+        LOGS_FOLDER,
+        LOGS_APP,
+        LOGS_ERROR,
+      },
+      NODE_ENV: NODE_ENV as z.infer<typeof configSchema>['NODE_ENV'],
+      rootFolder: '/runtipi',
+      internalIp: INTERNAL_IP,
+      version: TIPI_VERSION,
+      jwtSecret: JWT_SECRET,
+      clientUrls: ['http://localhost:3000', `http://${INTERNAL_IP}`, `http://${INTERNAL_IP}:${NGINX_PORT}`, `http://${INTERNAL_IP}:3000`, DOMAIN && `https://${DOMAIN}`].filter(Boolean),
+      appsRepoId: APPS_REPO_ID,
+      appsRepoUrl: APPS_REPO_URL,
+      domain: DOMAIN,
+      dnsIp: '9.9.9.9',
+      status: 'RUNNING',
+      storagePath: STORAGE_PATH,
+    };
+
+    const parsed = configSchema.parse({
+      ...envConfig,
+    });
+
+    this.config = parsed;
+  }
+
+  public static getInstance(): Config {
+    if (!Config.instance) {
+      Config.instance = new Config();
+    }
+    return Config.instance;
+  }
+
+  public getConfig() {
+    return this.config;
+  }
+
+  public applyJsonConfig() {
+    const fileConfig = readJsonFile('/runtipi/state/settings.json') || {};
+
+    const parsed = configSchema.parse({
+      ...this.config,
+      ...fileConfig,
+    });
+
+    this.config = parsed;
+  }
+
+  public setConfig<T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile: boolean = false) {
+    const newConf: z.infer<typeof configSchema> = { ...this.getConfig() };
+    newConf[key] = value;
+
+    this.config = configSchema.parse(newConf);
+
+    if (writeFile) {
+      const currentJsonConf = readJsonFile('/runtipi/state/settings.json') || {};
+      currentJsonConf[key] = value;
+      const partialConfig = configSchema.partial();
+      const parsed = partialConfig.parse(currentJsonConf);
+
+      fs.writeFileSync('/runtipi/state/settings.json', JSON.stringify(parsed));
+    }
+  }
+}
+
+export const setConfig = <T extends keyof typeof configSchema.shape>(key: T, value: z.infer<typeof configSchema>[T], writeFile: boolean = false) => {
+  Config.getInstance().setConfig(key, value, writeFile);
+};
+
+export const getConfig = () => Config.getInstance().getConfig();
+
+export const applyJsonConfig = () => Config.getInstance().applyJsonConfig();

+ 199 - 0
packages/system-api/src/core/config/__tests__/EventDispatcher.test.ts

@@ -0,0 +1,199 @@
+import fs from 'fs-extra';
+import { eventDispatcher, EventTypes } from '../EventDispatcher';
+
+const WATCH_FILE = '/runtipi/state/events';
+
+jest.mock('fs-extra');
+
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
+
+beforeEach(() => {
+  eventDispatcher.clear();
+  fs.writeFileSync(WATCH_FILE, '');
+  fs.writeFileSync('/app/logs/123.log', 'test');
+});
+
+describe('EventDispatcher - dispatchEvent', () => {
+  it('should dispatch an event', () => {
+    const event = eventDispatcher.dispatchEvent(EventTypes.APP);
+    expect(event.id).toBeDefined();
+  });
+
+  it('should dispatch an event with args', () => {
+    const event = eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
+    expect(event.id).toBeDefined();
+  });
+
+  it('Should put events into queue', async () => {
+    eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
+    eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
+
+    // @ts-ignore
+    const queue = eventDispatcher.queue;
+
+    expect(queue.length).toBe(2);
+  });
+
+  it('Should put first event into lock after 1 sec', async () => {
+    eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
+    eventDispatcher.dispatchEvent(EventTypes.UPDATE, ['--help']);
+
+    // @ts-ignore
+    const queue = eventDispatcher.queue;
+
+    await wait(1050);
+
+    // @ts-ignore
+    const lock = eventDispatcher.lock;
+
+    expect(queue.length).toBe(2);
+    expect(lock).toBeDefined();
+    expect(lock?.type).toBe(EventTypes.APP);
+  });
+
+  it('Should clear event once its status is success', async () => {
+    // @ts-ignore
+    jest.spyOn(eventDispatcher, 'getEventStatus').mockReturnValueOnce('success');
+    eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
+
+    await wait(1050);
+
+    // @ts-ignore
+    const queue = eventDispatcher.queue;
+
+    expect(queue.length).toBe(0);
+  });
+
+  it('Should clear event once its status is error', async () => {
+    // @ts-ignore
+    jest.spyOn(eventDispatcher, 'getEventStatus').mockReturnValueOnce('error');
+    eventDispatcher.dispatchEvent(EventTypes.APP, ['--help']);
+
+    await wait(1050);
+
+    // @ts-ignore
+    const queue = eventDispatcher.queue;
+
+    expect(queue.length).toBe(0);
+  });
+});
+
+describe('EventDispatcher - dispatchEventAsync', () => {
+  it('Should dispatch an event and wait for it to finish', async () => {
+    // @ts-ignore
+    jest.spyOn(eventDispatcher, 'getEventStatus').mockReturnValueOnce('success');
+    const { success } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['--help']);
+
+    expect(success).toBe(true);
+  });
+
+  it('Should dispatch an event and wait for it to finish with error', async () => {
+    // @ts-ignore
+    jest.spyOn(eventDispatcher, 'getEventStatus').mockReturnValueOnce('error');
+
+    const { success } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['--help']);
+
+    expect(success).toBe(false);
+  });
+});
+
+describe('EventDispatcher - runEvent', () => {
+  it('Should do nothing if there is a lock', async () => {
+    // @ts-ignore
+    eventDispatcher.lock = { id: '123', type: EventTypes.APP, args: [] };
+
+    // @ts-ignore
+    await eventDispatcher.runEvent();
+
+    // @ts-ignore
+    const file = fs.readFileSync(WATCH_FILE, 'utf8');
+
+    expect(file).toBe('');
+  });
+
+  it('Should do nothing if there is no event in queue', async () => {
+    // @ts-ignore
+    await eventDispatcher.runEvent();
+
+    // @ts-ignore
+    const file = fs.readFileSync(WATCH_FILE, 'utf8');
+
+    expect(file).toBe('');
+  });
+});
+
+describe('EventDispatcher - getEventStatus', () => {
+  it('Should return success if event is not in the queue', async () => {
+    // @ts-ignore
+    eventDispatcher.queue = [];
+    // @ts-ignore
+    const status = eventDispatcher.getEventStatus('123');
+
+    expect(status).toBe('success');
+  });
+
+  it('Should return error if event is expired', async () => {
+    const dateFiveMinutesAgo = new Date(new Date().getTime() - 5 * 60 * 10000);
+    // @ts-ignore
+    eventDispatcher.queue = [{ id: '123', type: EventTypes.APP, args: [], creationDate: dateFiveMinutesAgo }];
+    // @ts-ignore
+    const status = eventDispatcher.getEventStatus('123');
+
+    expect(status).toBe('error');
+  });
+
+  it('Should be waiting if line is not found in the file', async () => {
+    // @ts-ignore
+    eventDispatcher.queue = [{ id: '123', type: EventTypes.APP, args: [], creationDate: new Date() }];
+    // @ts-ignore
+    const status = eventDispatcher.getEventStatus('123');
+
+    expect(status).toBe('waiting');
+  });
+});
+
+describe('EventDispatcher - clearEvent', () => {
+  it('Should clear event', async () => {
+    const event = { id: '123', type: EventTypes.APP, args: [], creationDate: new Date() };
+    // @ts-ignore
+    eventDispatcher.queue = [event];
+    // @ts-ignore
+    eventDispatcher.clearEvent(event);
+
+    // @ts-ignore
+    const queue = eventDispatcher.queue;
+
+    expect(queue.length).toBe(0);
+  });
+});
+
+describe('EventDispatcher - pollQueue', () => {
+  it('Should not create a new interval if one already exists', async () => {
+    // @ts-ignore
+    eventDispatcher.interval = 123;
+    // @ts-ignore
+    const id = eventDispatcher.pollQueue();
+    // @ts-ignore
+    const interval = eventDispatcher.interval;
+
+    expect(interval).toBe(123);
+    expect(id).toBe(123);
+
+    clearInterval(interval);
+    clearInterval(id);
+  });
+});
+
+describe('EventDispatcher - collectLockStatusAndClean', () => {
+  it('Should do nothing if there is no lock', async () => {
+    // @ts-ignore
+    eventDispatcher.lock = null;
+    // @ts-ignore
+    eventDispatcher.collectLockStatusAndClean();
+
+    // @ts-ignore
+    const lock = eventDispatcher.lock;
+
+    expect(lock).toBeNull();
+  });
+});

+ 99 - 0
packages/system-api/src/core/config/__tests__/TipiConfig.test.ts

@@ -0,0 +1,99 @@
+import { faker } from '@faker-js/faker';
+import fs from 'fs-extra';
+import { readJsonFile } from '../../../modules/fs/fs.helpers';
+import { applyJsonConfig, getConfig, setConfig } from '../TipiConfig';
+
+jest.mock('fs-extra');
+
+beforeEach(async () => {
+  jest.resetModules();
+  jest.resetAllMocks();
+});
+
+describe('Test: getConfig', () => {
+  it('It should return config from .env', () => {
+    const config = getConfig();
+
+    expect(config).toBeDefined();
+    expect(config.NODE_ENV).toBe('test');
+    expect(config.logs.LOGS_FOLDER).toBe('/app/logs');
+    expect(config.logs.LOGS_APP).toBe('app.log');
+    expect(config.logs.LOGS_ERROR).toBe('error.log');
+    expect(config.dnsIp).toBe('9.9.9.9');
+    expect(config.rootFolder).toBe('/runtipi');
+    expect(config.internalIp).toBe('192.168.1.10');
+  });
+});
+
+describe('Test: setConfig', () => {
+  it('It should be able set config', () => {
+    const randomWord = faker.random.word();
+    setConfig('appsRepoUrl', randomWord);
+    const config = getConfig();
+
+    expect(config).toBeDefined();
+    expect(config.appsRepoUrl).toBe(randomWord);
+  });
+
+  it('Should not be able to set invalid NODE_ENV', () => {
+    // @ts-ignore
+    expect(() => setConfig('NODE_ENV', 'invalid')).toThrow();
+  });
+
+  it('Should write config to json file', () => {
+    const randomWord = faker.random.word();
+    setConfig('appsRepoUrl', randomWord, true);
+    const config = getConfig();
+
+    expect(config).toBeDefined();
+    expect(config.appsRepoUrl).toBe(randomWord);
+
+    const settingsJson = readJsonFile('/runtipi/state/settings.json');
+
+    expect(settingsJson).toBeDefined();
+    expect(settingsJson.appsRepoUrl).toBe(randomWord);
+  });
+});
+
+describe('Test: applyJsonConfig', () => {
+  it('It should be able to apply json config', () => {
+    const settingsJson = {
+      appsRepoUrl: faker.random.word(),
+      appsRepoId: faker.random.word(),
+      domain: faker.random.word(),
+    };
+
+    const MockFiles = {
+      '/runtipi/state/settings.json': JSON.stringify(settingsJson),
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    applyJsonConfig();
+    const config = getConfig();
+
+    expect(config).toBeDefined();
+
+    expect(config.appsRepoUrl).toBe(settingsJson.appsRepoUrl);
+    expect(config.appsRepoId).toBe(settingsJson.appsRepoId);
+    expect(config.domain).toBe(settingsJson.domain);
+  });
+
+  it('Should not be able to apply an invalid value from json config', () => {
+    const settingsJson = {
+      appsRepoUrl: faker.random.word(),
+      appsRepoId: faker.random.word(),
+      domain: 10,
+    };
+
+    const MockFiles = {
+      '/runtipi/state/settings.json': JSON.stringify(settingsJson),
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    expect(() => applyJsonConfig()).toThrow();
+  });
+});

+ 37 - 0
packages/system-api/src/core/jobs/__tests__/jobs.test.ts

@@ -0,0 +1,37 @@
+import cron from 'node-cron';
+import { getConfig } from '../../config/TipiConfig';
+import startJobs from '../jobs';
+import { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
+
+jest.mock('node-cron');
+jest.mock('child_process');
+
+beforeEach(async () => {
+  jest.resetModules();
+  jest.resetAllMocks();
+});
+
+describe('Test: startJobs', () => {
+  it('Should start cron jobs', () => {
+    const spy = jest.spyOn(cron, 'schedule');
+
+    startJobs();
+    expect(spy).toHaveBeenCalled();
+    expect(spy).toHaveBeenCalledWith('*/30 * * * *', expect.any(Function));
+    spy.mockRestore();
+  });
+
+  it('Should update apps repo on cron trigger', () => {
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEvent');
+
+    // Act
+    startJobs();
+
+    // Assert
+    expect(spy.mock.calls.length).toBe(2);
+    expect(spy.mock.calls[0]).toEqual([EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]]);
+    expect(spy.mock.calls[1]).toEqual([EventTypes.SYSTEM_INFO, []]);
+
+    spy.mockRestore();
+  });
+});

+ 10 - 5
packages/system-api/src/core/jobs/jobs.ts

@@ -1,14 +1,19 @@
 import cron from 'node-cron';
 import cron from 'node-cron';
-import config from '../../config';
 import logger from '../../config/logger/logger';
 import logger from '../../config/logger/logger';
-import { updateRepo } from '../../helpers/repo-helpers';
+import { getConfig } from '../../core/config/TipiConfig';
+import { eventDispatcher, EventTypes } from '../config/EventDispatcher';
 
 
 const startJobs = () => {
 const startJobs = () => {
   logger.info('Starting cron jobs...');
   logger.info('Starting cron jobs...');
 
 
-  cron.schedule('0 * * * *', () => {
-    logger.info('Cloning apps repo...');
-    updateRepo(config.APPS_REPO_URL);
+  // Every 30 minutes
+  cron.schedule('*/30 * * * *', async () => {
+    eventDispatcher.dispatchEvent(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
+  });
+
+  // every minute
+  cron.schedule('* * * * *', () => {
+    eventDispatcher.dispatchEvent(EventTypes.SYSTEM_INFO, []);
   });
   });
 };
 };
 
 

+ 2 - 2
packages/system-api/src/core/middlewares/sessionMiddleware.ts

@@ -1,7 +1,7 @@
 import session from 'express-session';
 import session from 'express-session';
-import config from '../../config';
 import SessionFileStore from 'session-file-store';
 import SessionFileStore from 'session-file-store';
 import { COOKIE_MAX_AGE, __prod__ } from '../../config/constants/constants';
 import { COOKIE_MAX_AGE, __prod__ } from '../../config/constants/constants';
+import { getConfig } from '../config/TipiConfig';
 
 
 const getSessionMiddleware = () => {
 const getSessionMiddleware = () => {
   const FileStore = SessionFileStore(session);
   const FileStore = SessionFileStore(session);
@@ -12,7 +12,7 @@ const getSessionMiddleware = () => {
     name: 'qid',
     name: 'qid',
     store: new FileStore(),
     store: new FileStore(),
     cookie: { maxAge: COOKIE_MAX_AGE, secure: false, sameSite, httpOnly: true },
     cookie: { maxAge: COOKIE_MAX_AGE, secure: false, sameSite, httpOnly: true },
-    secret: config.JWT_SECRET,
+    secret: getConfig().jwtSecret,
     resave: false,
     resave: false,
     saveUninitialized: false,
     saveUninitialized: false,
   });
   });

+ 9 - 8
packages/system-api/src/core/updates/__tests__/v040.test.ts

@@ -6,6 +6,7 @@ import { AppInfo, AppStatusEnum } from '../../../modules/apps/apps.types';
 import { createApp } from '../../../modules/apps/__tests__/apps.factory';
 import { createApp } from '../../../modules/apps/__tests__/apps.factory';
 import Update, { UpdateStatusEnum } from '../../../modules/system/update.entity';
 import Update, { UpdateStatusEnum } from '../../../modules/system/update.entity';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import { setupConnection, teardownConnection } from '../../../test/connection';
+import { getConfig } from '../../config/TipiConfig';
 import { updateV040 } from '../v040';
 import { updateV040 } from '../v040';
 
 
 jest.mock('fs');
 jest.mock('fs');
@@ -61,7 +62,7 @@ describe('No state/apps.json', () => {
 describe('State/apps.json exists with no installed app', () => {
 describe('State/apps.json exists with no installed app', () => {
   beforeEach(async () => {
   beforeEach(async () => {
     const { MockFiles } = await createApp({});
     const { MockFiles } = await createApp({});
-    MockFiles['/tipi/state/apps.json'] = createState([]);
+    MockFiles[`${getConfig().rootFolder}/state/apps.json`] = createState([]);
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
   });
   });
@@ -79,7 +80,7 @@ describe('State/apps.json exists with no installed app', () => {
 
 
   it('Should delete state file after update', async () => {
   it('Should delete state file after update', async () => {
     await updateV040();
     await updateV040();
-    expect(fs.existsSync('/tipi/state/apps.json')).toBe(false);
+    expect(fs.existsSync('/runtipi/state/apps.json')).toBe(false);
   });
   });
 });
 });
 
 
@@ -88,9 +89,9 @@ describe('State/apps.json exists with one installed app', () => {
   beforeEach(async () => {
   beforeEach(async () => {
     const { MockFiles, appInfo } = await createApp({});
     const { MockFiles, appInfo } = await createApp({});
     app1 = appInfo;
     app1 = appInfo;
-    MockFiles['/tipi/state/apps.json'] = createState([appInfo.id]);
-    MockFiles[`/tipi/app-data/${appInfo.id}`] = '';
-    MockFiles[`/tipi/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
+    MockFiles['/runtipi/state/apps.json'] = createState([appInfo.id]);
+    MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
+    MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
   });
   });
@@ -117,9 +118,9 @@ describe('State/apps.json exists with one installed app', () => {
   it('Should not try to migrate app if it already exists', async () => {
   it('Should not try to migrate app if it already exists', async () => {
     const { MockFiles, appInfo } = await createApp({ installed: true });
     const { MockFiles, appInfo } = await createApp({ installed: true });
     app1 = appInfo;
     app1 = appInfo;
-    MockFiles['/tipi/state/apps.json'] = createState([appInfo.id]);
-    MockFiles[`/tipi/app-data/${appInfo.id}`] = '';
-    MockFiles[`/tipi/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
+    MockFiles['/runtipi/state/apps.json'] = createState([appInfo.id]);
+    MockFiles[`/app/storage/app-data/${appInfo.id}`] = '';
+    MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 

+ 6 - 5
packages/system-api/src/core/updates/recover-migrations.ts

@@ -1,10 +1,10 @@
-import datasource from '../../config/datasource';
+import { DataSource } from 'typeorm';
 import logger from '../../config/logger/logger';
 import logger from '../../config/logger/logger';
 import App from '../../modules/apps/app.entity';
 import App from '../../modules/apps/app.entity';
 import User from '../../modules/auth/user.entity';
 import User from '../../modules/auth/user.entity';
 import Update from '../../modules/system/update.entity';
 import Update from '../../modules/system/update.entity';
 
 
-const recover = async () => {
+const recover = async (datasource: DataSource) => {
   logger.info('Recovering broken database');
   logger.info('Recovering broken database');
 
 
   const queryRunner = datasource.createQueryRunner();
   const queryRunner = datasource.createQueryRunner();
@@ -33,9 +33,10 @@ const recover = async () => {
     await Update.create(update).save();
     await Update.create(update).save();
   }
   }
 
 
-  logger.info('Users recovered', users.length);
-  logger.info('Apps recovered', apps.length);
-  logger.info('Database recovered');
+  logger.info(`Users recovered ${users.length}`);
+  logger.info(`Apps recovered ${apps.length}`);
+  logger.info(`Updates recovered ${updates.length}`);
+  logger.info('Database fully recovered');
 };
 };
 
 
 export default recover;
 export default recover;

+ 8 - 9
packages/system-api/src/core/updates/v040.ts

@@ -1,10 +1,10 @@
-import config from '../../config';
 import logger from '../../config/logger/logger';
 import logger from '../../config/logger/logger';
 import App from '../../modules/apps/app.entity';
 import App from '../../modules/apps/app.entity';
 import { AppInfo, AppStatusEnum } from '../../modules/apps/apps.types';
 import { AppInfo, AppStatusEnum } from '../../modules/apps/apps.types';
 import User from '../../modules/auth/user.entity';
 import User from '../../modules/auth/user.entity';
 import { deleteFolder, fileExists, readFile, readJsonFile } from '../../modules/fs/fs.helpers';
 import { deleteFolder, fileExists, readFile, readJsonFile } from '../../modules/fs/fs.helpers';
 import Update, { UpdateStatusEnum } from '../../modules/system/update.entity';
 import Update, { UpdateStatusEnum } from '../../modules/system/update.entity';
+import { getConfig } from '../config/TipiConfig';
 
 
 type AppsState = { installed: string };
 type AppsState = { installed: string };
 
 
@@ -20,15 +20,15 @@ export const updateV040 = async (): Promise<void> => {
     }
     }
 
 
     // Migrate apps
     // Migrate apps
-    if (fileExists('/state/apps.json')) {
-      const state: AppsState = await readJsonFile('/state/apps.json');
+    if (fileExists('/runtipi/state/apps.json')) {
+      const state: AppsState = await readJsonFile('/runtipi/state/apps.json');
       const installed: string[] = state.installed.split(' ').filter(Boolean);
       const installed: string[] = state.installed.split(' ').filter(Boolean);
 
 
       for (const appId of installed) {
       for (const appId of installed) {
         const app = await App.findOne({ where: { id: appId } });
         const app = await App.findOne({ where: { id: appId } });
 
 
         if (!app) {
         if (!app) {
-          const envFile = readFile(`/app-data/${appId}/app.env`).toString();
+          const envFile = readFile(`/app/storage/app-data/${appId}/app.env`).toString();
           const envVars = envFile.split('\n');
           const envVars = envFile.split('\n');
           const envVarsMap = new Map<string, string>();
           const envVarsMap = new Map<string, string>();
 
 
@@ -39,7 +39,7 @@ export const updateV040 = async (): Promise<void> => {
 
 
           const form: Record<string, string> = {};
           const form: Record<string, string> = {};
 
 
-          const configFile: AppInfo | null = readJsonFile(`/repos/${config.APPS_REPO_ID}/apps/${appId}/config.json`);
+          const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appId}/config.json`);
           configFile?.form_fields?.forEach((field) => {
           configFile?.form_fields?.forEach((field) => {
             const envVar = field.env_variable;
             const envVar = field.env_variable;
             const envVarValue = envVarsMap.get(envVar);
             const envVarValue = envVarsMap.get(envVar);
@@ -54,23 +54,22 @@ export const updateV040 = async (): Promise<void> => {
           logger.info('App already migrated');
           logger.info('App already migrated');
         }
         }
       }
       }
-      deleteFolder('/state/apps.json');
+      deleteFolder('/runtipi/state/apps.json');
     }
     }
 
 
     // Migrate users
     // Migrate users
     if (fileExists('/state/users.json')) {
     if (fileExists('/state/users.json')) {
-      const state: { email: string; password: string }[] = await readJsonFile('/state/users.json');
+      const state: { email: string; password: string }[] = await readJsonFile('/runtipi/state/users.json');
 
 
       for (const user of state) {
       for (const user of state) {
         await User.create({ username: user.email.trim().toLowerCase(), password: user.password }).save();
         await User.create({ username: user.email.trim().toLowerCase(), password: user.password }).save();
       }
       }
-      deleteFolder('/state/users.json');
+      deleteFolder('/runtipi/state/users.json');
     }
     }
 
 
     await Update.create({ name: UPDATE_NAME, status: UpdateStatusEnum.SUCCESS }).save();
     await Update.create({ name: UPDATE_NAME, status: UpdateStatusEnum.SUCCESS }).save();
   } catch (error) {
   } catch (error) {
     logger.error(error);
     logger.error(error);
-    console.error(error);
     await Update.create({ name: UPDATE_NAME, status: UpdateStatusEnum.FAILED }).save();
     await Update.create({ name: UPDATE_NAME, status: UpdateStatusEnum.FAILED }).save();
   }
   }
 };
 };

+ 0 - 29
packages/system-api/src/helpers/repo-helpers.ts

@@ -1,29 +0,0 @@
-import { runScript } from '../modules/fs/fs.helpers';
-
-export const updateRepo = (repo: string): Promise<void> => {
-  return new Promise((resolve, reject) => {
-    runScript('/scripts/git.sh', ['update', repo], (err: string, stdout: string) => {
-      if (err) {
-        reject(err);
-      }
-
-      console.info('Update result', stdout);
-
-      resolve();
-    });
-  });
-};
-
-export const cloneRepo = (repo: string): Promise<void> => {
-  return new Promise((resolve, reject) => {
-    runScript('/scripts/git.sh', ['clone', repo], (err: string, stdout: string) => {
-      if (err) {
-        reject(err);
-      }
-
-      console.info('Clone result', stdout);
-
-      resolve();
-    });
-  });
-};

+ 9 - 10
packages/system-api/src/modules/apps/__tests__/apps.factory.ts

@@ -1,6 +1,5 @@
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
 import { AppCategoriesEnum, AppInfo, AppStatusEnum, FieldTypes } from '../apps.types';
 import { AppCategoriesEnum, AppInfo, AppStatusEnum, FieldTypes } from '../apps.types';
-import config from '../../../config';
 import App from '../app.entity';
 import App from '../app.entity';
 
 
 interface IProps {
 interface IProps {
@@ -55,11 +54,11 @@ const createApp = async (props: IProps) => {
   }
   }
 
 
   let MockFiles: any = {};
   let MockFiles: any = {};
-  MockFiles[`${config.ROOT_FOLDER}/.env`] = 'TEST=test';
-  MockFiles[`${config.ROOT_FOLDER}/repos/repo-id`] = '';
-  MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
-  MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
-  MockFiles[`${config.ROOT_FOLDER}/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
+  MockFiles['/runtipi/.env'] = 'TEST=test';
+  MockFiles['/runtipi/repos/repo-id'] = '';
+  MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(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 = new App();
   let appEntity = new App();
   if (installed) {
   if (installed) {
@@ -71,10 +70,10 @@ const createApp = async (props: IProps) => {
       domain,
       domain,
     }).save();
     }).save();
 
 
-    MockFiles[`${config.ROOT_FOLDER}/app-data/${appInfo.id}`] = '';
-    MockFiles[`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
-    MockFiles[`${config.ROOT_FOLDER}/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
-    MockFiles[`${config.ROOT_FOLDER}/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
+    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 };
   return { appInfo, MockFiles, appEntity };

+ 89 - 29
packages/system-api/src/modules/apps/__tests__/apps.helpers.test.ts

@@ -1,10 +1,10 @@
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
 import { DataSource } from 'typeorm';
 import { DataSource } from 'typeorm';
-import config from '../../../config';
+import logger from '../../../config/logger/logger';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import App from '../app.entity';
 import App from '../app.entity';
-import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo, runAppScript } from '../apps.helpers';
+import { checkAppRequirements, checkEnvFile, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from '../apps.helpers';
 import { AppInfo } from '../apps.types';
 import { AppInfo } from '../apps.types';
 import { createApp } from './apps.factory';
 import { createApp } from './apps.factory';
 
 
@@ -95,7 +95,7 @@ describe('checkEnvFile', () => {
 
 
   it('Should throw if a required field is missing', () => {
   it('Should throw if a required field is missing', () => {
     const newAppEnv = 'APP_PORT=test\n';
     const newAppEnv = 'APP_PORT=test\n';
-    fs.writeFileSync(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`, newAppEnv);
+    fs.writeFileSync(`/app/storage/app-data/${app1.id}/app.env`, newAppEnv);
 
 
     try {
     try {
       checkEnvFile(app1.id);
       checkEnvFile(app1.id);
@@ -107,26 +107,7 @@ describe('checkEnvFile', () => {
   });
   });
 });
 });
 
 
-describe('runAppScript', () => {
-  let app1: AppInfo;
-
-  beforeEach(async () => {
-    const app1create = await createApp({ installed: true });
-    app1 = app1create.appInfo;
-    // @ts-ignore
-    fs.__createMockFiles(app1create.MockFiles);
-  });
-
-  it('Should run the app script', async () => {
-    const { MockFiles } = await createApp({ installed: true });
-    // @ts-ignore
-    fs.__createMockFiles(MockFiles);
-
-    await runAppScript(['install', app1.id]);
-  });
-});
-
-describe('generateEnvFile', () => {
+describe('Test: generateEnvFile', () => {
   let app1: AppInfo;
   let app1: AppInfo;
   let appEntity1: App;
   let appEntity1: App;
   beforeEach(async () => {
   beforeEach(async () => {
@@ -167,7 +148,7 @@ describe('generateEnvFile', () => {
 
 
     const randomField = faker.random.alphaNumeric(32);
     const randomField = faker.random.alphaNumeric(32);
 
 
-    fs.writeFileSync(`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`, `RANDOM_FIELD=${randomField}`);
+    fs.writeFileSync(`/app/storage/app-data/${appInfo.id}/app.env`, `RANDOM_FIELD=${randomField}`);
 
 
     generateEnvFile(appEntity);
     generateEnvFile(appEntity);
 
 
@@ -234,6 +215,18 @@ describe('generateEnvFile', () => {
     expect(envmap.get('APP_EXPOSED')).toBeUndefined();
     expect(envmap.get('APP_EXPOSED')).toBeUndefined();
     expect(envmap.get('APP_DOMAIN')).toBe(`192.168.1.10:${appInfo.port}`);
     expect(envmap.get('APP_DOMAIN')).toBe(`192.168.1.10:${appInfo.port}`);
   });
   });
+
+  it('Should create app folder if it does not exist', async () => {
+    const { appEntity, appInfo, MockFiles } = await createApp({ installed: true });
+    // @ts-ignore
+    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', () => {
 describe('getAvailableApps', () => {
@@ -251,7 +244,7 @@ describe('getAvailableApps', () => {
   });
   });
 });
 });
 
 
-describe('getAppInfo', () => {
+describe('Test: getAppInfo', () => {
   let app1: AppInfo;
   let app1: AppInfo;
   beforeEach(async () => {
   beforeEach(async () => {
     const app1create = await createApp({ installed: false });
     const app1create = await createApp({ installed: false });
@@ -267,15 +260,82 @@ describe('getAppInfo', () => {
   });
   });
 
 
   it('Should take config.json locally if app is installed', async () => {
   it('Should take config.json locally if app is installed', async () => {
-    const { appInfo, MockFiles } = await createApp({ installed: true });
+    const { appInfo, MockFiles, appEntity } = await createApp({ installed: true });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    const newConfig = {
+      id: faker.random.alphaNumeric(32),
+    };
+
+    fs.writeFileSync(`/runtipi/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
+
+    const app = await 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 });
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    const newConfig = {
+      id: faker.random.alphaNumeric(32),
+      available: true,
+    };
+
+    fs.writeFileSync(`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
+
+    const app = await 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 });
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 
-    fs.writeFileSync(`${config.ROOT_FOLDER}/repos/repo-id/apps/${app1.id}/config.json`, '{}');
+    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 = await getAppInfo(appInfo.id, appEntity.status);
 
 
-    const app = await getAppInfo(appInfo.id);
+    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 });
+    // @ts-ignore
+    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 {
+      await getAppInfo(appInfo.id, appEntity.status);
+      expect(true).toBe(false);
+    } catch (e: any) {
+      expect(e.message).toBe(`Error loading app: ${appInfo.id}`);
+      expect(log).toBeCalledWith(`Error loading app: ${appInfo.id}`);
+    }
 
 
-    expect(app?.id).toEqual(appInfo.id);
+    spy.mockRestore();
+    log.mockRestore();
   });
   });
 
 
   it('Should return null if app does not exist', async () => {
   it('Should return null if app does not exist', async () => {

+ 2 - 0
packages/system-api/src/modules/apps/__tests__/apps.resolver.test.ts

@@ -10,6 +10,7 @@ import { createUser } from '../../auth/__tests__/user.factory';
 import User from '../../auth/user.entity';
 import User from '../../auth/user.entity';
 import { installAppMutation, startAppMutation, stopAppMutation, uninstallAppMutation, updateAppConfigMutation, updateAppMutation } from '../../../test/mutations';
 import { installAppMutation, startAppMutation, stopAppMutation, uninstallAppMutation, updateAppConfigMutation, updateAppMutation } from '../../../test/mutations';
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
+import EventDispatcher from '../../../core/config/EventDispatcher';
 
 
 jest.mock('fs');
 jest.mock('fs');
 jest.mock('child_process');
 jest.mock('child_process');
@@ -36,6 +37,7 @@ beforeEach(async () => {
   jest.resetModules();
   jest.resetModules();
   jest.resetAllMocks();
   jest.resetAllMocks();
   jest.restoreAllMocks();
   jest.restoreAllMocks();
+  EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
   await App.clear();
   await App.clear();
   await User.clear();
   await User.clear();
 });
 });

+ 64 - 85
packages/system-api/src/modules/apps/__tests__/apps.service.test.ts

@@ -1,13 +1,12 @@
 import AppsService from '../apps.service';
 import AppsService from '../apps.service';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
-import config from '../../../config';
-import childProcess from 'child_process';
 import { AppInfo, AppStatusEnum } from '../apps.types';
 import { AppInfo, AppStatusEnum } from '../apps.types';
 import App from '../app.entity';
 import App from '../app.entity';
 import { createApp } from './apps.factory';
 import { createApp } from './apps.factory';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import { setupConnection, teardownConnection } from '../../../test/connection';
 import { DataSource } from 'typeorm';
 import { DataSource } from 'typeorm';
 import { getEnvMap } from '../apps.helpers';
 import { getEnvMap } from '../apps.helpers';
+import EventDispatcher, { eventDispatcher, EventTypes } from '../../../core/config/EventDispatcher';
 
 
 jest.mock('fs-extra');
 jest.mock('fs-extra');
 jest.mock('child_process');
 jest.mock('child_process');
@@ -23,6 +22,7 @@ beforeEach(async () => {
   jest.resetModules();
   jest.resetModules();
   jest.resetAllMocks();
   jest.resetAllMocks();
   jest.restoreAllMocks();
   jest.restoreAllMocks();
+  EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
   await App.clear();
   await App.clear();
 });
 });
 
 
@@ -42,8 +42,9 @@ describe('Install app', () => {
   });
   });
 
 
   it('Should correctly generate env file for app', async () => {
   it('Should correctly generate env file for app', async () => {
+    // EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
-    const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`).toString();
+    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=192.168.1.10:${app1.port}`);
     expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
   });
   });
@@ -59,39 +60,28 @@ describe('Install app', () => {
     expect(app!.status).toBe(AppStatusEnum.RUNNING);
     expect(app!.status).toBe(AppStatusEnum.RUNNING);
   });
   });
 
 
-  it('Should correctly run app script', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
-
-    expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
-    spy.mockRestore();
-  });
-
   it('Should start app if already installed', async () => {
   it('Should start app if already installed', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
 
 
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     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.length).toBe(2);
-    expect(spy.mock.calls[0]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['install', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
-    expect(spy.mock.calls[1]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
+    expect(spy.mock.calls[0]).toEqual([EventTypes.APP, ['install', app1.id]]);
+    expect(spy.mock.calls[1]).toEqual([EventTypes.APP, ['start', app1.id]]);
 
 
     spy.mockRestore();
     spy.mockRestore();
   });
   });
 
 
   it('Should delete app if install script fails', async () => {
   it('Should delete app if install script fails', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    spy.mockImplementation(() => {
-      throw new Error('Test error');
-    });
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
 
 
-    await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow('Test error');
+    await expect(AppsService.installApp(app1.id, { TEST_FIELD: 'test' })).rejects.toThrow(`App ${app1.id} failed to install\nstdout: error`);
 
 
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
 
 
     expect(app).toBeNull();
     expect(app).toBeNull();
-    spy.mockRestore();
   });
   });
 
 
   it('Should throw if required form fields are missing', async () => {
   it('Should throw if required form fields are missing', async () => {
@@ -112,7 +102,7 @@ describe('Install app', () => {
 
 
   it('Should correctly copy app from repos to apps folder', async () => {
   it('Should correctly copy app from repos to apps folder', async () => {
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
-    const appFolder = fs.readdirSync(`${config.ROOT_FOLDER}/apps/${app1.id}`);
+    const appFolder = fs.readdirSync(`/runtipi/apps/${app1.id}`);
 
 
     expect(appFolder).toBeDefined();
     expect(appFolder).toBeDefined();
     expect(appFolder.indexOf('docker-compose.yml')).toBeGreaterThanOrEqual(0);
     expect(appFolder.indexOf('docker-compose.yml')).toBeGreaterThanOrEqual(0);
@@ -121,19 +111,19 @@ describe('Install app', () => {
   it('Should cleanup any app folder existing before install', async () => {
   it('Should cleanup any app folder existing before install', async () => {
     const { MockFiles, appInfo } = await createApp({});
     const { MockFiles, appInfo } = await createApp({});
     app1 = appInfo;
     app1 = appInfo;
-    MockFiles[`/tipi/apps/${appInfo.id}/docker-compose.yml`] = 'test';
-    MockFiles[`/tipi/apps/${appInfo.id}/test.yml`] = 'test';
-    MockFiles[`/tipi/apps/${appInfo.id}`] = ['test.yml', 'docker-compose.yml'];
+    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-ignore
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 
-    expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/test.yml`)).toBe(true);
+    expect(fs.existsSync(`/runtipi/apps/${app1.id}/test.yml`)).toBe(true);
 
 
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
     await AppsService.installApp(app1.id, { TEST_FIELD: 'test' });
 
 
-    expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/test.yml`)).toBe(false);
-    expect(fs.existsSync(`${config.ROOT_FOLDER}/apps/${app1.id}/docker-compose.yml`)).toBe(true);
+    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 () => {
   it('Should throw if app is exposed and domain is not provided', async () => {
@@ -175,56 +165,51 @@ describe('Uninstall app', () => {
   });
   });
 
 
   it('App should be installed by default', async () => {
   it('App should be installed by default', async () => {
+    // Act
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
+
+    // Assert
     expect(app).toBeDefined();
     expect(app).toBeDefined();
     expect(app!.id).toBe(app1.id);
     expect(app!.id).toBe(app1.id);
     expect(app!.status).toBe(AppStatusEnum.RUNNING);
     expect(app!.status).toBe(AppStatusEnum.RUNNING);
   });
   });
 
 
   it('Should correctly remove app from database', async () => {
   it('Should correctly remove app from database', async () => {
+    // Act
     await AppsService.uninstallApp(app1.id);
     await AppsService.uninstallApp(app1.id);
-
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
 
 
+    // Assert
     expect(app).toBeNull();
     expect(app).toBeNull();
   });
   });
 
 
-  it('Should correctly run app script', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-
-    await AppsService.uninstallApp(app1.id);
-
-    expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['uninstall', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
-
-    spy.mockRestore();
-  });
-
   it('Should stop app if it is running', async () => {
   it('Should stop app if it is running', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+    // Arrange
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
 
 
+    // Act
     await AppsService.uninstallApp(app1.id);
     await AppsService.uninstallApp(app1.id);
 
 
+    // Assert
     expect(spy.mock.calls.length).toBe(2);
     expect(spy.mock.calls.length).toBe(2);
-    expect(spy.mock.calls[0]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['stop', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
-    expect(spy.mock.calls[1]).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['uninstall', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
+    expect(spy.mock.calls[0]).toEqual([EventTypes.APP, ['stop', app1.id]]);
+    expect(spy.mock.calls[1]).toEqual([EventTypes.APP, ['uninstall', app1.id]]);
 
 
     spy.mockRestore();
     spy.mockRestore();
   });
   });
 
 
   it('Should throw if app is not installed', async () => {
   it('Should throw if app is not installed', async () => {
+    // Act & Assert
     await expect(AppsService.uninstallApp('any')).rejects.toThrowError('App any not found');
     await expect(AppsService.uninstallApp('any')).rejects.toThrowError('App any not found');
   });
   });
 
 
   it('Should throw if uninstall script fails', async () => {
   it('Should throw if uninstall script fails', async () => {
-    // Update app
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
     await App.update({ id: app1.id }, { status: AppStatusEnum.UPDATING });
     await App.update({ id: app1.id }, { status: AppStatusEnum.UPDATING });
 
 
-    const spy = jest.spyOn(childProcess, 'execFile');
-    spy.mockImplementation(() => {
-      throw new Error('Test error');
-    });
-
-    await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow('Test error');
+    // Act & Assert
+    await expect(AppsService.uninstallApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to uninstall\nstdout: test`);
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
     expect(app!.status).toBe(AppStatusEnum.STOPPED);
     expect(app!.status).toBe(AppStatusEnum.STOPPED);
   });
   });
@@ -240,12 +225,12 @@ describe('Start app', () => {
     fs.__createMockFiles(Object.assign(app1create.MockFiles));
     fs.__createMockFiles(Object.assign(app1create.MockFiles));
   });
   });
 
 
-  it('Should correctly run app script', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+  it('Should correctly dispatch event', async () => {
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
 
 
     await AppsService.startApp(app1.id);
     await AppsService.startApp(app1.id);
 
 
-    expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
+    expect(spy.mock.lastCall).toEqual([EventTypes.APP, ['start', app1.id]]);
 
 
     spy.mockRestore();
     spy.mockRestore();
   });
   });
@@ -255,7 +240,7 @@ describe('Start app', () => {
   });
   });
 
 
   it('Should restart if app is already running', async () => {
   it('Should restart if app is already running', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
 
 
     await AppsService.startApp(app1.id);
     await AppsService.startApp(app1.id);
     expect(spy.mock.calls.length).toBe(1);
     expect(spy.mock.calls.length).toBe(1);
@@ -266,22 +251,21 @@ describe('Start app', () => {
   });
   });
 
 
   it('Regenerate env file', async () => {
   it('Regenerate env file', async () => {
-    fs.writeFile(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
+    fs.writeFile(`/app/storage/app-data/${app1.id}/app.env`, 'TEST=test\nAPP_PORT=3000', () => {});
 
 
     await AppsService.startApp(app1.id);
     await AppsService.startApp(app1.id);
 
 
-    const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`).toString();
+    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=192.168.1.10:${app1.port}`);
     expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
   });
   });
 
 
   it('Should throw if start script fails', async () => {
   it('Should throw if start script fails', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    spy.mockImplementation(() => {
-      throw new Error('Test error');
-    });
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
 
 
-    await expect(AppsService.startApp(app1.id)).rejects.toThrow('Test error');
+    // Act & Assert
+    await expect(AppsService.startApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to start\nstdout: test`);
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
     expect(app!.status).toBe(AppStatusEnum.STOPPED);
     expect(app!.status).toBe(AppStatusEnum.STOPPED);
   });
   });
@@ -297,12 +281,12 @@ describe('Stop app', () => {
     fs.__createMockFiles(Object.assign(app1create.MockFiles));
     fs.__createMockFiles(Object.assign(app1create.MockFiles));
   });
   });
 
 
-  it('Should correctly run app script', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+  it('Should correctly dispatch stop event', async () => {
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
 
 
     await AppsService.stopApp(app1.id);
     await AppsService.stopApp(app1.id);
 
 
-    expect(spy.mock.lastCall).toEqual([`${config.ROOT_FOLDER}/scripts/app.sh`, ['stop', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)]);
+    expect(spy.mock.lastCall).toEqual([EventTypes.APP, ['stop', app1.id]]);
   });
   });
 
 
   it('Should throw if app is not installed', async () => {
   it('Should throw if app is not installed', async () => {
@@ -310,12 +294,11 @@ describe('Stop app', () => {
   });
   });
 
 
   it('Should throw if stop script fails', async () => {
   it('Should throw if stop script fails', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    spy.mockImplementation(() => {
-      throw new Error('Test error');
-    });
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'test' });
 
 
-    await expect(AppsService.stopApp(app1.id)).rejects.toThrow('Test error');
+    // Act & Assert
+    await expect(AppsService.stopApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to stop\nstdout: test`);
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
     expect(app!.status).toBe(AppStatusEnum.RUNNING);
     expect(app!.status).toBe(AppStatusEnum.RUNNING);
   });
   });
@@ -334,7 +317,7 @@ describe('Update app config', () => {
   it('Should correctly update app config', async () => {
   it('Should correctly update app config', async () => {
     await AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' });
     await AppsService.updateAppConfig(app1.id, { TEST_FIELD: 'test' });
 
 
-    const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${app1.id}/app.env`).toString();
+    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=192.168.1.10:${app1.port}`);
     expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${app1.port}\nTEST_FIELD=test\nAPP_DOMAIN=192.168.1.10:${app1.port}`);
   });
   });
@@ -352,8 +335,8 @@ describe('Update app config', () => {
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(MockFiles);
     fs.__createMockFiles(MockFiles);
 
 
-    const envFile = fs.readFileSync(`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`).toString();
-    fs.writeFileSync(`${config.ROOT_FOLDER}/app-data/${appInfo.id}/app.env`, `${envFile}\nRANDOM_FIELD=test`);
+    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' });
     await AppsService.updateAppConfig(appInfo.id, { TEST_FIELD: 'test' });
 
 
@@ -464,19 +447,19 @@ describe('Start all apps', () => {
   });
   });
 
 
   it('Should correctly start all apps', async () => {
   it('Should correctly start all apps', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
 
 
     await AppsService.startAllApps();
     await AppsService.startAllApps();
 
 
     expect(spy.mock.calls.length).toBe(2);
     expect(spy.mock.calls.length).toBe(2);
     expect(spy.mock.calls).toEqual([
     expect(spy.mock.calls).toEqual([
-      [`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', app1.id, '/tipi', 'repo-id'], {}, expect.any(Function)],
-      [`${config.ROOT_FOLDER}/scripts/app.sh`, ['start', app2.id, '/tipi', 'repo-id'], {}, expect.any(Function)],
+      [EventTypes.APP, ['start', app1.id]],
+      [EventTypes.APP, ['start', app2.id]],
     ]);
     ]);
   });
   });
 
 
   it('Should not start app which has not status RUNNING', async () => {
   it('Should not start app which has not status RUNNING', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
+    const spy = jest.spyOn(eventDispatcher, 'dispatchEventAsync');
     await createApp({ installed: true, status: AppStatusEnum.STOPPED });
     await createApp({ installed: true, status: AppStatusEnum.STOPPED });
 
 
     await AppsService.startAllApps();
     await AppsService.startAllApps();
@@ -487,16 +470,14 @@ describe('Start all apps', () => {
   });
   });
 
 
   it('Should put app status to STOPPED if start script fails', async () => {
   it('Should put app status to STOPPED if start script fails', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    spy.mockImplementation(() => {
-      throw new Error('Test error');
-    });
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
 
 
+    // Act
     await AppsService.startAllApps();
     await AppsService.startAllApps();
-
     const apps = await App.find();
     const apps = await App.find();
 
 
-    expect(spy.mock.calls.length).toBe(2);
+    // Assert
     expect(apps.length).toBe(2);
     expect(apps.length).toBe(2);
     expect(apps[0].status).toBe(AppStatusEnum.STOPPED);
     expect(apps[0].status).toBe(AppStatusEnum.STOPPED);
     expect(apps[1].status).toBe(AppStatusEnum.STOPPED);
     expect(apps[1].status).toBe(AppStatusEnum.STOPPED);
@@ -529,12 +510,10 @@ describe('Update app', () => {
   });
   });
 
 
   it('Should throw if update script fails', async () => {
   it('Should throw if update script fails', async () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    spy.mockImplementation(() => {
-      throw new Error('Test error');
-    });
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'error' });
 
 
-    await expect(AppsService.updateApp(app1.id)).rejects.toThrow('Test error');
+    await expect(AppsService.updateApp(app1.id)).rejects.toThrow(`App ${app1.id} failed to update\nstdout: error`);
     const app = await App.findOne({ where: { id: app1.id } });
     const app = await App.findOne({ where: { id: app1.id } });
     expect(app!.status).toBe(AppStatusEnum.STOPPED);
     expect(app!.status).toBe(AppStatusEnum.STOPPED);
   });
   });

+ 28 - 37
packages/system-api/src/modules/apps/apps.helpers.ts

@@ -1,16 +1,17 @@
 import portUsed from 'tcp-port-used';
 import portUsed from 'tcp-port-used';
-import { fileExists, getSeed, readdirSync, readFile, readJsonFile, runScript, writeFile } from '../fs/fs.helpers';
+import { fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../fs/fs.helpers';
 import InternalIp from 'internal-ip';
 import InternalIp from 'internal-ip';
 import crypto from 'crypto';
 import crypto from 'crypto';
-import config from '../../config';
 import { AppInfo, AppStatusEnum } from './apps.types';
 import { AppInfo, AppStatusEnum } from './apps.types';
 import logger from '../../config/logger/logger';
 import logger from '../../config/logger/logger';
 import App from './app.entity';
 import App from './app.entity';
+import { getConfig } from '../../core/config/TipiConfig';
+import fs from 'fs-extra';
 
 
 export const checkAppRequirements = async (appName: string) => {
 export const checkAppRequirements = async (appName: string) => {
   let valid = true;
   let valid = true;
 
 
-  const configFile: AppInfo | null = readJsonFile(`/repos/${config.APPS_REPO_ID}/apps/${appName}/config.json`);
+  const configFile: AppInfo | null = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}/config.json`);
 
 
   if (!configFile) {
   if (!configFile) {
     throw new Error(`App ${appName} not found`);
     throw new Error(`App ${appName} not found`);
@@ -29,7 +30,7 @@ export const checkAppRequirements = async (appName: string) => {
 };
 };
 
 
 export const getEnvMap = (appName: string): Map<string, string> => {
 export const getEnvMap = (appName: string): Map<string, string> => {
-  const envFile = readFile(`/app-data/${appName}/app.env`).toString();
+  const envFile = readFile(`/app/storage/app-data/${appName}/app.env`).toString();
   const envVars = envFile.split('\n');
   const envVars = envFile.split('\n');
   const envVarsMap = new Map<string, string>();
   const envVarsMap = new Map<string, string>();
 
 
@@ -42,7 +43,7 @@ export const getEnvMap = (appName: string): Map<string, string> => {
 };
 };
 
 
 export const checkEnvFile = (appName: string) => {
 export const checkEnvFile = (appName: string) => {
-  const configFile: AppInfo | null = readJsonFile(`/apps/${appName}/config.json`);
+  const configFile: AppInfo | null = readJsonFile(`/runtipi/apps/${appName}/config.json`);
   const envMap = getEnvMap(appName);
   const envMap = getEnvMap(appName);
 
 
   configFile?.form_fields?.forEach((field) => {
   configFile?.form_fields?.forEach((field) => {
@@ -55,19 +56,6 @@ export const checkEnvFile = (appName: string) => {
   });
   });
 };
 };
 
 
-export const runAppScript = async (params: string[]): Promise<void> => {
-  return new Promise((resolve, reject) => {
-    runScript('/scripts/app.sh', [...params, config.ROOT_FOLDER_HOST, config.APPS_REPO_ID], (err: string) => {
-      if (err) {
-        logger.error(err);
-        reject(err);
-      }
-
-      resolve();
-    });
-  });
-};
-
 const getEntropy = (name: string, length: number) => {
 const getEntropy = (name: string, length: number) => {
   const hash = crypto.createHash('sha256');
   const hash = crypto.createHash('sha256');
   hash.update(name + getSeed());
   hash.update(name + getSeed());
@@ -75,13 +63,13 @@ const getEntropy = (name: string, length: number) => {
 };
 };
 
 
 export const generateEnvFile = (app: App) => {
 export const generateEnvFile = (app: App) => {
-  const configFile: AppInfo | null = readJsonFile(`/apps/${app.id}/config.json`);
+  const configFile: AppInfo | null = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
 
 
   if (!configFile) {
   if (!configFile) {
     throw new Error(`App ${app.id} not found`);
     throw new Error(`App ${app.id} not found`);
   }
   }
 
 
-  const baseEnvFile = readFile('/.env').toString();
+  const baseEnvFile = readFile('/runtipi/.env').toString();
   let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
   let envFile = `${baseEnvFile}\nAPP_PORT=${configFile.port}\n`;
   const envMap = getEnvMap(app.id);
   const envMap = getEnvMap(app.id);
 
 
@@ -110,20 +98,25 @@ export const generateEnvFile = (app: App) => {
     envFile += `APP_DOMAIN=${app.domain}\n`;
     envFile += `APP_DOMAIN=${app.domain}\n`;
     envFile += 'APP_PROTOCOL=https\n';
     envFile += 'APP_PROTOCOL=https\n';
   } else {
   } else {
-    envFile += `APP_DOMAIN=${config.INTERNAL_IP}:${configFile.port}\n`;
+    envFile += `APP_DOMAIN=${getConfig().internalIp}:${configFile.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-data/${app.id}/app.env`, envFile);
+  writeFile(`/app/storage/app-data/${app.id}/app.env`, envFile);
 };
 };
 
 
 export const getAvailableApps = async (): Promise<string[]> => {
 export const getAvailableApps = async (): Promise<string[]> => {
   const apps: string[] = [];
   const apps: string[] = [];
 
 
-  const appsDir = readdirSync(`/repos/${config.APPS_REPO_ID}/apps`);
+  const appsDir = readdirSync(`/runtipi/repos/${getConfig().appsRepoId}/apps`);
 
 
   appsDir.forEach((app) => {
   appsDir.forEach((app) => {
-    if (fileExists(`/repos/${config.APPS_REPO_ID}/apps/${app}/config.json`)) {
-      const configFile: AppInfo = readJsonFile(`/repos/${config.APPS_REPO_ID}/apps/${app}/config.json`);
+    if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`)) {
+      const configFile: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
 
 
       if (configFile.available) {
       if (configFile.available) {
         apps.push(app);
         apps.push(app);
@@ -136,18 +129,16 @@ export const getAvailableApps = async (): Promise<string[]> => {
 
 
 export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null => {
 export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null => {
   try {
   try {
-    const repoId = config.APPS_REPO_ID;
-
     // Check if app is installed
     // Check if app is installed
     const installed = typeof status !== 'undefined' && status !== AppStatusEnum.MISSING;
     const installed = typeof status !== 'undefined' && status !== AppStatusEnum.MISSING;
 
 
-    if (installed && fileExists(`/apps/${id}/config.json`)) {
-      const configFile: AppInfo = readJsonFile(`/apps/${id}/config.json`);
-      configFile.description = readFile(`/apps/${id}/metadata/description.md`).toString();
+    if (installed && fileExists(`/runtipi/apps/${id}/config.json`)) {
+      const configFile: AppInfo = readJsonFile(`/runtipi/apps/${id}/config.json`);
+      configFile.description = readFile(`/runtipi/apps/${id}/metadata/description.md`).toString();
       return configFile;
       return configFile;
-    } else if (fileExists(`/repos/${repoId}/apps/${id}/config.json`)) {
-      const configFile: AppInfo = readJsonFile(`/repos/${repoId}/apps/${id}/config.json`);
-      configFile.description = readFile(`/repos/${repoId}/apps/${id}/metadata/description.md`);
+    } else if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
+      const configFile: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
+      configFile.description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/metadata/description.md`);
 
 
       if (configFile.available) {
       if (configFile.available) {
         return configFile;
         return configFile;
@@ -156,21 +147,21 @@ export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null =
 
 
     return null;
     return null;
   } catch (e) {
   } catch (e) {
-    console.error(e);
-    throw new Error(`Error loading app ${id}`);
+    logger.error(`Error loading app: ${id}`);
+    throw new Error(`Error loading app: ${id}`);
   }
   }
 };
 };
 
 
 export const getUpdateInfo = async (id: string) => {
 export const getUpdateInfo = async (id: string) => {
   const app = await App.findOne({ where: { id } });
   const app = await App.findOne({ where: { id } });
 
 
-  const doesFileExist = fileExists(`/repos/${config.APPS_REPO_ID}/apps/${id}`);
+  const doesFileExist = fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}`);
 
 
   if (!app || !doesFileExist) {
   if (!app || !doesFileExist) {
     return null;
     return null;
   }
   }
 
 
-  const repoConfig: AppInfo = readJsonFile(`/repos/${config.APPS_REPO_ID}/apps/${id}/config.json`);
+  const repoConfig: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
 
 
   return {
   return {
     current: app.version,
     current: app.version,

+ 92 - 37
packages/system-api/src/modules/apps/apps.service.ts

@@ -1,14 +1,18 @@
 import validator from 'validator';
 import validator from 'validator';
 import { createFolder, ensureAppFolder, readFile, readJsonFile } from '../fs/fs.helpers';
 import { createFolder, ensureAppFolder, readFile, readJsonFile } from '../fs/fs.helpers';
-import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, runAppScript } from './apps.helpers';
+import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps } from './apps.helpers';
 import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
 import { AppInfo, AppStatusEnum, ListAppsResonse } from './apps.types';
 import App from './app.entity';
 import App from './app.entity';
 import logger from '../../config/logger/logger';
 import logger from '../../config/logger/logger';
-import config from '../../config';
 import { Not } from 'typeorm';
 import { Not } from 'typeorm';
+import { getConfig } from '../../core/config/TipiConfig';
+import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
 
 
 const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
 const sortApps = (a: AppInfo, b: AppInfo) => a.name.localeCompare(b.name);
 
 
+/**
+ * Start all apps which had the status RUNNING in the database
+ */
 const startAllApps = async (): Promise<void> => {
 const startAllApps = async (): Promise<void> => {
   const apps = await App.find({ where: { status: AppStatusEnum.RUNNING } });
   const apps = await App.find({ where: { status: AppStatusEnum.RUNNING } });
 
 
@@ -22,8 +26,13 @@ const startAllApps = async (): Promise<void> => {
 
 
         await App.update({ id: app.id }, { status: AppStatusEnum.STARTING });
         await App.update({ id: app.id }, { status: AppStatusEnum.STARTING });
 
 
-        await runAppScript(['start', app.id]);
-        await App.update({ id: app.id }, { status: AppStatusEnum.RUNNING });
+        eventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]).then(({ success }) => {
+          if (success) {
+            App.update({ id: app.id }, { status: AppStatusEnum.RUNNING });
+          } else {
+            App.update({ id: app.id }, { status: AppStatusEnum.STOPPED });
+          }
+        });
       } catch (e) {
       } catch (e) {
         await App.update({ id: app.id }, { status: AppStatusEnum.STOPPED });
         await App.update({ id: app.id }, { status: AppStatusEnum.STOPPED });
         logger.error(e);
         logger.error(e);
@@ -32,6 +41,11 @@ const startAllApps = async (): Promise<void> => {
   );
   );
 };
 };
 
 
+/**
+ * Start an app
+ * @param appName - id of the app to start
+ * @returns - the app entity
+ */
 const startApp = async (appName: string): Promise<App> => {
 const startApp = async (appName: string): Promise<App> => {
   let app = await App.findOne({ where: { id: appName } });
   let app = await App.findOne({ where: { id: appName } });
 
 
@@ -40,20 +54,18 @@ const startApp = async (appName: string): Promise<App> => {
   }
   }
 
 
   ensureAppFolder(appName);
   ensureAppFolder(appName);
-
   // Regenerate env file
   // Regenerate env file
   generateEnvFile(app);
   generateEnvFile(app);
-
   checkEnvFile(appName);
   checkEnvFile(appName);
 
 
   await App.update({ id: appName }, { status: AppStatusEnum.STARTING });
   await App.update({ id: appName }, { status: AppStatusEnum.STARTING });
-  // Run script
-  try {
-    await runAppScript(['start', appName]);
+  const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['start', app.id]);
+
+  if (success) {
     await App.update({ id: appName }, { status: AppStatusEnum.RUNNING });
     await App.update({ id: appName }, { status: AppStatusEnum.RUNNING });
-  } catch (e) {
+  } else {
     await App.update({ id: appName }, { status: AppStatusEnum.STOPPED });
     await App.update({ id: appName }, { status: AppStatusEnum.STOPPED });
-    throw e;
+    throw new Error(`App ${appName} failed to start\nstdout: ${stdout}`);
   }
   }
 
 
   app = (await App.findOne({ where: { id: appName } })) as App;
   app = (await App.findOne({ where: { id: appName } })) as App;
@@ -61,6 +73,14 @@ const startApp = async (appName: string): Promise<App> => {
   return app;
   return app;
 };
 };
 
 
+/**
+ * Given parameters, create a new app and start it
+ * @param id - id of the app to stop
+ * @param form - form data
+ * @param exposed - if the app should be exposed
+ * @param domain - domain to expose the app on
+ * @returns - the app entity
+ */
 const installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
 const installApp = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
   let app = await App.findOne({ where: { id } });
   let app = await App.findOne({ where: { id } });
 
 
@@ -83,9 +103,9 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
     }
     }
 
 
     // Create app folder
     // Create app folder
-    createFolder(`/app-data/${id}`);
+    createFolder(`/app/storage/app-data/${id}`);
 
 
-    const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
+    const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
 
 
     if (!appInfo?.exposable && exposed) {
     if (!appInfo?.exposable && exposed) {
       throw new Error(`App ${id} is not exposable`);
       throw new Error(`App ${id} is not exposable`);
@@ -104,11 +124,11 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
     generateEnvFile(app);
     generateEnvFile(app);
 
 
     // Run script
     // Run script
-    try {
-      await runAppScript(['install', id]);
-    } catch (e) {
+    const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['install', id]);
+
+    if (!success) {
       await App.delete({ id });
       await App.delete({ id });
-      throw e;
+      throw new Error(`App ${id} failed to install\nstdout: ${stdout}`);
     }
     }
   }
   }
 
 
@@ -118,13 +138,17 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
   return app;
   return app;
 };
 };
 
 
+/**
+ * List all apps available for installation
+ * @returns - list of all apps available
+ */
 const listApps = async (): Promise<ListAppsResonse> => {
 const listApps = async (): Promise<ListAppsResonse> => {
   const folders: string[] = await getAvailableApps();
   const folders: string[] = await getAvailableApps();
 
 
   const apps: AppInfo[] = folders
   const apps: AppInfo[] = folders
     .map((app) => {
     .map((app) => {
       try {
       try {
-        return readJsonFile(`/repos/${config.APPS_REPO_ID}/apps/${app}/config.json`);
+        return readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app}/config.json`);
       } catch (e) {
       } catch (e) {
         return null;
         return null;
       }
       }
@@ -132,12 +156,20 @@ const listApps = async (): Promise<ListAppsResonse> => {
     .filter(Boolean);
     .filter(Boolean);
 
 
   apps.forEach((app) => {
   apps.forEach((app) => {
-    app.description = readFile(`/repos/${config.APPS_REPO_ID}/apps/${app.id}/metadata/description.md`);
+    app.description = readFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${app.id}/metadata/description.md`);
   });
   });
 
 
   return { apps: apps.sort(sortApps), total: apps.length };
   return { apps: apps.sort(sortApps), total: apps.length };
 };
 };
 
 
+/**
+ * Given parameters, updates an app config and regenerates the env file
+ * @param id - id of the app to stop
+ * @param form - form data
+ * @param exposed - if the app should be exposed
+ * @param domain - domain to expose the app on
+ * @returns - the app entity
+ */
 const updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
 const updateAppConfig = async (id: string, form: Record<string, string>, exposed?: boolean, domain?: string): Promise<App> => {
   if (exposed && !domain) {
   if (exposed && !domain) {
     throw new Error('Domain is required if app is exposed');
     throw new Error('Domain is required if app is exposed');
@@ -147,7 +179,7 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
     throw new Error(`Domain ${domain} is not valid`);
     throw new Error(`Domain ${domain} is not valid`);
   }
   }
 
 
-  const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
+  const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
 
 
   if (!appInfo?.exposable && exposed) {
   if (!appInfo?.exposable && exposed) {
     throw new Error(`App ${id} is not exposable`);
     throw new Error(`App ${id} is not exposable`);
@@ -175,6 +207,11 @@ const updateAppConfig = async (id: string, form: Record<string, string>, exposed
   return app;
   return app;
 };
 };
 
 
+/**
+ * Stops an app
+ * @param id - id of the app to stop
+ * @returns - the app entity
+ */
 const stopApp = async (id: string): Promise<App> => {
 const stopApp = async (id: string): Promise<App> => {
   let app = await App.findOne({ where: { id } });
   let app = await App.findOne({ where: { id } });
 
 
@@ -183,16 +220,18 @@ const stopApp = async (id: string): Promise<App> => {
   }
   }
 
 
   ensureAppFolder(id);
   ensureAppFolder(id);
+  generateEnvFile(app);
 
 
   // Run script
   // Run script
   await App.update({ id }, { status: AppStatusEnum.STOPPING });
   await App.update({ id }, { status: AppStatusEnum.STOPPING });
 
 
-  try {
-    await runAppScript(['stop', id]);
+  const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['stop', id]);
+
+  if (success) {
     await App.update({ id }, { status: AppStatusEnum.STOPPED });
     await App.update({ id }, { status: AppStatusEnum.STOPPED });
-  } catch (e) {
+  } else {
     await App.update({ id }, { status: AppStatusEnum.RUNNING });
     await App.update({ id }, { status: AppStatusEnum.RUNNING });
-    throw e;
+    throw new Error(`App ${id} failed to stop\nstdout: ${stdout}`);
   }
   }
 
 
   app = (await App.findOne({ where: { id } })) as App;
   app = (await App.findOne({ where: { id } })) as App;
@@ -200,6 +239,11 @@ const stopApp = async (id: string): Promise<App> => {
   return app;
   return app;
 };
 };
 
 
+/**
+ * Uninstalls an app
+ * @param id - id of the app to uninstall
+ * @returns - the app entity
+ */
 const uninstallApp = async (id: string): Promise<App> => {
 const uninstallApp = async (id: string): Promise<App> => {
   let app = await App.findOne({ where: { id } });
   let app = await App.findOne({ where: { id } });
 
 
@@ -211,14 +255,15 @@ const uninstallApp = async (id: string): Promise<App> => {
   }
   }
 
 
   ensureAppFolder(id);
   ensureAppFolder(id);
+  generateEnvFile(app);
 
 
   await App.update({ id }, { status: AppStatusEnum.UNINSTALLING });
   await App.update({ id }, { status: AppStatusEnum.UNINSTALLING });
-  // Run script
-  try {
-    await runAppScript(['uninstall', id]);
-  } catch (e) {
+
+  const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['uninstall', id]);
+
+  if (!success) {
     await App.update({ id }, { status: AppStatusEnum.STOPPED });
     await App.update({ id }, { status: AppStatusEnum.STOPPED });
-    throw e;
+    throw new Error(`App ${id} failed to uninstall\nstdout: ${stdout}`);
   }
   }
 
 
   await App.delete({ id });
   await App.delete({ id });
@@ -226,6 +271,11 @@ const uninstallApp = async (id: string): Promise<App> => {
   return { id, status: AppStatusEnum.MISSING, config: {} } as App;
   return { id, status: AppStatusEnum.MISSING, config: {} } as App;
 };
 };
 
 
+/**
+ * Get an app entity
+ * @param id - id of the app
+ * @returns - the app entity
+ */
 const getApp = async (id: string): Promise<App> => {
 const getApp = async (id: string): Promise<App> => {
   let app = await App.findOne({ where: { id } });
   let app = await App.findOne({ where: { id } });
 
 
@@ -236,6 +286,11 @@ const getApp = async (id: string): Promise<App> => {
   return app;
   return app;
 };
 };
 
 
+/**
+ * Updates an app to the latest version from repository
+ * @param id - id of the app
+ * @returns - the app entity
+ */
 const updateApp = async (id: string) => {
 const updateApp = async (id: string) => {
   let app = await App.findOne({ where: { id } });
   let app = await App.findOne({ where: { id } });
 
 
@@ -244,21 +299,21 @@ const updateApp = async (id: string) => {
   }
   }
 
 
   ensureAppFolder(id);
   ensureAppFolder(id);
+  generateEnvFile(app);
 
 
   await App.update({ id }, { status: AppStatusEnum.UPDATING });
   await App.update({ id }, { status: AppStatusEnum.UPDATING });
 
 
-  // Run script
-  try {
-    await runAppScript(['update', id]);
-    const appInfo: AppInfo | null = await readJsonFile(`/apps/${id}/config.json`);
+  const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.APP, ['update', id]);
+
+  if (success) {
+    const appInfo: AppInfo | null = await readJsonFile(`/runtipi/apps/${id}/config.json`);
     await App.update({ id }, { status: AppStatusEnum.RUNNING, version: Number(appInfo?.tipi_version) });
     await App.update({ id }, { status: AppStatusEnum.RUNNING, version: Number(appInfo?.tipi_version) });
-  } catch (e) {
-    logger.error(e);
-    throw e;
-  } finally {
+  } else {
     await App.update({ id }, { status: AppStatusEnum.STOPPED });
     await App.update({ id }, { status: AppStatusEnum.STOPPED });
+    throw new Error(`App ${id} failed to update\nstdout: ${stdout}`);
   }
   }
 
 
+  await App.update({ id }, { status: AppStatusEnum.STOPPED });
   app = (await App.findOne({ where: { id } })) as App;
   app = (await App.findOne({ where: { id } })) as App;
 
 
   return app;
   return app;

+ 61 - 43
packages/system-api/src/modules/fs/__tests__/fs.helpers.test.ts

@@ -1,7 +1,7 @@
-import childProcess from 'child_process';
-import config from '../../../config';
-import { getAbsolutePath, readJsonFile, readFile, readdirSync, fileExists, writeFile, createFolder, deleteFolder, runScript, getSeed, ensureAppFolder } from '../fs.helpers';
+import { readJsonFile, readFile, readdirSync, fileExists, writeFile, createFolder, deleteFolder, getSeed, ensureAppFolder } from '../fs.helpers';
 import fs from 'fs-extra';
 import fs from 'fs-extra';
+import { getConfig } from '../../../core/config/TipiConfig';
+import { faker } from '@faker-js/faker';
 
 
 jest.mock('fs-extra');
 jest.mock('fs-extra');
 
 
@@ -10,24 +10,18 @@ beforeEach(() => {
   fs.__resetAllMocks();
   fs.__resetAllMocks();
 });
 });
 
 
-describe('Test: getAbsolutePath', () => {
-  it('should return the absolute path', () => {
-    expect(getAbsolutePath('/test')).toBe(`${config.ROOT_FOLDER}/test`);
-  });
-});
-
 describe('Test: readJsonFile', () => {
 describe('Test: readJsonFile', () => {
   it('should return the json file', () => {
   it('should return the json file', () => {
     // Arrange
     // Arrange
     const rawFile = '{"test": "test"}';
     const rawFile = '{"test": "test"}';
     const mockFiles = {
     const mockFiles = {
-      [`${config.ROOT_FOLDER}/test-file.json`]: rawFile,
+      ['/runtipi/test-file.json']: rawFile,
     };
     };
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(mockFiles);
     fs.__createMockFiles(mockFiles);
 
 
     // Act
     // Act
-    const file = readJsonFile('/test-file.json');
+    const file = readJsonFile('/runtipi/test-file.json');
 
 
     // Assert
     // Assert
     expect(file).toEqual({ test: 'test' });
     expect(file).toEqual({ test: 'test' });
@@ -36,19 +30,35 @@ describe('Test: readJsonFile', () => {
   it('should return null if the file does not exist', () => {
   it('should return null if the file does not exist', () => {
     expect(readJsonFile('/test')).toBeNull();
     expect(readJsonFile('/test')).toBeNull();
   });
   });
+
+  it('Should return null if fs.readFile throws an error', () => {
+    // Arrange
+    // @ts-ignore
+    const spy = jest.spyOn(fs, 'readFileSync');
+    spy.mockImplementation(() => {
+      throw new Error('Error');
+    });
+
+    // Act
+    const file = readJsonFile('/test');
+
+    // Assert
+    expect(file).toBeNull();
+    spy.mockRestore();
+  });
 });
 });
 
 
 describe('Test: readFile', () => {
 describe('Test: readFile', () => {
   it('should return the file', () => {
   it('should return the file', () => {
     const rawFile = 'test';
     const rawFile = 'test';
     const mockFiles = {
     const mockFiles = {
-      [`${config.ROOT_FOLDER}/test-file.txt`]: rawFile,
+      ['/runtipi/test-file.txt']: rawFile,
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(mockFiles);
     fs.__createMockFiles(mockFiles);
 
 
-    expect(readFile('/test-file.txt')).toEqual('test');
+    expect(readFile('/runtipi/test-file.txt')).toEqual('test');
   });
   });
 
 
   it('should return empty string if the file does not exist', () => {
   it('should return empty string if the file does not exist', () => {
@@ -59,13 +69,13 @@ describe('Test: readFile', () => {
 describe('Test: readdirSync', () => {
 describe('Test: readdirSync', () => {
   it('should return the files', () => {
   it('should return the files', () => {
     const mockFiles = {
     const mockFiles = {
-      [`${config.ROOT_FOLDER}/test/test-file.txt`]: 'test',
+      ['/runtipi/test/test-file.txt']: 'test',
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(mockFiles);
     fs.__createMockFiles(mockFiles);
 
 
-    expect(readdirSync('/test')).toEqual(['test-file.txt']);
+    expect(readdirSync('/runtipi/test')).toEqual(['test-file.txt']);
   });
   });
 
 
   it('should return empty array if the directory does not exist', () => {
   it('should return empty array if the directory does not exist', () => {
@@ -76,13 +86,13 @@ describe('Test: readdirSync', () => {
 describe('Test: fileExists', () => {
 describe('Test: fileExists', () => {
   it('should return true if the file exists', () => {
   it('should return true if the file exists', () => {
     const mockFiles = {
     const mockFiles = {
-      [`${config.ROOT_FOLDER}/test-file.txt`]: 'test',
+      ['/runtipi/test-file.txt']: 'test',
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(mockFiles);
     fs.__createMockFiles(mockFiles);
 
 
-    expect(fileExists('/test-file.txt')).toBeTruthy();
+    expect(fileExists('/runtipi/test-file.txt')).toBeTruthy();
   });
   });
 
 
   it('should return false if the file does not exist', () => {
   it('should return false if the file does not exist', () => {
@@ -94,9 +104,9 @@ describe('Test: writeFile', () => {
   it('should write the file', () => {
   it('should write the file', () => {
     const spy = jest.spyOn(fs, 'writeFileSync');
     const spy = jest.spyOn(fs, 'writeFileSync');
 
 
-    writeFile('/test-file.txt', 'test');
+    writeFile('/runtipi/test-file.txt', 'test');
 
 
-    expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test-file.txt`, 'test');
+    expect(spy).toHaveBeenCalledWith('/runtipi/test-file.txt', 'test');
   });
   });
 });
 });
 
 
@@ -106,7 +116,7 @@ describe('Test: createFolder', () => {
 
 
     createFolder('/test');
     createFolder('/test');
 
 
-    expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test`);
+    expect(spy).toHaveBeenCalledWith('/test', { recursive: true });
   });
   });
 });
 });
 
 
@@ -116,25 +126,14 @@ describe('Test: deleteFolder', () => {
 
 
     deleteFolder('/test');
     deleteFolder('/test');
 
 
-    expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test`, { recursive: true });
-  });
-});
-
-describe('Test: runScript', () => {
-  it('should run the script', () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    const callback = jest.fn();
-
-    runScript('/test', [], callback);
-
-    expect(spy).toHaveBeenCalledWith(`${config.ROOT_FOLDER}/test`, [], {}, callback);
+    expect(spy).toHaveBeenCalledWith('/test', { recursive: true });
   });
   });
 });
 });
 
 
 describe('Test: getSeed', () => {
 describe('Test: getSeed', () => {
   it('should return the seed', () => {
   it('should return the seed', () => {
     const mockFiles = {
     const mockFiles = {
-      [`${config.ROOT_FOLDER}/state/seed`]: 'test',
+      ['/runtipi/state/seed']: 'test',
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
@@ -147,7 +146,7 @@ describe('Test: getSeed', () => {
 describe('Test: ensureAppFolder', () => {
 describe('Test: ensureAppFolder', () => {
   beforeEach(() => {
   beforeEach(() => {
     const mockFiles = {
     const mockFiles = {
-      [`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}/apps/test`]: ['test.yml'],
+      [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
     };
     };
     // @ts-ignore
     // @ts-ignore
     fs.__createMockFiles(mockFiles);
     fs.__createMockFiles(mockFiles);
@@ -158,15 +157,15 @@ describe('Test: ensureAppFolder', () => {
     ensureAppFolder('test');
     ensureAppFolder('test');
 
 
     // Assert
     // Assert
-    const files = fs.readdirSync(`${config.ROOT_FOLDER}/apps/test`);
+    const files = fs.readdirSync('/runtipi/apps/test');
     expect(files).toEqual(['test.yml']);
     expect(files).toEqual(['test.yml']);
   });
   });
 
 
   it('should not copy the folder if it already exists', () => {
   it('should not copy the folder if it already exists', () => {
     const mockFiles = {
     const mockFiles = {
-      [`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}/apps/test`]: ['test.yml'],
-      [`${config.ROOT_FOLDER}/apps/test`]: ['docker-compose.yml'],
-      [`${config.ROOT_FOLDER}/apps/test/docker-compose.yml`]: 'test',
+      [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
+      ['/runtipi/apps/test']: ['docker-compose.yml'],
+      ['/runtipi/apps/test/docker-compose.yml']: 'test',
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
@@ -176,15 +175,15 @@ describe('Test: ensureAppFolder', () => {
     ensureAppFolder('test');
     ensureAppFolder('test');
 
 
     // Assert
     // Assert
-    const files = fs.readdirSync(`${config.ROOT_FOLDER}/apps/test`);
+    const files = fs.readdirSync('/runtipi/apps/test');
     expect(files).toEqual(['docker-compose.yml']);
     expect(files).toEqual(['docker-compose.yml']);
   });
   });
 
 
   it('Should overwrite the folder if clean up is true', () => {
   it('Should overwrite the folder if clean up is true', () => {
     const mockFiles = {
     const mockFiles = {
-      [`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}/apps/test`]: ['test.yml'],
-      [`${config.ROOT_FOLDER}/apps/test`]: ['docker-compose.yml'],
-      [`${config.ROOT_FOLDER}/apps/test/docker-compose.yml`]: 'test',
+      [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
+      ['/runtipi/apps/test']: ['docker-compose.yml'],
+      ['/runtipi/apps/test/docker-compose.yml']: 'test',
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
@@ -194,7 +193,26 @@ describe('Test: ensureAppFolder', () => {
     ensureAppFolder('test', true);
     ensureAppFolder('test', true);
 
 
     // Assert
     // Assert
-    const files = fs.readdirSync(`${config.ROOT_FOLDER}/apps/test`);
+    const files = fs.readdirSync('/runtipi/apps/test');
     expect(files).toEqual(['test.yml']);
     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/${getConfig().appsRepoId}/apps/test`]: [randomFileName],
+      ['/runtipi/apps/test']: ['test.yml'],
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(mockFiles);
+
+    // Act
+    ensureAppFolder('test');
+
+    // Assert
+    const files = fs.readdirSync('/runtipi/apps/test');
+    expect(files).toEqual([randomFileName]);
+  });
 });
 });

+ 19 - 22
packages/system-api/src/modules/fs/fs.helpers.ts

@@ -1,55 +1,52 @@
 import fs from 'fs-extra';
 import fs from 'fs-extra';
-import childProcess from 'child_process';
-import config from '../../config';
-
-export const getAbsolutePath = (path: string) => `${config.ROOT_FOLDER}${path}`;
+import { getConfig } from '../../core/config/TipiConfig';
 
 
 export const readJsonFile = (path: string): any => {
 export const readJsonFile = (path: string): any => {
-  const rawFile = fs.readFileSync(getAbsolutePath(path))?.toString();
+  try {
+    const rawFile = fs.readFileSync(path).toString();
 
 
-  if (!rawFile) {
+    return JSON.parse(rawFile);
+  } catch (e) {
     return null;
     return null;
   }
   }
-
-  return JSON.parse(rawFile);
 };
 };
 
 
 export const readFile = (path: string): string => {
 export const readFile = (path: string): string => {
   try {
   try {
-    return fs.readFileSync(getAbsolutePath(path)).toString();
+    return fs.readFileSync(path).toString();
   } catch {
   } catch {
     return '';
     return '';
   }
   }
 };
 };
 
 
-export const readdirSync = (path: string): string[] => fs.readdirSync(getAbsolutePath(path));
+export const readdirSync = (path: string): string[] => fs.readdirSync(path);
 
 
-export const fileExists = (path: string): boolean => fs.existsSync(getAbsolutePath(path));
+export const fileExists = (path: string): boolean => fs.existsSync(path);
 
 
-export const writeFile = (path: string, data: any) => fs.writeFileSync(getAbsolutePath(path), data);
+export const writeFile = (path: string, data: any) => fs.writeFileSync(path, data);
 
 
 export const createFolder = (path: string) => {
 export const createFolder = (path: string) => {
   if (!fileExists(path)) {
   if (!fileExists(path)) {
-    fs.mkdirSync(getAbsolutePath(path));
+    fs.mkdirSync(path, { recursive: true });
   }
   }
 };
 };
-export const deleteFolder = (path: string) => fs.rmSync(getAbsolutePath(path), { recursive: true });
-
-export const runScript = (path: string, args: string[], callback?: any) => childProcess.execFile(getAbsolutePath(path), args, {}, callback);
+export const deleteFolder = (path: string) => fs.rmSync(path, { recursive: true });
 
 
 export const getSeed = () => {
 export const getSeed = () => {
-  const seed = readFile('/state/seed');
+  const seed = readFile('/runtipi/state/seed');
   return seed.toString();
   return seed.toString();
 };
 };
 
 
 export const ensureAppFolder = (appName: string, cleanup = false) => {
 export const ensureAppFolder = (appName: string, cleanup = false) => {
-  if (cleanup && fileExists(`/apps/${appName}`)) {
-    deleteFolder(`/apps/${appName}`);
+  if (cleanup && fileExists(`/runtipi/apps/${appName}`)) {
+    deleteFolder(`/runtipi/apps/${appName}`);
   }
   }
 
 
-  if (!fileExists(`/apps/${appName}/docker-compose.yml`)) {
-    if (fileExists(`/apps/${appName}`)) deleteFolder(`/apps/${appName}`);
+  if (!fileExists(`/runtipi/apps/${appName}/docker-compose.yml`)) {
+    if (fileExists(`/runtipi/apps/${appName}`)) {
+      deleteFolder(`/runtipi/apps/${appName}`);
+    }
     // Copy from apps repo
     // Copy from apps repo
-    fs.copySync(getAbsolutePath(`/repos/${config.APPS_REPO_ID}/apps/${appName}`), getAbsolutePath(`/apps/${appName}`));
+    fs.copySync(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}`, `/runtipi/apps/${appName}`);
   }
   }
 };
 };

+ 242 - 0
packages/system-api/src/modules/system/__tests__/system.resolver.test.ts

@@ -0,0 +1,242 @@
+import { faker } from '@faker-js/faker';
+import axios from 'axios';
+import fs from 'fs-extra';
+import { DataSource } from 'typeorm';
+import TipiCache from '../../../config/TipiCache';
+import * as TipiConfig from '../../../core/config/TipiConfig';
+import { setConfig } from '../../../core/config/TipiConfig';
+import { setupConnection, teardownConnection } from '../../../test/connection';
+import { gcall } from '../../../test/gcall';
+import { restartMutation, updateMutation } from '../../../test/mutations';
+import { systemInfoQuery, versionQuery } from '../../../test/queries';
+import User from '../../auth/user.entity';
+import { createUser } from '../../auth/__tests__/user.factory';
+import { SystemInfoResponse } from '../system.types';
+import EventDispatcher from '../../../core/config/EventDispatcher';
+
+jest.mock('fs-extra');
+jest.mock('axios');
+
+beforeEach(async () => {
+  jest.resetModules();
+  jest.resetAllMocks();
+  jest.restoreAllMocks();
+});
+
+let db: DataSource | null = null;
+const TEST_SUITE = 'systemresolver';
+beforeAll(async () => {
+  db = await setupConnection(TEST_SUITE);
+});
+
+afterAll(async () => {
+  await db?.destroy();
+  await teardownConnection(TEST_SUITE);
+});
+
+beforeEach(async () => {
+  jest.resetModules();
+  jest.resetAllMocks();
+  jest.restoreAllMocks();
+  await User.clear();
+});
+
+describe('Test: systemInfo', () => {
+  beforeEach(async () => {});
+
+  it('Should return correct system info from file', async () => {
+    const systemInfo = {
+      cpu: { load: 10 },
+      memory: { available: 100, total: 1000, used: 900 },
+      disk: { available: 100, total: 1000, used: 900 },
+    };
+
+    const MockFiles = {
+      '/runtipi/state/system-info.json': JSON.stringify(systemInfo),
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    const { data } = await gcall<{ systemInfo: SystemInfoResponse }>({ source: systemInfoQuery });
+
+    expect(data?.systemInfo).toBeDefined();
+    expect(data?.systemInfo.cpu).toBeDefined();
+    expect(data?.systemInfo.cpu.load).toBe(systemInfo.cpu.load);
+    expect(data?.systemInfo.memory).toBeDefined();
+    expect(data?.systemInfo.memory.available).toBe(systemInfo.memory.available);
+    expect(data?.systemInfo.memory.total).toBe(systemInfo.memory.total);
+    expect(data?.systemInfo.memory.used).toBe(systemInfo.memory.used);
+    expect(data?.systemInfo.disk).toBeDefined();
+    expect(data?.systemInfo.disk.available).toBe(systemInfo.disk.available);
+    expect(data?.systemInfo.disk.total).toBe(systemInfo.disk.total);
+    expect(data?.systemInfo.disk.used).toBe(systemInfo.disk.used);
+  });
+
+  it('Should return 0 for missing values', async () => {
+    const systemInfo = {
+      cpu: {},
+      memory: {},
+      disk: {},
+    };
+
+    const MockFiles = {
+      '/runtipi/state/system-info.json': JSON.stringify(systemInfo),
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    const { data } = await gcall<{ systemInfo: SystemInfoResponse }>({ source: systemInfoQuery });
+
+    expect(data?.systemInfo).toBeDefined();
+    expect(data?.systemInfo.cpu).toBeDefined();
+    expect(data?.systemInfo.cpu.load).toBe(0);
+    expect(data?.systemInfo.memory).toBeDefined();
+    expect(data?.systemInfo.memory.available).toBe(0);
+    expect(data?.systemInfo.memory.total).toBe(0);
+    expect(data?.systemInfo.memory.used).toBe(0);
+    expect(data?.systemInfo.disk).toBeDefined();
+    expect(data?.systemInfo.disk.available).toBe(0);
+    expect(data?.systemInfo.disk.total).toBe(0);
+    expect(data?.systemInfo.disk.used).toBe(0);
+  });
+});
+
+describe('Test: getVersion', () => {
+  const current = `${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}`;
+  const latest = `${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}`;
+  beforeEach(async () => {
+    jest.spyOn(axios, 'get').mockResolvedValue({
+      data: { name: `v${latest}` },
+    });
+    setConfig('version', current);
+  });
+
+  it('Should return correct version', async () => {
+    const { data } = await gcall<{ version: { current: string; latest?: string } }>({
+      source: versionQuery,
+    });
+
+    expect(data?.version).toBeDefined();
+    expect(data?.version.current).toBeDefined();
+    expect(data?.version.latest).toBeDefined();
+    expect(data?.version.current).toBe(current);
+    expect(data?.version.latest).toBe(latest);
+  });
+});
+
+describe('Test: restart', () => {
+  beforeEach(async () => {
+    setConfig('status', 'RUNNING');
+    setConfig('version', '1.0.0');
+    TipiCache.set('latestVersion', '1.0.1');
+  });
+
+  it('Should return true', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
+
+    // Act
+    const user = await createUser();
+    const { data } = await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id });
+
+    // Assert
+    expect(data?.restart).toBeDefined();
+    expect(data?.restart).toBe(true);
+  });
+
+  it("Should return an error if user doesn't exist", async () => {
+    // Arrange
+    const { data, errors } = await gcall<{ restart: boolean }>({
+      source: restartMutation,
+      userId: 1,
+    });
+
+    // Assert
+    expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
+    expect(data?.restart).toBeUndefined();
+  });
+
+  it('Should throw an error if no userId is not provided', async () => {
+    // Arrange
+    const { data, errors } = await gcall<{ restart: boolean }>({ source: restartMutation });
+
+    // Assert
+    expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
+    expect(data?.restart).toBeUndefined();
+  });
+
+  it('Should set app status to restarting', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
+    const spy = jest.spyOn(TipiConfig, 'setConfig');
+    const user = await createUser();
+
+    // Act
+    await gcall<{ restart: boolean }>({ source: restartMutation, userId: user.id });
+
+    // Assert
+    expect(spy).toHaveBeenCalledTimes(2);
+    expect(spy).toHaveBeenNthCalledWith(1, 'status', 'RESTARTING');
+    expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING');
+
+    spy.mockRestore();
+  });
+});
+
+describe('Test: update', () => {
+  beforeEach(async () => {
+    setConfig('status', 'RUNNING');
+    setConfig('version', '1.0.0');
+    TipiCache.set('latestVersion', '1.0.1');
+  });
+
+  it('Should return true', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
+    const user = await createUser();
+
+    // Act
+    const { data } = await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id });
+
+    // Assert
+    expect(data?.update).toBeDefined();
+    expect(data?.update).toBe(true);
+  });
+
+  it("Should return an error if user doesn't exist", async () => {
+    // Act
+    const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation, userId: 1 });
+
+    // Assert
+    expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
+    expect(data?.update).toBeUndefined();
+  });
+
+  it('Should throw an error if no userId is not provided', async () => {
+    // Act
+    const { data, errors } = await gcall<{ update: boolean }>({ source: updateMutation });
+
+    // Assert
+    expect(errors?.[0].message).toBe('Access denied! You need to be authorized to perform this action!');
+    expect(data?.update).toBeUndefined();
+  });
+
+  it('Should set app status to updating', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
+    const spy = jest.spyOn(TipiConfig, 'setConfig');
+    const user = await createUser();
+
+    // Act
+    await gcall<{ update: boolean }>({ source: updateMutation, userId: user.id });
+
+    // Assert
+    expect(spy).toHaveBeenCalledTimes(2);
+    expect(spy).toHaveBeenNthCalledWith(1, 'status', 'UPDATING');
+    expect(spy).toHaveBeenNthCalledWith(2, 'status', 'RUNNING');
+
+    spy.mockRestore();
+  });
+});

+ 213 - 0
packages/system-api/src/modules/system/__tests__/system.service.test.ts

@@ -0,0 +1,213 @@
+import fs from 'fs-extra';
+import semver from 'semver';
+import axios from 'axios';
+import SystemService from '../system.service';
+import { faker } from '@faker-js/faker';
+import TipiCache from '../../../config/TipiCache';
+import { setConfig } from '../../../core/config/TipiConfig';
+import logger from '../../../config/logger/logger';
+import EventDispatcher from '../../../core/config/EventDispatcher';
+
+jest.mock('fs-extra');
+jest.mock('axios');
+
+beforeEach(async () => {
+  jest.resetModules();
+  jest.resetAllMocks();
+});
+
+describe('Test: systemInfo', () => {
+  it('Should throw if system-info.json does not exist', () => {
+    try {
+      SystemService.systemInfo();
+    } catch (e: any) {
+      expect(e).toBeDefined();
+      expect(e.message).toBe('Error parsing system info');
+    }
+  });
+
+  it('It should return system info', async () => {
+    // Arrange
+    const info = {
+      cpu: { load: 0.1 },
+      memory: { available: 1000, total: 2000, used: 1000 },
+      disk: { available: 1000, total: 2000, used: 1000 },
+    };
+
+    const MockFiles = {
+      '/runtipi/state/system-info.json': JSON.stringify(info),
+    };
+
+    // @ts-ignore
+    fs.__createMockFiles(MockFiles);
+
+    // Act
+    const systemInfo = SystemService.systemInfo();
+
+    // Assert
+    expect(systemInfo).toBeDefined();
+    expect(systemInfo.cpu).toBeDefined();
+    expect(systemInfo.memory).toBeDefined();
+  });
+});
+
+describe('Test: getVersion', () => {
+  beforeEach(() => {
+    TipiCache.del('latestVersion');
+  });
+
+  afterAll(() => {
+    jest.restoreAllMocks();
+  });
+
+  it('It should return version', async () => {
+    // Arrange
+    const spy = jest.spyOn(axios, 'get').mockResolvedValue({
+      data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
+    });
+
+    // Act
+    const version = await SystemService.getVersion();
+
+    // Assert
+    expect(version).toBeDefined();
+    expect(version.current).toBeDefined();
+    expect(semver.valid(version.latest)).toBeTruthy();
+
+    spy.mockRestore();
+  });
+
+  it('Should return undefined for latest if request fails', async () => {
+    jest.spyOn(axios, 'get').mockImplementation(() => {
+      throw new Error('Error');
+    });
+
+    const version = await SystemService.getVersion();
+
+    expect(version).toBeDefined();
+    expect(version.current).toBeDefined();
+    expect(version.latest).toBeUndefined();
+  });
+
+  it('Should return cached version', async () => {
+    // Arrange
+    const spy = jest.spyOn(axios, 'get').mockResolvedValue({
+      data: { name: `v${faker.random.numeric(1)}.${faker.random.numeric(1)}.${faker.random.numeric()}` },
+    });
+
+    // Act
+    const version = await SystemService.getVersion();
+    const version2 = await SystemService.getVersion();
+
+    // Assert
+    expect(version).toBeDefined();
+    expect(version.current).toBeDefined();
+    expect(semver.valid(version.latest)).toBeTruthy();
+
+    expect(version2.latest).toBe(version.latest);
+    expect(version2.current).toBeDefined();
+    expect(semver.valid(version2.latest)).toBeTruthy();
+
+    expect(spy).toHaveBeenCalledTimes(1);
+
+    spy.mockRestore();
+  });
+});
+
+describe('Test: restart', () => {
+  it('Should return true', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
+
+    // Act
+    const restart = await SystemService.restart();
+
+    // Assert
+    expect(restart).toBeTruthy();
+  });
+
+  it('Should log error if fails', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'fake' });
+    const log = jest.spyOn(logger, 'error');
+
+    // Act
+    const restart = await SystemService.restart();
+
+    // Assert
+    expect(restart).toBeFalsy();
+    expect(log).toHaveBeenCalledWith('Error restarting system: fake');
+    log.mockRestore();
+  });
+});
+
+describe('Test: update', () => {
+  it('Should return true', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: true });
+    setConfig('version', '0.0.1');
+    TipiCache.set('latestVersion', '0.0.2');
+
+    // Act
+    const update = await SystemService.update();
+
+    // Assert
+    expect(update).toBeTruthy();
+  });
+
+  it('Should throw an error if latest version is not set', async () => {
+    // Arrange
+    TipiCache.del('latestVersion');
+    const spy = jest.spyOn(axios, 'get').mockResolvedValue({
+      data: { name: null },
+    });
+    setConfig('version', '0.0.1');
+
+    // Act & Assert
+    await expect(SystemService.update()).rejects.toThrow('Could not get latest version');
+    spy.mockRestore();
+  });
+
+  it('Should throw if current version is higher than latest', async () => {
+    // Arrange
+    setConfig('version', '0.0.2');
+    TipiCache.set('latestVersion', '0.0.1');
+
+    // Act & Assert
+    await expect(SystemService.update()).rejects.toThrow('Current version is newer than latest version');
+  });
+
+  it('Should throw if current version is equal to latest', async () => {
+    // Arrange
+    setConfig('version', '0.0.1');
+    TipiCache.set('latestVersion', '0.0.1');
+
+    // Act & Assert
+    await expect(SystemService.update()).rejects.toThrow('Current version is already up to date');
+  });
+
+  it('Should throw an error if there is a major version difference', async () => {
+    // Arrange
+    setConfig('version', '0.0.1');
+    TipiCache.set('latestVersion', '1.0.0');
+
+    // Act & Assert
+    await expect(SystemService.update()).rejects.toThrow('The major version has changed. Please update manually');
+  });
+
+  it('Should log error if fails', async () => {
+    // Arrange
+    EventDispatcher.prototype.dispatchEventAsync = jest.fn().mockResolvedValueOnce({ success: false, stdout: 'fake2' });
+    const log = jest.spyOn(logger, 'error');
+
+    // Act
+    setConfig('version', '0.0.1');
+    TipiCache.set('latestVersion', '0.0.2');
+    const update = await SystemService.update();
+
+    // Assert
+    expect(update).toBeFalsy();
+    expect(log).toHaveBeenCalledWith('Error updating system: fake2');
+    log.mockRestore();
+  });
+});

+ 12 - 0
packages/system-api/src/modules/system/system.controller.ts

@@ -0,0 +1,12 @@
+import { Request, Response } from 'express';
+import { getConfig } from '../../core/config/TipiConfig';
+
+const status = async (req: Request, res: Response) => {
+  res.status(200).json({
+    status: getConfig().status,
+  });
+};
+
+export default {
+  status,
+};

+ 13 - 1
packages/system-api/src/modules/system/system.resolver.ts

@@ -1,4 +1,4 @@
-import { Query, Resolver } from 'type-graphql';
+import { Authorized, Mutation, Query, Resolver } from 'type-graphql';
 import SystemService from './system.service';
 import SystemService from './system.service';
 import { SystemInfoResponse, VersionResponse } from './system.types';
 import { SystemInfoResponse, VersionResponse } from './system.types';
 
 
@@ -13,4 +13,16 @@ export default class AuthResolver {
   async version(): Promise<VersionResponse> {
   async version(): Promise<VersionResponse> {
     return SystemService.getVersion();
     return SystemService.getVersion();
   }
   }
+
+  @Authorized()
+  @Mutation(() => Boolean)
+  async restart(): Promise<boolean> {
+    return SystemService.restart();
+  }
+
+  @Authorized()
+  @Mutation(() => Boolean)
+  async update(): Promise<boolean> {
+    return SystemService.update();
+  }
 }
 }

+ 82 - 21
packages/system-api/src/modules/system/system.service.ts

@@ -1,28 +1,38 @@
 import axios from 'axios';
 import axios from 'axios';
-import config from '../../config';
+import z from 'zod';
+import semver from 'semver';
+import logger from '../../config/logger/logger';
 import TipiCache from '../../config/TipiCache';
 import TipiCache from '../../config/TipiCache';
+import { getConfig, setConfig } from '../../core/config/TipiConfig';
 import { readJsonFile } from '../fs/fs.helpers';
 import { readJsonFile } from '../fs/fs.helpers';
+import { eventDispatcher, EventTypes } from '../../core/config/EventDispatcher';
 
 
-type SystemInfo = {
-  cpu: {
-    load: number;
-  };
-  disk: {
-    total: number;
-    used: number;
-    available: number;
-  };
-  memory: {
-    total: number;
-    available: number;
-    used: number;
-  };
-};
+const systemInfoSchema = z.object({
+  cpu: z.object({
+    load: z.number().default(0),
+  }),
+  disk: z.object({
+    total: z.number().default(0),
+    used: z.number().default(0),
+    available: z.number().default(0),
+  }),
+  memory: z.object({
+    total: z.number().default(0),
+    available: z.number().default(0),
+    used: z.number().default(0),
+  }),
+});
 
 
-const systemInfo = (): SystemInfo => {
-  const info: SystemInfo = readJsonFile('/state/system-info.json');
+const systemInfo = (): z.infer<typeof systemInfoSchema> => {
+  const info = systemInfoSchema.safeParse(readJsonFile('/runtipi/state/system-info.json'));
 
 
-  return info;
+  if (!info.success) {
+    logger.error('Error parsing system info');
+    logger.error(info.error);
+    throw new Error('Error parsing system info');
+  } else {
+    return info.data;
+  }
 };
 };
 
 
 const getVersion = async (): Promise<{ current: string; latest?: string }> => {
 const getVersion = async (): Promise<{ current: string; latest?: string }> => {
@@ -38,15 +48,66 @@ const getVersion = async (): Promise<{ current: string; latest?: string }> => {
 
 
     TipiCache.set('latestVersion', version?.replace('v', ''));
     TipiCache.set('latestVersion', version?.replace('v', ''));
 
 
-    return { current: config.VERSION, latest: version?.replace('v', '') };
+    return { current: getConfig().version, latest: version?.replace('v', '') };
   } catch (e) {
   } catch (e) {
-    return { current: config.VERSION, latest: undefined };
+    logger.error(e);
+    return { current: getConfig().version, latest: undefined };
+  }
+};
+
+const restart = async (): Promise<boolean> => {
+  setConfig('status', 'RESTARTING');
+
+  const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.RESTART);
+
+  if (!success) {
+    logger.error(`Error restarting system: ${stdout}`);
+    return false;
+  }
+
+  setConfig('status', 'RUNNING');
+
+  return true;
+};
+
+const update = async (): Promise<boolean> => {
+  const { current, latest } = await getVersion();
+
+  if (!latest) {
+    throw new Error('Could not get latest version');
   }
   }
+
+  if (semver.gt(current, latest)) {
+    throw new Error('Current version is newer than latest version');
+  }
+
+  if (semver.eq(current, latest)) {
+    throw new Error('Current version is already up to date');
+  }
+
+  if (semver.major(current) !== semver.major(latest)) {
+    throw new Error('The major version has changed. Please update manually');
+  }
+
+  setConfig('status', 'UPDATING');
+
+  const { success, stdout } = await eventDispatcher.dispatchEventAsync(EventTypes.UPDATE);
+
+  if (!success) {
+    logger.error(`Error updating system: ${stdout}`);
+    return false;
+  }
+
+  setConfig('status', 'RUNNING');
+
+  return true;
 };
 };
 
 
 const SystemService = {
 const SystemService = {
   systemInfo,
   systemInfo,
   getVersion,
   getVersion,
+  restart,
+  update,
 };
 };
 
 
 export default SystemService;
 export default SystemService;

+ 30 - 9
packages/system-api/src/server.ts

@@ -1,7 +1,6 @@
 import 'reflect-metadata';
 import 'reflect-metadata';
 import express from 'express';
 import express from 'express';
 import { ApolloServerPluginLandingPageGraphQLPlayground as Playground } from 'apollo-server-core';
 import { ApolloServerPluginLandingPageGraphQLPlayground as Playground } from 'apollo-server-core';
-import config from './config';
 import { ApolloServer } from 'apollo-server-express';
 import { ApolloServer } from 'apollo-server-express';
 import { createSchema } from './schema';
 import { createSchema } from './schema';
 import { ApolloLogs } from './config/logger/apollo.logger';
 import { ApolloLogs } from './config/logger/apollo.logger';
@@ -15,8 +14,11 @@ import datasource from './config/datasource';
 import appsService from './modules/apps/apps.service';
 import appsService from './modules/apps/apps.service';
 import { runUpdates } from './core/updates/run';
 import { runUpdates } from './core/updates/run';
 import recover from './core/updates/recover-migrations';
 import recover from './core/updates/recover-migrations';
-import { cloneRepo, updateRepo } from './helpers/repo-helpers';
 import startJobs from './core/jobs/jobs';
 import startJobs from './core/jobs/jobs';
+import { applyJsonConfig, getConfig, setConfig } from './core/config/TipiConfig';
+import { ZodError } from 'zod';
+import systemController from './modules/system/system.controller';
+import { eventDispatcher, EventTypes } from './core/config/EventDispatcher';
 
 
 let corsOptions = {
 let corsOptions = {
   credentials: true,
   credentials: true,
@@ -27,7 +29,7 @@ let corsOptions = {
     // disallow requests with no origin
     // disallow requests with no origin
     if (!origin) return callback(new Error('Not allowed by CORS'), false);
     if (!origin) return callback(new Error('Not allowed by CORS'), false);
 
 
-    if (config.CLIENT_URLS.includes(origin)) {
+    if (getConfig().clientUrls.includes(origin)) {
       return callback(null, true);
       return callback(null, true);
     }
     }
 
 
@@ -36,12 +38,29 @@ let corsOptions = {
   },
   },
 };
 };
 
 
+const applyCustomConfig = () => {
+  try {
+    applyJsonConfig();
+  } catch (e) {
+    logger.error('Error applying settings.json config');
+    if (e instanceof ZodError) {
+      Object.keys(e.flatten().fieldErrors).forEach((key) => {
+        logger.error(`Error in field ${key}`);
+      });
+    }
+  }
+};
+
 const main = async () => {
 const main = async () => {
   try {
   try {
+    eventDispatcher.clear();
+    applyCustomConfig();
+
     const app = express();
     const app = express();
     const port = 3001;
     const port = 3001;
 
 
-    app.use(express.static(`${config.ROOT_FOLDER}/repos/${config.APPS_REPO_ID}`));
+    app.use(express.static(`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}`));
+    app.use('/status', systemController.status);
     app.use(cors(corsOptions));
     app.use(cors(corsOptions));
     app.use(getSessionMiddleware());
     app.use(getSessionMiddleware());
 
 
@@ -68,22 +87,24 @@ const main = async () => {
       await datasource.runMigrations();
       await datasource.runMigrations();
     } catch (e) {
     } catch (e) {
       logger.error(e);
       logger.error(e);
-      await recover();
+      await recover(datasource);
     }
     }
 
 
     // Run migrations
     // Run migrations
     await runUpdates();
     await runUpdates();
 
 
     httpServer.listen(port, async () => {
     httpServer.listen(port, async () => {
-      await cloneRepo(config.APPS_REPO_URL);
-      await updateRepo(config.APPS_REPO_URL);
+      await eventDispatcher.dispatchEventAsync(EventTypes.CLONE_REPO, [getConfig().appsRepoUrl]);
+      await eventDispatcher.dispatchEventAsync(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
+
       startJobs();
       startJobs();
+      setConfig('status', 'RUNNING');
+
       // Start apps
       // Start apps
       appsService.startAllApps();
       appsService.startAllApps();
-      console.info(`Server running on port ${port} 🚀 Production => ${__prod__}`);
+      logger.info(`Server running on port ${port} 🚀 Production => ${__prod__}`);
     });
     });
   } catch (error) {
   } catch (error) {
-    console.log(error);
     logger.error(error);
     logger.error(error);
   }
   }
 };
 };

+ 2 - 1
packages/system-api/src/test/connection.ts

@@ -38,7 +38,8 @@ export const setupConnection = async (testsuite: string): Promise<DataSource> =>
     entities: [App, User, Update],
     entities: [App, User, Update],
   });
   });
 
 
-  return AppDataSource.initialize();
+  await AppDataSource.initialize();
+  return AppDataSource;
 };
 };
 
 
 export const teardownConnection = async (testsuite: string): Promise<void> => {
 export const teardownConnection = async (testsuite: string): Promise<void> => {

+ 6 - 0
packages/system-api/src/test/jest-setup.ts

@@ -1,5 +1,11 @@
+import { eventDispatcher } from '../core/config/EventDispatcher';
+
 jest.mock('../config/logger/logger', () => ({
 jest.mock('../config/logger/logger', () => ({
   error: jest.fn(),
   error: jest.fn(),
   info: jest.fn(),
   info: jest.fn(),
   warn: jest.fn(),
   warn: jest.fn(),
 }));
 }));
+
+afterAll(() => {
+  eventDispatcher.clearInterval();
+});

+ 4 - 0
packages/system-api/src/test/mutations/index.ts

@@ -10,6 +10,8 @@ import * as updateAppConfig from './updateAppConfig.graphql';
 import * as updateApp from './updateApp.graphql';
 import * as updateApp from './updateApp.graphql';
 import * as register from './register.graphql';
 import * as register from './register.graphql';
 import * as login from './login.graphql';
 import * as login from './login.graphql';
+import * as restart from './restart.graphql';
+import * as update from './update.graphql';
 
 
 export const installAppMutation = print(installApp);
 export const installAppMutation = print(installApp);
 export const startAppMutation = print(startApp);
 export const startAppMutation = print(startApp);
@@ -19,3 +21,5 @@ export const updateAppConfigMutation = print(updateAppConfig);
 export const updateAppMutation = print(updateApp);
 export const updateAppMutation = print(updateApp);
 export const registerMutation = print(register);
 export const registerMutation = print(register);
 export const loginMutation = print(login);
 export const loginMutation = print(login);
+export const restartMutation = print(restart);
+export const updateMutation = print(update);

+ 3 - 0
packages/system-api/src/test/mutations/restart.graphql

@@ -0,0 +1,3 @@
+mutation {
+  restart
+}

+ 3 - 0
packages/system-api/src/test/mutations/update.graphql

@@ -0,0 +1,3 @@
+mutation {
+  update
+}

+ 4 - 0
packages/system-api/src/test/queries/index.ts

@@ -7,9 +7,13 @@ import * as getApp from './getApp.graphql';
 import * as InstalledApps from './installedApps.graphql';
 import * as InstalledApps from './installedApps.graphql';
 import * as Me from './me.graphql';
 import * as Me from './me.graphql';
 import * as isConfigured from './isConfigured.graphql';
 import * as isConfigured from './isConfigured.graphql';
+import * as systemInfo from './systemInfo.graphql';
+import * as version from './version.graphql';
 
 
 export const listAppInfosQuery = print(listAppInfos);
 export const listAppInfosQuery = print(listAppInfos);
 export const getAppQuery = print(getApp);
 export const getAppQuery = print(getApp);
 export const InstalledAppsQuery = print(InstalledApps);
 export const InstalledAppsQuery = print(InstalledApps);
 export const MeQuery = print(Me);
 export const MeQuery = print(Me);
 export const isConfiguredQuery = print(isConfigured);
 export const isConfiguredQuery = print(isConfigured);
+export const systemInfoQuery = print(systemInfo);
+export const versionQuery = print(version);

+ 17 - 0
packages/system-api/src/test/queries/systemInfo.graphql

@@ -0,0 +1,17 @@
+query {
+  systemInfo {
+    cpu {
+      load
+    }
+    memory {
+      total
+      available
+      used
+    }
+    disk {
+      total
+      available
+      used
+    }
+  }
+}

+ 6 - 0
packages/system-api/src/test/queries/version.graphql

@@ -0,0 +1,6 @@
+query {
+  version {
+    current
+    latest
+  }
+}

+ 39 - 282
pnpm-lock.yaml

@@ -39,7 +39,6 @@ importers:
       '@typescript-eslint/eslint-plugin': ^5.18.0
       '@typescript-eslint/eslint-plugin': ^5.18.0
       '@typescript-eslint/parser': ^5.0.0
       '@typescript-eslint/parser': ^5.0.0
       autoprefixer: ^10.4.4
       autoprefixer: ^10.4.4
-      axios: ^0.26.1
       clsx: ^1.1.1
       clsx: ^1.1.1
       eslint: 8.12.0
       eslint: 8.12.0
       eslint-config-airbnb-typescript: ^17.0.0
       eslint-config-airbnb-typescript: ^17.0.0
@@ -49,9 +48,7 @@ importers:
       framer-motion: ^6
       framer-motion: ^6
       graphql: ^15.8.0
       graphql: ^15.8.0
       graphql-tag: ^2.12.6
       graphql-tag: ^2.12.6
-      immer: ^9.0.12
       jest: ^28.1.0
       jest: ^28.1.0
-      js-cookie: ^3.0.1
       next: 12.1.6
       next: 12.1.6
       postcss: ^8.4.12
       postcss: ^8.4.12
       react: 18.1.0
       react: 18.1.0
@@ -64,7 +61,6 @@ importers:
       remark-gfm: ^3.0.1
       remark-gfm: ^3.0.1
       remark-mdx: ^2.1.1
       remark-mdx: ^2.1.1
       swr: ^1.3.0
       swr: ^1.3.0
-      systeminformation: ^5.11.9
       tailwindcss: ^3.0.23
       tailwindcss: ^3.0.23
       ts-jest: ^28.0.2
       ts-jest: ^28.0.2
       tslib: ^2.4.0
       tslib: ^2.4.0
@@ -77,14 +73,11 @@ importers:
       '@emotion/react': 11.9.0_4mdsreeeydipjms3kbrjyybtve
       '@emotion/react': 11.9.0_4mdsreeeydipjms3kbrjyybtve
       '@emotion/styled': 11.8.1_tnefweo2a67ybg6wfzi6ieqilm
       '@emotion/styled': 11.8.1_tnefweo2a67ybg6wfzi6ieqilm
       '@fontsource/open-sans': 4.5.8
       '@fontsource/open-sans': 4.5.8
-      axios: 0.26.1
       clsx: 1.1.1
       clsx: 1.1.1
       final-form: 4.20.7
       final-form: 4.20.7
       framer-motion: 6.3.3_ef5jwxihqo6n7gxfmzogljlgcm
       framer-motion: 6.3.3_ef5jwxihqo6n7gxfmzogljlgcm
       graphql: 15.8.0
       graphql: 15.8.0
       graphql-tag: 2.12.6_graphql@15.8.0
       graphql-tag: 2.12.6_graphql@15.8.0
-      immer: 9.0.12
-      js-cookie: 3.0.1
       next: 12.1.6_talmm3uuvp6ssixt2qevhfgvue
       next: 12.1.6_talmm3uuvp6ssixt2qevhfgvue
       react: 18.1.0
       react: 18.1.0
       react-dom: 18.1.0_react@18.1.0
       react-dom: 18.1.0_react@18.1.0
@@ -96,7 +89,6 @@ importers:
       remark-gfm: 3.0.1
       remark-gfm: 3.0.1
       remark-mdx: 2.1.1
       remark-mdx: 2.1.1
       swr: 1.3.0_react@18.1.0
       swr: 1.3.0_react@18.1.0
-      systeminformation: 5.11.14
       tslib: 2.4.0
       tslib: 2.4.0
       validator: 13.7.0
       validator: 13.7.0
       zustand: 3.7.2_react@18.1.0
       zustand: 3.7.2_react@18.1.0
@@ -130,18 +122,15 @@ importers:
       '@faker-js/faker': ^7.3.0
       '@faker-js/faker': ^7.3.0
       '@swc/cli': ^0.1.57
       '@swc/cli': ^0.1.57
       '@swc/core': ^1.2.210
       '@swc/core': ^1.2.210
-      '@types/compression': ^1.7.2
-      '@types/cookie-parser': ^1.4.3
       '@types/cors': ^2.8.12
       '@types/cors': ^2.8.12
       '@types/express': ^4.17.13
       '@types/express': ^4.17.13
       '@types/express-session': ^1.17.4
       '@types/express-session': ^1.17.4
       '@types/fs-extra': ^9.0.13
       '@types/fs-extra': ^9.0.13
       '@types/jest': ^27.5.0
       '@types/jest': ^27.5.0
-      '@types/jsonwebtoken': ^8.5.8
-      '@types/mock-fs': ^4.13.1
       '@types/node': 17.0.31
       '@types/node': 17.0.31
       '@types/node-cron': ^3.0.2
       '@types/node-cron': ^3.0.2
       '@types/pg': ^8.6.5
       '@types/pg': ^8.6.5
+      '@types/semver': ^7.3.12
       '@types/session-file-store': ^1.2.2
       '@types/session-file-store': ^1.2.2
       '@types/tcp-port-used': ^1.0.1
       '@types/tcp-port-used': ^1.0.1
       '@types/validator': ^13.7.2
       '@types/validator': ^13.7.2
@@ -152,9 +141,7 @@ importers:
       argon2: ^0.29.1
       argon2: ^0.29.1
       axios: ^0.26.1
       axios: ^0.26.1
       class-validator: ^0.13.2
       class-validator: ^0.13.2
-      compression: ^1.7.4
       concurrently: ^7.1.0
       concurrently: ^7.1.0
-      cookie-parser: ^1.4.6
       cors: ^2.8.5
       cors: ^2.8.5
       dotenv: ^16.0.0
       dotenv: ^16.0.0
       eslint: ^8.13.0
       eslint: ^8.13.0
@@ -171,20 +158,16 @@ importers:
       http: 0.0.1-security
       http: 0.0.1-security
       internal-ip: ^6.0.0
       internal-ip: ^6.0.0
       jest: ^28.1.0
       jest: ^28.1.0
-      jsonwebtoken: ^8.5.1
-      mock-fs: ^5.1.2
       node-cache: ^5.1.2
       node-cache: ^5.1.2
       node-cron: ^3.0.1
       node-cron: ^3.0.1
       node-port-scanner: ^3.0.1
       node-port-scanner: ^3.0.1
       nodemon: ^2.0.15
       nodemon: ^2.0.15
-      p-iteration: ^1.1.8
       pg: ^8.7.3
       pg: ^8.7.3
       prettier: 2.6.2
       prettier: 2.6.2
-      public-ip: ^5.0.0
       reflect-metadata: ^0.1.13
       reflect-metadata: ^0.1.13
       rimraf: ^3.0.2
       rimraf: ^3.0.2
+      semver: ^7.3.7
       session-file-store: ^1.5.0
       session-file-store: ^1.5.0
-      systeminformation: ^5.11.9
       tcp-port-used: ^1.0.2
       tcp-port-used: ^1.0.2
       ts-jest: ^28.0.2
       ts-jest: ^28.0.2
       ts-node: ^10.8.2
       ts-node: ^10.8.2
@@ -193,14 +176,13 @@ importers:
       typescript: 4.6.4
       typescript: 4.6.4
       validator: ^13.7.0
       validator: ^13.7.0
       winston: ^3.7.2
       winston: ^3.7.2
+      zod: ^3.19.1
     dependencies:
     dependencies:
       apollo-server-core: 3.10.0_graphql@15.8.0
       apollo-server-core: 3.10.0_graphql@15.8.0
       apollo-server-express: 3.9.0_jfj6k5cqxqbusbdzwqjdzioxzm
       apollo-server-express: 3.9.0_jfj6k5cqxqbusbdzwqjdzioxzm
       argon2: 0.29.1
       argon2: 0.29.1
       axios: 0.26.1
       axios: 0.26.1
       class-validator: 0.13.2
       class-validator: 0.13.2
-      compression: 1.7.4
-      cookie-parser: 1.4.6
       cors: 2.8.5
       cors: 2.8.5
       dotenv: 16.0.0
       dotenv: 16.0.0
       express: 4.18.1
       express: 4.18.1
@@ -210,38 +192,32 @@ importers:
       graphql-type-json: 0.3.2_graphql@15.8.0
       graphql-type-json: 0.3.2_graphql@15.8.0
       http: 0.0.1-security
       http: 0.0.1-security
       internal-ip: 6.2.0
       internal-ip: 6.2.0
-      jsonwebtoken: 8.5.1
-      mock-fs: 5.1.2
       node-cache: 5.1.2
       node-cache: 5.1.2
       node-cron: 3.0.1
       node-cron: 3.0.1
       node-port-scanner: 3.0.1
       node-port-scanner: 3.0.1
-      p-iteration: 1.1.8
       pg: 8.7.3
       pg: 8.7.3
-      public-ip: 5.0.0
       reflect-metadata: 0.1.13
       reflect-metadata: 0.1.13
+      semver: 7.3.7
       session-file-store: 1.5.0
       session-file-store: 1.5.0
-      systeminformation: 5.11.14
       tcp-port-used: 1.0.2
       tcp-port-used: 1.0.2
       type-graphql: 1.1.1_v2revtygxcm7xrdg2oz3ssohfu
       type-graphql: 1.1.1_v2revtygxcm7xrdg2oz3ssohfu
       typeorm: 0.3.6_pg@8.7.3+ts-node@10.8.2
       typeorm: 0.3.6_pg@8.7.3+ts-node@10.8.2
       validator: 13.7.0
       validator: 13.7.0
       winston: 3.7.2
       winston: 3.7.2
+      zod: 3.19.1
     devDependencies:
     devDependencies:
       '@faker-js/faker': 7.3.0
       '@faker-js/faker': 7.3.0
       '@swc/cli': 0.1.57_@swc+core@1.2.210
       '@swc/cli': 0.1.57_@swc+core@1.2.210
       '@swc/core': 1.2.210
       '@swc/core': 1.2.210
-      '@types/compression': 1.7.2
-      '@types/cookie-parser': 1.4.3
       '@types/cors': 2.8.12
       '@types/cors': 2.8.12
       '@types/express': 4.17.13
       '@types/express': 4.17.13
       '@types/express-session': 1.17.4
       '@types/express-session': 1.17.4
       '@types/fs-extra': 9.0.13
       '@types/fs-extra': 9.0.13
       '@types/jest': 27.5.0
       '@types/jest': 27.5.0
-      '@types/jsonwebtoken': 8.5.8
-      '@types/mock-fs': 4.13.1
       '@types/node': 17.0.31
       '@types/node': 17.0.31
       '@types/node-cron': 3.0.2
       '@types/node-cron': 3.0.2
       '@types/pg': 8.6.5
       '@types/pg': 8.6.5
+      '@types/semver': 7.3.12
       '@types/session-file-store': 1.2.2
       '@types/session-file-store': 1.2.2
       '@types/tcp-port-used': 1.0.1
       '@types/tcp-port-used': 1.0.1
       '@types/validator': 13.7.2
       '@types/validator': 13.7.2
@@ -3167,10 +3143,6 @@ packages:
       '@jridgewell/sourcemap-codec': 1.4.13
       '@jridgewell/sourcemap-codec': 1.4.13
     dev: true
     dev: true
 
 
-  /@leichtgewicht/ip-codec/2.0.3:
-    resolution: {integrity: sha512-nkalE/f1RvRGChwBnEIoBfSEYOXnCRdleKuv6+lePbMDrMZXeDQnqak5XDOeBgrPPyPfAdcCu/B5z+v3VhplGg==}
-    dev: false
-
   /@mapbox/node-pre-gyp/1.0.9:
   /@mapbox/node-pre-gyp/1.0.9:
     resolution: {integrity: sha512-aDF3S3rK9Q2gey/WAttUlISduDItz5BU3306M9Eyv6/oS40aMprnopshtlKTykxRNIBEZuRMaZAnbrQ4QtKGyw==}
     resolution: {integrity: sha512-aDF3S3rK9Q2gey/WAttUlISduDItz5BU3306M9Eyv6/oS40aMprnopshtlKTykxRNIBEZuRMaZAnbrQ4QtKGyw==}
     hasBin: true
     hasBin: true
@@ -3419,11 +3391,6 @@ packages:
     engines: {node: '>=6'}
     engines: {node: '>=6'}
     dev: true
     dev: true
 
 
-  /@sindresorhus/is/4.6.0:
-    resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==}
-    engines: {node: '>=10'}
-    dev: false
-
   /@sinonjs/commons/1.8.3:
   /@sinonjs/commons/1.8.3:
     resolution: {integrity: sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==}
     resolution: {integrity: sha512-xkNcLAn/wZaX14RPlwizcKicDk9G3F8m2nU3L7Ukm5zBgTwiT0wsoFAHx9Jq56fJA1z/7uKGtCRu16sOUCLIHQ==}
     dependencies:
     dependencies:
@@ -3602,13 +3569,6 @@ packages:
       defer-to-connect: 1.1.3
       defer-to-connect: 1.1.3
     dev: true
     dev: true
 
 
-  /@szmarczak/http-timer/5.0.1:
-    resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==}
-    engines: {node: '>=14.16'}
-    dependencies:
-      defer-to-connect: 2.0.1
-    dev: false
-
   /@tootallnate/once/2.0.0:
   /@tootallnate/once/2.0.0:
     resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
     resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
     engines: {node: '>= 10'}
     engines: {node: '>= 10'}
@@ -3677,32 +3637,11 @@ packages:
       '@types/connect': 3.4.35
       '@types/connect': 3.4.35
       '@types/node': 17.0.31
       '@types/node': 17.0.31
 
 
-  /@types/cacheable-request/6.0.2:
-    resolution: {integrity: sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA==}
-    dependencies:
-      '@types/http-cache-semantics': 4.0.1
-      '@types/keyv': 3.1.4
-      '@types/node': 17.0.31
-      '@types/responselike': 1.0.0
-    dev: false
-
-  /@types/compression/1.7.2:
-    resolution: {integrity: sha512-lwEL4M/uAGWngWFLSG87ZDr2kLrbuR8p7X+QZB1OQlT+qkHsCPDVFnHPyXf4Vyl4yDDorNY+mAhosxkCvppatg==}
-    dependencies:
-      '@types/express': 4.17.13
-    dev: true
-
   /@types/connect/3.4.35:
   /@types/connect/3.4.35:
     resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
     resolution: {integrity: sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==}
     dependencies:
     dependencies:
       '@types/node': 17.0.31
       '@types/node': 17.0.31
 
 
-  /@types/cookie-parser/1.4.3:
-    resolution: {integrity: sha512-CqSKwFwefj4PzZ5n/iwad/bow2hTCh0FlNAeWLtQM3JA/NX/iYagIpWG2cf1bQKQ2c9gU2log5VUCrn7LDOs0w==}
-    dependencies:
-      '@types/express': 4.17.13
-    dev: true
-
   /@types/cors/2.8.12:
   /@types/cors/2.8.12:
     resolution: {integrity: sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==}
     resolution: {integrity: sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==}
 
 
@@ -3776,10 +3715,6 @@ packages:
       '@types/unist': 2.0.6
       '@types/unist': 2.0.6
     dev: false
     dev: false
 
 
-  /@types/http-cache-semantics/4.0.1:
-    resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==}
-    dev: false
-
   /@types/istanbul-lib-coverage/2.0.4:
   /@types/istanbul-lib-coverage/2.0.4:
     resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==}
     resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==}
     dev: true
     dev: true
@@ -3811,10 +3746,6 @@ packages:
     resolution: {integrity: sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==}
     resolution: {integrity: sha512-FhpRzf927MNQdRZP0J5DLIdTXhjLYzeUTmLAu69mnVksLH9CJY3IuSeEgbKUki7GQZm0WqDkGzyxju2EZGD2wA==}
     dev: true
     dev: true
 
 
-  /@types/json-buffer/3.0.0:
-    resolution: {integrity: sha512-3YP80IxxFJB4b5tYC2SUPwkg0XQLiu0nWvhRgEatgjf+29IcWO9X1k8xRv5DGssJ/lCrjYTjQPcobJr2yWIVuQ==}
-    dev: false
-
   /@types/json-schema/7.0.11:
   /@types/json-schema/7.0.11:
     resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
     resolution: {integrity: sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==}
     dev: true
     dev: true
@@ -3837,6 +3768,7 @@ packages:
     resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
     resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
     dependencies:
     dependencies:
       '@types/node': 17.0.31
       '@types/node': 17.0.31
+    dev: true
 
 
   /@types/lodash.mergewith/4.6.6:
   /@types/lodash.mergewith/4.6.6:
     resolution: {integrity: sha512-RY/8IaVENjG19rxTZu9Nukqh0W2UrYgmBj5sdns4hWRZaV8PqR7wIKHFKzvOTjo4zVRV7sVI+yFhAJql12Kfqg==}
     resolution: {integrity: sha512-RY/8IaVENjG19rxTZu9Nukqh0W2UrYgmBj5sdns4hWRZaV8PqR7wIKHFKzvOTjo4zVRV7sVI+yFhAJql12Kfqg==}
@@ -3873,12 +3805,6 @@ packages:
     resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==}
     resolution: {integrity: sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==}
     dev: true
     dev: true
 
 
-  /@types/mock-fs/4.13.1:
-    resolution: {integrity: sha512-m6nFAJ3lBSnqbvDZioawRvpLXSaPyn52Srf7OfzjubYbYX8MTUdIgDxQl0wEapm4m/pNYSd9TXocpQ0TvZFlYA==}
-    dependencies:
-      '@types/node': 17.0.31
-    dev: true
-
   /@types/ms/0.7.31:
   /@types/ms/0.7.31:
     resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
     resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==}
     dev: false
     dev: false
@@ -3951,13 +3877,13 @@ packages:
     resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==}
     resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==}
     dependencies:
     dependencies:
       '@types/node': 17.0.31
       '@types/node': 17.0.31
+    dev: true
 
 
   /@types/scheduler/0.16.2:
   /@types/scheduler/0.16.2:
     resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
     resolution: {integrity: sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew==}
 
 
-  /@types/semver/7.3.10:
-    resolution: {integrity: sha512-zsv3fsC7S84NN6nPK06u79oWgrPVd0NvOyqgghV1haPaFcVxIrP4DLomRwGAXk0ui4HZA7mOcSFL98sMVW9viw==}
-    dev: false
+  /@types/semver/7.3.12:
+    resolution: {integrity: sha512-WwA1MW0++RfXmCr12xeYOOC5baSC9mSb0ZqCquFzKhcoF4TvHu5MKOuXsncgZcpVFhB1pXd5hZmM0ryAoCp12A==}
 
 
   /@types/serve-static/1.13.10:
   /@types/serve-static/1.13.10:
     resolution: {integrity: sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==}
     resolution: {integrity: sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==}
@@ -5023,6 +4949,7 @@ packages:
 
 
   /buffer-equal-constant-time/1.0.1:
   /buffer-equal-constant-time/1.0.1:
     resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=}
     resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=}
+    dev: true
 
 
   /buffer-from/1.1.2:
   /buffer-from/1.1.2:
     resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
     resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
@@ -5054,21 +4981,11 @@ packages:
       streamsearch: 1.1.0
       streamsearch: 1.1.0
     dev: true
     dev: true
 
 
-  /bytes/3.0.0:
-    resolution: {integrity: sha1-0ygVQE1olpn4Wk6k+odV3ROpYEg=}
-    engines: {node: '>= 0.8'}
-    dev: false
-
   /bytes/3.1.2:
   /bytes/3.1.2:
     resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
     resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
     engines: {node: '>= 0.8'}
     engines: {node: '>= 0.8'}
     dev: false
     dev: false
 
 
-  /cacheable-lookup/6.0.4:
-    resolution: {integrity: sha512-mbcDEZCkv2CZF4G01kr8eBd/5agkt9oCqz75tJMSIsquvRZ2sL6Hi5zGVKi/0OSC9oO1GHfJ2AV0ZIOY9vye0A==}
-    engines: {node: '>=10.6.0'}
-    dev: false
-
   /cacheable-request/6.1.0:
   /cacheable-request/6.1.0:
     resolution: {integrity: sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==}
     resolution: {integrity: sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==}
     engines: {node: '>=8'}
     engines: {node: '>=8'}
@@ -5082,19 +4999,6 @@ packages:
       responselike: 1.0.2
       responselike: 1.0.2
     dev: true
     dev: true
 
 
-  /cacheable-request/7.0.2:
-    resolution: {integrity: sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==}
-    engines: {node: '>=8'}
-    dependencies:
-      clone-response: 1.0.2
-      get-stream: 5.2.0
-      http-cache-semantics: 4.1.0
-      keyv: 4.2.2
-      lowercase-keys: 2.0.0
-      normalize-url: 6.1.0
-      responselike: 2.0.0
-    dev: false
-
   /cachedir/2.3.0:
   /cachedir/2.3.0:
     resolution: {integrity: sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==}
     resolution: {integrity: sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==}
     engines: {node: '>=6'}
     engines: {node: '>=6'}
@@ -5347,6 +5251,7 @@ packages:
     resolution: {integrity: sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q==}
     resolution: {integrity: sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q==}
     dependencies:
     dependencies:
       mimic-response: 1.0.1
       mimic-response: 1.0.1
+    dev: true
 
 
   /clone/1.0.4:
   /clone/1.0.4:
     resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
     resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==}
@@ -5476,36 +5381,6 @@ packages:
       dot-prop: 5.3.0
       dot-prop: 5.3.0
     dev: true
     dev: true
 
 
-  /compress-brotli/1.3.8:
-    resolution: {integrity: sha512-lVcQsjhxhIXsuupfy9fmZUFtAIdBmXA7EGY6GBdgZ++qkM9zG4YFT8iU7FoBxzryNDMOpD1HIFHUSX4D87oqhQ==}
-    engines: {node: '>= 12'}
-    dependencies:
-      '@types/json-buffer': 3.0.0
-      json-buffer: 3.0.1
-    dev: false
-
-  /compressible/2.0.18:
-    resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
-    engines: {node: '>= 0.6'}
-    dependencies:
-      mime-db: 1.52.0
-    dev: false
-
-  /compression/1.7.4:
-    resolution: {integrity: sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ==}
-    engines: {node: '>= 0.8.0'}
-    dependencies:
-      accepts: 1.3.8
-      bytes: 3.0.0
-      compressible: 2.0.18
-      debug: 2.6.9
-      on-headers: 1.0.2
-      safe-buffer: 5.1.2
-      vary: 1.1.2
-    transitivePeerDependencies:
-      - supports-color
-    dev: false
-
   /compute-scroll-into-view/1.0.14:
   /compute-scroll-into-view/1.0.14:
     resolution: {integrity: sha512-mKDjINe3tc6hGelUMNDzuhorIUZ7kS7BwyY0r2wQd2HOH2tRuJykiC06iSEX8y1TuhNzvz4GcJnK16mM2J1NMQ==}
     resolution: {integrity: sha512-mKDjINe3tc6hGelUMNDzuhorIUZ7kS7BwyY0r2wQd2HOH2tRuJykiC06iSEX8y1TuhNzvz4GcJnK16mM2J1NMQ==}
     dev: false
     dev: false
@@ -5607,23 +5482,10 @@ packages:
     dependencies:
     dependencies:
       safe-buffer: 5.1.2
       safe-buffer: 5.1.2
 
 
-  /cookie-parser/1.4.6:
-    resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==}
-    engines: {node: '>= 0.8.0'}
-    dependencies:
-      cookie: 0.4.1
-      cookie-signature: 1.0.6
-    dev: false
-
   /cookie-signature/1.0.6:
   /cookie-signature/1.0.6:
     resolution: {integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw=}
     resolution: {integrity: sha1-4wOogrNCzD7oylE6eZmXNNqzriw=}
     dev: false
     dev: false
 
 
-  /cookie/0.4.1:
-    resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==}
-    engines: {node: '>= 0.6'}
-    dev: false
-
   /cookie/0.4.2:
   /cookie/0.4.2:
     resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
     resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
     engines: {node: '>= 0.6'}
     engines: {node: '>= 0.6'}
@@ -5879,13 +5741,6 @@ packages:
       mimic-response: 1.0.1
       mimic-response: 1.0.1
     dev: true
     dev: true
 
 
-  /decompress-response/6.0.0:
-    resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
-    engines: {node: '>=10'}
-    dependencies:
-      mimic-response: 3.1.0
-    dev: false
-
   /dedent/0.7.0:
   /dedent/0.7.0:
     resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
     resolution: {integrity: sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA==}
     dev: true
     dev: true
@@ -5920,11 +5775,6 @@ packages:
     resolution: {integrity: sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==}
     resolution: {integrity: sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==}
     dev: true
     dev: true
 
 
-  /defer-to-connect/2.0.1:
-    resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==}
-    engines: {node: '>=10'}
-    dev: false
-
   /define-properties/1.1.4:
   /define-properties/1.1.4:
     resolution: {integrity: sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==}
     resolution: {integrity: sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA==}
     engines: {node: '>= 0.4'}
     engines: {node: '>= 0.4'}
@@ -6035,20 +5885,6 @@ packages:
     resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
     resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
     dev: true
     dev: true
 
 
-  /dns-packet/5.3.1:
-    resolution: {integrity: sha512-spBwIj0TK0Ey3666GwIdWVfUpLyubpU53BTCu8iPn4r4oXd9O14Hjg3EHw3ts2oed77/SeckunUYCyRlSngqHw==}
-    engines: {node: '>=6'}
-    dependencies:
-      '@leichtgewicht/ip-codec': 2.0.3
-    dev: false
-
-  /dns-socket/4.2.2:
-    resolution: {integrity: sha512-BDeBd8najI4/lS00HSKpdFia+OvUMytaVjfzR9n5Lq8MlZRSvtbI+uLtx1+XmQFls5wFU9dssccTmQQ6nfpjdg==}
-    engines: {node: '>=6'}
-    dependencies:
-      dns-packet: 5.3.1
-    dev: false
-
   /doctrine/2.1.0:
   /doctrine/2.1.0:
     resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
     resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
@@ -6101,6 +5937,7 @@ packages:
     resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
     resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
     dependencies:
     dependencies:
       safe-buffer: 5.2.1
       safe-buffer: 5.2.1
+    dev: true
 
 
   /ee-first/1.1.1:
   /ee-first/1.1.1:
     resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=}
     resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=}
@@ -6140,6 +5977,7 @@ packages:
     resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
     resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==}
     dependencies:
     dependencies:
       once: 1.4.0
       once: 1.4.0
+    dev: true
 
 
   /error-ex/1.3.2:
   /error-ex/1.3.2:
     resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
     resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==}
@@ -6398,7 +6236,7 @@ packages:
       eslint-import-resolver-webpack:
       eslint-import-resolver-webpack:
         optional: true
         optional: true
     dependencies:
     dependencies:
-      '@typescript-eslint/parser': 5.22.0_hcfsmds2fshutdssjqluwm76uu
+      '@typescript-eslint/parser': 5.22.0_uhoeudlwl7kc47h4kncsfowede
       debug: 3.2.7
       debug: 3.2.7
       eslint-import-resolver-node: 0.3.6
       eslint-import-resolver-node: 0.3.6
       find-up: 2.1.0
       find-up: 2.1.0
@@ -7078,6 +6916,7 @@ packages:
 
 
   /form-data-encoder/1.7.1:
   /form-data-encoder/1.7.1:
     resolution: {integrity: sha512-EFRDrsMm/kyqbTQocNvRXMLjc7Es2Vk+IQFx/YW7hkUH1eBl4J1fqiP34l74Yt0pFLCNpc06fkbVk00008mzjg==}
     resolution: {integrity: sha512-EFRDrsMm/kyqbTQocNvRXMLjc7Es2Vk+IQFx/YW7hkUH1eBl4J1fqiP34l74Yt0pFLCNpc06fkbVk00008mzjg==}
+    dev: true
 
 
   /form-data/3.0.1:
   /form-data/3.0.1:
     resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==}
     resolution: {integrity: sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==}
@@ -7258,6 +7097,7 @@ packages:
     engines: {node: '>=8'}
     engines: {node: '>=8'}
     dependencies:
     dependencies:
       pump: 3.0.0
       pump: 3.0.0
+    dev: true
 
 
   /get-stream/6.0.1:
   /get-stream/6.0.1:
     resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
     resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
@@ -7386,25 +7226,6 @@ packages:
       slash: 3.0.0
       slash: 3.0.0
     dev: true
     dev: true
 
 
-  /got/12.0.4:
-    resolution: {integrity: sha512-2Eyz4iU/ktq7wtMFXxzK7g5p35uNYLLdiZarZ5/Yn3IJlNEpBd5+dCgcAyxN8/8guZLszffwe3wVyw+DEVrpBg==}
-    engines: {node: '>=14.16'}
-    dependencies:
-      '@sindresorhus/is': 4.6.0
-      '@szmarczak/http-timer': 5.0.1
-      '@types/cacheable-request': 6.0.2
-      '@types/responselike': 1.0.0
-      cacheable-lookup: 6.0.4
-      cacheable-request: 7.0.2
-      decompress-response: 6.0.0
-      form-data-encoder: 1.7.1
-      get-stream: 6.0.1
-      http2-wrapper: 2.1.11
-      lowercase-keys: 3.0.0
-      p-cancelable: 3.0.0
-      responselike: 2.0.0
-    dev: false
-
   /got/9.6.0:
   /got/9.6.0:
     resolution: {integrity: sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==}
     resolution: {integrity: sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==}
     engines: {node: '>=8.6'}
     engines: {node: '>=8.6'}
@@ -7637,6 +7458,7 @@ packages:
 
 
   /http-cache-semantics/4.1.0:
   /http-cache-semantics/4.1.0:
     resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==}
     resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==}
+    dev: true
 
 
   /http-errors/2.0.0:
   /http-errors/2.0.0:
     resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
     resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
@@ -7664,14 +7486,6 @@ packages:
     resolution: {integrity: sha512-RnDvP10Ty9FxqOtPZuxtebw1j4L/WiqNMDtuc1YMH1XQm5TgDRaR1G9u8upL6KD1bXHSp9eSXo/ED+8Q7FAr+g==}
     resolution: {integrity: sha512-RnDvP10Ty9FxqOtPZuxtebw1j4L/WiqNMDtuc1YMH1XQm5TgDRaR1G9u8upL6KD1bXHSp9eSXo/ED+8Q7FAr+g==}
     dev: false
     dev: false
 
 
-  /http2-wrapper/2.1.11:
-    resolution: {integrity: sha512-aNAk5JzLturWEUiuhAN73Jcbq96R7rTitAoXV54FYMatvihnpD2+6PUgU4ce3D/m5VDbw+F5CsyKSF176ptitQ==}
-    engines: {node: '>=10.19.0'}
-    dependencies:
-      quick-lru: 5.1.1
-      resolve-alpn: 1.2.1
-    dev: false
-
   /https-proxy-agent/5.0.1:
   /https-proxy-agent/5.0.1:
     resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
     resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
     engines: {node: '>= 6'}
     engines: {node: '>= 6'}
@@ -7709,10 +7523,6 @@ packages:
     engines: {node: '>= 4'}
     engines: {node: '>= 4'}
     dev: true
     dev: true
 
 
-  /immer/9.0.12:
-    resolution: {integrity: sha512-lk7UNmSbAukB5B6dh9fnh5D0bJTOFKxVg2cyJWTYrWRfhLrLMBquONcUs3aFq507hNoIZEDDh8lb8UtOizSMhA==}
-    dev: false
-
   /immutable/3.7.6:
   /immutable/3.7.6:
     resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==}
     resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==}
     engines: {node: '>=0.8.0'}
     engines: {node: '>=0.8.0'}
@@ -8316,7 +8126,7 @@ packages:
       chalk: 4.1.2
       chalk: 4.1.2
       ci-info: 3.3.0
       ci-info: 3.3.0
       deepmerge: 4.2.2
       deepmerge: 4.2.2
-      glob: 7.2.3
+      glob: 7.2.0
       graceful-fs: 4.2.10
       graceful-fs: 4.2.10
       jest-circus: 28.1.0
       jest-circus: 28.1.0
       jest-environment-node: 28.1.0
       jest-environment-node: 28.1.0
@@ -8722,11 +8532,6 @@ packages:
       - ts-node
       - ts-node
     dev: true
     dev: true
 
 
-  /js-cookie/3.0.1:
-    resolution: {integrity: sha512-+0rgsUXZu4ncpPxRL+lNEptWMOWl9etvPHc/koSRp6MPwpRYAhmk0dUG00J4bxVV3r9uUzfo24wW0knS07SKSw==}
-    engines: {node: '>=12'}
-    dev: false
-
   /js-tokens/4.0.0:
   /js-tokens/4.0.0:
     resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
     resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
 
 
@@ -8754,10 +8559,6 @@ packages:
     resolution: {integrity: sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==}
     resolution: {integrity: sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==}
     dev: true
     dev: true
 
 
-  /json-buffer/3.0.1:
-    resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
-    dev: false
-
   /json-parse-even-better-errors/2.3.1:
   /json-parse-even-better-errors/2.3.1:
     resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
     resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
 
 
@@ -8836,6 +8637,7 @@ packages:
       lodash.once: 4.1.1
       lodash.once: 4.1.1
       ms: 2.1.3
       ms: 2.1.3
       semver: 5.7.1
       semver: 5.7.1
+    dev: true
 
 
   /jsx-ast-utils/3.3.0:
   /jsx-ast-utils/3.3.0:
     resolution: {integrity: sha512-XzO9luP6L0xkxwhIJMTJQpZo/eeN60K08jHdexfD569AGxeNug6UketeHXEhROoM8aR7EcUoOQmIhcJQjcuq8Q==}
     resolution: {integrity: sha512-XzO9luP6L0xkxwhIJMTJQpZo/eeN60K08jHdexfD569AGxeNug6UketeHXEhROoM8aR7EcUoOQmIhcJQjcuq8Q==}
@@ -8851,12 +8653,14 @@ packages:
       buffer-equal-constant-time: 1.0.1
       buffer-equal-constant-time: 1.0.1
       ecdsa-sig-formatter: 1.0.11
       ecdsa-sig-formatter: 1.0.11
       safe-buffer: 5.2.1
       safe-buffer: 5.2.1
+    dev: true
 
 
   /jws/3.2.2:
   /jws/3.2.2:
     resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
     resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
     dependencies:
     dependencies:
       jwa: 1.4.1
       jwa: 1.4.1
       safe-buffer: 5.2.1
       safe-buffer: 5.2.1
+    dev: true
 
 
   /keyv/3.1.0:
   /keyv/3.1.0:
     resolution: {integrity: sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==}
     resolution: {integrity: sha512-9ykJ/46SN/9KPM/sichzQ7OvXyGDYKGTaDlKMGCAlg2UK8KRy4jb0d8sFc+0Tt0YYnThq8X2RZgCg74RPxgcVA==}
@@ -8864,13 +8668,6 @@ packages:
       json-buffer: 3.0.0
       json-buffer: 3.0.0
     dev: true
     dev: true
 
 
-  /keyv/4.2.2:
-    resolution: {integrity: sha512-uYS0vKTlBIjNCAUqrjlxmruxOEiZxZIHXyp32sdcGmP+ukFrmWUnE//RcPXJH3Vxrni1H2gsQbjHE0bH7MtMQQ==}
-    dependencies:
-      compress-brotli: 1.3.8
-      json-buffer: 3.0.1
-    dev: false
-
   /kind-of/6.0.3:
   /kind-of/6.0.3:
     resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
     resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
     engines: {node: '>=0.10.0'}
     engines: {node: '>=0.10.0'}
@@ -9016,21 +8813,27 @@ packages:
 
 
   /lodash.includes/4.3.0:
   /lodash.includes/4.3.0:
     resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
     resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
+    dev: true
 
 
   /lodash.isboolean/3.0.3:
   /lodash.isboolean/3.0.3:
     resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
     resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==}
+    dev: true
 
 
   /lodash.isinteger/4.0.4:
   /lodash.isinteger/4.0.4:
     resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
     resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==}
+    dev: true
 
 
   /lodash.isnumber/3.0.3:
   /lodash.isnumber/3.0.3:
     resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
     resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==}
+    dev: true
 
 
   /lodash.isplainobject/4.0.6:
   /lodash.isplainobject/4.0.6:
     resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
     resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==}
+    dev: true
 
 
   /lodash.isstring/4.0.1:
   /lodash.isstring/4.0.1:
     resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
     resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==}
+    dev: true
 
 
   /lodash.map/4.6.0:
   /lodash.map/4.6.0:
     resolution: {integrity: sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==}
     resolution: {integrity: sha512-worNHGKLDetmcEYDvh2stPCrrQRkP20E4l0iIS7F8EvzMqBBi7ltvFN5m1HvTf1P7Jk1txKhvFcmYsCr8O2F1Q==}
@@ -9050,6 +8853,7 @@ packages:
 
 
   /lodash.once/4.1.1:
   /lodash.once/4.1.1:
     resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
     resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==}
+    dev: true
 
 
   /lodash.sortby/4.7.0:
   /lodash.sortby/4.7.0:
     resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
     resolution: {integrity: sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==}
@@ -9137,11 +8941,7 @@ packages:
   /lowercase-keys/2.0.0:
   /lowercase-keys/2.0.0:
     resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
     resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==}
     engines: {node: '>=8'}
     engines: {node: '>=8'}
-
-  /lowercase-keys/3.0.0:
-    resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==}
-    engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
-    dev: false
+    dev: true
 
 
   /lru-cache/6.0.0:
   /lru-cache/6.0.0:
     resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
     resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==}
@@ -9790,11 +9590,7 @@ packages:
   /mimic-response/1.0.1:
   /mimic-response/1.0.1:
     resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
     resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==}
     engines: {node: '>=4'}
     engines: {node: '>=4'}
-
-  /mimic-response/3.1.0:
-    resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==}
-    engines: {node: '>=10'}
-    dev: false
+    dev: true
 
 
   /min-indent/1.0.1:
   /min-indent/1.0.1:
     resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
     resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
@@ -9850,11 +9646,6 @@ packages:
     engines: {node: '>=10'}
     engines: {node: '>=10'}
     hasBin: true
     hasBin: true
 
 
-  /mock-fs/5.1.2:
-    resolution: {integrity: sha512-YkjQkdLulFrz0vD4BfNQdQRVmgycXTV7ykuHMlyv+C8WCHazpkiQRDthwa02kSyo8wKnY9wRptHfQLgmf0eR+A==}
-    engines: {node: '>=12.0.0'}
-    dev: false
-
   /mri/1.2.0:
   /mri/1.2.0:
     resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
     resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==}
     engines: {node: '>=4'}
     engines: {node: '>=4'}
@@ -10062,11 +9853,6 @@ packages:
     engines: {node: '>=8'}
     engines: {node: '>=8'}
     dev: true
     dev: true
 
 
-  /normalize-url/6.1.0:
-    resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==}
-    engines: {node: '>=10'}
-    dev: false
-
   /npm-run-path/4.0.1:
   /npm-run-path/4.0.1:
     resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
     resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
     engines: {node: '>=8'}
     engines: {node: '>=8'}
@@ -10232,11 +10018,6 @@ packages:
     engines: {node: '>=6'}
     engines: {node: '>=6'}
     dev: true
     dev: true
 
 
-  /p-cancelable/3.0.0:
-    resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==}
-    engines: {node: '>=12.20'}
-    dev: false
-
   /p-event/4.2.0:
   /p-event/4.2.0:
     resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==}
     resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==}
     engines: {node: '>=8'}
     engines: {node: '>=8'}
@@ -10249,11 +10030,6 @@ packages:
     engines: {node: '>=4'}
     engines: {node: '>=4'}
     dev: false
     dev: false
 
 
-  /p-iteration/1.1.8:
-    resolution: {integrity: sha512-IMFBSDIYcPNnW7uWYGrBqmvTiq7W0uB0fJn6shQZs7dlF3OvrHOre+JT9ikSZ7gZS3vWqclVgoQSvToJrns7uQ==}
-    engines: {node: '>=8.0.0'}
-    dev: false
-
   /p-limit/1.3.0:
   /p-limit/1.3.0:
     resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==}
     resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==}
     engines: {node: '>=4'}
     engines: {node: '>=4'}
@@ -10701,20 +10477,12 @@ packages:
     resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
     resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
     dev: true
     dev: true
 
 
-  /public-ip/5.0.0:
-    resolution: {integrity: sha512-xaH3pZMni/R2BG7ZXXaWS9Wc9wFlhyDVJF47IJ+3ali0TGv+2PsckKxbmo+rnx3ZxiV2wblVhtdS3bohAP6GGw==}
-    engines: {node: ^14.13.1 || >=16.0.0}
-    dependencies:
-      dns-socket: 4.2.2
-      got: 12.0.4
-      is-ip: 3.1.0
-    dev: false
-
   /pump/3.0.0:
   /pump/3.0.0:
     resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
     resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==}
     dependencies:
     dependencies:
       end-of-stream: 1.4.4
       end-of-stream: 1.4.4
       once: 1.4.0
       once: 1.4.0
+    dev: true
 
 
   /punycode/2.1.1:
   /punycode/2.1.1:
     resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==}
     resolution: {integrity: sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==}
@@ -10752,6 +10520,7 @@ packages:
   /quick-lru/5.1.1:
   /quick-lru/5.1.1:
     resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
     resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
     engines: {node: '>=10'}
     engines: {node: '>=10'}
+    dev: true
 
 
   /random-bytes/1.0.0:
   /random-bytes/1.0.0:
     resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==}
     resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==}
@@ -11138,10 +10907,6 @@ packages:
     resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
     resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
     dev: true
     dev: true
 
 
-  /resolve-alpn/1.2.1:
-    resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==}
-    dev: false
-
   /resolve-cwd/3.0.0:
   /resolve-cwd/3.0.0:
     resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
     resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
     engines: {node: '>=8'}
     engines: {node: '>=8'}
@@ -11199,12 +10964,6 @@ packages:
       lowercase-keys: 1.0.1
       lowercase-keys: 1.0.1
     dev: true
     dev: true
 
 
-  /responselike/2.0.0:
-    resolution: {integrity: sha512-xH48u3FTB9VsZw7R+vvgaKeLKzT6jOogbQhEe/jewwnZgzPcnyWui2Av6JpoYZF/91uueC+lqhWqeURw5/qhCw==}
-    dependencies:
-      lowercase-keys: 2.0.0
-    dev: false
-
   /restore-cursor/2.0.0:
   /restore-cursor/2.0.0:
     resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==}
     resolution: {integrity: sha512-6IzJLuGi4+R14vwagDHX+JrXmPVtPpn4mffDJ1UdR7/Edm87fl6yi8mMBIVvFtJaNTUvjughmW4hwLhRG7gC1Q==}
     engines: {node: '>=4'}
     engines: {node: '>=4'}
@@ -11311,6 +11070,7 @@ packages:
   /semver/5.7.1:
   /semver/5.7.1:
     resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
     resolution: {integrity: sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==}
     hasBin: true
     hasBin: true
+    dev: true
 
 
   /semver/6.3.0:
   /semver/6.3.0:
     resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
     resolution: {integrity: sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==}
@@ -11784,13 +11544,6 @@ packages:
       - encoding
       - encoding
     dev: true
     dev: true
 
 
-  /systeminformation/5.11.14:
-    resolution: {integrity: sha512-m8CJx3fIhKohanB0ExTk5q53uI1J0g5B09p77kU+KxnxRVpADVqTAwCg1PFelqKsj4LHd+qmVnumb511Hg4xow==}
-    engines: {node: '>=8.0.0'}
-    os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android]
-    hasBin: true
-    dev: false
-
   /tailwindcss/3.0.24:
   /tailwindcss/3.0.24:
     resolution: {integrity: sha512-H3uMmZNWzG6aqmg9q07ZIRNIawoiEcNFKDfL+YzOPuPsXuDXxJxB9icqzLgdzKNwjG3SAro2h9SYav8ewXNgig==}
     resolution: {integrity: sha512-H3uMmZNWzG6aqmg9q07ZIRNIawoiEcNFKDfL+YzOPuPsXuDXxJxB9icqzLgdzKNwjG3SAro2h9SYav8ewXNgig==}
     engines: {node: '>=12.13.0'}
     engines: {node: '>=12.13.0'}
@@ -12207,7 +11960,7 @@ packages:
     dependencies:
     dependencies:
       '@types/glob': 7.2.0
       '@types/glob': 7.2.0
       '@types/node': 17.0.31
       '@types/node': 17.0.31
-      '@types/semver': 7.3.10
+      '@types/semver': 7.3.12
       class-validator: 0.13.2
       class-validator: 0.13.2
       glob: 7.2.0
       glob: 7.2.0
       graphql: 15.8.0
       graphql: 15.8.0
@@ -12920,6 +12673,10 @@ packages:
     resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==}
     resolution: {integrity: sha512-PQ2PC7R9rslx84ndNBZB/Dkv8V8fZEpk83RLgXtYd0fwUgEjseMn1Dgajh2x6S8QbZAFa9p2qVCEuYZNgve0dQ==}
     dev: false
     dev: false
 
 
+  /zod/3.19.1:
+    resolution: {integrity: sha512-LYjZsEDhCdYET9ikFu6dVPGp2YH9DegXjdJToSzD9rO6fy4qiRYFoyEYwps88OseJlPyl2NOe2iJuhEhL7IpEA==}
+    dev: false
+
   /zustand/3.7.2_react@18.1.0:
   /zustand/3.7.2_react@18.1.0:
     resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==}
     resolution: {integrity: sha512-PIJDIZKtokhof+9+60cpockVOq05sJzHCriyvaLBmEJixseQ1a5Kdov6fWZfWOu5SK9c+FhH1jU0tntLxRJYMA==}
     engines: {node: '>=12.7.0'}
     engines: {node: '>=12.7.0'}

+ 95 - 99
scripts/app.sh

@@ -2,49 +2,32 @@
 # Required Notice: Copyright
 # Required Notice: Copyright
 # Umbrel (https://umbrel.com)
 # Umbrel (https://umbrel.com)
 
 
-set -euo pipefail
-
-# use greadlink instead of readlink on osx
-if [[ "$(uname)" == "Darwin" ]]; then
-  rdlk=greadlink
-else
-  rdlk=readlink
-fi
-
-ROOT_FOLDER="$($rdlk -f $(dirname "${BASH_SOURCE[0]}")/..)"
-REPO_ID="$(echo -n "https://github.com/meienberger/runtipi-appstore" | sha256sum | awk '{print $1}')"
-STATE_FOLDER="${ROOT_FOLDER}/state"
-
-show_help() {
-  cat <<EOF
-app 0.0.1
-
-CLI for managing Tipi apps
+echo "Starting app script"
 
 
-Usage: app <command> <app> [<arguments>]
+source "${BASH_SOURCE%/*}/common.sh"
 
 
-Commands:
-    install                    Pulls down images for an app and starts it
-    uninstall                  Removes images and destroys all data for an app
-    stop                       Stops an installed app
-    start                      Starts an installed app
-    compose                    Passes all arguments to docker-compose
-    ls-installed               Lists installed apps
-EOF
-}
+set -euo pipefail
 
 
-# Get field from json file
-function get_json_field() {
-  local json_file="$1"
-  local field="$2"
+ensure_pwd
 
 
-  echo $(jq -r ".${field}" "${json_file}")
-}
+ROOT_FOLDER="${PWD}"
+STATE_FOLDER="${ROOT_FOLDER}/state"
+ENV_FILE="${ROOT_FOLDER}/.env"
+
+# Root folder in host system
+ROOT_FOLDER_HOST=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep ROOT_FOLDER_HOST | cut -d '=' -f2)
+REPO_ID=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep APPS_REPO_ID | cut -d '=' -f2)
+STORAGE_PATH=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep STORAGE_PATH | cut -d '=' -f2)
+
+# Override vars with values from settings.json
+if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
+  # If storagePath is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)" != "null" ]]; then
+    STORAGE_PATH="$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)"
+  fi
+fi
 
 
-list_installed_apps() {
-  str=$(get_json_field ${STATE_FOLDER}/apps.json installed)
-  echo $str
-}
+write_log "Running app script: ROOT_FOLDER=${ROOT_FOLDER}, ROOT_FOLDER_HOST=${ROOT_FOLDER_HOST}, REPO_ID=${REPO_ID}, STORAGE_PATH=${STORAGE_PATH}"
 
 
 if [ -z ${1+x} ]; then
 if [ -z ${1+x} ]; then
   command=""
   command=""
@@ -52,31 +35,10 @@ else
   command="$1"
   command="$1"
 fi
 fi
 
 
-# Lists installed apps
-if [[ "$command" = "ls-installed" ]]; then
-  list_installed_apps
-
-  exit
-fi
-
 if [ -z ${2+x} ]; then
 if [ -z ${2+x} ]; then
-  show_help
   exit 1
   exit 1
 else
 else
-
   app="$2"
   app="$2"
-  root_folder_host="${3:-$ROOT_FOLDER}"
-  repo_id="${4:-$REPO_ID}"
-
-  if [[ -z "${repo_id}" ]]; then
-    echo "Error: Repo id not provided"
-    exit 1
-  fi
-
-  if [[ -z "${root_folder_host}" ]]; then
-    echo "Error: Root folder not provided"
-    exit 1
-  fi
 
 
   app_dir="${ROOT_FOLDER}/apps/${app}"
   app_dir="${ROOT_FOLDER}/apps/${app}"
 
 
@@ -84,36 +46,35 @@ else
     # copy from repo
     # copy from repo
     echo "Copying app from repo"
     echo "Copying app from repo"
     mkdir -p "${app_dir}"
     mkdir -p "${app_dir}"
-    cp -r "${ROOT_FOLDER}/repos/${repo_id}/apps/${app}"/* "${app_dir}"
+    cp -r "${ROOT_FOLDER}/repos/${REPO_ID}/apps/${app}"/* "${app_dir}"
   fi
   fi
 
 
-  app_data_dir="${ROOT_FOLDER}/app-data/${app}"
+  app_data_dir="${STORAGE_PATH}/app-data/${app}"
 
 
   if [[ -z "${app}" ]] || [[ ! -d "${app_dir}" ]]; then
   if [[ -z "${app}" ]] || [[ ! -d "${app_dir}" ]]; then
     echo "Error: \"${app}\" is not a valid app"
     echo "Error: \"${app}\" is not a valid app"
     exit 1
     exit 1
   fi
   fi
-
 fi
 fi
 
 
 if [ -z ${3+x} ]; then
 if [ -z ${3+x} ]; then
   args=""
   args=""
 else
 else
-  args="${@:3}"
+  args="${*:3}"
 fi
 fi
 
 
 compose() {
 compose() {
   local app="${1}"
   local app="${1}"
   shift
   shift
 
 
-  local architecture="$(uname -m)"
+  arch=$(uname -m)
+  local architecture="${arch}"
 
 
   if [[ "$architecture" == "aarch64" ]]; then
   if [[ "$architecture" == "aarch64" ]]; then
     architecture="arm64"
     architecture="arm64"
   fi
   fi
 
 
   # App data folder
   # App data folder
-  local env_file="${ROOT_FOLDER}/.env"
   local app_compose_file="${app_dir}/docker-compose.yml"
   local app_compose_file="${app_dir}/docker-compose.yml"
 
 
   # Pick arm architecture if running on arm and if the app has a docker-compose.arm.yml file
   # Pick arm architecture if running on arm and if the app has a docker-compose.arm.yml file
@@ -121,19 +82,23 @@ compose() {
     app_compose_file="${app_dir}/docker-compose.arm.yml"
     app_compose_file="${app_dir}/docker-compose.arm.yml"
   fi
   fi
 
 
-  local common_compose_file="${ROOT_FOLDER}/repos/${repo_id}/apps/docker-compose.common.yml"
+  # Pick arm architecture if running on arm and if the app has a docker-compose.arm64.yml file
+  if [[ "$architecture" == "arm64" ]] && [[ -f "${app_dir}/docker-compose.arm64.yml" ]]; then
+    app_compose_file="${app_dir}/docker-compose.arm64.yml"
+  fi
+
+  local common_compose_file="${ROOT_FOLDER}/repos/${REPO_ID}/apps/docker-compose.common.yml"
 
 
   # Vars to use in compose file
   # Vars to use in compose file
-  export APP_DATA_DIR="${root_folder_host}/app-data/${app}"
-  export APP_DIR="${app_dir}"
-  export ROOT_FOLDER_HOST="${root_folder_host}"
-  export ROOT_FOLDER="${ROOT_FOLDER}"
+  export APP_DATA_DIR="${STORAGE_PATH}/app-data/${app}"
+  export ROOT_FOLDER_HOST="${ROOT_FOLDER_HOST}"
 
 
-  # Docker-compose does not support multiple env files
-  # --env-file "${env_file}" \
+  write_log "Running docker compose -f ${app_compose_file} -f ${common_compose_file} ${*}"
+  write_log "APP_DATA_DIR=${APP_DATA_DIR}"
+  write_log "ROOT_FOLDER_HOST=${ROOT_FOLDER_HOST}"
 
 
-  docker-compose \
-    --env-file "${ROOT_FOLDER}/app-data/${app}/app.env" \
+  docker compose \
+    --env-file "${app_data_dir}/app.env" \
     --project-name "${app}" \
     --project-name "${app}" \
     --file "${app_compose_file}" \
     --file "${app_compose_file}" \
     --file "${common_compose_file}" \
     --file "${common_compose_file}" \
@@ -142,7 +107,13 @@ compose() {
 
 
 # Install new app
 # Install new app
 if [[ "$command" = "install" ]]; then
 if [[ "$command" = "install" ]]; then
-  compose "${app}" pull
+  # Write to file script.log
+  write_log "Installing app ${app}..."
+
+  if ! compose "${app}" pull; then
+    write_log "Failed to pull app ${app}"
+    exit 1
+  fi
 
 
   # Copy default data dir to app data dir if it exists
   # Copy default data dir to app data dir if it exists
   if [[ -d "${app_dir}/data" ]]; then
   if [[ -d "${app_dir}/data" ]]; then
@@ -152,20 +123,30 @@ if [[ "$command" = "install" ]]; then
   # Remove all .gitkeep files from app data dir
   # Remove all .gitkeep files from app data dir
   find "${app_data_dir}" -name ".gitkeep" -exec rm -f {} \;
   find "${app_data_dir}" -name ".gitkeep" -exec rm -f {} \;
 
 
-  chown -R "1000:1000" "${app_data_dir}"
+  chmod -R a+rwx "${app_data_dir}"
 
 
-  compose "${app}" up -d
-  exit
+  if ! compose "${app}" up -d; then
+    write_log "Failed to start app ${app}"
+    exit 1
+  fi
+
+  exit 0
 fi
 fi
 
 
 # Removes images and destroys all data for an app
 # Removes images and destroys all data for an app
 if [[ "$command" = "uninstall" ]]; then
 if [[ "$command" = "uninstall" ]]; then
-  echo "Removing images for app ${app}..."
+  write_log "Removing images for app ${app}..."
 
 
-  compose "${app}" up --detach
-  compose "${app}" down --rmi all --remove-orphans
+  if ! compose "${app}" up --detach; then
+    write_log "Failed to uninstall app ${app}"
+    exit 1
+  fi
+  if ! compose "${app}" down --rmi all --remove-orphans; then
+    write_log "Failed to uninstall app ${app}"
+    exit 1
+  fi
 
 
-  echo "Deleting app data for app ${app}..."
+  write_log "Deleting app data for app ${app}..."
   if [[ -d "${app_data_dir}" ]]; then
   if [[ -d "${app_data_dir}" ]]; then
     rm -rf "${app_data_dir}"
     rm -rf "${app_data_dir}"
   fi
   fi
@@ -174,14 +155,21 @@ if [[ "$command" = "uninstall" ]]; then
     rm -rf "${app_dir}"
     rm -rf "${app_dir}"
   fi
   fi
 
 
-  echo "Successfully uninstalled app ${app}"
+  write_log "Successfully uninstalled app ${app}"
   exit
   exit
 fi
 fi
 
 
 # Update an app
 # Update an app
 if [[ "$command" = "update" ]]; then
 if [[ "$command" = "update" ]]; then
-  compose "${app}" up --detach
-  compose "${app}" down --rmi all --remove-orphans
+  if ! compose "${app}" up --detach; then
+    write_log "Failed to update app ${app}"
+    exit 1
+  fi
+
+  if ! compose "${app}" down --rmi all --remove-orphans; then
+    write_log "Failed to update app ${app}"
+    exit 1
+  fi
 
 
   # Remove app
   # Remove app
   if [[ -d "${app_dir}" ]]; then
   if [[ -d "${app_dir}" ]]; then
@@ -189,33 +177,41 @@ if [[ "$command" = "update" ]]; then
   fi
   fi
 
 
   # Copy app from repo
   # Copy app from repo
-  cp -r "${ROOT_FOLDER}/repos/${repo_id}/apps/${app}" "${app_dir}"
+  cp -r "${ROOT_FOLDER}/repos/${REPO_ID}/apps/${app}" "${app_dir}"
 
 
   compose "${app}" pull
   compose "${app}" pull
-  exit
+  exit 0
 fi
 fi
 
 
 # Stops an installed app
 # Stops an installed app
 if [[ "$command" = "stop" ]]; then
 if [[ "$command" = "stop" ]]; then
-  echo "Stopping app ${app}..."
-  compose "${app}" rm --force --stop
-  exit
+  write_log "Stopping app ${app}..."
+
+  if ! compose "${app}" rm --force --stop; then
+    write_log "Failed to stop app ${app}"
+    exit 1
+  fi
+
+  exit 0
 fi
 fi
 
 
 # Starts an installed app
 # Starts an installed app
 if [[ "$command" = "start" ]]; then
 if [[ "$command" = "start" ]]; then
-  echo "Starting app ${app}..."
-  compose "${app}" up --detach
-  exit
+  write_log "Starting app ${app}..."
+  if ! compose "${app}" up --detach; then
+    write_log "Failed to start app ${app}"
+    exit 1
+  fi
+  exit 0
 fi
 fi
 
 
-# Passes all arguments to docker-compose
+# Passes all arguments to Docker Compose
 if [[ "$command" = "compose" ]]; then
 if [[ "$command" = "compose" ]]; then
-  compose "${app}" ${args}
-  exit
+  if ! compose "${app}" "${args}"; then
+    write_log "Failed to run compose command for app ${app}"
+    exit 1
+  fi
+  exit 0
 fi
 fi
 
 
-# If we get here it means no valid command was supplied
-# Show help and exit
-show_help
 exit 1
 exit 1

+ 90 - 0
scripts/common.sh

@@ -0,0 +1,90 @@
+#!/usr/bin/env bash
+
+# Get field from json file
+function get_json_field() {
+    local json_file="$1"
+    local field="$2"
+
+    jq -r ".${field}" "${json_file}"
+}
+
+function write_log() {
+    local message="$1"
+    local log_file="${PWD}/logs/script.log"
+
+    echo "$(date) - ${message}" >>"${log_file}"
+}
+
+function derive_entropy() {
+    SEED_FILE="${STATE_FOLDER}/seed"
+    identifier="${1}"
+    tipi_seed=$(cat "${SEED_FILE}") || true
+
+    if [[ -z "$tipi_seed" ]] || [[ -z "$identifier" ]]; then
+        echo >&2 "Missing derivation parameter, this is unsafe, exiting."
+        exit 1
+    fi
+
+    printf "%s" "${identifier}" | openssl dgst -sha256 -hmac "${tipi_seed}" | sed 's/^.* //'
+}
+
+function ensure_pwd() {
+    if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
+        echo "Please run this script from the runtipi directory"
+        exit 1
+    fi
+}
+
+function ensure_root() {
+    if [[ $UID != 0 ]]; then
+        echo "Tipi must be started as root"
+        echo "Please re-run this script as"
+        echo "  sudo ./scripts/start"
+        exit 1
+    fi
+}
+
+function ensure_linux() {
+    # Check we are on linux
+    if [[ "$(uname)" != "Linux" ]]; then
+        echo "Tipi only works on Linux"
+        exit 1
+    fi
+}
+
+function clean_logs() {
+    # Clean logs folder
+    logs_folder="${ROOT_FOLDER}/logs"
+
+    # Create the folder if it doesn't exist
+    if [[ ! -d "${logs_folder}" ]]; then
+        mkdir "${logs_folder}"
+    fi
+
+    if [ "$(find "${logs_folder}" -maxdepth 1 -type f | wc -l)" -gt 0 ]; then
+        echo "Cleaning logs folder..."
+
+        files=($(ls -d "${logs_folder}"/* | xargs -n 1 basename | sed 's/\///g'))
+
+        for file in "${files[@]}"; do
+            echo "Removing ${file}"
+            rm -rf "${ROOT_FOLDER}/logs/${file}"
+        done
+    fi
+}
+
+function kill_watcher() {
+    watcher_pid="$(ps aux | grep "scripts/watcher" | grep -v grep | awk '{print $2}')"
+
+    # kill it if it's running
+    if [[ -n $watcher_pid ]]; then
+        # If multiline kill each pid
+        if [[ $watcher_pid == *" "* ]]; then
+            for pid in $watcher_pid; do
+                kill -9 $pid
+            done
+        else
+            kill -9 $watcher_pid
+        fi
+    fi
+}

+ 84 - 50
scripts/configure.sh

@@ -1,16 +1,33 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
-ROOT_FOLDER="$(readlink -f "$(dirname "${BASH_SOURCE[0]}")"/..)"
 
 
-echo
-echo "======================================"
-if [[ -f "${ROOT_FOLDER}/state/configured" ]]; then
-  echo "=========== RECONFIGURING ============"
-else
-  echo "============ CONFIGURING ============="
-fi
-echo "=============== TIPI ================="
-echo "======================================"
-echo
+OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')"
+SUB_OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID_LIKE=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')"
+
+function install_generic() {
+  local dependency="${1}"
+  local os="${2}"
+
+  if [[ "${os}" == "debian" ]]; then
+    sudo apt-get update
+    sudo apt-get install -y "${dependency}"
+    return 0
+  elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
+    sudo apt-get update
+    sudo apt-get install -y "${dependency}"
+    return 0
+  elif [[ "${os}" == "centos" ]]; then
+    sudo yum install -y --allowerasing "${dependency}"
+    return 0
+  elif [[ "${os}" == "fedora" ]]; then
+    sudo dnf -y install "${dependency}"
+    return 0
+  elif [[ "${os}" == "arch" ]]; then
+    sudo pacman -Sy --noconfirm "${dependency}"
+    return 0
+  else
+    return 1
+  fi
+}
 
 
 function install_docker() {
 function install_docker() {
   local os="${1}"
   local os="${1}"
@@ -51,61 +68,74 @@ function install_docker() {
     sudo systemctl enable docker
     sudo systemctl enable docker
     return 0
     return 0
   elif [[ "${os}" == "arch" ]]; then
   elif [[ "${os}" == "arch" ]]; then
-    sudo pacman -Sy --noconfirm docker
+    sudo pacman -Sy --noconfirm docker docker-compose
     sudo systemctl start docker.service
     sudo systemctl start docker.service
     sudo systemctl enable docker.service
     sudo systemctl enable docker.service
-
-    if ! command -v crontab >/dev/null; then
-      sudo pacman -Sy --noconfirm cronie
-      systemctl enable --now cronie.service
-    fi
-
     return 0
     return 0
   else
   else
     return 1
     return 1
   fi
   fi
 }
 }
 
 
-function install_jq() {
+function update_docker() {
   local os="${1}"
   local os="${1}"
-  echo "Installing jq for os ${os}" >/dev/tty
+  echo "Updating Docker for os ${os}" >/dev/tty
 
 
-  if [[ "${os}" == "debian" || "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
+  if [[ "${os}" == "debian" ]]; then
     sudo apt-get update
     sudo apt-get update
-    sudo apt-get install -y jq
+    sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
+    return 0
+  elif [[ "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
+    sudo apt-get update
+    sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
     return 0
     return 0
   elif [[ "${os}" == "centos" ]]; then
   elif [[ "${os}" == "centos" ]]; then
-    sudo yum install -y jq
+    sudo yum install -y --allowerasing docker-ce docker-ce-cli containerd.io docker-compose-plugin
     return 0
     return 0
   elif [[ "${os}" == "fedora" ]]; then
   elif [[ "${os}" == "fedora" ]]; then
-    sudo dnf -y install jq
+    sudo dnf -y install docker-ce docker-ce-cli containerd.io docker-compose-plugin
     return 0
     return 0
   elif [[ "${os}" == "arch" ]]; then
   elif [[ "${os}" == "arch" ]]; then
-    sudo pacman -Sy --noconfirm jq
+    sudo pacman -Sy --noconfirm docker docker-compose
     return 0
     return 0
   else
   else
     return 1
     return 1
   fi
   fi
 }
 }
 
 
-OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')"
-SUB_OS="$(cat /etc/[A-Za-z]*[_-][rv]e[lr]* | grep "^ID_LIKE=" | cut -d= -f2 | uniq | tr '[:upper:]' '[:lower:]' | tr -d '"')"
-
 if command -v docker >/dev/null; then
 if command -v docker >/dev/null; then
-  echo "Docker is already installed"
+  echo "Docker is already installed, ensuring Docker is fully up to date"
+
+  update_docker "${OS}"
+  docker_result=$?
+
+  if [[ docker_result -eq 0 ]]; then
+    echo "Docker is fully up to date"
+  else
+    echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}"
+    install_docker "${SUB_OS}"
+    docker_sub_result=$?
+
+    if [[ docker_sub_result -eq 0 ]]; then
+      echo "Docker is fully up to date"
+    else
+      echo "Your system ${SUB_OS} is not supported please update Docker manually"
+      exit 1
+    fi
+  fi
 else
 else
   install_docker "${OS}"
   install_docker "${OS}"
   docker_result=$?
   docker_result=$?
 
 
   if [[ docker_result -eq 0 ]]; then
   if [[ docker_result -eq 0 ]]; then
-    echo "docker installed"
+    echo "Docker installed"
   else
   else
     echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}"
     echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}"
     install_docker "${SUB_OS}"
     install_docker "${SUB_OS}"
     docker_sub_result=$?
     docker_sub_result=$?
 
 
     if [[ docker_sub_result -eq 0 ]]; then
     if [[ docker_sub_result -eq 0 ]]; then
-      echo "docker installed"
+      echo "Docker installed"
     else
     else
       echo "Your system ${SUB_OS} is not supported please install docker manually"
       echo "Your system ${SUB_OS} is not supported please install docker manually"
       exit 1
       exit 1
@@ -113,27 +143,31 @@ else
   fi
   fi
 fi
 fi
 
 
-if ! command -v docker-compose >/dev/null; then
-  sudo curl -L "https://github.com/docker/compose/releases/download/v2.3.4/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
-  sudo chmod +x /usr/local/bin/docker-compose
-fi
-
-if ! command -v jq >/dev/null; then
-  install_jq "${OS}"
-  jq_result=$?
+function check_dependency_and_install() {
+  local dependency="${1}"
 
 
-  if [[ jq_result -eq 0 ]]; then
-    echo "jq installed"
-  else
-    echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}"
-    install_jq "${SUB_OS}"
-    jq_sub_result=$?
+  if ! command -v fswatch >/dev/null; then
+    echo "Installing ${dependency}"
+    install_generic "${dependency}" "${OS}"
+    install_result=$?
 
 
-    if [[ jq_sub_result -eq 0 ]]; then
-      echo "jq installed"
+    if [[ install_result -eq 0 ]]; then
+      echo "${dependency} installed"
     else
     else
-      echo "Your system ${SUB_OS} is not supported please install jq manually"
-      exit 1
+      echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}"
+      install_generic "${dependency}" "${SUB_OS}"
+      install_sub_result=$?
+
+      if [[ install_sub_result -eq 0 ]]; then
+        echo "${dependency} installed"
+      else
+        echo "Your system ${SUB_OS} is not supported please install ${dependency} manually"
+        exit 1
+      fi
     fi
     fi
   fi
   fi
-fi
+}
+
+check_dependency_and_install "jq"
+check_dependency_and_install "fswatch"
+check_dependency_and_install "openssl"

+ 3 - 0
scripts/deploy/release-rc.sh

@@ -0,0 +1,3 @@
+#!/usr/bin/env bash
+
+docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 -t meienberger/runtipi:rc-"$(npm run version --silent)" . --push

+ 31 - 36
scripts/git.sh

@@ -1,33 +1,15 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
 
 
-# use greadlink instead of readlink on osx
-if [[ "$(uname)" == "Darwin" ]]; then
-    rdlk=greadlink
-else
-    rdlk=readlink
-fi
-
-ROOT_FOLDER="$($rdlk -f $(dirname "${BASH_SOURCE[0]}")/..)"
-
-show_help() {
-    cat <<EOF
-app 0.0.1
+source "${BASH_SOURCE%/*}/common.sh"
 
 
-CLI for managing Tipi apps
+ensure_pwd
 
 
-Usage: git <command> <repo> [<arguments>]
-
-Commands:
-    clone                      Clones a repo in the repo folder
-    update                     Updates the repo folder
-    get_hash                   Gets the local hash of the repo
-EOF
-}
+ROOT_FOLDER="${PWD}"
 
 
 # Get a static hash based on the repo url
 # Get a static hash based on the repo url
 function get_hash() {
 function get_hash() {
     url="${1}"
     url="${1}"
-    echo $(echo -n "${url}" | sha256sum | awk '{print $1}')
+    echo -n "${url}" | sha256sum | awk '{print $1}'
 }
 }
 
 
 if [ -z ${1+x} ]; then
 if [ -z ${1+x} ]; then
@@ -41,17 +23,22 @@ if [[ "$command" = "clone" ]]; then
     repo="$2"
     repo="$2"
     repo_hash=$(get_hash "${repo}")
     repo_hash=$(get_hash "${repo}")
 
 
-    echo "Cloning ${repo} to ${ROOT_FOLDER}/repos/${repo_hash}"
+    write_log "Cloning ${repo} to ${ROOT_FOLDER}/repos/${repo_hash}"
     repo_dir="${ROOT_FOLDER}/repos/${repo_hash}"
     repo_dir="${ROOT_FOLDER}/repos/${repo_hash}"
     if [ -d "${repo_dir}" ]; then
     if [ -d "${repo_dir}" ]; then
-        echo "Repo already exists"
+        write_log "Repo already exists"
         exit 0
         exit 0
     fi
     fi
 
 
-    echo "Cloning ${repo} to ${repo_dir}"
-    git clone "${repo}" "${repo_dir}"
-    echo "Done"
-    exit
+    write_log "Cloning ${repo} to ${repo_dir}"
+
+    if ! git clone "${repo}" "${repo_dir}"; then
+        write_log "Failed to clone repo"
+        exit 1
+    fi
+
+    write_log "Done"
+    exit 0
 fi
 fi
 
 
 # Update a repo
 # Update a repo
@@ -59,20 +46,28 @@ if [[ "$command" = "update" ]]; then
     repo="$2"
     repo="$2"
     repo_hash=$(get_hash "${repo}")
     repo_hash=$(get_hash "${repo}")
     repo_dir="${ROOT_FOLDER}/repos/${repo_hash}"
     repo_dir="${ROOT_FOLDER}/repos/${repo_hash}"
+    git config --global --add safe.directory "${repo_dir}"
     if [ ! -d "${repo_dir}" ]; then
     if [ ! -d "${repo_dir}" ]; then
-        echo "Repo does not exist"
-        exit 0
+        write_log "Repo does not exist"
+        exit 1
     fi
     fi
 
 
-    echo "Updating ${repo} in ${repo_hash}"
-    cd "${repo_dir}"
-    git pull origin master
-    echo "Done"
-    exit
+    write_log "Updating ${repo} in ${repo_hash}"
+    cd "${repo_dir}" || exit
+
+    if ! git pull origin master; then
+        cd "${ROOT_FOLDER}" || exit
+        write_log "Failed to update repo"
+        exit 1
+    fi
+
+    cd "${ROOT_FOLDER}" || exit
+    write_log "Done"
+    exit 0
 fi
 fi
 
 
 if [[ "$command" = "get_hash" ]]; then
 if [[ "$command" = "get_hash" ]]; then
     repo="$2"
     repo="$2"
-    echo $(get_hash "${repo}")
+    get_hash "${repo}"
     exit
     exit
 fi
 fi

+ 11 - 0
scripts/start-dev.sh

@@ -0,0 +1,11 @@
+#!/usr/bin/env bash
+source "${BASH_SOURCE%/*}/common.sh"
+
+ROOT_FOLDER="${PWD}"
+
+kill_watcher
+chmod -R a+rwx "${ROOT_FOLDER}/state/events"
+chmod -R a+rwx "${ROOT_FOLDER}/state/system-info.json"
+"${ROOT_FOLDER}/scripts/watcher.sh" &
+
+docker compose -f docker-compose.dev.yml --env-file "${ROOT_FOLDER}/.env.dev" up --build

+ 118 - 138
scripts/start.sh

@@ -1,23 +1,61 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
 
 
-# Required Notice: Copyright
-# Umbrel (https://umbrel.com)
-
 set -e # Exit immediately if a command exits with a non-zero status.
 set -e # Exit immediately if a command exits with a non-zero status.
 
 
-# use greadlink instead of readlink on osx
-if [[ "$(uname)" == "Darwin" ]]; then
-  readlink=greadlink
-else
-  readlink=readlink
-fi
+source "${BASH_SOURCE%/*}/common.sh"
+
+ROOT_FOLDER="${PWD}"
+
+# Cleanup and ensure environment
+ensure_linux
+ensure_pwd
+ensure_root
+clean_logs
 
 
+# Default variables
 NGINX_PORT=80
 NGINX_PORT=80
 NGINX_PORT_SSL=443
 NGINX_PORT_SSL=443
-PROXY_PORT=8080
 DOMAIN=tipi.localhost
 DOMAIN=tipi.localhost
+STATE_FOLDER="${ROOT_FOLDER}/state"
+SED_ROOT_FOLDER="$(echo "$ROOT_FOLDER" | sed 's/\//\\\//g')"
+DNS_IP=9.9.9.9 # Default to Quad9 DNS
+ARCHITECTURE="$(uname -m)"
+TZ="$(timedatectl | grep "Time zone" | awk '{print $3}' | sed 's/\//\\\//g' || Europe\/Berlin)"
+apps_repository="https://github.com/meienberger/runtipi-appstore"
+REPO_ID="$("${ROOT_FOLDER}"/scripts/git.sh get_hash ${apps_repository})"
+APPS_REPOSITORY_ESCAPED="$(echo ${apps_repository} | sed 's/\//\\\//g')"
+JWT_SECRET=$(derive_entropy "jwt")
+POSTGRES_PASSWORD=$(derive_entropy "postgres")
+TIPI_VERSION=$(get_json_field "${ROOT_FOLDER}/package.json" version)
+storage_path="${ROOT_FOLDER}"
+STORAGE_PATH_ESCAPED="$(echo "${storage_path}" | sed 's/\//\\\//g')"
+NETWORK_INTERFACE="$(ip route | grep default | awk '{print $5}' | uniq)"
+NETWORK_INTERFACE_COUNT=$(echo "$NETWORK_INTERFACE" | wc -l)
 
 
-while [ -n "$1" ]; do # while loop starts
+if [[ "$NETWORK_INTERFACE_COUNT" -eq 0 ]]; then
+  echo "No network interface found!"
+  exit 1
+elif [[ "$NETWORK_INTERFACE_COUNT" -gt 1 ]]; then
+  echo "Found multiple network interfaces. Please select one of the following interfaces:"
+  echo "$NETWORK_INTERFACE"
+  while true; do
+    read -rp "> " USER_NETWORK_INTERFACE
+    if echo "$NETWORK_INTERFACE" | grep -x "$USER_NETWORK_INTERFACE"; then
+      NETWORK_INTERFACE="$USER_NETWORK_INTERFACE"
+      break
+    else
+      echo "Please select one of the interfaces above. (CTRL+C to abort)"
+    fi
+  done
+fi
+INTERNAL_IP="$(ip addr show "${NETWORK_INTERFACE}" | grep "inet " | awk '{print $2}' | cut -d/ -f1)"
+
+if [[ "$ARCHITECTURE" == "aarch64" ]]; then
+  ARCHITECTURE="arm64"
+fi
+
+# Parse arguments
+while [ -n "$1" ]; do
   case "$1" in
   case "$1" in
   --rc) rc="true" ;;
   --rc) rc="true" ;;
   --ci) ci="true" ;;
   --ci) ci="true" ;;
@@ -43,24 +81,24 @@ while [ -n "$1" ]; do # while loop starts
     fi
     fi
     shift
     shift
     ;;
     ;;
-  --proxy-port)
-    proxy_port="$2"
+  --domain)
+    domain="$2"
 
 
-    if [[ "${proxy_port}" =~ ^[0-9]+$ ]]; then
-      PROXY_PORT="${proxy_port}"
+    if [[ "${domain}" =~ ^[a-zA-Z0-9.-]+$ ]]; then
+      DOMAIN="${domain}"
     else
     else
-      echo "--proxy-port must be a number"
+      echo "--domain must be a valid domain"
       exit 1
       exit 1
     fi
     fi
     shift
     shift
     ;;
     ;;
-  --domain)
-    domain="$2"
+  --listen-ip)
+    listen_ip="$2"
 
 
-    if [[ "${domain}" =~ ^[a-zA-Z0-9.-]+$ ]]; then
-      DOMAIN="${domain}"
+    if [[ "${listen_ip}" =~ ^[a-fA-F0-9.:]+$ ]]; then
+      INTERNAL_IP="${listen_ip}"
     else
     else
-      echo "--domain must be a valid domain"
+      echo "--listen-ip must be a valid IP address"
       exit 1
       exit 1
     fi
     fi
     shift
     shift
@@ -74,107 +112,27 @@ while [ -n "$1" ]; do # while loop starts
   shift
   shift
 done
 done
 
 
-# Ensure BASH_SOURCE is ./scripts/start.sh
-if [[ $(basename $(pwd)) != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
-  echo "Please make sure this script is executed from runtipi/"
-  exit 1
-fi
-
-# Check we are on linux
-if [[ "$(uname)" != "Linux" ]]; then
-  echo "Tipi only works on Linux"
-  exit 1
-fi
-
 # If port is not 80 and domain is not tipi.localhost, we exit
 # If port is not 80 and domain is not tipi.localhost, we exit
 if [[ "${NGINX_PORT}" != "80" ]] && [[ "${DOMAIN}" != "tipi.localhost" ]]; then
 if [[ "${NGINX_PORT}" != "80" ]] && [[ "${DOMAIN}" != "tipi.localhost" ]]; then
   echo "Using a custom domain with a custom port is not supported"
   echo "Using a custom domain with a custom port is not supported"
   exit 1
   exit 1
 fi
 fi
 
 
-ROOT_FOLDER="$($readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
-STATE_FOLDER="${ROOT_FOLDER}/state"
-SED_ROOT_FOLDER="$(echo $ROOT_FOLDER | sed 's/\//\\\//g')"
-
-NETWORK_INTERFACE="$(ip route | grep default | awk '{print $5}' | uniq)"
-NETWORK_INTERFACE_COUNT=$(echo "$NETWORK_INTERFACE" | wc -l)
-
-if [[ "$NETWORK_INTERFACE_COUNT" -eq 0 ]]; then
-  echo "No network interface found!"
-  exit 1
-elif [[ "$NETWORK_INTERFACE_COUNT" -gt 1 ]]; then
-  echo "Found multiple network interfaces. Please select one of the following interfaces:"
-  echo "$NETWORK_INTERFACE"
-  while true; do
-    read -rp "> " USER_NETWORK_INTERFACE
-    if echo "$NETWORK_INTERFACE" | grep -x "$USER_NETWORK_INTERFACE"; then
-      NETWORK_INTERFACE="$USER_NETWORK_INTERFACE"
-      break
-    else
-      echo "Please select one of the interfaces above. (CTRL+C to abort)"
-    fi
-  done
-fi
-
-INTERNAL_IP="$(ip addr show "${NETWORK_INTERFACE}" | grep "inet " | awk '{print $2}' | cut -d/ -f1)"
-DNS_IP=9.9.9.9 # Default to Quad9 DNS
-ARCHITECTURE="$(uname -m)"
-TZ="$(timedatectl | grep "Time zone" | awk '{print $3}' | sed 's/\//\\\//g' || Europe\/Berlin)"
-APPS_REPOSITORY="https://github.com/meienberger/runtipi-appstore"
-REPO_ID="$(${ROOT_FOLDER}/scripts/git.sh get_hash ${APPS_REPOSITORY})"
-APPS_REPOSITORY_ESCAPED="$(echo ${APPS_REPOSITORY} | sed 's/\//\\\//g')"
-
-if [[ "$ARCHITECTURE" == "aarch64" ]]; then
-  ARCHITECTURE="arm64"
-fi
-
-if [[ $UID != 0 ]]; then
-  echo "Tipi must be started as root"
-  echo "Please re-run this script as"
-  echo "  sudo ./scripts/start"
-  exit 1
-fi
-
-# Configure Tipi if it isn't already configured
+kill_watcher
+# Configure Tipi
 "${ROOT_FOLDER}/scripts/configure.sh"
 "${ROOT_FOLDER}/scripts/configure.sh"
-
-# Get field from json file
-function get_json_field() {
-  local json_file="$1"
-  local field="$2"
-
-  echo $(jq -r ".${field}" "${json_file}")
-}
-
-# Deterministically derives 128 bits of cryptographically secure entropy
-function derive_entropy() {
-  SEED_FILE="${STATE_FOLDER}/seed"
-  identifier="${1}"
-  tipi_seed=$(cat "${SEED_FILE}") || true
-
-  if [[ -z "$tipi_seed" ]] || [[ -z "$identifier" ]]; then
-    echo >&2 "Missing derivation parameter, this is unsafe, exiting."
-    exit 1
-  fi
-
-  # We need `sed 's/^.* //'` to trim the "(stdin)= " prefix from some versions of openssl
-  printf "%s" "${identifier}" | openssl dgst -sha256 -hmac "${tipi_seed}" | sed 's/^.* //'
-}
+chmod -R a+rwx "${ROOT_FOLDER}/state/system-info.json"
+"${ROOT_FOLDER}/scripts/watcher.sh" &
 
 
 # Copy the config sample if it isn't here
 # Copy the config sample if it isn't here
 if [[ ! -f "${STATE_FOLDER}/apps.json" ]]; then
 if [[ ! -f "${STATE_FOLDER}/apps.json" ]]; then
   cp "${ROOT_FOLDER}/templates/config-sample.json" "${STATE_FOLDER}/config.json"
   cp "${ROOT_FOLDER}/templates/config-sample.json" "${STATE_FOLDER}/config.json"
 fi
 fi
 
 
-# Get current dns from host
-if [[ -f "/etc/resolv.conf" ]]; then
-  TEMP=$(grep -E -o '[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}' /etc/resolv.conf | head -n 1)
-fi
-
 # Create seed file with cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1
 # Create seed file with cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1
 if [[ ! -f "${STATE_FOLDER}/seed" ]]; then
 if [[ ! -f "${STATE_FOLDER}/seed" ]]; then
   echo "Generating seed..."
   echo "Generating seed..."
-  cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1 >"${STATE_FOLDER}/seed"
+  tr </dev/urandom -dc 'a-zA-Z0-9' | fold -w 32 | head -n 1 >"${STATE_FOLDER}/seed"
 fi
 fi
 
 
 export DOCKER_CLIENT_TIMEOUT=240
 export DOCKER_CLIENT_TIMEOUT=240
@@ -190,26 +148,54 @@ ENV_FILE=$(mktemp)
 # Copy template configs to intermediary configs
 # Copy template configs to intermediary configs
 [[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$ENV_FILE"
 [[ -f "$ROOT_FOLDER/templates/env-sample" ]] && cp "$ROOT_FOLDER/templates/env-sample" "$ENV_FILE"
 
 
-JWT_SECRET=$(derive_entropy "jwt")
-POSTGRES_PASSWORD=$(derive_entropy "postgres")
-TIPI_VERSION=$(get_json_field "${ROOT_FOLDER}/package.json" version)
+# Override vars with values from settings.json
+if [[ -f "${STATE_FOLDER}/settings.json" ]]; then
+
+  # If dnsIp is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" dnsIp)" != "null" ]]; then
+    DNS_IP=$(get_json_field "${STATE_FOLDER}/settings.json" dnsIp)
+  fi
+
+  # If domain is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" domain)" != "null" ]]; then
+    DOMAIN=$(get_json_field "${STATE_FOLDER}/settings.json" domain)
+  fi
 
 
-echo "Creating .env file with the following values:"
-echo "  DOMAIN=${DOMAIN}"
-echo "  INTERNAL_IP=${INTERNAL_IP}"
-echo "  NGINX_PORT=${NGINX_PORT}"
-echo "  NGINX_PORT_SSL=${NGINX_PORT_SSL}"
-echo "  PROXY_PORT=${PROXY_PORT}"
-echo "  DNS_IP=${DNS_IP}"
-echo "  ARCHITECTURE=${ARCHITECTURE}"
-echo "  TZ=${TZ}"
-echo "  APPS_REPOSITORY=${APPS_REPOSITORY}"
-echo "  REPO_ID=${REPO_ID}"
-echo "  JWT_SECRET=<redacted>"
-echo "  POSTGRES_PASSWORD=<redacted>"
-echo "  TIPI_VERSION=${TIPI_VERSION}"
-echo "  ROOT_FOLDER=${SED_ROOT_FOLDER}"
-echo "  APPS_REPOSITORY=${APPS_REPOSITORY_ESCAPED}"
+  # If appsRepoUrl is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoUrl)" != "null" ]]; then
+    APPS_REPOSITORY_ESCAPED="$(echo ${apps_repository} | sed 's/\//\\\//g')"
+  fi
+
+  # If appsRepoId is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoId)" != "null" ]]; then
+    REPO_ID=$(get_json_field "${STATE_FOLDER}/settings.json" appsRepoId)
+  fi
+
+  # If port is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" port)" != "null" ]]; then
+    NGINX_PORT=$(get_json_field "${STATE_FOLDER}/settings.json" port)
+  fi
+
+  # If sslPort is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" sslPort)" != "null" ]]; then
+    NGINX_PORT_SSL=$(get_json_field "${STATE_FOLDER}/settings.json" sslPort)
+  fi
+
+  # If listenIp is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)" != "null" ]]; then
+    INTERNAL_IP=$(get_json_field "${STATE_FOLDER}/settings.json" listenIp)
+  fi
+
+  # If storagePath is set in settings.json, use it
+  if [[ "$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)" != "null" ]]; then
+    storage_path="$(get_json_field "${STATE_FOLDER}/settings.json" storagePath)"
+    STORAGE_PATH_ESCAPED="$(echo "${storage_path}" | sed 's/\//\\\//g')"
+  fi
+fi
+
+# Set array with all new values
+new_values="DOMAIN=${DOMAIN}\nDNS_IP=${DNS_IP}\nAPPS_REPOSITORY=${APPS_REPOSITORY_ESCAPED}\nREPO_ID=${REPO_ID}\nNGINX_PORT=${NGINX_PORT}\nNGINX_PORT_SSL=${NGINX_PORT_SSL}\nINTERNAL_IP=${INTERNAL_IP}\nSTORAGE_PATH=${STORAGE_PATH_ESCAPED}\nTZ=${TZ}\nJWT_SECRET=${JWT_SECRET}\nROOT_FOLDER=${SED_ROOT_FOLDER}\nTIPI_VERSION=${TIPI_VERSION}\nARCHITECTURE=${ARCHITECTURE}"
+write_log "Final values: \n${new_values}"
 
 
 for template in ${ENV_FILE}; do
 for template in ${ENV_FILE}; do
   sed -i "s/<dns_ip>/${DNS_IP}/g" "${template}"
   sed -i "s/<dns_ip>/${DNS_IP}/g" "${template}"
@@ -221,11 +207,11 @@ for template in ${ENV_FILE}; do
   sed -i "s/<architecture>/${ARCHITECTURE}/g" "${template}"
   sed -i "s/<architecture>/${ARCHITECTURE}/g" "${template}"
   sed -i "s/<nginx_port>/${NGINX_PORT}/g" "${template}"
   sed -i "s/<nginx_port>/${NGINX_PORT}/g" "${template}"
   sed -i "s/<nginx_port_ssl>/${NGINX_PORT_SSL}/g" "${template}"
   sed -i "s/<nginx_port_ssl>/${NGINX_PORT_SSL}/g" "${template}"
-  sed -i "s/<proxy_port>/${PROXY_PORT}/g" "${template}"
   sed -i "s/<postgres_password>/${POSTGRES_PASSWORD}/g" "${template}"
   sed -i "s/<postgres_password>/${POSTGRES_PASSWORD}/g" "${template}"
   sed -i "s/<apps_repo_id>/${REPO_ID}/g" "${template}"
   sed -i "s/<apps_repo_id>/${REPO_ID}/g" "${template}"
   sed -i "s/<apps_repo_url>/${APPS_REPOSITORY_ESCAPED}/g" "${template}"
   sed -i "s/<apps_repo_url>/${APPS_REPOSITORY_ESCAPED}/g" "${template}"
   sed -i "s/<domain>/${DOMAIN}/g" "${template}"
   sed -i "s/<domain>/${DOMAIN}/g" "${template}"
+  sed -i "s/<storage_path>/${STORAGE_PATH_ESCAPED}/g" "${template}"
 done
 done
 
 
 mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
 mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
@@ -234,26 +220,20 @@ mv -f "$ENV_FILE" "$ROOT_FOLDER/.env"
 echo "Running system-info.sh..."
 echo "Running system-info.sh..."
 bash "${ROOT_FOLDER}/scripts/system-info.sh"
 bash "${ROOT_FOLDER}/scripts/system-info.sh"
 
 
-# Add crontab to run system-info.sh every minute
-! (crontab -l | grep -q "${ROOT_FOLDER}/scripts/system-info.sh") && (
-  crontab -l
-  echo "* * * * * ${ROOT_FOLDER}/scripts/system-info.sh"
-) | crontab -
-
 ## Don't run if config-only
 ## Don't run if config-only
 if [[ ! $ci == "true" ]]; then
 if [[ ! $ci == "true" ]]; then
 
 
   if [[ $rc == "true" ]]; then
   if [[ $rc == "true" ]]; then
-    docker-compose -f docker-compose.rc.yml --env-file "${ROOT_FOLDER}/.env" pull
-    # Run docker-compose
-    docker-compose -f docker-compose.rc.yml --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
+    docker compose -f docker-compose.rc.yml --env-file "${ROOT_FOLDER}/.env" pull
+    # Run docker compose
+    docker compose -f docker-compose.rc.yml --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
       echo "Failed to start containers"
       echo "Failed to start containers"
       exit 1
       exit 1
     }
     }
   else
   else
-    docker-compose --env-file "${ROOT_FOLDER}/.env" pull
-    # Run docker-compose
-    docker-compose --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
+    docker compose --env-file "${ROOT_FOLDER}/.env" pull
+    # Run docker compose
+    docker compose --env-file "${ROOT_FOLDER}/.env" up --detach --remove-orphans --build || {
       echo "Failed to start containers"
       echo "Failed to start containers"
       exit 1
       exit 1
     }
     }

+ 10 - 20
scripts/stop.sh

@@ -1,38 +1,28 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
 set -euo pipefail
 set -euo pipefail
 
 
-# use greadlink instead of readlink on osx
-if [[ "$(uname)" == "Darwin" ]]; then
-  readlink=greadlink
-else
-  readlink=readlink
-fi
-
-if [[ $UID != 0 ]]; then
-  echo "Tipi must be stopped as root"
-  echo "Please re-run this script as"
-  echo "  sudo ./scripts/stop.sh"
-  exit 1
-fi
+source "${BASH_SOURCE%/*}/common.sh"
 
 
-ROOT_FOLDER="$($readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
-STATE_FOLDER="${ROOT_FOLDER}/state"
+ensure_pwd
+ensure_root
 
 
-cd "$ROOT_FOLDER"
+ROOT_FOLDER="${PWD}"
+ENV_FILE="${ROOT_FOLDER}/.env"
+STORAGE_PATH=$(grep -v '^#' "${ENV_FILE}" | xargs -n 1 | grep STORAGE_PATH | cut -d '=' -f2)
 
 
 export DOCKER_CLIENT_TIMEOUT=240
 export DOCKER_CLIENT_TIMEOUT=240
 export COMPOSE_HTTP_TIMEOUT=240
 export COMPOSE_HTTP_TIMEOUT=240
 
 
 # Stop all installed apps if there are any
 # Stop all installed apps if there are any
 apps_folder="${ROOT_FOLDER}/apps"
 apps_folder="${ROOT_FOLDER}/apps"
-if [ "$(find ${apps_folder} -maxdepth 1 -type d | wc -l)" -gt 1 ]; then
+if [ "$(find "${apps_folder}" -maxdepth 1 -type d | wc -l)" -gt 1 ]; then
   apps_names=($(ls -d ${apps_folder}/*/ | xargs -n 1 basename | sed 's/\///g'))
   apps_names=($(ls -d ${apps_folder}/*/ | xargs -n 1 basename | sed 's/\///g'))
 
 
   for app_name in "${apps_names[@]}"; do
   for app_name in "${apps_names[@]}"; do
     # if folder ${ROOT_FOLDER}/app-data/app_name exists, then stop app
     # if folder ${ROOT_FOLDER}/app-data/app_name exists, then stop app
-    if [[ -d "${ROOT_FOLDER}/app-data/${app_name}" ]]; then
+    if [[ -d "${STORAGE_PATH}/app-data/${app_name}" ]]; then
       echo "Stopping ${app_name}"
       echo "Stopping ${app_name}"
-      "${ROOT_FOLDER}/scripts/app.sh" stop $app_name
+      "${ROOT_FOLDER}/scripts/app.sh" stop "$app_name"
     fi
     fi
   done
   done
 else
 else
@@ -41,4 +31,4 @@ fi
 
 
 echo "Stopping Docker services..."
 echo "Stopping Docker services..."
 echo
 echo
-docker-compose down --remove-orphans --rmi local
+docker compose down --remove-orphans --rmi local

+ 15 - 6
scripts/system-info.sh

@@ -1,25 +1,34 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
 set -e # Exit immediately if a command exits with a non-zero status.
 set -e # Exit immediately if a command exits with a non-zero status.
 
 
-ROOT_FOLDER="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
+ROOT_FOLDER="${PWD}"
+STATE_FOLDER="${ROOT_FOLDER}/state"
+
+# if not on linux exit
+if [[ "$(uname)" != "Linux" ]]; then
+    echo '{"cpu": { "load": 0 },"memory": { "available": 0, "total": 0, "used": 0 },"disk": { "available": 0, "total": 0, "used": 0 }}' >"${STATE_FOLDER}/system-info.json"
+    exit 0
+fi
+
+ROOT_FOLDER="$(pwd)"
 STATE_FOLDER="${ROOT_FOLDER}/state"
 STATE_FOLDER="${ROOT_FOLDER}/state"
 
 
 # Available disk space
 # Available disk space
 TOTAL_DISK_SPACE_BYTES=$(df -P -B 1 / | tail -n 1 | awk '{print $2}')
 TOTAL_DISK_SPACE_BYTES=$(df -P -B 1 / | tail -n 1 | awk '{print $2}')
 AVAILABLE_DISK_SPACE_BYTES=$(df -P -B 1 / | tail -n 1 | awk '{print $4}')
 AVAILABLE_DISK_SPACE_BYTES=$(df -P -B 1 / | tail -n 1 | awk '{print $4}')
-USED_DISK_SPACE_BYTES=$(($TOTAL_DISK_SPACE_BYTES - $AVAILABLE_DISK_SPACE_BYTES))
+USED_DISK_SPACE_BYTES=$((TOTAL_DISK_SPACE_BYTES - AVAILABLE_DISK_SPACE_BYTES))
 
 
 # CPU info
 # CPU info
 CPU_LOAD_PERCENTAGE=$(top -bn1 | grep "Cpu(s)" | sed "s/.*, *\([0-9.]*\)%* id.*/\1/" | awk '{print 100 - $1}')
 CPU_LOAD_PERCENTAGE=$(top -bn1 | grep "Cpu(s)" | sed "s/.*, *\([0-9.]*\)%* id.*/\1/" | awk '{print 100 - $1}')
 
 
 # Memory info
 # Memory info
-MEM_TOTAL_BYTES=$(free -b | grep Mem | awk '{print $2}')
-MEM_AVAILABLE_BYTES=$(free -b | grep Mem | awk '{print $7}')
-MEM_USED_BYTES=$(($MEM_TOTAL_BYTES - $MEM_AVAILABLE_BYTES))
+MEM_TOTAL_BYTES=$(($(grep </proc/meminfo MemTotal | awk '{print $2}') * 1024))
+MEM_AVAILABLE_BYTES=$(($(grep </proc/meminfo MemAvailable | awk '{print $2}') * 1024))
+MEM_USED_BYTES=$((MEM_TOTAL_BYTES - MEM_AVAILABLE_BYTES))
 
 
 # Create temporary json file
 # Create temporary json file
 TEMP_JSON_FILE=$(mktemp)
 TEMP_JSON_FILE=$(mktemp)
 echo '{ "cpu": { "load": '"${CPU_LOAD_PERCENTAGE}"' }, "memory": { "total": '"${MEM_TOTAL_BYTES}"' , "used": '"${MEM_USED_BYTES}"', "available": '"${MEM_AVAILABLE_BYTES}"' }, "disk": { "total": '"${TOTAL_DISK_SPACE_BYTES}"' , "used": '"${USED_DISK_SPACE_BYTES}"', "available": '"${AVAILABLE_DISK_SPACE_BYTES}"' } }' >"${TEMP_JSON_FILE}"
 echo '{ "cpu": { "load": '"${CPU_LOAD_PERCENTAGE}"' }, "memory": { "total": '"${MEM_TOTAL_BYTES}"' , "used": '"${MEM_USED_BYTES}"', "available": '"${MEM_AVAILABLE_BYTES}"' }, "disk": { "total": '"${TOTAL_DISK_SPACE_BYTES}"' , "used": '"${USED_DISK_SPACE_BYTES}"', "available": '"${AVAILABLE_DISK_SPACE_BYTES}"' } }' >"${TEMP_JSON_FILE}"
 
 
 # Write to state file
 # Write to state file
-echo "$(cat "${TEMP_JSON_FILE}")" >"${STATE_FOLDER}/system-info.json"
+cat "${TEMP_JSON_FILE}" >"${STATE_FOLDER}/system-info.json"

+ 33 - 0
scripts/system.sh

@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+
+source "${BASH_SOURCE%/*}/common.sh"
+ensure_pwd
+
+ROOT_FOLDER="${PWD}"
+
+if [ -z ${1+x} ]; then
+    command=""
+else
+    command="$1"
+fi
+
+# Restart Tipi
+if [[ "$command" = "restart" ]]; then
+    write_log "Restarting Tipi..."
+
+    scripts/stop.sh
+    scripts/start.sh
+
+    exit
+fi
+
+# Update Tipi
+if [[ "$command" = "update" ]]; then
+    write_log "Updating Tipi..."
+
+    scripts/stop.sh
+    git config --global --add safe.directory "${ROOT_FOLDER}"
+    git pull origin master
+    scripts/start.sh
+    exit
+fi

+ 3 - 3
scripts/unsafe-cleanup.sh

@@ -3,13 +3,13 @@
 
 
 # Prompt to confirm
 # Prompt to confirm
 echo "This will reset your system to factory defaults. Are you sure you want to do this? (y/n)"
 echo "This will reset your system to factory defaults. Are you sure you want to do this? (y/n)"
-read confirm
+read -r confirm
 if [ "$confirm" != "y" ]; then
 if [ "$confirm" != "y" ]; then
     echo "Aborting."
     echo "Aborting."
     exit 1
     exit 1
 fi
 fi
 
 
-ROOT_FOLDER="$(readlink -f $(dirname "${BASH_SOURCE[0]}")/..)"
+ROOT_FOLDER="$(readlink -f "$(dirname "${BASH_SOURCE[0]}")"/..)"
 
 
 # Stop Tipi
 # Stop Tipi
 "${ROOT_FOLDER}/scripts/stop.sh"
 "${ROOT_FOLDER}/scripts/stop.sh"
@@ -25,5 +25,5 @@ rm -rf "${ROOT_FOLDER}/app-data"
 rm -rf "${ROOT_FOLDER}/data/postgres"
 rm -rf "${ROOT_FOLDER}/data/postgres"
 mkdir -p "${ROOT_FOLDER}/app-data"
 mkdir -p "${ROOT_FOLDER}/app-data"
 
 
-cd "$ROOT_FOLDER"
+cd "$ROOT_FOLDER" || echo ""
 "${ROOT_FOLDER}/scripts/start.sh"
 "${ROOT_FOLDER}/scripts/start.sh"

+ 125 - 0
scripts/watcher.sh

@@ -0,0 +1,125 @@
+#!/usr/bin/env bash
+
+source "${BASH_SOURCE%/*}/common.sh"
+
+ROOT_FOLDER="${PWD}"
+WATCH_FILE="${ROOT_FOLDER}/state/events"
+
+function clean_events() {
+    echo "Cleaning events..."
+
+    # Create the file if it doesn't exist
+    if [[ ! -f "${WATCH_FILE}" ]]; then
+        touch "${WATCH_FILE}"
+    fi
+
+    echo "" >"$WATCH_FILE"
+
+    chmod -R a+rwx "${ROOT_FOLDER}/state/events"
+}
+
+function set_status() {
+    local id=$1
+    local status=$2
+
+    write_log "Setting status for ${id} to ${status}"
+
+    # Update the status of the event
+    if [[ "$(uname)" != "Linux" ]]; then
+        sed -i '' "s/${id} [a-z]*/${id} ${status}/g" "${WATCH_FILE}"
+    else
+        sed -i "s/${id}.*$/$(echo "${id} ${status}" | sed 's/\//\\\//g')/" "$WATCH_FILE"
+    fi
+}
+
+function run_command() {
+    local command_path="${1}"
+    local id=$2
+    shift 2
+
+    set_status "$id" "running"
+
+    $command_path "$@" >>"${ROOT_FOLDER}/logs/${id}.log" 2>&1
+
+    local result=$?
+
+    echo "Command ${command_path} exited with code ${result}"
+
+    if [[ $result -eq 0 ]]; then
+        set_status "$id" "success"
+    else
+        set_status "$id" "error"
+    fi
+}
+
+function select_command() {
+    # Example command:
+    # clone_repo id waiting "args"
+
+    local command=$(echo "$1" | cut -d ' ' -f 1)
+    local id=$(echo "$1" | cut -d ' ' -f 2)
+    local status=$(echo "$1" | cut -d ' ' -f 3)
+    local args=$(echo "$1" | cut -d ' ' -f 4-)
+
+    if [[ "$status" != "waiting" ]]; then
+        return 0
+    fi
+
+    write_log "Executing command ${command}"
+
+    if [ -z "$command" ]; then
+        return 0
+    fi
+
+    if [ "$command" = "clone_repo" ]; then
+        run_command "${ROOT_FOLDER}/scripts/git.sh" "$id" "clone" "$args"
+        return 0
+    fi
+
+    if [ "$command" = "update_repo" ]; then
+        run_command "${ROOT_FOLDER}/scripts/git.sh" "$id" "update" "$args"
+        return 0
+    fi
+
+    if [ "$command" = "app" ]; then
+        local arg1=$(echo "$args" | cut -d ' ' -f 1)
+        local arg2=$(echo "$args" | cut -d ' ' -f 2)
+
+        # Args example: start filebrowser
+        run_command "${ROOT_FOLDER}/scripts/app.sh" "$id" "$arg1" "$arg2"
+        return 0
+    fi
+
+    if [ "$command" = "system_info" ]; then
+        run_command "${ROOT_FOLDER}/scripts/system-info.sh" "$id"
+        return 0
+    fi
+
+    if [ "$command" = "update" ]; then
+        run_command "${ROOT_FOLDER}/scripts/system.sh" "$id" "update"
+        return 0
+    fi
+
+    if [ "$command" = "restart" ]; then
+        run_command "${ROOT_FOLDER}/scripts/system.sh" "$id" "restart"
+        return 0
+    fi
+
+    echo "Unknown command ${command}"
+    return 0
+}
+
+write_log "Listening for events in ${WATCH_FILE}..."
+clean_events
+# Listen in for changes in the WATCH_FILE
+fswatch -0 "${WATCH_FILE}" | while read -d ""; do
+    # Read the command from the last line of the file
+    command=$(tail -n 1 "${WATCH_FILE}")
+    status=$(echo "$command" | cut -d ' ' -f 3)
+
+    if [ -z "$command" ] || [ "$status" != "waiting" ]; then
+        continue
+    else
+        select_command "$command"
+    fi
+done

+ 2 - 2
templates/env-sample

@@ -12,6 +12,6 @@ JWT_SECRET=<jwt_secret>
 ROOT_FOLDER_HOST=<root_folder>
 ROOT_FOLDER_HOST=<root_folder>
 NGINX_PORT=<nginx_port>
 NGINX_PORT=<nginx_port>
 NGINX_PORT_SSL=<nginx_port_ssl>
 NGINX_PORT_SSL=<nginx_port_ssl>
-PROXY_PORT=<proxy_port>
 POSTGRES_PASSWORD=<postgres_password>
 POSTGRES_PASSWORD=<postgres_password>
-DOMAIN=<domain>
+DOMAIN=<domain>
+STORAGE_PATH=<storage_path>