Compare commits

...

3 commits

Author SHA1 Message Date
Nicolas Meienberger
b673bfba3a wip [skip ci] 2023-07-18 20:48:42 +02:00
Nicolas Meienberger
23f702ab0f feat: add a warning for the user before updating an app 2023-07-06 11:09:02 +02:00
Nicolas Meienberger
a9ae28ae26 feat: add APP_HOST variable and refactor fs mocks with memfs 2023-07-06 00:30:18 +02:00
35 changed files with 1114 additions and 463 deletions

View file

@ -41,10 +41,12 @@ module.exports = {
'**/*.spec.{ts,tsx}',
'**/*.factory.{ts,tsx}',
'**/mocks/**',
'**/__mocks__/**',
'tests/**',
'**/*.d.ts',
'**/*.workspace.ts',
'**/*.setup.{ts,js}',
'**/*.config.{ts,js}',
],
},
],

View file

@ -1,179 +1,35 @@
import path from 'path';
import { fs, vol } from 'memfs';
class FsMock {
private static instance: FsMock;
private mockFiles = Object.create(null);
// private constructor() {}
static getInstance(): FsMock {
if (!FsMock.instance) {
FsMock.instance = new FsMock();
}
return FsMock.instance;
const copyFolderRecursiveSync = (src: string, dest: string) => {
const exists = vol.existsSync(src);
const stats = vol.statSync(src);
const isDirectory = exists && stats.isDirectory();
if (isDirectory) {
vol.mkdirSync(dest, { recursive: true });
vol.readdirSync(src).forEach((childItemName) => {
copyFolderRecursiveSync(`${src}/${childItemName}`, `${dest}/${childItemName}`);
});
} else {
vol.copyFileSync(src, dest);
}
};
__applyMockFiles = (newMockFiles: Record<string, string>) => {
export default {
...fs,
copySync: (src: string, dest: string) => {
copyFolderRecursiveSync(src, dest);
},
__resetAllMocks: () => {
vol.reset();
},
__applyMockFiles: (newMockFiles: Record<string, string>) => {
// Create folder tree
Object.keys(newMockFiles).forEach((file) => {
const dir = path.dirname(file);
if (!this.mockFiles[dir]) {
this.mockFiles[dir] = [];
}
this.mockFiles[dir].push(path.basename(file));
this.mockFiles[file] = newMockFiles[file];
});
};
__createMockFiles = (newMockFiles: Record<string, string>) => {
this.mockFiles = Object.create(null);
vol.fromJSON(newMockFiles, 'utf8');
},
__createMockFiles: (newMockFiles: Record<string, string>) => {
vol.reset();
// Create folder tree
Object.keys(newMockFiles).forEach((file) => {
const dir = path.dirname(file);
if (!this.mockFiles[dir]) {
this.mockFiles[dir] = [];
}
this.mockFiles[dir].push(path.basename(file));
this.mockFiles[file] = newMockFiles[file];
});
};
__resetAllMocks = () => {
this.mockFiles = Object.create(null);
};
readFileSync = (p: string) => this.mockFiles[p];
existsSync = (p: string) => this.mockFiles[p] !== undefined;
writeFileSync = (p: string, data: string | string[]) => {
this.mockFiles[p] = data;
};
mkdirSync = (p: string) => {
if (!this.mockFiles[p]) {
this.mockFiles[p] = [];
}
};
rmSync = (p: string) => {
if (this.mockFiles[p] instanceof Array) {
this.mockFiles[p].forEach((file: string) => {
delete this.mockFiles[path.join(p, file)];
});
}
delete this.mockFiles[p];
};
readdirSync = (p: string) => {
const files: string[] = [];
const depth = p.split('/').length;
Object.keys(this.mockFiles).forEach((file) => {
if (file.startsWith(p)) {
const fileDepth = file.split('/').length;
if (fileDepth === depth + 1) {
files.push(file.split('/').pop() || '');
}
}
});
return files;
};
copyFileSync = (source: string, destination: string) => {
this.mockFiles[destination] = this.mockFiles[source];
};
copySync = (source: string, destination: string) => {
this.mockFiles[destination] = this.mockFiles[source];
if (this.mockFiles[source] instanceof Array) {
this.mockFiles[source].forEach((file: string) => {
this.mockFiles[`${destination}/${file}`] = this.mockFiles[`${source}/${file}`];
});
}
};
createFileSync = (p: string) => {
this.mockFiles[p] = '';
};
unlinkSync = (p: string) => {
if (this.mockFiles[p] instanceof Array) {
this.mockFiles[p].forEach((file: string) => {
delete this.mockFiles[path.join(p, file)];
});
}
delete this.mockFiles[p];
};
getMockFiles = () => this.mockFiles;
promises = {
unlink: async (p: string) => {
if (this.mockFiles[p] instanceof Array) {
this.mockFiles[p].forEach((file: string) => {
delete this.mockFiles[path.join(p, file)];
});
}
delete this.mockFiles[p];
},
writeFile: async (p: string, data: string | string[]) => {
this.mockFiles[p] = data;
const dir = path.dirname(p);
if (!this.mockFiles[dir]) {
this.mockFiles[dir] = [];
}
this.mockFiles[dir].push(path.basename(p));
},
mkdir: async (p: string) => {
if (!this.mockFiles[p]) {
this.mockFiles[p] = [];
}
},
readdir: async (p: string) => {
const files: string[] = [];
const depth = p.split('/').length;
Object.keys(this.mockFiles).forEach((file) => {
if (file.startsWith(p)) {
const fileDepth = file.split('/').length;
if (fileDepth === depth + 1) {
files.push(file.split('/').pop() || '');
}
}
});
return files;
},
lstat: async (p: string) => {
return {
isDirectory: () => {
return this.mockFiles[p] instanceof Array;
},
};
},
readFile: async (p: string) => {
return this.mockFiles[p];
},
copyFile: async (source: string, destination: string) => {
this.mockFiles[destination] = this.mockFiles[source];
},
};
}
export default FsMock.getInstance();
vol.fromJSON(newMockFiles, 'utf8');
},
__printVol: () => console.log(vol.toTree()),
};

View file

@ -22,6 +22,7 @@ export default async () => {
const serverConfig = await createJestConfig(customServerConfig)();
return {
randomize: true,
verbose: true,
collectCoverage: true,
collectCoverageFrom: ['src/server/**/*.{ts,tsx}', 'src/client/**/*.{ts,tsx}', '!src/**/mocks/**/*.{ts,tsx}', '!**/*.{spec,test}.{ts,tsx}', '!**/index.{ts,tsx}'],

View file

@ -45,6 +45,7 @@
"@tabler/icons-react": "^2.23.0",
"@tanstack/react-query": "^4.29.7",
"@tanstack/react-query-devtools": "^4.29.7",
"@tanstack/react-table": "^8.9.3",
"@trpc/client": "^10.27.1",
"@trpc/next": "^10.27.1",
"@trpc/react-query": "^10.27.1",
@ -66,6 +67,7 @@
"qrcode.react": "^3.1.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-headless-pagination": "^1.1.4",
"react-hook-form": "^7.45.1",
"react-hot-toast": "^2.4.1",
"react-markdown": "^8.0.7",
@ -79,6 +81,7 @@
"semver": "^7.5.3",
"sharp": "0.32.1",
"superjson": "^1.12.3",
"trpc-panel": "^1.3.4",
"tslib": "^2.5.3",
"uuid": "^9.0.0",
"validator": "^13.7.0",
@ -137,6 +140,7 @@
"eslint-plugin-testing-library": "^5.11.0",
"jest": "^29.5.0",
"jest-environment-jsdom": "^29.5.0",
"memfs": "^4.2.0",
"msw": "^1.2.2",
"next-router-mock": "^0.9.7",
"nodemon": "^2.0.22",

View file

@ -1,9 +1,5 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
'@hookform/resolvers':
specifier: ^3.1.1
@ -47,6 +43,9 @@ dependencies:
'@tanstack/react-query-devtools':
specifier: ^4.29.7
version: 4.29.7(@tanstack/react-query@4.29.7)(react-dom@18.2.0)(react@18.2.0)
'@tanstack/react-table':
specifier: ^8.9.3
version: 8.9.3(react-dom@18.2.0)(react@18.2.0)
'@trpc/client':
specifier: ^10.27.1
version: 10.27.1(@trpc/server@10.27.1)
@ -110,6 +109,9 @@ dependencies:
react-dom:
specifier: 18.2.0
version: 18.2.0(react@18.2.0)
react-headless-pagination:
specifier: ^1.1.4
version: 1.1.4(react@18.2.0)
react-hook-form:
specifier: ^7.45.1
version: 7.45.1(react@18.2.0)
@ -149,6 +151,9 @@ dependencies:
superjson:
specifier: ^1.12.3
version: 1.12.3
trpc-panel:
specifier: ^1.3.4
version: 1.3.4(@trpc/server@10.27.1)(zod@3.21.4)
tslib:
specifier: ^2.5.3
version: 2.5.3
@ -319,6 +324,9 @@ devDependencies:
jest-environment-jsdom:
specifier: ^29.5.0
version: 29.5.0
memfs:
specifier: ^4.2.0
version: 4.2.0(quill-delta@5.1.0)(rxjs@7.8.0)(tslib@2.5.3)
msw:
specifier: ^1.2.2
version: 1.2.2(typescript@5.1.5)
@ -1615,10 +1623,10 @@ packages:
resolution: {integrity: sha512-NEpkObxPwyw/XxZVLPmAGKE89IQRp4puc6IQRPru6JKd1M3fW9v1xM1AnzIJE65hbCkzQAdnL8P47e9hzhiYLQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
chalk: 4.1.2
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-util: 29.5.0
slash: 3.0.0
dev: true
@ -1636,7 +1644,7 @@ packages:
'@jest/reporters': 29.5.0
'@jest/test-result': 29.5.0
'@jest/transform': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
ansi-escapes: 4.3.2
chalk: 4.1.2
@ -1646,7 +1654,7 @@ packages:
jest-changed-files: 29.5.0
jest-config: 29.5.0(@types/node@20.3.2)(ts-node@10.9.1)
jest-haste-map: 29.5.0
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-regex-util: 29.4.3
jest-resolve: 29.5.0
jest-resolve-dependencies: 29.5.0
@ -1657,7 +1665,7 @@ packages:
jest-validate: 29.5.0
jest-watcher: 29.5.0
micromatch: 4.0.5
pretty-format: 29.5.0
pretty-format: 29.6.0
slash: 3.0.0
strip-ansi: 6.0.1
transitivePeerDependencies:
@ -1670,7 +1678,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/fake-timers': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
jest-mock: 29.5.0
dev: true
@ -1696,10 +1704,10 @@ packages:
resolution: {integrity: sha512-9ARvuAAQcBwDAqOnglWq2zwNIRUDtk/SCkp/ToGEhFv5r86K21l+VEs0qNTaXtyiY0lEePl3kylijSYJQqdbDg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@sinonjs/fake-timers': 10.0.2
'@types/node': 20.3.2
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-mock: 29.5.0
jest-util: 29.5.0
dev: true
@ -1710,7 +1718,7 @@ packages:
dependencies:
'@jest/environment': 29.5.0
'@jest/expect': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
jest-mock: 29.5.0
transitivePeerDependencies:
- supports-color
@ -1729,7 +1737,7 @@ packages:
'@jest/console': 29.5.0
'@jest/test-result': 29.5.0
'@jest/transform': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@jridgewell/trace-mapping': 0.3.17
'@types/node': 20.3.2
chalk: 4.1.2
@ -1742,7 +1750,7 @@ packages:
istanbul-lib-report: 3.0.0
istanbul-lib-source-maps: 4.0.1
istanbul-reports: 3.1.5
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-util: 29.5.0
jest-worker: 29.5.0
slash: 3.0.0
@ -1760,6 +1768,13 @@ packages:
'@sinclair/typebox': 0.25.23
dev: true
/@jest/schemas@29.6.0:
resolution: {integrity: sha512-rxLjXyJBTL4LQeJW3aKo0M/+GkCOXsO+8i9Iu7eDb6KwtP65ayoDsitrdPBtujxQ88k4wI2FNYfa6TOGwSn6cQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@sinclair/typebox': 0.27.8
dev: true
/@jest/source-map@29.4.3:
resolution: {integrity: sha512-qyt/mb6rLyd9j1jUts4EQncvS6Yy3PM9HghnNv86QBlV+zdL2inCdK1tuVlL+J+lpiw2BI67qXOrX3UurBqQ1w==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -1774,7 +1789,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/console': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/istanbul-lib-coverage': 2.0.4
collect-v8-coverage: 1.0.1
dev: true
@ -1794,7 +1809,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@babel/core': 7.22.5
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@jridgewell/trace-mapping': 0.3.17
babel-plugin-istanbul: 6.1.1
chalk: 4.1.2
@ -1824,6 +1839,18 @@ packages:
chalk: 4.1.2
dev: true
/@jest/types@29.6.0:
resolution: {integrity: sha512-8XCgL9JhqbJTFnMRjEAO+TuW251+MoMd5BSzLiE3vvzpQ8RlBxy8NoyNkDhs3K3OL3HeVinlOl9or5p7GTeOLg==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/schemas': 29.6.0
'@types/istanbul-lib-coverage': 2.0.4
'@types/istanbul-reports': 3.0.1
'@types/node': 20.3.2
'@types/yargs': 17.0.22
chalk: 4.1.2
dev: true
/@jridgewell/gen-mapping@0.1.1:
resolution: {integrity: sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w==}
engines: {node: '>=6.0.0'}
@ -2766,6 +2793,10 @@ packages:
resolution: {integrity: sha512-VEB8ygeP42CFLWyAJhN5OklpxUliqdNEUcXb4xZ/CINqtYGTjL5ukluKdKzQ0iWdUxyQ7B0539PAUhHKrCNWSQ==}
dev: true
/@sinclair/typebox@0.27.8:
resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==}
dev: true
/@sinonjs/commons@2.0.0:
resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==}
dependencies:
@ -2899,6 +2930,23 @@ packages:
use-sync-external-store: 1.2.0(react@18.2.0)
dev: false
/@tanstack/react-table@8.9.3(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-Ng9rdm3JPoSCi6cVZvANsYnF+UoGVRxflMb270tVj0+LjeT/ZtZ9ckxF6oLPLcKesza6VKBqtdF9mQ+vaz24Aw==}
engines: {node: '>=12'}
peerDependencies:
react: '>=16'
react-dom: '>=16'
dependencies:
'@tanstack/table-core': 8.9.3
react: 18.2.0
react-dom: 18.2.0(react@18.2.0)
dev: false
/@tanstack/table-core@8.9.3:
resolution: {integrity: sha512-NpHZBoHTfqyJk0m/s/+CSuAiwtebhYK90mDuf5eylTvgViNOujiaOaxNDxJkQQAsVvHWZftUGAx1EfO1rkKtLg==}
engines: {node: '>=12'}
dev: false
/@testing-library/dom@9.3.1:
resolution: {integrity: sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w==}
engines: {node: '>=14'}
@ -3813,6 +3861,10 @@ packages:
resolution: {integrity: sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==}
dev: true
/arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
dev: true
/argon2@0.30.3:
resolution: {integrity: sha512-DoH/kv8c9127ueJSBxAVJXinW9+EuPA3EMUxoV2sAY1qDE5H9BjTyVF/aD2XyHqbqUWabgBkIfcP3ZZuGhbJdg==}
engines: {node: '>=14.0.0'}
@ -4294,6 +4346,10 @@ packages:
resolution: {integrity: sha512-cOU9usZw8/dXIXKtwa8pM0OTJQuJkxMN6w30csNRUerHfeQ5R6U3kkU/FtJeIf3M202OHfY2U8ccInBG7/xogA==}
dev: true
/classnames@2.3.1:
resolution: {integrity: sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==}
dev: false
/classnames@2.3.2:
resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==}
dev: false
@ -5708,7 +5764,7 @@ packages:
'@jest/expect-utils': 29.5.0
jest-get-type: 29.4.3
jest-matcher-utils: 29.5.0
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-util: 29.5.0
dev: true
@ -5985,6 +6041,10 @@ packages:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
dev: true
/fuzzysort@2.0.4:
resolution: {integrity: sha512-Api1mJL+Ad7W7vnDZnWq5pGaXJjyencT+iKGia2PlHUcSsSzWwIQ3S1isiMpwpavjYtGd2FzhUIhnnhOULZgDw==}
dev: false
/gauge@3.0.2:
resolution: {integrity: sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==}
engines: {node: '>=10'}
@ -6322,6 +6382,11 @@ packages:
engines: {node: '>=10.17.0'}
dev: true
/hyperdyperid@1.2.0:
resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==}
engines: {node: '>=10.18'}
dev: true
/iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@ -6382,6 +6447,10 @@ packages:
once: 1.4.0
wrappy: 1.0.2
/inherits@2.0.3:
resolution: {integrity: sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw==}
dev: false
/inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@ -6753,7 +6822,7 @@ packages:
'@jest/environment': 29.5.0
'@jest/expect': 29.5.0
'@jest/test-result': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
chalk: 4.1.2
co: 4.6.0
@ -6761,12 +6830,12 @@ packages:
is-generator-fn: 2.1.0
jest-each: 29.5.0
jest-matcher-utils: 29.5.0
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-runtime: 29.5.0
jest-snapshot: 29.5.0
jest-util: 29.5.0
p-limit: 3.1.0
pretty-format: 29.5.0
pretty-format: 29.6.0
pure-rand: 6.0.1
slash: 3.0.0
stack-utils: 2.0.6
@ -6786,7 +6855,7 @@ packages:
dependencies:
'@jest/core': 29.5.0(ts-node@10.9.1)
'@jest/test-result': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
chalk: 4.1.2
exit: 0.1.2
graceful-fs: 4.2.10
@ -6816,7 +6885,7 @@ packages:
dependencies:
'@babel/core': 7.22.5
'@jest/test-sequencer': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
babel-jest: 29.5.0(@babel/core@7.22.5)
chalk: 4.1.2
@ -6834,7 +6903,7 @@ packages:
jest-validate: 29.5.0
micromatch: 4.0.5
parse-json: 5.2.0
pretty-format: 29.5.0
pretty-format: 29.6.0
slash: 3.0.0
strip-json-comments: 3.1.1
ts-node: 10.9.1(@types/node@20.3.2)(typescript@5.1.5)
@ -6849,7 +6918,7 @@ packages:
chalk: 4.1.2
diff-sequences: 29.4.3
jest-get-type: 29.4.3
pretty-format: 29.5.0
pretty-format: 29.6.0
dev: true
/jest-docblock@29.4.3:
@ -6863,11 +6932,11 @@ packages:
resolution: {integrity: sha512-HM5kIJ1BTnVt+DQZ2ALp3rzXEl+g726csObrW/jpEGl+CDSSQpOJJX2KE/vEg8cxcMXdyEPu6U4QX5eruQv5hA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
chalk: 4.1.2
jest-get-type: 29.4.3
jest-util: 29.5.0
pretty-format: 29.5.0
pretty-format: 29.6.0
dev: true
/jest-environment-jsdom@29.5.0:
@ -6899,7 +6968,7 @@ packages:
dependencies:
'@jest/environment': 29.5.0
'@jest/fake-timers': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
jest-mock: 29.5.0
jest-util: 29.5.0
@ -6914,7 +6983,7 @@ packages:
resolution: {integrity: sha512-IspOPnnBro8YfVYSw6yDRKh/TiCdRngjxeacCps1cQ9cgVN6+10JUcuJ1EabrgYLOATsIAigxA0rLR9x/YlrSA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/graceful-fs': 4.1.6
'@types/node': 20.3.2
anymatch: 3.1.3
@ -6934,7 +7003,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
jest-get-type: 29.4.3
pretty-format: 29.5.0
pretty-format: 29.6.0
dev: true
/jest-matcher-utils@29.5.0:
@ -6944,20 +7013,20 @@ packages:
chalk: 4.1.2
jest-diff: 29.5.0
jest-get-type: 29.4.3
pretty-format: 29.5.0
pretty-format: 29.6.0
dev: true
/jest-message-util@29.5.0:
resolution: {integrity: sha512-Kijeg9Dag6CKtIDA7O21zNTACqD5MD/8HfIV8pdD94vFyFuer52SigdC3IQMhab3vACxXMiFk+yMHNdbqtyTGA==}
/jest-message-util@29.6.0:
resolution: {integrity: sha512-mkCp56cETbpoNtsaeWVy6SKzk228mMi9FPHSObaRIhbR2Ujw9PqjW/yqVHD2tN1bHbC8ol6h3UEo7dOPmIYwIA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@babel/code-frame': 7.22.5
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/stack-utils': 2.0.1
chalk: 4.1.2
graceful-fs: 4.2.10
micromatch: 4.0.5
pretty-format: 29.5.0
pretty-format: 29.6.0
slash: 3.0.0
stack-utils: 2.0.6
dev: true
@ -6966,7 +7035,7 @@ packages:
resolution: {integrity: sha512-GqOzvdWDE4fAV2bWQLQCkujxYWL7RxjCnj71b5VhDAGOevB3qj3Ovg26A5NI84ZpODxyzaozXLOh2NCgkbvyaw==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
jest-util: 29.5.0
dev: true
@ -7021,7 +7090,7 @@ packages:
'@jest/environment': 29.5.0
'@jest/test-result': 29.5.0
'@jest/transform': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
chalk: 4.1.2
emittery: 0.13.1
@ -7030,7 +7099,7 @@ packages:
jest-environment-node: 29.5.0
jest-haste-map: 29.5.0
jest-leak-detector: 29.5.0
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-resolve: 29.5.0
jest-runtime: 29.5.0
jest-util: 29.5.0
@ -7052,7 +7121,7 @@ packages:
'@jest/source-map': 29.4.3
'@jest/test-result': 29.5.0
'@jest/transform': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
chalk: 4.1.2
cjs-module-lexer: 1.2.2
@ -7060,7 +7129,7 @@ packages:
glob: 7.2.3
graceful-fs: 4.2.10
jest-haste-map: 29.5.0
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-mock: 29.5.0
jest-regex-util: 29.4.3
jest-resolve: 29.5.0
@ -7084,7 +7153,7 @@ packages:
'@babel/types': 7.22.5
'@jest/expect-utils': 29.5.0
'@jest/transform': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/babel__traverse': 7.18.3
'@types/prettier': 2.7.2
babel-preset-current-node-syntax: 1.0.1(@babel/core@7.22.5)
@ -7094,10 +7163,10 @@ packages:
jest-diff: 29.5.0
jest-get-type: 29.4.3
jest-matcher-utils: 29.5.0
jest-message-util: 29.5.0
jest-message-util: 29.6.0
jest-util: 29.5.0
natural-compare: 1.4.0
pretty-format: 29.5.0
pretty-format: 29.6.0
semver: 7.5.3
transitivePeerDependencies:
- supports-color
@ -7107,7 +7176,7 @@ packages:
resolution: {integrity: sha512-RYMgG/MTadOr5t8KdhejfvUU82MxsCu5MF6KuDUHl+NuwzUt+Sm6jJWxTJVrDR1j5M/gJVCPKQEpWXY+yIQ6lQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
chalk: 4.1.2
ci-info: 3.8.0
@ -7119,12 +7188,12 @@ packages:
resolution: {integrity: sha512-pC26etNIi+y3HV8A+tUGr/lph9B18GnzSRAkPaaZJIE1eFdiYm6/CewuiJQ8/RlfHd1u/8Ioi8/sJ+CmbA+zAQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/types': 29.5.0
'@jest/types': 29.6.0
camelcase: 6.3.0
chalk: 4.1.2
jest-get-type: 29.4.3
leven: 3.1.0
pretty-format: 29.5.0
pretty-format: 29.6.0
dev: true
/jest-watcher@29.5.0:
@ -7132,7 +7201,7 @@ packages:
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/test-result': 29.5.0
'@jest/types': 29.5.0
'@jest/types': 29.6.0
'@types/node': 20.3.2
ansi-escapes: 4.3.2
chalk: 4.1.2
@ -7259,6 +7328,22 @@ packages:
dreamopt: 0.8.0
dev: true
/json-joy@9.3.0(quill-delta@5.1.0)(rxjs@7.8.0)(tslib@2.5.3):
resolution: {integrity: sha512-ZQiyMcbcfqki5Bsk0kWfne/Ixl4Q6cLBzCd3VE/TSp7jhns/WDBrIMTuyzDfwmLxuFtQdojiLSLX8MxTyK23QA==}
engines: {node: '>=10.0'}
hasBin: true
peerDependencies:
quill-delta: ^5
rxjs: '7'
tslib: '2'
dependencies:
arg: 5.0.2
hyperdyperid: 1.2.0
quill-delta: 5.1.0
rxjs: 7.8.0
tslib: 2.5.3
dev: true
/json-parse-even-better-errors@2.3.1:
resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}
@ -7384,6 +7469,14 @@ packages:
p-locate: 5.0.0
dev: true
/lodash.clonedeep@4.5.0:
resolution: {integrity: sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==}
dev: true
/lodash.isequal@4.5.0:
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
dev: true
/lodash.memoize@4.1.2:
resolution: {integrity: sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==}
dev: true
@ -7633,6 +7726,20 @@ packages:
engines: {node: '>= 0.6'}
dev: false
/memfs@4.2.0(quill-delta@5.1.0)(rxjs@7.8.0)(tslib@2.5.3):
resolution: {integrity: sha512-V5/xE+zl6+soWxlBjiVTQSkfXybTwhEBj2I8sK9LaS5lcZsTuhRftakrcRpDY7Ycac2NTK/VzEtpKMp+gpymrQ==}
engines: {node: '>= 4.0.0'}
peerDependencies:
tslib: '2'
dependencies:
json-joy: 9.3.0(quill-delta@5.1.0)(rxjs@7.8.0)(tslib@2.5.3)
thingies: 1.12.0(tslib@2.5.3)
tslib: 2.5.3
transitivePeerDependencies:
- quill-delta
- rxjs
dev: true
/memoize-one@6.0.0:
resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
dev: false
@ -8529,6 +8636,13 @@ packages:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
/path@0.12.7:
resolution: {integrity: sha512-aXXC6s+1w7otVF9UletFkFcDsJeO7lSZBPUQhtb5O0xJe8LtYhj/GxldoL09bBj9+ZmE2hNoHqQSFMN5fikh4Q==}
dependencies:
process: 0.11.10
util: 0.10.4
dev: false
/pathe@1.1.1:
resolution: {integrity: sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==}
dev: true
@ -8761,6 +8875,20 @@ packages:
react-is: 18.2.0
dev: true
/pretty-format@29.6.0:
resolution: {integrity: sha512-XH+D4n7Ey0iSR6PdAnBs99cWMZdGsdKrR33iUHQNr79w1szKTCIZDVdXuccAsHVwDBp0XeWPfNEoaxP9EZgRmQ==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
dependencies:
'@jest/schemas': 29.6.0
ansi-styles: 5.2.0
react-is: 18.2.0
dev: true
/process@0.11.10:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
dev: false
/prompts@2.4.2:
resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==}
engines: {node: '>= 6'}
@ -8803,6 +8931,10 @@ packages:
once: 1.4.0
dev: false
/punycode@1.4.1:
resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==}
dev: false
/punycode@2.3.0:
resolution: {integrity: sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==}
engines: {node: '>=6'}
@ -8834,6 +8966,15 @@ packages:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
dev: true
/quill-delta@5.1.0:
resolution: {integrity: sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==}
engines: {node: '>= 12.0.0'}
dependencies:
fast-diff: 1.3.0
lodash.clonedeep: 4.5.0
lodash.isequal: 4.5.0
dev: true
/random-bytes@1.0.0:
resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==}
engines: {node: '>= 0.8'}
@ -8873,6 +9014,16 @@ packages:
react: 18.2.0
scheduler: 0.23.0
/react-headless-pagination@1.1.4(react@18.2.0):
resolution: {integrity: sha512-Z5d55g3gM2BQMvHJUGm1jbbQ5Bgtq54kNlI5ca1NTwdVR8ZNunN0EdOtNKNobsFRKuZGkQ24VTIu6ulNq190Iw==}
engines: {node: '>=12.13'}
peerDependencies:
react: '>=16'
dependencies:
classnames: 2.3.1
react: 18.2.0
dev: false
/react-hook-form@7.45.1(react@18.2.0):
resolution: {integrity: sha512-6dWoFJwycbuFfw/iKMcl+RdAOAOHDiF11KWYhNDRN/OkUt+Di5qsZHwA0OwsVnu9y135gkHpTw9DJA+WzCeR9w==}
engines: {node: '>=12.22.0'}
@ -9783,6 +9934,15 @@ packages:
resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==}
dev: true
/thingies@1.12.0(tslib@2.5.3):
resolution: {integrity: sha512-AiGqfYC1jLmJagbzQGuoZRM48JPsr9yB734a7K6wzr34NMhjUPrWSQrkF7ZBybf3yCerCL2Gcr02kMv4NmaZfA==}
engines: {node: '>=10.18'}
peerDependencies:
tslib: ^2
dependencies:
tslib: 2.5.3
dev: true
/thirty-two@1.0.2:
resolution: {integrity: sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==}
engines: {node: '>=0.2.6'}
@ -9890,6 +10050,20 @@ packages:
resolution: {integrity: sha512-AqTiAOLcj85xS7vQ8QkAV41hPDIJ71XJB4RCUrzo/1GM2CQwhkJGaf9Hgr7BOugMRpgGUrqRg/DrBDl4H40+8g==}
dev: false
/trpc-panel@1.3.4(@trpc/server@10.27.1)(zod@3.21.4):
resolution: {integrity: sha512-u5/dCi/AAp2tpJcCL5ZCfrdJtHHu8hrtm2hzSBZCE7z9Tw6MB1rCcliSQvgMPIEXMQrgwXk4t4IedfWkxioKng==}
peerDependencies:
'@trpc/server': ^10.0.0
zod: ^3.19.1
dependencies:
'@trpc/server': 10.27.1
fuzzysort: 2.0.4
path: 0.12.7
url: 0.11.1
zod: 3.21.4
zod-to-json-schema: 3.21.3(zod@3.21.4)
dev: false
/ts-jest@29.1.0(@babel/core@7.22.5)(esbuild@0.16.17)(jest@29.5.0)(typescript@5.1.5):
resolution: {integrity: sha512-ZhNr7Z4PcYa+JjMl62ir+zPiNJfXJN6E8hSLnaUKhOgqcn8vb3e537cpkd0FuAfRK3sR1LSqM1MOhliXNgOFPA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@ -10185,6 +10359,13 @@ packages:
requires-port: 1.0.0
dev: true
/url@0.11.1:
resolution: {integrity: sha512-rWS3H04/+mzzJkv0eZ7vEDGiQbgquI1fGfOad6zKvgYQi1SzMmhl7c/DdRGxhaWrVH6z0qWITo8rpnxK/RfEhA==}
dependencies:
punycode: 1.4.1
qs: 6.11.0
dev: false
/urlsafe-base64@1.0.0:
resolution: {integrity: sha512-RtuPeMy7c1UrHwproMZN9gN6kiZ0SvJwRaEzwZY0j9MypEkFqyBaKv176jvlPtg58Zh36bOkS0NFABXMHvvGCA==}
dev: false
@ -10255,6 +10436,12 @@ packages:
/util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
/util@0.10.4:
resolution: {integrity: sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==}
dependencies:
inherits: 2.0.3
dev: false
/util@0.12.5:
resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==}
dependencies:
@ -10746,6 +10933,14 @@ packages:
engines: {node: '>=12.20'}
dev: true
/zod-to-json-schema@3.21.3(zod@3.21.4):
resolution: {integrity: sha512-09W/9oyxeF1/wWnzCb6MursW+lOzgKi91QwE7eTBbC+t/qgfuLsUVDai3lHemSQnQu/UONAcT/fv3ZnDvbTeKg==}
peerDependencies:
zod: ^3.21.4
dependencies:
zod: 3.21.4
dev: false
/zod@3.21.4:
resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==}
@ -10768,3 +10963,7 @@ packages:
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

View file

@ -1,5 +1,4 @@
#!/usr/bin/env bash
echo "Starting app script"
source "${BASH_SOURCE%/*}/common.sh"
@ -33,7 +32,7 @@ else
if [[ ! -d "${app_dir}" ]]; then
# copy from repo
echo "Copying app from repo"
write_log "Copying app from repo"
mkdir -p "${app_dir}"
cp -r "${ROOT_FOLDER}/repos/${REPO_ID}/apps/${app}"/* "${app_dir}"
fi
@ -41,7 +40,7 @@ else
app_data_dir="${STORAGE_PATH}/app-data/${app}"
if [[ -z "${app}" ]] || [[ ! -d "${app_dir}" ]]; then
echo "Error: \"${app}\" is not a valid app"
write_log "Error: \"${app}\" is not a valid app"
exit 1
fi
fi
@ -243,6 +242,34 @@ function stop_app() {
exit 0
}
function backup_app() {
local app="${1}"
write_log "Backing up app ${app}..."
local file_name="${app}-$(date +%Y-%m-%d-%H-%M-%S).tar.gz"
local backup_file="${STORAGE_PATH}/backups/apps/${app}/${file_name}"
if [[ ! -d "${ROOT_FOLDER_HOST}/backups/apps/${app}" ]]; then
mkdir -p "${ROOT_FOLDER_HOST}/backups/apps/${app}"
fi
# Create a temp folder
local temp_dir=$(mktemp -d)
# Copy app data to temp folder
cp -a "${app_data_dir}" "${temp_dir}/app-data"
cp -a "${ROOT_FOLDER_HOST}/apps/${app}" "${temp_dir}/app"
if ! tar -czf "${backup_file}" -C "${temp_dir}" .; then
write_log "Failed to backup app ${app}"
exit 1
fi
echo "${file_name}"
exit 0
}
# Install new app
if [[ "$command" = "install" ]]; then
install_app "${app}"
@ -268,6 +295,11 @@ if [[ "$command" = "start" ]]; then
start_app "${app}"
fi
# Backups an installed app
if [[ "$command" = "backup" ]]; then
backup_app "${app}"
fi
if [[ "$command" = "clean" ]]; then
# Remove all stopped containers and unused images
write_log "Cleaning up..."

View file

@ -37,10 +37,14 @@ function run_command() {
set_status "$id" "running"
write_log "Running command ${command_path} with args $@"
$command_path "$@" >>"${ROOT_FOLDER}/logs/${id}.log" 2>&1
local result=$?
write_log "Command ${command_path} finished with result ${result}"
if [[ $result -eq 0 ]]; then
set_status "$id" "success"
else
@ -61,7 +65,7 @@ function select_command() {
return 0
fi
write_log "Executing command ${command}"
write_log "Executing command ${command} for ${id} with args ${args}"
if [ -z "$command" ]; then
return 0

View file

@ -16,6 +16,11 @@ jest.mock('next/router', () => {
describe('Test: StatusProvider', () => {
it("should render it's children when system is RUNNING", async () => {
const { result, unmount } = renderHook(() => useSystemStore());
act(() => {
result.current.setStatus('RUNNING');
});
render(
<StatusProvider>
<div>system running</div>
@ -25,10 +30,12 @@ describe('Test: StatusProvider', () => {
await waitFor(() => {
expect(screen.getByText('system running')).toBeInTheDocument();
});
unmount();
});
it('should render StatusScreen when system is RESTARTING', async () => {
const { result } = renderHook(() => useSystemStore());
const { result, unmount } = renderHook(() => useSystemStore());
act(() => {
result.current.setStatus('RESTARTING');
});
@ -41,10 +48,12 @@ describe('Test: StatusProvider', () => {
await waitFor(() => {
expect(screen.getByText('Your system is restarting...')).toBeInTheDocument();
});
unmount();
});
it('should render StatusScreen when system is UPDATING', async () => {
const { result } = renderHook(() => useSystemStore());
const { result, unmount } = renderHook(() => useSystemStore());
act(() => {
result.current.setStatus('UPDATING');
});
@ -58,10 +67,12 @@ describe('Test: StatusProvider', () => {
await waitFor(() => {
expect(screen.getByText('Your system is updating...')).toBeInTheDocument();
});
unmount();
});
it('should reload the page when system is RUNNING after being something else than RUNNING', async () => {
const { result } = renderHook(() => useSystemStore());
const { result, unmount } = renderHook(() => useSystemStore());
act(() => {
result.current.setStatus('UPDATING');
});
@ -82,5 +93,6 @@ describe('Test: StatusProvider', () => {
await waitFor(() => {
expect(reloadFn).toHaveBeenCalled();
});
unmount();
});
});

View file

@ -0,0 +1,38 @@
import React from 'react';
import clsx from 'clsx';
type CardProps = React.HTMLAttributes<HTMLDivElement>;
const Card = ({ children, className, ...rest }: CardProps) => {
return (
<div className={clsx('card', className)} {...rest}>
{children}
</div>
);
};
const CardHeader = ({ children, className, ...rest }: CardProps) => {
return (
<div className={clsx('card-header border-bottom d-flex align-items-center', className)} {...rest}>
{children}
</div>
);
};
const CardTitle = ({ children, className, ...rest }: CardProps) => {
return (
<div className={clsx('card-title', className)} {...rest}>
{children}
</div>
);
};
const CardActions = ({ children, className, ...rest }: CardProps) => {
return (
<div className={clsx('card-actions', className)} {...rest}>
{children}
</div>
);
};
export { Card, CardHeader, CardActions, CardTitle };

View file

@ -0,0 +1 @@
export { Card, CardActions, CardHeader, CardTitle } from './Card';

View file

@ -0,0 +1,130 @@
import React from 'react';
import { Pagination } from 'react-headless-pagination';
import { ColumnDef, OnChangeFn, SortingState, Header, flexRender, getCoreRowModel, getSortedRowModel, useReactTable, PaginationState } from '@tanstack/react-table';
import clsx from 'clsx';
import { Card, CardActions, CardHeader } from '../Card';
import { Button } from '../Button';
import { Input } from '../Input';
type IProps<T> = {
data: T[];
columns: ColumnDef<T, any>[];
sorting?: SortingState;
onSortingChange?: OnChangeFn<SortingState>;
pagination?: PaginationState;
pageCount?: number;
onPaginationChange?: OnChangeFn<PaginationState>;
total?: number;
loading?: boolean;
tableActions?: React.ReactNode;
};
export const DataTable = <T = unknown,>(props: IProps<T>) => {
const { columns, data, sorting, onSortingChange, pagination, pageCount = 1, onPaginationChange, total, loading, tableActions } = props;
const table = useReactTable({
data,
columns,
state: {
sorting,
pagination,
},
onSortingChange,
pageCount,
onPaginationChange,
manualPagination: true,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
const renderHeader = (header: Header<T, unknown>) => {
if (header.isPlaceholder) {
return null;
}
if (header.column.getCanSort()) {
return (
<button
className={clsx('table-sort cursor-pointer', { asc: header.column.getIsSorted() === 'asc', desc: header.column.getIsSorted() === 'desc' })}
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</button>
);
}
return flexRender(header.column.columnDef.header, header.getContext());
};
const renderPagination = () => {
if (!pagination) {
return null;
}
return (
<Pagination
edgePageCount={1}
middlePagesSiblingCount={1}
currentPage={pagination.pageIndex}
setCurrentPage={table.setPageIndex}
totalPages={pageCount}
className="card-footer d-sm-flex align-items-center"
truncableClassName="page-item page-link"
truncableText="..."
>
<p className="m-0 mb-2 mb-sm-0 text-muted">
Showing {pagination.pageIndex * pagination.pageSize + 1} to {pagination.pageIndex * pagination.pageSize + data.length} of {total} items
</p>
<ul className="pagination mb-0 ms-auto">
<Pagination.PageButton activeClassName="active" className="page-item page-link cursor-pointer" />
</ul>
</Pagination>
);
};
return (
<Card>
<CardHeader>
<div className="text-muted flex-1">
Page
<div className="mx-2 d-inline-block">
<Input
type="number"
value={String(pagination?.pageIndex || 0 + 1)}
onChange={(e) => table.setPageIndex(Number(e.target.value || 0))}
min={1}
max={pageCount + 1}
size="sm"
aria-label="Current page"
/>
</div>
of {Math.max(pageCount - 1 || 0, 1)}
</div>
<div className={clsx('ms-2 spinner-border spinner-border-sm text-muted d-block', { 'd-none': !loading })} role="status" />
<CardActions>{tableActions}</CardActions>
</CardHeader>
<div className="table-responsive">
<table className="table card-table table-vcenter text-nowrap datatable">
<thead>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<th key={header.id}>{header.isPlaceholder ? null : renderHeader(header)}</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
{renderPagination()}
</Card>
);
};

View file

@ -14,31 +14,38 @@ interface IProps {
disabled?: boolean;
value?: string;
readOnly?: boolean;
min?: number;
max?: number;
size?: 'sm' | 'lg';
}
export const Input = React.forwardRef<HTMLInputElement, IProps>(({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled, readOnly }, ref) => (
<div className={clsx(className)}>
{label && (
<label htmlFor={name} className="form-label">
{label}
</label>
)}
{/* eslint-disable-next-line jsx-a11y/no-redundant-roles */}
<input
aria-label={name}
role="textbox"
disabled={disabled}
name={name}
id={name}
onBlur={onBlur}
onChange={onChange}
value={value}
type={type}
ref={ref}
className={clsx('form-control', { 'is-invalid is-invalid-lite': error || isInvalid })}
placeholder={placeholder}
readOnly={readOnly}
/>
{error && <div className="invalid-feedback">{error}</div>}
</div>
));
export const Input = React.forwardRef<HTMLInputElement, IProps>(
({ onChange, onBlur, name, label, placeholder, error, type = 'text', className, value, isInvalid, disabled, readOnly, min, max, size }, ref) => (
<div className={clsx(className)}>
{label && (
<label htmlFor={name} className="form-label">
{label}
</label>
)}
{/* eslint-disable-next-line jsx-a11y/no-redundant-roles */}
<input
min={type === 'number' ? min : undefined}
max={type === 'number' ? max : undefined}
aria-label={name}
role="textbox"
disabled={disabled}
name={name}
id={name}
onBlur={onBlur}
onChange={onChange}
value={value}
type={type}
ref={ref}
className={clsx('form-control', { 'is-invalid is-invalid-lite': error || isInvalid, 'form-control-sm': size === 'sm' })}
placeholder={placeholder}
readOnly={readOnly}
/>
{error && <div className="invalid-feedback">{error}</div>}
</div>
),
);

View file

@ -137,6 +137,7 @@
"version": "Version",
"description": "Description",
"base-info": "Base info",
"backups": "Backups",
"source-code": "Source code",
"author": "Author",
"port": "Port",
@ -208,6 +209,7 @@
},
"update-form": {
"title": "Update {name} ?",
"warning": "You are about to update {name} from version {current} to {latest}. Make sure you have a backup of your data before proceeding. If you are skipping any major version, follow any migration instructions from the app developers and read the release notes for each version in between.",
"subtitle1": "Update app to latest verion :",
"subtitle2": "This will reset your custom configuration (e.g. changes in docker-compose.yml)",
"submit": "Update"

View file

@ -5,12 +5,14 @@ import { useTranslations } from 'next-intl';
import { DataGrid, DataGridItem } from '../../../components/ui/DataGrid';
import Markdown from '../../../components/Markdown/Markdown';
import { AppInfo } from '../../../core/types';
import { BackupsList } from './BackupsList';
interface IProps {
info: AppInfo;
installed?: boolean;
}
export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
export const AppDetailsTabs: React.FC<IProps> = ({ info, installed }) => {
const t = useTranslations('apps.app-details');
return (
@ -18,6 +20,9 @@ export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
<TabsList>
<TabsTrigger value="description">{t('description')}</TabsTrigger>
<TabsTrigger value="info">{t('base-info')}</TabsTrigger>
<TabsTrigger disabled={!installed} value="backups">
{t('backups')}
</TabsTrigger>
</TabsList>
<TabsContent value="description">
<Markdown className="markdown">{info.description}</Markdown>
@ -61,6 +66,9 @@ export const AppDetailsTabs: React.FC<IProps> = ({ info }) => {
)}
</DataGrid>
</TabsContent>
<TabsContent value="backups">
<BackupsList id={info.id} />
</TabsContent>
</Tabs>
);
};

View file

@ -0,0 +1,132 @@
import React from 'react';
import { toast } from 'react-hot-toast';
import { PaginationState, SortingState, createColumnHelper } from '@tanstack/react-table';
import { DataTable } from '@/components/ui/DataTable/DataTable';
import { Backup } from '@/server/db/schema';
import { trpc } from '@/utils/trpc';
import { Button } from '@/components/ui/Button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuTrigger } from '@/components/ui/DropdownMenu';
const columnHelper = createColumnHelper<Backup>();
const getBestUnit = (sizeInBytes: bigint) => {
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let unitIndex = 0;
let size = Number(sizeInBytes);
while (size > 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${Math.round(size * 100) / 100} ${units[unitIndex]}`;
};
type IProps = {
id: string;
};
const getColumns = (onDownload: (id: string) => void, onRestore: (id: string) => void, onDelete: (id: string) => void) => {
return [
columnHelper.accessor('filename', {
cell: (info) => info.getValue(),
enableSorting: false,
}),
columnHelper.accessor('version', {
id: 'version',
cell: (info) => <b>{info.getValue()}</b>,
header: () => <span>Version</span>,
enableSorting: false,
}),
columnHelper.accessor('size', {
id: 'size',
cell: (info) => <span>{getBestUnit(info.getValue())}</span>,
header: () => <span>Size</span>,
enableSorting: false,
}),
columnHelper.accessor((row) => row.createdAt, {
id: 'date',
cell: (info) => <span>{new Date(info.getValue()).toLocaleString()}</span>,
header: () => <span>Date</span>,
enableSorting: false,
}),
columnHelper.accessor('id', {
id: 'actions',
cell: (cell) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>
<span>Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Action</DropdownMenuLabel>
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => onDownload(cell.row.getValue('filename'))}>Download</DropdownMenuItem>
<DropdownMenuItem onClick={() => onRestore(cell.row.getValue('filename'))}>Restore</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDelete(cell.row.getValue('filename'))}>Delete</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
),
header: () => <span>Actions</span>,
enableSorting: false,
}),
];
};
export const BackupsList: React.FC<IProps> = ({ id }) => {
const [sorting, setSorting] = React.useState<SortingState>([]);
const [{ pageIndex, pageSize }, setPagination] = React.useState<PaginationState>({
pageIndex: 0,
pageSize: 10,
});
const context = trpc.useContext();
const defaultData = React.useMemo(() => [], []);
const { data, isFetched, isFetching } = trpc.app.listBackups.useQuery({ pageIndex, pageSize, id });
const { mutate, isLoading } = trpc.app.backupApp.useMutation({
onSuccess: () => {
context.app.listBackups.invalidate({ pageIndex, pageSize, id });
toast.success('Backup created');
},
});
const renderTableActions = () => {
return (
<Button onClick={() => mutate({ id })} loading={isLoading}>
Backup now
</Button>
);
};
const downloadBackup = async (filename: string) => {
alert(`Download ${filename}`);
};
const restoreBackup = async (filename: string) => {
alert(`Restore ${filename}`);
};
const deleteBackup = async (filename: string) => {
alert(`Delete ${filename}`);
};
const columns = React.useMemo(() => getColumns(downloadBackup, restoreBackup, deleteBackup), []);
return (
<DataTable
tableActions={renderTableActions()}
data={data?.data || defaultData}
total={data?.total || 0}
pagination={{ pageSize, pageIndex }}
pageCount={data?.pageCount || 0}
columns={columns}
sorting={sorting}
onSortingChange={setSorting}
onPaginationChange={setPagination}
loading={isFetching && !isFetched}
/>
);
};

View file

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

View file

@ -1,34 +1,39 @@
import React from 'react';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
import { useTranslations } from 'next-intl';
import { IconAlertTriangle } from '@tabler/icons-react';
import { Button } from '../../../../components/ui/Button';
import { AppInfo } from '../../../../core/types';
interface IProps {
newVersion: string;
info: Pick<AppInfo, 'name'>;
info: Pick<AppInfo, 'name' | 'version'>;
isOpen: boolean;
onClose: () => void;
onDownloadBackup: () => void;
onConfirm: () => void;
}
export const UpdateModal: React.FC<IProps> = ({ info, newVersion, isOpen, onClose, onConfirm }) => {
export const UpdateModal: React.FC<IProps> = ({ info, newVersion, isOpen, onClose, onConfirm, onDownloadBackup }) => {
const t = useTranslations('apps.app-details.update-form');
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent size="sm">
<DialogContent type="danger" size="lg">
<DialogHeader>
<h5 className="modal-title">{t('title', { name: info.name })}</h5>
</DialogHeader>
<DialogDescription>
<DialogDescription className="text-center">
<IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
<h4>{t('warning', { name: info.name, current: info.version, latest: newVersion })}</h4>
<div className="text-muted">
{t('subtitle1')} <b>{newVersion}</b> ?<br />
{t('subtitle2')}
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={onConfirm} className="btn-success">
<Button onClick={onDownloadBackup}>Download backup</Button>
<Button onClick={onConfirm} className="btn-danger">
{t('submit')}
</Button>
</DialogFooter>

View file

@ -202,7 +202,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
/>
</div>
</div>
<AppDetailsTabs info={app.info} />
<AppDetailsTabs info={app.info} installed={app.status !== 'missing'} />
</div>
);
};

17
src/pages/api/panel.ts Normal file
View file

@ -0,0 +1,17 @@
import { mainRouter } from '@/server/routers/_app';
import type { NextApiRequest, NextApiResponse } from 'next';
import { renderTrpcPanel } from 'trpc-panel';
/**
*
* @param _
* @param res
*/
export default async function handler(_: NextApiRequest, res: NextApiResponse) {
res.status(200).send(
renderTrpcPanel(mainRouter, {
url: 'http://localhost:3000/api/trpc',
transformer: 'superjson',
}),
);
}

View file

@ -8,10 +8,13 @@ jest.mock('fs-extra');
// eslint-disable-next-line no-promise-executor-return
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
beforeEach(() => {
beforeEach(async () => {
await fs.promises.mkdir('/runtipi/state', { recursive: true });
await fs.promises.mkdir('/app/logs', { recursive: true });
await fs.promises.writeFile(WATCH_FILE, '');
await fs.promises.writeFile('/app/logs/123.log', 'test');
EventDispatcher.clear();
fs.writeFileSync(WATCH_FILE, '');
fs.writeFileSync('/app/logs/123.log', 'test');
});
describe('EventDispatcher - dispatchEvent', () => {

View file

@ -3,11 +3,7 @@ import fs from 'fs-extra';
import { getConfig, setConfig, getSettings, setSettings, TipiConfig } from '.';
import { readJsonFile } from '../../common/fs.helpers';
beforeEach(async () => {
// @ts-expect-error - We are mocking fs
fs.__resetAllMocks();
jest.mock('fs-extra');
});
jest.mock('fs-extra');
jest.mock('next/config', () =>
jest.fn(() => ({
@ -124,9 +120,9 @@ describe('Test: setConfig', () => {
expect(error).toBeDefined();
});
it('Should write config to json file', () => {
it('Should write config to json file', async () => {
const randomWord = faker.internet.url();
setConfig('appsRepoUrl', randomWord, true);
await setConfig('appsRepoUrl', randomWord, true);
const config = getConfig();
expect(config).toBeDefined();
@ -175,14 +171,14 @@ describe('Test: getSettings', () => {
});
describe('Test: setSettings', () => {
it('should write settings to json file', () => {
it('should write settings to json file', async () => {
// arrange
const fakeSettings = {
appsRepoUrl: faker.internet.url(),
};
// act
setSettings(fakeSettings);
await setSettings(fakeSettings);
const settingsJson = readJsonFile('/runtipi/state/settings.json') as { [key: string]: string };
// assert

View file

@ -1,5 +1,5 @@
import { InferModel } from 'drizzle-orm';
import { pgTable, pgEnum, integer, varchar, timestamp, serial, boolean, text, jsonb } from 'drizzle-orm/pg-core';
import { pgTable, pgEnum, integer, varchar, timestamp, serial, boolean, text, jsonb, bigint } from 'drizzle-orm/pg-core';
export const updateStatusEnum = pgEnum('update_status_enum', ['SUCCESS', 'FAILED']);
export const appStatusEnum = pgEnum('app_status_enum', ['running', 'stopped', 'starting', 'stopping', 'updating', 'missing', 'installing', 'uninstalling']);
@ -15,7 +15,7 @@ export const migrations = pgTable('migrations', {
});
export const userTable = pgTable('user', {
id: serial('id').notNull(),
id: serial('id').primaryKey(),
username: varchar('username').notNull(),
password: varchar('password').notNull(),
createdAt: timestamp('createdAt', { mode: 'string' }).defaultNow().notNull(),
@ -38,7 +38,7 @@ export const update = pgTable('update', {
});
export const appTable = pgTable('app', {
id: varchar('id').notNull(),
id: varchar('id').primaryKey(),
status: appStatusEnum('status').default('stopped').notNull(),
lastOpened: timestamp('lastOpened', { withTimezone: true, mode: 'string' }).defaultNow(),
numOpened: integer('numOpened').default(0).notNull(),
@ -51,3 +51,17 @@ export const appTable = pgTable('app', {
});
export type App = InferModel<typeof appTable>;
export type NewApp = InferModel<typeof appTable, 'insert'>;
export const backupTable = pgTable('backup', {
id: serial('id').notNull(),
appId: varchar('app_id')
.references(() => appTable.id)
.notNull(),
filename: varchar('filename').notNull(),
version: varchar('version').notNull(),
size: bigint('size', { mode: 'bigint' }).notNull(),
createdAt: timestamp('createdAt', { mode: 'string' }).defaultNow().notNull(),
updatedAt: timestamp('updatedAt', { mode: 'string' }).defaultNow().notNull(),
});
export type Backup = InferModel<typeof backupTable>;
export type NewBackup = InferModel<typeof backupTable, 'insert'>;

View file

@ -4,6 +4,7 @@ import express from 'express';
import { parse } from 'url';
import type { NextServer } from 'next/dist/server/next';
import { z } from 'zod';
import { EventDispatcher } from './core/EventDispatcher';
import { getConfig, setConfig } from './core/TipiConfig';
import { Logger } from './core/Logger';
@ -42,12 +43,36 @@ nextApp.prepare().then(async () => {
app.use('/static', express.static(`${getConfig().rootFolder}/repos/${getConfig().appsRepoId}/`));
app.use('/certificate', async (req, res) => {
app.get('/backup/app/:id', async (req, res) => {
const userId = req.session?.userId;
const user = await authService.getUserById(userId as number);
if (user?.operator) {
res.setHeader('Content-Dispositon', 'attachment; filename=cert.pem');
const appId = z.string().parse(req.params.id);
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['backup', appId]);
if (success) {
res.setHeader('Content-Disposition', `attachment; filename=${appId}.zip`);
return res.sendFile(`${getConfig().rootFolder}/backups/apps/${appId}.zip`);
}
Logger.error(`Error while backing up app ${appId}: ${stdout}`);
return res.status(500).send(stdout);
}
return res.status(403).send('Forbidden');
});
app.post('/backup/app/:id', async (req, res) => {
// Multer file upload
});
app.get('/certificate', async (req, res) => {
const userId = req.session?.userId;
const user = await authService.getUserById(userId as number);
if (user?.operator) {
res.setHeader('Content-Disposition', 'attachment; filename=cert.pem');
return res.sendFile(`${getConfig().rootFolder}/traefik/tls/cert.pem`);
}

View file

@ -0,0 +1,12 @@
-- Create table backup if it doesn't exist
CREATE TABLE IF NOT EXISTS "backup" (
"id" serial NOT NULL,
"app_id" character varying,
"filename" character varying NOT NULL,
"version" character varying NOT NULL,
"size" bigint NOT NULL DEFAULT '0',
"createdAt" timestamp NOT NULL DEFAULT now(),
"updatedAt" timestamp NOT NULL DEFAULT now(),
PRIMARY KEY ("id"),
FOREIGN KEY ("app_id") REFERENCES "app" ("id") ON DELETE CASCADE
);

View file

@ -1,6 +1,6 @@
import { and, asc, eq, ne, notInArray } from 'drizzle-orm';
import { and, asc, eq, ne, notInArray, sql } from 'drizzle-orm';
import { Database } from '@/server/db';
import { appTable, NewApp, AppStatus } from '../../db/schema';
import { appTable, NewApp, AppStatus, NewBackup, backupTable } from '../../db/schema';
export class AppQueries {
private db;
@ -83,4 +83,42 @@ export class AppQueries {
public async updateAppsByStatusNotIn(statuses: AppStatus[], data: Partial<NewApp>) {
return this.db.update(appTable).set(data).where(notInArray(appTable.status, statuses)).returning();
}
/**
* Given Backup data, creates a new Backup
*
* @param {NewBackup} data - The data to create the Backup with
*/
public async createAppBackup(data: NewBackup) {
const newBackups = await this.db.insert(backupTable).values(data).returning();
return newBackups[0];
}
/**
* Given an app id and pagination data, return all backups for the app
*
* @param {string} appId - The id of the app to return backups for
* @param {number} page - The page of backups to return
* @param {number} limit - The number of backups to return per page
*/
public async getAppBackups(appId: string, page: number, limit: number) {
const result = await this.db
.select({ count: sql<number>`count(*)` })
.from(backupTable)
.where(eq(backupTable.appId, appId));
const total = result[0]?.count || 0;
const backups = await this.db
.select()
.from(backupTable)
.where(eq(backupTable.appId, appId))
.limit(limit)
.offset(page * limit);
return {
total,
pageCount: Math.ceil(total / limit) || 0,
data: backups,
};
}
}

View file

@ -24,4 +24,6 @@ export const appRouter = router({
updateApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.updateApp(input.id)),
installedApps: protectedProcedure.query(AppService.installedApps),
listApps: protectedProcedure.query(() => AppServiceClass.listApps()),
backupApp: protectedProcedure.input(z.object({ id: z.string() })).mutation(({ input }) => AppService.backupApp(input.id)),
listBackups: protectedProcedure.input(z.object({ id: z.string(), pageIndex: z.number(), pageSize: z.number() })).query(({ input }) => AppService.listBackups(input)),
});

View file

@ -2,21 +2,20 @@ import fs from 'fs-extra';
import { fromAny, fromPartial } from '@total-typescript/shoehorn';
import { faker } from '@faker-js/faker';
import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
import { getAppEnvMap } from '@/server/utils/env-generation';
import { setConfig } from '../../core/TipiConfig';
import { appInfoSchema, checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getEnvMap, getUpdateInfo } from './apps.helpers';
import { appInfoSchema, checkAppRequirements, checkEnvFile, ensureAppFolder, generateEnvFile, getAppInfo, getAvailableApps, getUpdateInfo } from './apps.helpers';
import { createAppConfig, insertApp } from '../../tests/apps.factory';
let db: TestDatabase;
const TEST_SUITE = 'appshelpers';
jest.mock('fs-extra');
beforeAll(async () => {
db = await createDatabase(TEST_SUITE);
});
beforeEach(async () => {
jest.mock('fs-extra');
// @ts-expect-error - fs-extra mock is not typed
fs.__resetAllMocks();
await clearDatabase(db);
});
@ -50,20 +49,6 @@ describe('Test: checkAppRequirements()', () => {
});
});
describe('Test: getEnvMap()', () => {
it('should return a map of env vars', async () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST_FIELD', type: 'text', label: 'test', required: true }] });
insertApp({ config: { TEST_FIELD: 'test' } }, appConfig, db);
// act
const envMap = getEnvMap(appConfig.id);
// assert
expect(envMap.get('TEST_FIELD')).toBe('test');
});
});
describe('Test: checkEnvFile()', () => {
it('Should not throw if all required fields are present', async () => {
// arrange
@ -71,7 +56,7 @@ describe('Test: checkEnvFile()', () => {
const app = await insertApp({}, appConfig, db);
// act
checkEnvFile(app.id);
await checkEnvFile(app.id);
});
it('Should throw if a required field is missing', async () => {
@ -83,7 +68,7 @@ describe('Test: checkEnvFile()', () => {
fs.writeFileSync(`/app/storage/app-data/${app.id}/app.env`, newAppEnv);
// act & assert
expect(() => checkEnvFile(app.id)).toThrowError('New info needed. App config needs to be updated');
await expect(checkEnvFile(app.id)).rejects.toThrowError('New info needed. App config needs to be updated');
});
it('Should throw if config.json is incorrect', async () => {
@ -93,7 +78,7 @@ describe('Test: checkEnvFile()', () => {
fs.writeFileSync(`/runtipi/apps/${app.id}/config.json`, 'invalid json');
// act & assert
expect(() => checkEnvFile(app.id)).toThrowError(`App ${app.id} has invalid config.json file`);
await expect(checkEnvFile(app.id)).rejects.toThrowError(`App ${app.id} has invalid config.json file`);
});
});
@ -101,7 +86,8 @@ describe('Test: appInfoSchema', () => {
it('should default form_field type to text if it is wrong', async () => {
// arrange
const appConfig = createAppConfig(fromAny({ form_fields: [{ env_variable: 'test', type: 'wrong', label: 'yo', required: true }] }));
fs.writeFileSync(`/app/storage/app-data/${appConfig.id}/config.json`, JSON.stringify(appConfig));
await fs.promises.mkdir(`/app/storage/app-data/${appConfig.id}`, { recursive: true });
await fs.promises.writeFile(`/app/storage/app-data/${appConfig.id}/config.json`, JSON.stringify(appConfig));
// act
const appInfo = appInfoSchema.safeParse(appConfig);
@ -118,7 +104,8 @@ describe('Test: appInfoSchema', () => {
it('should default categories to ["utilities"] if it is wrong', async () => {
// arrange
const appConfig = createAppConfig(fromAny({ categories: 'wrong' }));
fs.writeFileSync(`/app/storage/app-data/${appConfig.id}/config.json`, JSON.stringify(appConfig));
await fs.promises.mkdir(`/app/storage/app-data/${appConfig.id}`, { recursive: true });
await fs.promises.writeFile(`/app/storage/app-data/${appConfig.id}/config.json`, JSON.stringify(appConfig));
// act
const appInfo = appInfoSchema.safeParse(appConfig);
@ -141,8 +128,8 @@ describe('Test: generateEnvFile()', () => {
const fakevalue = faker.string.alphanumeric(10);
// act
generateEnvFile(Object.assign(app, { config: { TEST_FIELD: fakevalue } }));
const envmap = getEnvMap(app.id);
await generateEnvFile(Object.assign(app, { config: { TEST_FIELD: fakevalue } }));
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('TEST_FIELD')).toBe(fakevalue);
@ -154,8 +141,8 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({}, appConfig, db);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('RANDOM_FIELD')).toBeDefined();
@ -170,8 +157,8 @@ describe('Test: generateEnvFile()', () => {
fs.writeFileSync(`/app/storage/app-data/${app.id}/app.env`, `RANDOM_FIELD=${randomField}`);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('RANDOM_FIELD')).toBe(randomField);
@ -183,12 +170,12 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({}, appConfig, db);
// act & assert
expect(() => generateEnvFile(Object.assign(app, { config: { TEST_FIELD: undefined } }))).toThrowError('Variable test is required');
await expect(generateEnvFile(Object.assign(app, { config: { TEST_FIELD: undefined } }))).rejects.toThrowError('Variable test is required');
});
it('Should throw an error if app does not exist', async () => {
// act & assert
expect(() => generateEnvFile(fromPartial({ id: 'not-existing-app' }))).toThrowError('App not-existing-app has invalid config.json file');
await expect(generateEnvFile(fromPartial({ id: 'not-existing-app' }))).rejects.toThrowError('App not-existing-app has invalid config.json file');
});
it('Should add APP_EXPOSED to env file if domain is provided and app is exposed', async () => {
@ -198,8 +185,8 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({ domain, exposed: true }, appConfig, db);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('APP_EXPOSED')).toBe('true');
@ -212,8 +199,8 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({ exposed: true }, appConfig, db);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
@ -225,8 +212,8 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({ exposed: false, domain: faker.internet.domainName() }, appConfig, db);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('APP_EXPOSED')).toBeUndefined();
@ -240,7 +227,7 @@ describe('Test: generateEnvFile()', () => {
fs.rmSync(`/app/storage/app-data/${app.id}`, { recursive: true });
// act
generateEnvFile(app);
await generateEnvFile(app);
// assert
expect(fs.existsSync(`/app/storage/app-data/${app.id}`)).toBe(true);
@ -252,8 +239,8 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({}, appConfig, db);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('VAPID_PRIVATE_KEY')).toBeDefined();
@ -266,8 +253,8 @@ describe('Test: generateEnvFile()', () => {
const app = await insertApp({}, appConfig, db);
// act
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('VAPID_PRIVATE_KEY')).toBeUndefined();
@ -284,8 +271,8 @@ describe('Test: generateEnvFile()', () => {
// act
fs.writeFileSync(`/app/storage/app-data/${app.id}/app.env`, `VAPID_PRIVATE_KEY=${vapidPrivateKey}\nVAPID_PUBLIC_KEY=${vapidPublicKey}`);
generateEnvFile(app);
const envmap = getEnvMap(app.id);
await generateEnvFile(app);
const envmap = await getAppEnvMap(app.id);
// assert
expect(envmap.get('VAPID_PRIVATE_KEY')).toBe(vapidPrivateKey);
@ -428,15 +415,10 @@ describe('Test: getUpdateInfo()', () => {
});
describe('Test: ensureAppFolder()', () => {
beforeEach(() => {
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
};
// @ts-expect-error - Mocking fs
fs.__createMockFiles(mockFiles);
});
it('should copy the folder from repo', () => {
it('should copy the folder from repo', async () => {
// arrange
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/repos/repo-id/apps/test/test.yml', 'test');
// act
ensureAppFolder('test');
@ -445,15 +427,12 @@ describe('Test: ensureAppFolder()', () => {
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', async () => {
// arrange
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
'/runtipi/apps/test': ['docker-compose.yml'],
'/runtipi/apps/test/docker-compose.yml': 'test',
};
// @ts-expect-error - Mocking fs
fs.__createMockFiles(mockFiles);
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/repos/repo-id/apps/test/test.yml', 'test');
await fs.promises.mkdir('/runtipi/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/apps/test/docker-compose.yml', 'test');
// act
ensureAppFolder('test');
@ -463,15 +442,12 @@ describe('Test: ensureAppFolder()', () => {
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', async () => {
// arrange
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: ['test.yml'],
'/runtipi/apps/test': ['docker-compose.yml'],
'/runtipi/apps/test/docker-compose.yml': 'test',
};
// @ts-expect-error - Mocking fs
fs.__createMockFiles(mockFiles);
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/repos/repo-id/apps/test/test.yml', 'test');
await fs.promises.mkdir('/runtipi/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/apps/test/docker-compose.yml', 'test');
// act
ensureAppFolder('test', true);
@ -481,15 +457,13 @@ describe('Test: ensureAppFolder()', () => {
expect(files).toEqual(['test.yml']);
});
it('Should delete folder if it exists but has no docker-compose.yml file', () => {
it('Should delete folder if it exists but has no docker-compose.yml file', async () => {
// arrange
const randomFileName = `${faker.lorem.word()}.yml`;
const mockFiles = {
[`/runtipi/repos/repo-id/apps/test`]: [randomFileName],
'/runtipi/apps/test': ['test.yml'],
};
// @ts-expect-error - Mocking fs
fs.__createMockFiles(mockFiles);
await fs.promises.mkdir('/runtipi/repos/repo-id/apps/test', { recursive: true });
await fs.promises.writeFile(`/runtipi/repos/repo-id/apps/test/${randomFileName}`, 'test');
await fs.promises.mkdir('/runtipi/apps/test', { recursive: true });
await fs.promises.writeFile('/runtipi/apps/test/test.yml', 'test');
// act
ensureAppFolder('test');

View file

@ -2,8 +2,8 @@ import crypto from 'crypto';
import fs from 'fs-extra';
import { z } from 'zod';
import { App } from '@/server/db/schema';
import { generateVapidKeys } from '@/server/utils/env-generation';
import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile, writeFile } from '../../common/fs.helpers';
import { envMapToString, envStringToMap, generateVapidKeys, getAppEnvMap } from '@/server/utils/env-generation';
import { deleteFolder, fileExists, getSeed, readdirSync, readFile, readJsonFile } from '../../common/fs.helpers';
import { APP_CATEGORIES, FIELD_TYPES } from './apps.types';
import { getConfig } from '../../core/TipiConfig';
import { Logger } from '../../core/Logger';
@ -82,25 +82,6 @@ export const checkAppRequirements = (appName: string) => {
return parsedConfig.data;
};
/**
* This function reads the env file for the app with the provided name and returns a Map containing the key-value pairs of the environment variables.
* It reads the file, splits it into individual environment variables, and stores them in a Map, with the environment variable name as the key and its value as the value.
*
* @param {string} appName - The name of the app.
*/
export const getEnvMap = (appName: string) => {
const envFile = readFile(`/app/storage/app-data/${appName}/app.env`).toString();
const envVars = envFile.split('\n');
const envVarsMap = new Map<string, string>();
envVars.forEach((envVar) => {
const [key, value] = envVar.split('=');
if (key && value) envVarsMap.set(key, value);
});
return envVarsMap;
};
/**
* This function checks if the env file for the app with the provided name is valid.
* It reads the config.json file for the app, parses it,
@ -111,15 +92,23 @@ export const getEnvMap = (appName: string) => {
* @param {string} appName - The name of the app.
* @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing in the env file.
*/
export const checkEnvFile = (appName: string) => {
const configFile = readJsonFile(`/runtipi/apps/${appName}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
export const checkEnvFile = async (appName: string) => {
const configFile = await fs.promises.readFile(`/runtipi/apps/${appName}/config.json`);
let jsonConfig: unknown;
try {
jsonConfig = JSON.parse(configFile.toString());
} catch (e) {
throw new Error(`App ${appName} has invalid config.json file`);
}
const parsedConfig = appInfoSchema.safeParse(jsonConfig);
if (!parsedConfig.success) {
throw new Error(`App ${appName} has invalid config.json file`);
}
const envMap = getEnvMap(appName);
const envMap = await getAppEnvMap(appName);
parsedConfig.data.form_fields.forEach((field) => {
const envVar = field.env_variable;
@ -170,7 +159,7 @@ const castAppConfig = (json: unknown): Record<string, unknown> => {
* @param {App} app - The app for which the env file is generated.
* @throws Will throw an error if the app has an invalid config.json file or if a required variable is missing.
*/
export const generateEnvFile = (app: App) => {
export const generateEnvFile = async (app: App) => {
const configFile = readJsonFile(`/runtipi/apps/${app.id}/config.json`);
const parsedConfig = appInfoSchema.safeParse(configFile);
@ -179,17 +168,22 @@ export const generateEnvFile = (app: App) => {
}
const baseEnvFile = readFile('/runtipi/.env').toString();
let envFile = `${baseEnvFile}\nAPP_PORT=${parsedConfig.data.port}\nAPP_ID=${app.id}\n`;
const envMap = getEnvMap(app.id);
const envMap = envStringToMap(baseEnvFile);
// Default always present env variables
envMap.set('APP_PORT', String(parsedConfig.data.port));
envMap.set('APP_ID', app.id);
const existingEnvMap = await getAppEnvMap(app.id);
if (parsedConfig.data.generate_vapid_keys) {
if (envMap.has('VAPID_PUBLIC_KEY') && envMap.has('VAPID_PRIVATE_KEY')) {
envFile += `VAPID_PUBLIC_KEY=${envMap.get('VAPID_PUBLIC_KEY')}\n`;
envFile += `VAPID_PRIVATE_KEY=${envMap.get('VAPID_PRIVATE_KEY')}\n`;
if (existingEnvMap.has('VAPID_PUBLIC_KEY') && existingEnvMap.has('VAPID_PRIVATE_KEY')) {
envMap.set('VAPID_PUBLIC_KEY', existingEnvMap.get('VAPID_PUBLIC_KEY') as string);
envMap.set('VAPID_PRIVATE_KEY', existingEnvMap.get('VAPID_PRIVATE_KEY') as string);
} else {
const vapidKeys = generateVapidKeys();
envFile += `VAPID_PUBLIC_KEY=${vapidKeys.publicKey}\n`;
envFile += `VAPID_PRIVATE_KEY=${vapidKeys.privateKey}\n`;
envMap.set('VAPID_PUBLIC_KEY', vapidKeys.publicKey);
envMap.set('VAPID_PRIVATE_KEY', vapidKeys.privateKey);
}
}
@ -198,15 +192,15 @@ export const generateEnvFile = (app: App) => {
const envVar = field.env_variable;
if (formValue || typeof formValue === 'boolean') {
envFile += `${envVar}=${String(formValue)}\n`;
envMap.set(envVar, String(formValue));
} else if (field.type === 'random') {
if (envMap.has(envVar)) {
envFile += `${envVar}=${envMap.get(envVar)}\n`;
if (existingEnvMap.has(envVar)) {
envMap.set(envVar, existingEnvMap.get(envVar) as string);
} else {
const length = field.min || 32;
const randomString = getEntropy(field.env_variable, length);
envFile += `${envVar}=${randomString}\n`;
envMap.set(envVar, randomString);
}
} else if (field.required) {
throw new Error(`Variable ${field.label || field.env_variable} is required`);
@ -214,19 +208,22 @@ export const generateEnvFile = (app: App) => {
});
if (app.exposed && app.domain) {
envFile += 'APP_EXPOSED=true\n';
envFile += `APP_DOMAIN=${app.domain}\n`;
envFile += 'APP_PROTOCOL=https\n';
envMap.set('APP_EXPOSED', 'true');
envMap.set('APP_DOMAIN', app.domain);
envMap.set('APP_PROTOCOL', 'https');
envMap.set('APP_HOST', app.domain);
} else {
envFile += `APP_DOMAIN=${getConfig().internalIp}:${parsedConfig.data.port}\n`;
envMap.set('APP_DOMAIN', `${getConfig().internalIp}:${parsedConfig.data.port}`);
envMap.set('APP_HOST', getConfig().internalIp);
}
// 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 });
const appDataDirectoryExists = await fs.promises.stat(`/app/storage/app-data/${app.id}`).catch(() => false);
if (!appDataDirectoryExists) {
await fs.promises.mkdir(`/app/storage/app-data/${app.id}`, { recursive: true });
}
writeFile(`/app/storage/app-data/${app.id}/app.env`, envFile);
await fs.promises.writeFile(`/app/storage/app-data/${app.id}/app.env`, envMapToString(envMap));
};
/**
@ -253,7 +250,7 @@ const renderTemplate = (template: string, envMap: Map<string, string>) => {
* @param {string} id - The id of the app.
*/
export const copyDataDir = async (id: string) => {
const envMap = getEnvMap(id);
const envMap = await getAppEnvMap(id);
const appDataDirExists = (await fs.promises.lstat(`/runtipi/apps/${id}/data`).catch(() => false)) as fs.Stats;
if (!appDataDirExists || !appDataDirExists.isDirectory()) {

View file

@ -2,9 +2,9 @@ import fs from 'fs-extra';
import waitForExpect from 'wait-for-expect';
import { TestDatabase, clearDatabase, closeDatabase, createDatabase } from '@/server/tests/test-utils';
import { faker } from '@faker-js/faker';
import { getAppEnvMap } from '@/server/utils/env-generation';
import { AppServiceClass } from './apps.service';
import { EventDispatcher, EVENT_TYPES } from '../../core/EventDispatcher';
import { getEnvMap } from './apps.helpers';
import { getAllApps, getAppById, updateApp, createAppConfig, insertApp } from '../../tests/apps.factory';
import { setConfig } from '../../core/TipiConfig';
@ -18,11 +18,7 @@ beforeAll(async () => {
});
beforeEach(async () => {
jest.mock('fs-extra');
await clearDatabase(db);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore - we are mocking fs
fs.__resetAllMocks();
EventDispatcher.dispatchEventAsync = jest.fn().mockResolvedValue({ success: true });
});
@ -37,10 +33,13 @@ describe('Install app', () => {
// act
await AppsService.installApp(appConfig.id, { TEST_FIELD: 'test' });
const envFile = fs.readFileSync(`/app/storage/app-data/${appConfig.id}/app.env`).toString();
const envMap = await getAppEnvMap(appConfig.id);
// assert
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appConfig.port}\nAPP_ID=${appConfig.id}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${appConfig.port}`);
expect(envMap.get('TEST_FIELD')).toBe('test');
expect(envMap.get('APP_PORT')).toBe(appConfig.port.toString());
expect(envMap.get('APP_ID')).toBe(appConfig.id);
expect(envMap.get('APP_DOMAIN')).toBe(`localhost:${appConfig.port}`);
});
it('Should add app in database', async () => {
@ -102,7 +101,7 @@ describe('Install app', () => {
// act
await AppsService.installApp(appConfig.id, {});
const envMap = getEnvMap(appConfig.id);
const envMap = await getAppEnvMap(appConfig.id);
// assert
expect(envMap.get('RANDOM_FIELD')).toBeDefined();
@ -243,8 +242,9 @@ describe('Install app', () => {
it('should replace env variables in .templates files in data folder', async () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST', type: 'text', label: 'test', required: true }] });
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/data/test.txt.template`, 'test {{TEST}}');
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/data/test2.txt`, 'test {{TEST}}');
await fs.promises.mkdir(`/runtipi/repos/repo-id/apps/${appConfig.id}/data`, { recursive: true });
await fs.promises.writeFile(`/runtipi/repos/repo-id/apps/${appConfig.id}/data/test.txt.template`, 'test {{TEST}}');
await fs.promises.writeFile(`/runtipi/repos/repo-id/apps/${appConfig.id}/data/test2.txt`, 'test {{TEST}}');
// act
await AppsService.installApp(appConfig.id, { TEST: 'test' });
@ -259,10 +259,10 @@ describe('Install app', () => {
it('should copy and replace env variables in deeply nested .templates files in data folder', async () => {
// arrange
const appConfig = createAppConfig({ form_fields: [{ env_variable: 'TEST', type: 'text', label: 'test', required: true }] });
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/data/test.txt.template`, 'test {{TEST}}');
await fs.promises.mkdir(`/runtipi/apps/${appConfig.id}/data/test`);
await fs.promises.mkdir(`/runtipi/apps/${appConfig.id}/data/test/test`);
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/data/test/test/test.txt.template`, 'test {{TEST}}');
await fs.promises.mkdir(`/runtipi/repos/repo-id/apps/${appConfig.id}/data`, { recursive: true });
await fs.promises.writeFile(`/runtipi/repos/repo-id/apps/${appConfig.id}/data/test.txt.template`, 'test {{TEST}}');
await fs.promises.mkdir(`/runtipi/repos/repo-id/apps/${appConfig.id}/data/test/test`, { recursive: true });
await fs.promises.writeFile(`/runtipi/repos/repo-id/apps/${appConfig.id}/data/test/test/test.txt.template`, 'test {{TEST}}');
// act
await AppsService.installApp(appConfig.id, { TEST: 'test' });
@ -365,10 +365,14 @@ describe('Start app', () => {
// act
await AppsService.startApp(appConfig.id);
const envFile = fs.readFileSync(`/app/storage/app-data/${appConfig.id}/app.env`).toString();
const envMap = await getAppEnvMap(appConfig.id);
// assert
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appConfig.port}\nAPP_ID=${appConfig.id}\nTEST_FIELD=test\nAPP_DOMAIN=localhost:${appConfig.port}`);
expect(envMap.get('TEST_FIELD')).toBe('test');
expect(envMap.get('APP_PORT')).toBe(appConfig.port.toString());
expect(envMap.get('APP_ID')).toBe(appConfig.id);
expect(envMap.get('TEST')).toBe('test');
expect(envMap.get('APP_DOMAIN')).toBe(`localhost:${appConfig.port}`);
});
it('Should throw if start script fails', async () => {
@ -382,6 +386,18 @@ describe('Start app', () => {
const app = await getAppById(appConfig.id, db);
expect(app?.status).toBe('stopped');
});
it('Should throw if app has invalid config.json', async () => {
// arrange
const appConfig = createAppConfig({});
await insertApp({ status: 'stopped' }, appConfig, db);
await fs.promises.writeFile(`/runtipi/apps/${appConfig.id}/config.json`, 'test');
// act & assert
await expect(AppsService.startApp(appConfig.id)).rejects.toThrow(`App ${appConfig.id} has invalid config.json`);
const app = await getAppById(appConfig.id, db);
expect(app?.status).toBe('stopped');
});
});
describe('Stop app', () => {
@ -424,10 +440,13 @@ describe('Update app config', () => {
// act
await AppsService.updateAppConfig(appConfig.id, { TEST_FIELD: word });
const envFile = fs.readFileSync(`/app/storage/app-data/${appConfig.id}/app.env`).toString();
const envMap = await getAppEnvMap(appConfig.id);
// assert
expect(envFile.trim()).toBe(`TEST=test\nAPP_PORT=${appConfig.port}\nAPP_ID=${appConfig.id}\nTEST_FIELD=${word}\nAPP_DOMAIN=localhost:${appConfig.port}`);
expect(envMap.get('TEST_FIELD')).toBe(word);
expect(envMap.get('APP_PORT')).toBe(appConfig.port.toString());
expect(envMap.get('APP_ID')).toBe(appConfig.id);
expect(envMap.get('APP_DOMAIN')).toBe(`localhost:${appConfig.port}`);
});
it('Should throw if required field is missing', async () => {
@ -454,7 +473,7 @@ describe('Update app config', () => {
// act
await AppsService.updateAppConfig(appConfig.id, { TEST_FIELD: 'test' });
const envMap = getEnvMap(appConfig.id);
const envMap = await getAppEnvMap(appConfig.id);
// assert
expect(envMap.get(field)).toBe('test');
@ -478,15 +497,6 @@ describe('Update app config', () => {
expect(AppsService.updateAppConfig(appConfig.id, {}, true, 'test')).rejects.toThrowError('server-messages.errors.domain-not-valid');
});
it('Should throw if app is exposed and config does not allow it', async () => {
// arrange
const appConfig = createAppConfig({ exposable: false });
await insertApp({}, appConfig, db);
// act & assert
expect(AppsService.updateAppConfig(appConfig.id, {}, true, 'test.com')).rejects.toThrowError('server-messages.errors.app-not-exposable');
});
it('Should throw if app is exposed and domain is already used', async () => {
// arrange
const domain = faker.internet.domainName();
@ -518,6 +528,15 @@ describe('Update app config', () => {
// act & assert
await expect(AppsService.updateAppConfig(appConfig.id, {})).rejects.toThrowError('server-messages.errors.app-force-exposed');
});
it('Should throw if app is exposed and config does not allow it', async () => {
// arrange
const appConfig = createAppConfig({ exposable: false });
await insertApp({}, appConfig, db);
// act & assert
await expect(AppsService.updateAppConfig(appConfig.id, {}, true, 'test.com')).rejects.toThrowError('server-messages.errors.app-not-exposable');
});
});
describe('Get app config', () => {

View file

@ -1,8 +1,11 @@
import validator from 'validator';
import path from 'path';
import { App } from '@/server/db/schema';
import { AppQueries } from '@/server/queries/apps/apps.queries';
import { TranslatedError } from '@/server/utils/errors';
import { Database } from '@/server/db';
import fs from 'fs-extra';
import { z } from 'zod';
import { checkAppRequirements, checkEnvFile, generateEnvFile, getAvailableApps, ensureAppFolder, AppInfo, getAppInfo, getUpdateInfo, copyDataDir } from './apps.helpers';
import { getConfig } from '../../core/TipiConfig';
import { EventDispatcher } from '../../core/EventDispatcher';
@ -29,6 +32,12 @@ export class AppServiceClass {
this.queries = new AppQueries(p);
}
async regenerateEnvFile(app: App) {
ensureAppFolder(app.id);
await generateEnvFile(app);
await checkEnvFile(app.id);
}
/**
* This function starts all apps that are in the 'running' status.
* It finds all the running apps and starts them by regenerating the env file, checking the env file and dispatching the start event.
@ -43,11 +52,9 @@ export class AppServiceClass {
await Promise.all(
apps.map(async (app) => {
// Regenerate env file
try {
ensureAppFolder(app.id);
generateEnvFile(app);
checkEnvFile(app.id);
// Regenerate env file
await this.regenerateEnvFile(app);
await this.queries.updateApp(app.id, { status: 'starting' });
@ -79,10 +86,8 @@ export class AppServiceClass {
throw new TranslatedError('server-messages.errors.app-not-found', { id: appName });
}
ensureAppFolder(appName);
// Regenerate env file
generateEnvFile(app);
checkEnvFile(appName);
await this.regenerateEnvFile(app);
await this.queries.updateApp(appName, { status: 'starting' });
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['start', app.id]);
@ -153,7 +158,7 @@ export class AppServiceClass {
if (newApp) {
// Create env file
generateEnvFile(newApp);
await generateEnvFile(newApp);
await copyDataDir(id);
}
@ -229,7 +234,7 @@ export class AppServiceClass {
const updatedApp = await this.queries.updateApp(id, { exposed: exposed || false, domain: domain || null, config: form });
if (updatedApp) {
generateEnvFile(updatedApp);
await generateEnvFile(updatedApp);
}
return updatedApp;
@ -248,8 +253,7 @@ export class AppServiceClass {
throw new TranslatedError('server-messages.errors.app-not-found', { id });
}
ensureAppFolder(id);
generateEnvFile(app);
await this.regenerateEnvFile(app);
// Run script
await this.queries.updateApp(id, { status: 'stopping' });
@ -284,8 +288,7 @@ export class AppServiceClass {
await this.stopApp(id);
}
ensureAppFolder(id);
generateEnvFile(app);
await this.regenerateEnvFile(app);
await this.queries.updateApp(id, { status: 'uninstalling' });
@ -336,8 +339,7 @@ export class AppServiceClass {
throw new TranslatedError('server-messages.errors.app-not-found', { id });
}
ensureAppFolder(id);
generateEnvFile(app);
await this.regenerateEnvFile(app);
await this.queries.updateApp(id, { status: 'updating' });
@ -374,4 +376,59 @@ export class AppServiceClass {
})
.filter(notEmpty);
};
public backupApp = async (id: string) => {
const app = await this.queries.getApp(id);
if (!app) {
throw new TranslatedError('server-messages.errors.app-not-found', { id });
}
const { success, stdout } = await EventDispatcher.dispatchEventAsync('app', ['backup', id]);
if (!success) {
Logger.error(`Failed to backup app ${id}: ${stdout}`);
throw new Error('Yo'); // TODO: Translate error
}
const appInfo = getAppInfo(app.id, app.status);
const filename = z
.string()
.trim()
.startsWith(app.id)
.regex(/^([a-zA-Z0-9-]+)-\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}\.tar\.gz$/)
.parse(stdout);
// wait for the file to be written to disk
const fileStat = await fs.promises.stat(path.join('/app', 'storage', 'backups', 'apps', id, filename), { bigint: true }).then((stat) => stat);
console.log(path.join('/app', 'storage', 'backups', 'apps', id, filename), fileStat);
const backup = await this.queries.createAppBackup({ appId: id, filename, version: `${appInfo?.version} (${app.version})`, size: fileStat.size });
return backup;
};
/**
* Given an app id, returns a list of all available backups for that app
*
* @param {object} params - The parameters for the request
* @param {string} params.id - The id of the app to list backups for
* @param {number} params.pageIndex - The index of the page to return
* @param {number} params.pageSize - The size of the page to return
*/
public listBackups = async (params: { id: string; pageIndex: number; pageSize: number }) => {
const { id, pageIndex, pageSize } = params;
const app = await this.queries.getApp(id);
if (!app) {
throw new TranslatedError('server-messages.errors.app-not-found', { id });
}
const backups = await this.queries.getAppBackups(id, pageIndex, pageSize);
return backups;
};
}

View file

@ -15,6 +15,8 @@ const SystemService = new SystemServiceClass();
const server = setupServer();
beforeEach(async () => {
await setConfig('demoMode', false);
jest.mock('fs-extra');
jest.resetModules();
jest.resetAllMocks();

View file

@ -1,6 +1,6 @@
import fs from 'fs-extra';
import { faker } from '@faker-js/faker';
import { eq } from 'drizzle-orm';
import fs from 'fs-extra';
import { Architecture } from '../core/TipiConfig/TipiConfig';
import { AppInfo, appInfoSchema } from '../services/apps/apps.helpers';
import { APP_CATEGORIES } from '../services/apps/apps.types';
@ -21,8 +21,6 @@ interface IProps {
}
const createAppConfig = (props?: Partial<AppInfo>) => {
const mockFiles: Record<string, string | string[]> = {};
const appInfo = appInfoSchema.parse({
id: faker.string.alphanumeric(32),
available: true,
@ -37,13 +35,13 @@ const createAppConfig = (props?: Partial<AppInfo>) => {
...props,
});
const mockFiles: Record<string, string | string[]> = {};
mockFiles['/runtipi/.env'] = 'TEST=test';
mockFiles['/runtipi/repos/repo-id'] = '';
mockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo));
mockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
mockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
// @ts-expect-error - fs-extra mock is not typed
// @ts-expect-error - custom mock method
fs.__applyMockFiles(mockFiles);
return appInfo;
@ -103,12 +101,11 @@ const createApp = async (props: IProps, database: TestDatabase) => {
});
}
const MockFiles: Record<string, string | string[]> = {};
MockFiles['/runtipi/.env'] = 'TEST=test';
MockFiles['/runtipi/repos/repo-id'] = '';
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo));
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
MockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
const mockFiles: Record<string, string | string[]> = {};
mockFiles['/runtipi/.env'] = 'TEST=test';
mockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/config.json`] = JSON.stringify(appInfoSchema.parse(appInfo));
mockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/docker-compose.yml`] = 'compose';
mockFiles[`/runtipi/repos/repo-id/apps/${appInfo.id}/metadata/description.md`] = 'md desc';
let appEntity: App = {} as App;
if (installed) {
@ -126,14 +123,15 @@ const createApp = async (props: IProps, database: TestDatabase) => {
// eslint-disable-next-line prefer-destructuring
appEntity = insertedApp[0] as App;
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';
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 };
// @ts-expect-error - custom mock method
fs.__applyMockFiles(mockFiles);
return { appInfo, MockFiles: mockFiles, appEntity };
};
const insertApp = async (data: Partial<NewApp>, appInfo: AppInfo, database: TestDatabase) => {
@ -149,15 +147,15 @@ const insertApp = async (data: Partial<NewApp>, appInfo: AppInfo, database: Test
const mockFiles: Record<string, string | string[]> = {};
if (data.status !== 'missing') {
mockFiles[`/app/storage/app-data/${values.id}`] = '';
mockFiles[`/app/storage/app-data/${values.id}/app.env`] = `TEST=test\nAPP_PORT=3000\n${Object.entries(data.config || {})
.map(([key, value]) => `${key}=${value}`)
.join('\n')}`;
mockFiles[`/runtipi/apps/${values.id}/config.json`] = JSON.stringify(appInfo);
mockFiles[`/runtipi/apps/${values.id}/metadata/description.md`] = 'md desc';
mockFiles[`/runtipi/apps/${values.id}/docker-compose.yml`] = 'compose';
}
// @ts-expect-error - fs-extra mock is not typed
// @ts-expect-error - custom mock method
fs.__applyMockFiles(mockFiles);
const insertedApp = await database.db.insert(appTable).values(values).returning();

View file

@ -1,4 +1,58 @@
import webpush from 'web-push';
import fs from 'fs-extra';
/**
* Convert a string of environment variables to a Map
*
* @param {string} envString - String of environment variables
*/
export const envStringToMap = (envString: string) => {
const envMap = new Map<string, string>();
const envArray = envString.split('\n');
envArray.forEach((env) => {
const [key, value] = env.split('=');
if (key && value) {
envMap.set(key, value);
}
});
return envMap;
};
/**
* Convert a Map of environment variables to a valid string of environment variables
* that can be used in a .env file
*
* @param {Map<string, string>} envMap - Map of environment variables
*/
export const envMapToString = (envMap: Map<string, string>) => {
const envArray = Array.from(envMap).map(([key, value]) => `${key}=${value}`);
return envArray.join('\n');
};
/**
* This function reads the env file for the app with the provided id and returns a Map containing the key-value pairs of the environment variables.
* It reads the app.env file, splits it into individual environment variables, and stores them in a Map, with the environment variable name as the key and its value as the value.
*
* @param {string} id - App ID
*/
export const getAppEnvMap = async (id: string) => {
try {
const envFile = await fs.promises.readFile(`/app/storage/app-data/${id}/app.env`);
const envVars = envFile.toString().split('\n');
const envVarsMap = new Map<string, string>();
envVars.forEach((envVar) => {
const [key, value] = envVar.split('=');
if (key && value) envVarsMap.set(key, value);
});
return envVarsMap;
} catch (e) {
return new Map<string, string>();
}
};
/**
* Generate VAPID keys

View file

@ -1,3 +1,4 @@
import fs from 'fs-extra';
import { fromPartial } from '@total-typescript/shoehorn';
import { EventDispatcher } from '../../src/server/core/EventDispatcher';
@ -14,6 +15,14 @@ jest.mock('vitest', () => ({
console.error = jest.fn();
beforeEach(async () => {
// @ts-expect-error - custom mock method
fs.__resetAllMocks();
await fs.promises.mkdir('/runtipi/state', { recursive: true });
await fs.promises.writeFile('/runtipi/state/settings.json', '{}');
await fs.promises.mkdir('/app/logs', { recursive: true });
});
// Mock Logger
jest.mock('../../src/server/core/Logger', () => ({
Logger: {