Browse Source

feat: event base dispatcher to run commands from the host

Nicolas Meienberger 2 years ago
parent
commit
3b0fc56563

+ 2 - 4
Dockerfile

@@ -1,4 +1,4 @@
-FROM node:18 AS build
+FROM node:18-alpine3.16 AS build
 
 
 RUN npm install node-gyp -g
 RUN npm install node-gyp -g
 
 
@@ -19,7 +19,7 @@ COPY ./packages/dashboard /dashboard
 RUN npm run build
 RUN npm run build
 
 
 
 
-FROM ubuntu:22.04 as app
+FROM node:18-alpine3.16 as app
 
 
 WORKDIR /
 WORKDIR /
 
 
@@ -37,8 +37,6 @@ RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash -
 RUN apt-get install -y nodejs
 RUN apt-get install -y nodejs
 
 
 # Install dependencies
 # Install dependencies
-RUN apt-get install -y bash g++ make git 
-
 RUN npm install node-gyp -g
 RUN npm install node-gyp -g
 
 
 WORKDIR /api
 WORKDIR /api

+ 1 - 17
Dockerfile.dev

@@ -1,23 +1,7 @@
-FROM ubuntu:22.04
+FROM node:18-alpine3.16
 
 
 WORKDIR /
 WORKDIR /
 
 
-RUN apt-get update 
-# Install docker
-RUN apt-get install -y ca-certificates curl gnupg lsb-release jq
-RUN mkdir -p /etc/apt/keyrings
-RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
-RUN echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | tee /etc/apt/sources.list.d/docker.list >/dev/null
-RUN apt-get update
-RUN apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
-
-# Install node
-RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash -
-RUN apt-get install -y nodejs
-
-# Install dependencies
-RUN apt-get install -y bash g++ make git 
-
 RUN npm install node-gyp -g
 RUN npm install node-gyp -g
 
 
 WORKDIR /api
 WORKDIR /api

+ 4 - 1
docker-compose.dev.yml

@@ -53,10 +53,13 @@ services:
     volumes:
     volumes:
       ## Docker sock
       ## Docker sock
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - /var/run/docker.sock:/var/run/docker.sock:ro
-      - ${PWD}:/runtipi
+      - ${PWD}/apps:/runtipi/apps:ro
+      - ${PWD}/repos:/runtipi/repos:ro
+      - ${PWD}/state:/runtipi/state:ro
       - ${PWD}/packages/system-api/src:/api/src
       - ${PWD}/packages/system-api/src:/api/src
       - ${PWD}/logs:/app/logs
       - ${PWD}/logs:/app/logs
       - ${STORAGE_PATH}:/app/storage
       - ${STORAGE_PATH}:/app/storage
+      - ${PWD}/.env.dev:/runtipi/.env
       # - /api/node_modules
       # - /api/node_modules
     environment:
     environment:
       INTERNAL_IP: ${INTERNAL_IP}
       INTERNAL_IP: ${INTERNAL_IP}

+ 3 - 1
docker-compose.rc.yml

@@ -46,7 +46,9 @@ services:
     volumes:
     volumes:
       ## Docker sock
       ## Docker sock
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - /var/run/docker.sock:/var/run/docker.sock:ro
-      - ${PWD}:/runtipi
+      - ${PWD}/apps:/runtipi/apps:ro
+      - ${PWD}/repos:/runtipi/repos:ro
+      - ${PWD}/state:/runtipi/state:ro
       - ${PWD}/logs:/app/logs
       - ${PWD}/logs:/app/logs
       - ${STORAGE_PATH}:/app/storage
       - ${STORAGE_PATH}:/app/storage
     environment:
     environment:

+ 3 - 1
docker-compose.yml

@@ -46,7 +46,9 @@ services:
     volumes:
     volumes:
       ## Docker sock
       ## Docker sock
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - /var/run/docker.sock:/var/run/docker.sock:ro
-      - ${PWD}:/runtipi
+      - ${PWD}/apps:/runtipi/apps:ro
+      - ${PWD}/repos:/runtipi/repos:ro
+      - ${PWD}/state:/runtipi/state:ro
       - ${PWD}/logs:/app/logs
       - ${PWD}/logs:/app/logs
       - ${STORAGE_PATH}:/app/storage
       - ${STORAGE_PATH}:/app/storage
     environment:
     environment:

+ 1 - 1
package.json

