Compare commits
3 commits
develop
...
feat/updat
Author | SHA1 | Date | |
---|---|---|---|
|
b673bfba3a | ||
|
23f702ab0f | ||
|
a9ae28ae26 |
35 changed files with 1114 additions and 463 deletions
|
@ -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}',
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
@ -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()),
|
||||
};
|
||||
|
|
|
@ -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}'],
|
||||
|
|
|
@ -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",
|
||||
|
|
293
pnpm-lock.yaml
293
pnpm-lock.yaml
|
@ -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
|
||||
|
|
|
@ -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..."
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
38
src/client/components/ui/Card/Card.tsx
Normal file
38
src/client/components/ui/Card/Card.tsx
Normal 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 };
|
1
src/client/components/ui/Card/index.ts
Normal file
1
src/client/components/ui/Card/index.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export { Card, CardActions, CardHeader, CardTitle } from './Card';
|
130
src/client/components/ui/DataTable/DataTable.tsx
Normal file
130
src/client/components/ui/DataTable/DataTable.tsx
Normal 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>
|
||||
);
|
||||
};
|
0
src/client/components/ui/DataTable/index.ts
Normal file
0
src/client/components/ui/DataTable/index.ts
Normal 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>
|
||||
),
|
||||
);
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
132
src/client/modules/Apps/components/BackupsList/BackupsList.tsx
Normal file
132
src/client/modules/Apps/components/BackupsList/BackupsList.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
};
|
1
src/client/modules/Apps/components/BackupsList/index.tsx
Normal file
1
src/client/modules/Apps/components/BackupsList/index.tsx
Normal file
|
@ -0,0 +1 @@
|
|||
export { BackupsList } from './BackupsList';
|
|
@ -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>
|
||||
|
|
|
@ -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
17
src/pages/api/panel.ts
Normal 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',
|
||||
}),
|
||||
);
|
||||
}
|
|
@ -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', () => {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'>;
|
||||
|
|
|
@ -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`);
|
||||
}
|
||||
|
||||
|
|
12
src/server/migrations/00008-add-backups-table.sql
Normal file
12
src/server/migrations/00008-add-backups-table.sql
Normal 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
|
||||
);
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)),
|
||||
});
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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()) {
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
Loading…
Reference in a new issue