@@ -7,7 +7,7 @@
     "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": "./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",

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

@@ -0,0 +1,197 @@
+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 queue: SystemEvent[] = [];
+
+  private lock: SystemEvent | null = null;
+
+  constructor() {
+    this.pollQueue();
+  }
+
+  /**
+   * Generate a random task id
+   * @returns - Random id
+   */
+  private generateId() {
+    return Math.random().toString(36).substr(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;
+    }
+
+    console.log('Status: ', status, 'clearing');
+    this.clearEvent(this.lock.id);
+    this.lock = null;
+  }
+
+  /**
+   * Poll queue and run events
+   */
+  private pollQueue() {
+    logger.info('EventDispatcher: Polling queue...');
+    setInterval(() => {
+      this.runEvent();
+      this.collectLockStatusAndClean();
+    }, 1000);
+  }
+
+  /**
+   * 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}`;
+    console.log('Writing line: ', line);
+    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;
+
+    if (status === 'error') {
+      console.error(lines);
+    }
+
+    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(id: string) {
+    this.queue = this.queue.filter((e) => e.id !== id);
+    if (fs.existsSync(`/app/logs/${id}.log`)) {
+      fs.unlinkSync(`/app/logs/${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(() => {
+        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 clear() {
+    this.queue = [];
+    this.lock = null;
+    fs.writeFileSync(WATCH_FILE, '');
+  }
+}
+
+export default new EventDispatcher();

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

@@ -0,0 +1,3 @@
+import EventDispatcher from '../EventDispatcher';
+
+describe('EventDispatcher', () => {});

+ 9 - 4
packages/system-api/src/core/jobs/jobs.ts

@@ -1,14 +1,19 @@
 import cron from 'node-cron';
 import cron from 'node-cron';
 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 { 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('Updating apps repo...');
-    updateRepo(getConfig().appsRepoUrl);
+  // Every 30 minutes
+  cron.schedule('*/30 * * * *', async () => {
+    EventDispatcher.dispatchEvent(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
+  });
+
+  // every minute
+  cron.schedule('* * * * *', () => {
+    EventDispatcher.dispatchEvent(EventTypes.SYSTEM_INFO, []);
   });
   });
 };
 };
 
 

+ 0 - 99
packages/system-api/src/helpers/__tests__/repo-helpers.test.ts

@@ -1,99 +0,0 @@
-import { faker } from '@faker-js/faker';
-import childProcess from 'child_process';
-import logger from '../../config/logger/logger';
-import { cloneRepo, updateRepo } from '../repo-helpers';
-
-jest.mock('child_process');
-
-beforeEach(async () => {
-  jest.resetModules();
-  jest.resetAllMocks();
-});
-
-describe('Test: updateRepo', () => {
-  it('Should run update script', async () => {
-    const log = jest.spyOn(logger, 'info');
-    const spy = jest.spyOn(childProcess, 'execFile');
-    const url = faker.internet.url();
-    const stdout = faker.random.words();
-
-    // @ts-ignore
-    spy.mockImplementation((_path, _args, _, cb) => {
-      // @ts-ignore
-      if (cb) cb(null, stdout, null);
-    });
-
-    await updateRepo(url);
-
-    expect(spy).toHaveBeenCalledWith('/runtipi/scripts/git.sh', ['update', url], {}, expect.any(Function));
-    expect(log).toHaveBeenCalledWith(`Update result: ${stdout}`);
-    spy.mockRestore();
-  });
-
-  it('Should throw and log error if script failed', async () => {
-    const url = faker.internet.url();
-
-    const log = jest.spyOn(logger, 'error');
-    const spy = jest.spyOn(childProcess, 'execFile');
-
-    const randomWord = faker.random.word();
-
-    // @ts-ignore
-    spy.mockImplementation((_path, _args, _, cb) => {
-      // @ts-ignore
-      if (cb) cb(randomWord, null, null);
-    });
-
-    try {
-      await updateRepo(url);
-    } catch (e) {
-      expect(e).toBe(randomWord);
-      expect(log).toHaveBeenCalledWith(`Error updating repo: ${randomWord}`);
-    }
-    spy.mockRestore();
-  });
-});
-
-describe('Test: cloneRepo', () => {
-  it('Should run clone script', async () => {
-    const log = jest.spyOn(logger, 'info');
-    const spy = jest.spyOn(childProcess, 'execFile');
-    const url = faker.internet.url();
-    const stdout = faker.random.words();
-
-    // @ts-ignore
-    spy.mockImplementation((_path, _args, _, cb) => {
-      // @ts-ignore
-      if (cb) cb(null, stdout, null);
-    });
-
-    await cloneRepo(url);
-
-    expect(spy).toHaveBeenCalledWith('/runtipi/scripts/git.sh', ['clone', url], {}, expect.any(Function));
-    expect(log).toHaveBeenCalledWith(`Clone result ${stdout}`);
-    spy.mockRestore();
-  });
-
-  it('Should throw and log error if script failed', async () => {
-    const url = faker.internet.url();
-
-    const log = jest.spyOn(logger, 'error');
-    const spy = jest.spyOn(childProcess, 'execFile');
-
-    const randomWord = faker.random.word();
-
-    // @ts-ignore
-    spy.mockImplementation((_path, _args, _, cb) => {
-      // @ts-ignore
-      if (cb) cb(randomWord, null, null);
-    });
-
-    try {
-      await cloneRepo(url);
-    } catch (e) {
-      expect(e).toBe(randomWord);
-      expect(log).toHaveBeenCalledWith(`Error cloning repo: ${randomWord}`);
-    }
-    spy.mockRestore();
-  });
-});

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

@@ -1,32 +0,0 @@
-import Logger from '../config/logger/logger';
-import { runScript } from '../modules/fs/fs.helpers';
-
-export const updateRepo = (repo: string): Promise<void> => {
-  return new Promise((resolve, reject) => {
-    runScript('/runtipi/scripts/git.sh', ['update', repo], (err: string, stdout: string) => {
-      if (err) {
-        Logger.error(`Error updating repo: ${err}`);
-        reject(err);
-      }
-
-      Logger.info(`Update result: ${stdout}`);
-
-      resolve();
-    });
-  });
-};
-
-export const cloneRepo = (repo: string): Promise<void> => {
-  return new Promise((resolve, reject) => {
-    runScript('/runtipi/scripts/git.sh', ['clone', repo], (err: string, stdout: string) => {
-      if (err) {
-        Logger.error(`Error cloning repo: ${err}`);
-        reject(err);
-      }
-
-      Logger.info(`Clone result ${stdout}`);
-
-      resolve();
-    });
-  });
-};

+ 2 - 3
packages/system-api/src/modules/apps/__tests__/apps.factory.ts

@@ -1,7 +1,6 @@
 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 App from '../app.entity';
 import App from '../app.entity';
-import { getConfig } from '../../../core/config/TipiConfig';
 
 
 interface IProps {
 interface IProps {
   installed?: boolean;
   installed?: boolean;
@@ -73,8 +72,8 @@ const createApp = async (props: IProps) => {
 
 
     MockFiles[`/app/storage/app-data/${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';
     MockFiles[`/app/storage/app-data/${appInfo.id}/app.env`] = 'TEST=test\nAPP_PORT=3000\nTEST_FIELD=test';
-    MockFiles[`/app/storage/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfo);
-    MockFiles[`/app/storage/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
+    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 };

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

@@ -311,7 +311,7 @@ describe('Test: getAppInfo', () => {
       id: faker.random.alphaNumeric(32),
       id: faker.random.alphaNumeric(32),
     };
     };
 
 
-    fs.writeFileSync(`/app/storage/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
+    fs.writeFileSync(`/runtipi/apps/${appInfo.id}/config.json`, JSON.stringify(newConfig));
 
 
     const app = await getAppInfo(appInfo.id, appEntity.status);
     const app = await getAppInfo(appInfo.id, appEntity.status);
 
 

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

@@ -112,7 +112,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(`/app/storage/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 +121,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[`/app/storage/apps/${appInfo.id}/docker-compose.yml`] = 'test';
-    MockFiles[`/app/storage/apps/${appInfo.id}/test.yml`] = 'test';
-    MockFiles[`/app/storage/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(`/app/storage/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(`/app/storage/apps/${app1.id}/test.yml`)).toBe(false);
-    expect(fs.existsSync(`/app/storage/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 () => {

+ 6 - 19
packages/system-api/src/modules/apps/apps.helpers.ts

@@ -1,5 +1,5 @@
 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 { AppInfo, AppStatusEnum } from './apps.types';
 import { AppInfo, AppStatusEnum } from './apps.types';
@@ -43,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(`/app/storage/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) => {
@@ -56,19 +56,6 @@ export const checkEnvFile = (appName: string) => {
   });
   });
 };
 };
 
 
-export const runAppScript = async (params: string[]): Promise<void> => {
-  return new Promise((resolve, reject) => {
-    runScript('/runtipi/scripts/app.sh', [...params], (err: string) => {
-      if (err) {
-        logger.error(`Error running app script: ${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());
@@ -76,7 +63,7 @@ const getEntropy = (name: string, length: number) => {
 };
 };
 
 
 export const generateEnvFile = (app: App) => {
 export const generateEnvFile = (app: App) => {
-  const configFile: AppInfo | null = readJsonFile(`/app/storage/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`);
@@ -145,9 +132,9 @@ export const getAppInfo = (id: string, status?: AppStatusEnum): AppInfo | null =
     // 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(`/app/storage/apps/${id}/config.json`)) {
-      const configFile: AppInfo = readJsonFile(`/app/storage/apps/${id}/config.json`);
-      configFile.description = readFile(`/app/storage/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(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
     } else if (fileExists(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`)) {
       const configFile: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);
       const configFile: AppInfo = readJsonFile(`/runtipi/repos/${getConfig().appsRepoId}/apps/${id}/config.json`);

+ 83 - 33
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 { Not } from 'typeorm';
 import { Not } from 'typeorm';
 import { getConfig } from '../../core/config/TipiConfig';
 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,7 +26,8 @@ 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]);
+        EventDispatcher.dispatchEvent(EventTypes.APP, ['start', app.id]);
+
         await App.update({ id: app.id }, { status: AppStatusEnum.RUNNING });
         await App.update({ id: app.id }, { status: AppStatusEnum.RUNNING });
       } catch (e) {
       } catch (e) {
         await App.update({ id: app.id }, { status: AppStatusEnum.STOPPED });
         await App.update({ id: app.id }, { status: AppStatusEnum.STOPPED });
@@ -32,6 +37,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 +50,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 +69,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 } });
 
 
@@ -85,7 +101,7 @@ const installApp = async (id: string, form: Record<string, string>, exposed?: bo
     // Create app folder
     // Create app folder
     createFolder(`/app/storage/app-data/${id}`);
     createFolder(`/app/storage/app-data/${id}`);
 
 
-    const appInfo: AppInfo | null = await readJsonFile(`/app/storage/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 +120,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,6 +134,10 @@ 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();
 
 
@@ -138,6 +158,14 @@ const listApps = async (): Promise<ListAppsResonse> => {
   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 +175,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(`/app/storage/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 +203,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 +216,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 +235,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 +251,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 +267,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 +282,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 +295,20 @@ 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(`/app/storage/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 {
-    await App.update({ id }, { status: AppStatusEnum.STOPPED });
+  } else {
+    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;

+ 10 - 22
packages/system-api/src/modules/fs/__tests__/fs.helpers.test.ts

@@ -1,5 +1,4 @@
-import childProcess from 'child_process';
-import { 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 { getConfig } from '../../../core/config/TipiConfig';
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
@@ -131,17 +130,6 @@ describe('Test: deleteFolder', () => {
   });
   });
 });
 });
 
 
-describe('Test: runScript', () => {
-  it('should run the script', () => {
-    const spy = jest.spyOn(childProcess, 'execFile');
-    const callback = jest.fn();
-
-    runScript('/test', [], callback);
-
-    expect(spy).toHaveBeenCalledWith('/test', [], {}, callback);
-  });
-});
-
 describe('Test: getSeed', () => {
 describe('Test: getSeed', () => {
   it('should return the seed', () => {
   it('should return the seed', () => {
     const mockFiles = {
     const mockFiles = {
@@ -169,15 +157,15 @@ describe('Test: ensureAppFolder', () => {
     ensureAppFolder('test');
     ensureAppFolder('test');
 
 
     // Assert
     // Assert
-    const files = fs.readdirSync('/app/storage/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 = {
       [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
       [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
-      ['/app/storage/apps/test']: ['docker-compose.yml'],
-      ['/app/storage/apps/test/docker-compose.yml']: 'test',
+      ['/runtipi/apps/test']: ['docker-compose.yml'],
+      ['/runtipi/apps/test/docker-compose.yml']: 'test',
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
@@ -187,15 +175,15 @@ describe('Test: ensureAppFolder', () => {
     ensureAppFolder('test');
     ensureAppFolder('test');
 
 
     // Assert
     // Assert
-    const files = fs.readdirSync('/app/storage/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 = {
       [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
       [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: ['test.yml'],
-      ['/app/storage/apps/test']: ['docker-compose.yml'],
-      ['/app/storage/apps/test/docker-compose.yml']: 'test',
+      ['/runtipi/apps/test']: ['docker-compose.yml'],
+      ['/runtipi/apps/test/docker-compose.yml']: 'test',
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
@@ -205,7 +193,7 @@ describe('Test: ensureAppFolder', () => {
     ensureAppFolder('test', true);
     ensureAppFolder('test', true);
 
 
     // Assert
     // Assert
-    const files = fs.readdirSync('/app/storage/apps/test');
+    const files = fs.readdirSync('/runtipi/apps/test');
     expect(files).toEqual(['test.yml']);
     expect(files).toEqual(['test.yml']);
   });
   });
 
 
@@ -214,7 +202,7 @@ describe('Test: ensureAppFolder', () => {
     const randomFileName = `${faker.random.word()}.yml`;
     const randomFileName = `${faker.random.word()}.yml`;
     const mockFiles = {
     const mockFiles = {
       [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: [randomFileName],
       [`/runtipi/repos/${getConfig().appsRepoId}/apps/test`]: [randomFileName],
-      ['/app/storage/apps/test']: ['test.yml'],
+      ['/runtipi/apps/test']: ['test.yml'],
     };
     };
 
 
     // @ts-ignore
     // @ts-ignore
@@ -224,7 +212,7 @@ describe('Test: ensureAppFolder', () => {
     ensureAppFolder('test');
     ensureAppFolder('test');
 
 
     // Assert
     // Assert
-    const files = fs.readdirSync('/app/storage/apps/test');
+    const files = fs.readdirSync('/runtipi/apps/test');
     expect(files).toEqual([randomFileName]);
     expect(files).toEqual([randomFileName]);
   });
   });
 });
 });

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

@@ -4,7 +4,7 @@ import { getConfig } from '../../core/config/TipiConfig';
 
 
 export const readJsonFile = (path: string): any => {
 export const readJsonFile = (path: string): any => {
   try {
   try {
-    const rawFile = fs.readFileSync(path)?.toString();
+    const rawFile = fs.readFileSync(path).toString();
 
 
     if (!rawFile) {
     if (!rawFile) {
       return null;
       return null;
@@ -37,21 +37,21 @@ export const createFolder = (path: string) => {
 };
 };
 export const deleteFolder = (path: string) => fs.rmSync(path, { recursive: true });
 export const deleteFolder = (path: string) => fs.rmSync(path, { recursive: true });
 
 
-export const runScript = (path: string, args: string[], callback?: any) => childProcess.execFile(path, args, {}, callback);
-
 export const getSeed = () => {
 export const getSeed = () => {
   const seed = readFile('/runtipi/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(`/app/storage/apps/${appName}`)) {
-    deleteFolder(`/app/storage/apps/${appName}`);
+  if (cleanup && fileExists(`/runtipi/apps/${appName}`)) {
+    deleteFolder(`/runtipi/apps/${appName}`);
   }
   }
 
 
-  if (!fileExists(`/app/storage/apps/${appName}/docker-compose.yml`)) {
-    if (fileExists(`/app/storage/apps/${appName}`)) deleteFolder(`/app/storage/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(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}`, `/app/storage/apps/${appName}`);
+    fs.copySync(`/runtipi/repos/${getConfig().appsRepoId}/apps/${appName}`, `/runtipi/apps/${appName}`);
   }
   }
 };
 };

+ 14 - 13
packages/system-api/src/modules/system/system.service.ts

@@ -4,7 +4,8 @@ import semver from 'semver';
 import logger from '../../config/logger/logger';
 import logger from '../../config/logger/logger';
 import TipiCache from '../../config/TipiCache';
 import TipiCache from '../../config/TipiCache';
 import { getConfig, setConfig } from '../../core/config/TipiConfig';
 import { getConfig, setConfig } from '../../core/config/TipiConfig';
-import { readJsonFile, runScript } from '../fs/fs.helpers';
+import { readJsonFile } from '../fs/fs.helpers';
+import EventDispatcher, { EventTypes } from '../../core/config/EventDispatcher';
 
 
 const systemInfoSchema = z.object({
 const systemInfoSchema = z.object({
   cpu: z.object({
   cpu: z.object({
@@ -57,12 +58,12 @@ const getVersion = async (): Promise<{ current: string; latest?: string }> => {
 const restart = async (): Promise<boolean> => {
 const restart = async (): Promise<boolean> => {
   setConfig('status', 'RESTARTING');
   setConfig('status', 'RESTARTING');
 
 
-  runScript('/runtipi/scripts/system.sh', ['restart'], (err: string) => {
-    setConfig('status', 'RUNNING');
-    if (err) {
-      logger.error(`Error restarting: ${err}`);
-    }
-  });
+  const { success } = await EventDispatcher.dispatchEventAsync(EventTypes.RESTART);
+
+  if (!success) {
+    logger.error('Error restarting system');
+    return false;
+  }
 
 
   setConfig('status', 'RUNNING');
   setConfig('status', 'RUNNING');
 
 
@@ -90,12 +91,12 @@ const update = async (): Promise<boolean> => {
 
 
   setConfig('status', 'UPDATING');
   setConfig('status', 'UPDATING');
 
 
-  runScript('/runtipi/scripts/system.sh', ['update'], (err: string) => {
-    setConfig('status', 'RUNNING');
-    if (err) {
-      logger.error(`Error updating: ${err}`);
-    }
-  });
+  const { success } = await EventDispatcher.dispatchEventAsync(EventTypes.UPDATE);
+
+  if (!success) {
+    logger.error('Error updating system');
+    return false;
+  }
 
 
   setConfig('status', 'RUNNING');
   setConfig('status', 'RUNNING');
 
 

+ 5 - 3
packages/system-api/src/server.ts

@@ -14,11 +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 { applyJsonConfig, getConfig, setConfig } from './core/config/TipiConfig';
 import { ZodError } from 'zod';
 import { ZodError } from 'zod';
 import systemController from './modules/system/system.controller';
 import systemController from './modules/system/system.controller';
+import EventDispatcher, { EventTypes } from './core/config/EventDispatcher';
 
 
 let corsOptions = {
 let corsOptions = {
   credentials: true,
   credentials: true,
@@ -53,6 +53,7 @@ const applyCustomConfig = () => {
 
 
 const main = async () => {
 const main = async () => {
   try {
   try {
+    EventDispatcher.clear();
     applyCustomConfig();
     applyCustomConfig();
 
 
     const app = express();
     const app = express();
@@ -93,8 +94,9 @@ const main = async () => {
     await runUpdates();
     await runUpdates();
 
 
     httpServer.listen(port, async () => {
     httpServer.listen(port, async () => {
-      await cloneRepo(getConfig().appsRepoUrl);
-      await updateRepo(getConfig().appsRepoUrl);
+      await EventDispatcher.dispatchEventAsync(EventTypes.CLONE_REPO, [getConfig().appsRepoUrl]);
+      await EventDispatcher.dispatchEventAsync(EventTypes.UPDATE_REPO, [getConfig().appsRepoUrl]);
+
       startJobs();
       startJobs();
       setConfig('status', 'RUNNING');
       setConfig('status', 'RUNNING');
 
 

+ 52 - 21
scripts/app.sh

@@ -49,7 +49,7 @@ else
     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="/app/storage/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"
@@ -110,7 +110,10 @@ if [[ "$command" = "install" ]]; then
   # Write to file script.log
   # Write to file script.log
   write_log "Installing app ${app}..."
   write_log "Installing app ${app}..."
 
 
-  compose "${app}" pull
+  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
@@ -120,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
@@ -142,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
@@ -160,27 +180,38 @@ if [[ "$command" = "update" ]]; then
   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
 
 
 exit 1
 exit 1

+ 16 - 4
scripts/common.sh

@@ -10,7 +10,7 @@ function get_json_field() {
 
 
 function write_log() {
 function write_log() {
     local message="$1"
     local message="$1"
-    local log_file="/app/logs/script.log"
+    local log_file="${PWD}/logs/script.log"
 
 
     echo "$(date) - ${message}" >>"${log_file}"
     echo "$(date) - ${message}" >>"${log_file}"
 }
 }
@@ -29,9 +29,6 @@ function derive_entropy() {
 }
 }
 
 
 function ensure_pwd() {
 function ensure_pwd() {
-    # # Ensure PWD ends with /runtipi
-    cd /runtipi || echo ""
-
     if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
     if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
         echo "Please run this script from the runtipi directory"
         echo "Please run this script from the runtipi directory"
         exit 1
         exit 1
@@ -69,3 +66,18 @@ function clean_logs() {
         done
         done
     fi
     fi
 }
 }
+
+function kill_watcher() {
+    watcher_pid=$(pgrep -f "runtipi/state/events")
+    # 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
+}

+ 51 - 92
scripts/configure.sh

@@ -1,5 +1,34 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
 
 
+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}"
   echo "Installing docker for os ${os}" >/dev/tty
   echo "Installing docker for os ${os}" >/dev/tty
@@ -42,12 +71,6 @@ function install_docker() {
     sudo pacman -Sy --noconfirm docker docker-compose
     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
@@ -74,65 +97,12 @@ function update_docker() {
     return 0
     return 0
   elif [[ "${os}" == "arch" ]]; then
   elif [[ "${os}" == "arch" ]]; then
     sudo pacman -Sy --noconfirm docker docker-compose
     sudo pacman -Sy --noconfirm docker docker-compose
-
-    if ! command -v crontab >/dev/null; then
-      sudo pacman -Sy --noconfirm cronie
-      systemctl enable --now cronie.service
-    fi
-
-    return 0
-  else
-    return 1
-  fi
-}
-
-function install_jq() {
-  local os="${1}"
-  echo "Installing jq for os ${os}" >/dev/tty
-
-  if [[ "${os}" == "debian" || "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
-    sudo apt-get update
-    sudo apt-get install -y jq
-    return 0
-  elif [[ "${os}" == "centos" ]]; then
-    sudo yum install -y jq
-    return 0
-  elif [[ "${os}" == "fedora" ]]; then
-    sudo dnf -y install jq
-    return 0
-  elif [[ "${os}" == "arch" ]]; then
-    sudo pacman -Sy --noconfirm jq
     return 0
     return 0
   else
   else
     return 1
     return 1
   fi
   fi
 }
 }
 
 
-function install_openssl() {
-  local os="${1}"
-  echo "Installing openssl for os ${os}" >/dev/tty
-
-  if [[ "${os}" == "debian" || "${os}" == "ubuntu" || "${os}" == "pop" ]]; then
-    sudo apt-get update
-    sudo apt-get install -y openssl
-    return 0
-  elif [[ "${os}" == "centos" ]]; then
-    sudo yum install -y openssl
-    return 0
-  elif [[ "${os}" == "fedora" ]]; then
-    sudo dnf -y install openssl
-    return 0
-  elif [[ "${os}" == "arch" ]]; then
-    sudo pacman -Sy --noconfirm openssl
-    return 0
-  else
-    return 1
-  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, ensuring Docker is fully up to date"
   echo "Docker is already installed, ensuring Docker is fully up to date"
 
 
@@ -173,42 +143,31 @@ else
   fi
   fi
 fi
 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
-
-if ! command -v openssl >/dev/null; then
-  install_openssl "${OS}"
-  openssl_result=$?
-
-  if [[ openssl_result -eq 0 ]]; then
-    echo "openssl installed"
-  else
-    echo "Your system ${OS} is not supported trying with sub_os ${SUB_OS}"
-    install_openssl "${SUB_OS}"
-    openssl_sub_result=$?
+}
 
 
-    if [[ openssl_sub_result -eq 0 ]]; then
-      echo "openssl installed"
-    else
-      echo "Your system ${SUB_OS} is not supported please install openssl manually"
-      exit 1
-    fi
-  fi
-fi
+check_dependency_and_install "jq"
+check_dependency_and_install "fswatch"
+check_dependency_and_install "openssl"

+ 25 - 19
scripts/git.sh

@@ -1,13 +1,8 @@
 #!/usr/bin/env bash
 #!/usr/bin/env bash
-# Don't break if command fails
 
 
-cd /runtipi || echo ""
+source "${BASH_SOURCE%/*}/common.sh"
 
 
-# Ensure PWD ends with /runtipi
-if [[ $(basename "$(pwd)") != "runtipi" ]] || [[ ! -f "${BASH_SOURCE[0]}" ]]; then
-    echo "Please make sure this script is executed from runtipi/"
-    exit 1
-fi
+ensure_pwd
 
 
 ROOT_FOLDER="${PWD}"
 ROOT_FOLDER="${PWD}"
 
 
@@ -28,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
@@ -47,15 +47,21 @@ if [[ "$command" = "update" ]]; then
     repo_hash=$(get_hash "${repo}")
     repo_hash=$(get_hash "${repo}")
     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 does not exist"
-        exit 0
+        write_log "Repo does not exist"
+        exit 1
     fi
     fi
 
 
-    echo "Updating ${repo} in ${repo_hash}"
+    write_log "Updating ${repo} in ${repo_hash}"
     cd "${repo_dir}" || exit
     cd "${repo_dir}" || exit
-    git pull origin master
-    echo "Done"
-    exit
+
+    if ! git pull origin master; then
+        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

+ 9 - 0
scripts/start-dev.sh

@@ -0,0 +1,9 @@
+#!/usr/bin/env bash
+source "${BASH_SOURCE%/*}/common.sh"
+
+ROOT_FOLDER="${PWD}"
+
+kill_watcher
+"${ROOT_FOLDER}/scripts/watcher.sh" &
+
+docker compose -f docker-compose.dev.yml --env-file "${ROOT_FOLDER}/.env.dev" up --build

+ 3 - 8
scripts/start.sh

@@ -4,8 +4,6 @@ set -e # Exit immediately if a command exits with a non-zero status.
 
 
 source "${BASH_SOURCE%/*}/common.sh"
 source "${BASH_SOURCE%/*}/common.sh"
 
 
-write_log "Starting Tipi..."
-
 ROOT_FOLDER="${PWD}"
 ROOT_FOLDER="${PWD}"
 
 
 # Cleanup and ensure environment
 # Cleanup and ensure environment
@@ -105,6 +103,9 @@ fi
 # Configure Tipi
 # Configure Tipi
 "${ROOT_FOLDER}/scripts/configure.sh"
 "${ROOT_FOLDER}/scripts/configure.sh"
 
 
+kill_watcher
+"${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"
@@ -201,12 +202,6 @@ 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
 
 

+ 5 - 3
scripts/stop.sh

@@ -3,10 +3,12 @@ set -euo pipefail
 
 
 source "${BASH_SOURCE%/*}/common.sh"
 source "${BASH_SOURCE%/*}/common.sh"
 
 
-ensure_root
 ensure_pwd
 ensure_pwd
+ensure_root
 
 
 ROOT_FOLDER="${PWD}"
 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
@@ -18,9 +20,9 @@ if [ "$(find "${apps_folder}" -maxdepth 1 -type d | wc -l)" -gt 1 ]; then
 
 
   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

+ 4 - 3
scripts/system-info.sh

@@ -1,9 +1,10 @@
 #!/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.
 
 
-source "${BASH_SOURCE%/*}/common.sh"
-
-ensure_pwd
+# if not on linux exit
+if [[ "$(uname)" != "Linux" ]]; then
+    exit 0
+fi
 
 
 ROOT_FOLDER="$(pwd)"
 ROOT_FOLDER="$(pwd)"
 STATE_FOLDER="${ROOT_FOLDER}/state"
 STATE_FOLDER="${ROOT_FOLDER}/state"

+ 8 - 0
scripts/system/restart.sh

@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+echo "Restarting Tipi..."
+
+scripts/stop.sh
+scripts/start.sh
+
+exit

+ 8 - 0
scripts/system/update.sh

@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+
+echo "Updating Tipi to latest version..."
+
+scripts/stop.sh
+git pull origin master
+scripts/start.sh
+exit

+ 104 - 0
scripts/watcher.sh

@@ -0,0 +1,104 @@
+#!/usr/bin/env bash
+
+source "${BASH_SOURCE%/*}/common.sh"
+
+ROOT_FOLDER="${PWD}"
+WATCH_FILE="${ROOT_FOLDER}/state/events"
+
+function clean_events() {
+    echo "Cleaning events..."
+    echo "" >"$WATCH_FILE"
+}
+
+function set_status() {
+    local id=$1
+    local status=$2
+
+    write_log "Setting status for ${id} to ${status}"
+
+    # Update the status of the event
+    sed -i '' "s/${id} [a-z]*/${id} ${status}/g" "${WATCH_FILE}"
+}
+
+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
+
+    echo "Unknown command ${command}"
+    return 0
+}
+
+check_running
+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