From b0231aa2858f6e1ac6d4cb5d2b5d5db659241b29 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 27 Feb 2023 10:51:08 +0200 Subject: [PATCH 1/4] #RI-4231 BE move to array-based structure --- redisinsight/api/package.json | 2 +- .../custom-tutorial.controller.ts | 5 +- .../custom-tutorial.service.ts | 16 +++--- .../models/custom-tutorial.manifest.ts | 53 +++++++++++++++++-- .../custom-tutorial.manifest.provider.ts | 14 +++-- 5 files changed, 71 insertions(+), 19 deletions(-) diff --git a/redisinsight/api/package.json b/redisinsight/api/package.json index 7a1d7a2347..26656f0f4a 100644 --- a/redisinsight/api/package.json +++ b/redisinsight/api/package.json @@ -21,7 +21,7 @@ "format": "prettier --write \"src/**/*.ts\"", "lint": "eslint --ext .ts .", "start": "nest start", - "start:dev": "cross-env NODE_ENV=development SERVER_STATIC_CONTENT=1 nest start --watch", + "start:dev": "cross-env NODE_ENV=development SERVER_STATIC_CONTENT=1 nest start --watch --preserveWatchOutput", "start:debug": "nest start --debug --watch", "start:stage": "cross-env NODE_ENV=staging SERVER_STATIC_CONTENT=true node dist/src/main", "start:prod": "cross-env NODE_ENV=production node dist/src/main", diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts index 930b4cf65c..ef5c78126b 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.controller.ts @@ -17,6 +17,7 @@ import { CreateClientCertificateDto } from 'src/modules/certificate/dto/create.c import { UseClientCertificateDto } from 'src/modules/certificate/dto/use.client-certificate.dto'; import { CreateBasicSshOptionsDto } from 'src/modules/ssh/dto/create.basic-ssh-options.dto'; import { CreateCertSshOptionsDto } from 'src/modules/ssh/dto/create.cert-ssh-options.dto'; +import { RootCustomTutorialManifest } from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; @ApiExtraModels( CreateCaCertificateDto, UseCaCertificateDto, @@ -45,7 +46,7 @@ export class CustomTutorialController { }) async create( @Body() dto: UploadCustomTutorialDto, - ): Promise> { + ): Promise { return this.service.create(dto); } @@ -59,7 +60,7 @@ export class CustomTutorialController { }, ], }) - async getGlobalManifest(): Promise { + async getGlobalManifest(): Promise { return this.service.getGlobalManifest(); } diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts index 011b1193a8..af190f7613 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -13,7 +13,7 @@ import { } from 'src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider'; import { CustomTutorialManifestType, - ICustomTutorialManifest, + RootCustomTutorialManifest, } from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; import { wrapHttpError } from 'src/common/utils'; @@ -32,7 +32,7 @@ export class CustomTutorialService { * Currently from zip file only * @param dto */ - public async create(dto: UploadCustomTutorialDto): Promise> { + public async create(dto: UploadCustomTutorialDto): Promise { try { const tmpPath = await this.customTutorialFsProvider.unzipToTmpFolder(dto.file); @@ -59,8 +59,8 @@ export class CustomTutorialService { * Get global manifest for all custom tutorials * In the future will be removed with some kind of partial load */ - public async getGlobalManifest(): Promise> { - const children = {}; + public async getGlobalManifest(): Promise { + const children = []; try { const tutorials = await this.customTutorialRepository.list(); @@ -73,15 +73,15 @@ export class CustomTutorialService { manifests.forEach((manifest) => { if (manifest) { - children[manifest.id] = manifest; + children.push(manifest); } }); } catch (e) { this.logger.warn('Unable to generate entire custom tutorials manifest', e); } - return { - 'custom-tutorials': { + return [ + { type: CustomTutorialManifestType.Group, id: 'custom-tutorials', label: 'My Tutorials', @@ -92,7 +92,7 @@ export class CustomTutorialService { }, children, }, - }; + ]; } public async get(id: string): Promise { diff --git a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts index 17c2ced1f6..2b84f01622 100644 --- a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts +++ b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts @@ -1,7 +1,9 @@ import { CustomTutorialActions } from 'src/modules/custom-tutorial/models/custom-tutorial'; -import { ApiProperty } from '@nestjs/swagger'; -import { Expose } from 'class-transformer'; -import { IsEnum, IsNotEmpty } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Expose, Type } from 'class-transformer'; +import { + IsArray, IsBoolean, IsEnum, IsNotEmpty, IsString, ValidateNested, +} from 'class-validator'; export enum CustomTutorialManifestType { CodeButton = 'code-button', @@ -19,6 +21,24 @@ export interface ICustomTutorialManifest { _path?: string, } +export class CustomTutorialManifestArgs { + @ApiPropertyOptional({ type: Boolean }) + @Expose() + @IsString() + @IsNotEmpty() + path?: string; + + @ApiPropertyOptional({ type: Boolean }) + @Expose() + @IsBoolean() + initialIsOpen?: boolean; + + @ApiPropertyOptional({ type: Boolean }) + @Expose() + @IsBoolean() + withBorder?: boolean; +} + export class CustomTutorialManifest { @ApiProperty({ type: String }) @Expose() @@ -35,5 +55,30 @@ export class CustomTutorialManifest { @IsNotEmpty() label: string; - children: Record + @ApiPropertyOptional({ type: CustomTutorialManifestArgs }) + @Expose() + @ValidateNested() + @Type(() => CustomTutorialManifestArgs) + args?: CustomTutorialManifestArgs; + + @ApiPropertyOptional({ type: CustomTutorialManifest }) + @Expose() + @ValidateNested({ each: true }) + @IsArray() + @Type(() => CustomTutorialManifest) + children?: CustomTutorialManifest[]; +} + +export class RootCustomTutorialManifest extends CustomTutorialManifest { + @ApiPropertyOptional({ enum: CustomTutorialActions }) + @Expose() + @IsArray() + @IsEnum(CustomTutorialActions, { each: true }) + _actions?: CustomTutorialActions[]; + + @ApiPropertyOptional({ type: String }) + @Expose() + @IsString() + @IsNotEmpty() + _path?: string; } diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts index cf781c9891..a495dc21ba 100644 --- a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts @@ -3,9 +3,11 @@ import { join } from 'path'; import * as fs from 'fs-extra'; import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; import { + CustomTutorialManifest, CustomTutorialManifestType, - ICustomTutorialManifest, + RootCustomTutorialManifest, } from 'src/modules/custom-tutorial/models/custom-tutorial.manifest'; +import { plainToClass } from 'class-transformer'; const MANIFEST_FILE = 'manifest.json'; @@ -20,11 +22,15 @@ export class CustomTutorialManifestProvider { * So user will be able to fix (re-import) tutorial or remove it * @param path */ - public async getManifestJson(path: string): Promise { + public async getManifestJson(path: string): Promise { try { - return JSON.parse( + const manifestJson = JSON.parse( await fs.readFile(join(path, MANIFEST_FILE), 'utf8'), ); + + const model = plainToClass(CustomTutorialManifest, manifestJson as [], { excludeExtraneousValues: true }); + + return model?.length ? model : []; } catch (e) { this.logger.warn('Unable to get manifest for tutorial'); return null; @@ -36,7 +42,7 @@ export class CustomTutorialManifestProvider { * additional data from local database * @param tutorial */ - public async generateTutorialManifest(tutorial: CustomTutorial): Promise> { + public async generateTutorialManifest(tutorial: CustomTutorial): Promise { try { return { type: CustomTutorialManifestType.Group, From 9108b9986e7f43d7e52d1abf35cf33605ecacb97 Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 27 Feb 2023 11:28:43 +0200 Subject: [PATCH 2/4] #RI-4234 [BE] Upload custom tutorials by link. Initial implementation --- .../custom-tutorial.service.ts | 11 +++++- .../dto/upload.custom-tutorial.dto.ts | 18 +++++++--- .../models/custom-tutorial.manifest.ts | 9 ++++- .../custom-tutorial/models/custom-tutorial.ts | 9 ++++- .../providers/custom-tutorial.fs.provider.ts | 35 ++++++++++++++++--- 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts index af190f7613..356c0b0f51 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { v4 as uuidv4 } from 'uuid'; @@ -34,7 +35,15 @@ export class CustomTutorialService { */ public async create(dto: UploadCustomTutorialDto): Promise { try { - const tmpPath = await this.customTutorialFsProvider.unzipToTmpFolder(dto.file); + let tmpPath = ''; + + if (dto.file) { + tmpPath = await this.customTutorialFsProvider.unzipFromMemoryStoredFile(dto.file); + } else if (dto.link) { + tmpPath = await this.customTutorialFsProvider.unzipFromExternalLink(dto.link); + } else { + throw new BadRequestException('File or external link should be provided'); + } // todo: validate diff --git a/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts index 3631333f1d..ff96961f60 100644 --- a/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts +++ b/redisinsight/api/src/modules/custom-tutorial/dto/upload.custom-tutorial.dto.ts @@ -1,20 +1,30 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Expose } from 'class-transformer'; -import { IsNotEmpty, IsString } from 'class-validator'; +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { HasMimeType, IsFile, MaxFileSize, MemoryStoredFile, } from 'nestjs-form-data'; export class UploadCustomTutorialDto { - @ApiProperty({ + @ApiPropertyOptional({ type: 'string', format: 'binary', description: 'ZIP archive with tutorial static files', }) + @IsOptional() @IsFile() @HasMimeType(['application/zip']) @MaxFileSize(10 * 1024 * 1024) - file: MemoryStoredFile; + file?: MemoryStoredFile; + + @ApiPropertyOptional({ + type: 'string', + description: 'External link to zip archive', + }) + @IsOptional() + @IsString() + @IsNotEmpty() + link?: string; @ApiProperty({ description: 'Name to show for custom tutorials', diff --git a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts index 2b84f01622..4b5c03f697 100644 --- a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts +++ b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.manifest.ts @@ -2,7 +2,7 @@ import { CustomTutorialActions } from 'src/modules/custom-tutorial/models/custom import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Expose, Type } from 'class-transformer'; import { - IsArray, IsBoolean, IsEnum, IsNotEmpty, IsString, ValidateNested, + IsArray, IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString, ValidateNested } from 'class-validator'; export enum CustomTutorialManifestType { @@ -23,17 +23,20 @@ export interface ICustomTutorialManifest { export class CustomTutorialManifestArgs { @ApiPropertyOptional({ type: Boolean }) + @IsOptional() @Expose() @IsString() @IsNotEmpty() path?: string; @ApiPropertyOptional({ type: Boolean }) + @IsOptional() @Expose() @IsBoolean() initialIsOpen?: boolean; @ApiPropertyOptional({ type: Boolean }) + @IsOptional() @Expose() @IsBoolean() withBorder?: boolean; @@ -56,12 +59,14 @@ export class CustomTutorialManifest { label: string; @ApiPropertyOptional({ type: CustomTutorialManifestArgs }) + @IsOptional() @Expose() @ValidateNested() @Type(() => CustomTutorialManifestArgs) args?: CustomTutorialManifestArgs; @ApiPropertyOptional({ type: CustomTutorialManifest }) + @IsOptional() @Expose() @ValidateNested({ each: true }) @IsArray() @@ -71,12 +76,14 @@ export class CustomTutorialManifest { export class RootCustomTutorialManifest extends CustomTutorialManifest { @ApiPropertyOptional({ enum: CustomTutorialActions }) + @IsOptional() @Expose() @IsArray() @IsEnum(CustomTutorialActions, { each: true }) _actions?: CustomTutorialActions[]; @ApiPropertyOptional({ type: String }) + @IsOptional() @Expose() @IsString() @IsNotEmpty() diff --git a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts index 685fcc5f35..f1d25c16ca 100644 --- a/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts +++ b/redisinsight/api/src/modules/custom-tutorial/models/custom-tutorial.ts @@ -7,6 +7,7 @@ const PATH_CONFIG = config.get('dir_path'); export enum CustomTutorialActions { CREATE = 'create', DELETE = 'delete', + SYNC = 'sync', } export class CustomTutorial { @@ -26,7 +27,13 @@ export class CustomTutorial { createdAt: Date; get actions(): CustomTutorialActions[] { - return [CustomTutorialActions.DELETE]; + const actions = [CustomTutorialActions.DELETE]; + + if (this.link) { + actions.push(CustomTutorialActions.SYNC); + } + + return actions; } get path(): string { diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts index e082d41e41..fbc4d7362e 100644 --- a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.fs.provider.ts @@ -5,6 +5,8 @@ import { v4 as uuidv4 } from 'uuid'; import * as fs from 'fs-extra'; import config from 'src/utils/config'; import * as AdmZip from 'adm-zip'; +import axios from 'axios'; +import { wrapHttpError } from 'src/common/utils'; const PATH_CONFIG = config.get('dir_path'); @@ -15,13 +17,13 @@ export class CustomTutorialFsProvider { private logger = new Logger('CustomTutorialFsProvider'); /** - * Unzip custom tutorials to temporary folder - * @param file + * Unzip custom tutorials archive to temporary folder + * @param zip */ - public async unzipToTmpFolder(file: MemoryStoredFile): Promise { + public async unzipToTmpFolder(zip: AdmZip): Promise { try { const path = await CustomTutorialFsProvider.prepareTmpFolder(); - const zip = new AdmZip(file.buffer); + await fs.remove(path); await zip.extractAllTo(path, true); @@ -32,6 +34,31 @@ export class CustomTutorialFsProvider { } } + /** + * Unzip archive from multipart/form-data file input + * @param file + */ + public async unzipFromMemoryStoredFile(file: MemoryStoredFile): Promise { + return this.unzipToTmpFolder(new AdmZip(file.buffer)); + } + + /** + * Download zip archive from external source and unzip it to temporary directory + * @param link + */ + public async unzipFromExternalLink(link: string): Promise { + try { + const { data } = await axios.get(link, { + responseType: 'arraybuffer', + }); + + return this.unzipToTmpFolder(new AdmZip(data)); + } catch (e) { + this.logger.error('Unable fetch zip file from external source', e); + throw wrapHttpError(e); + } + } + /** * Move custom tutorial from tmp folder to proper path to serve static files * force - default false, will remove existing folder From 1c32a19b75a01be97bcfb619f29218d031f830eb Mon Sep 17 00:00:00 2001 From: Artem Date: Mon, 27 Feb 2023 12:04:29 +0200 Subject: [PATCH 3/4] #RI-4198 [BE] Import markdown guides and tutorials without a .json manifest --- .../custom-tutorial.manifest.provider.ts | 95 ++++++++++++++++++- 1 file changed, 91 insertions(+), 4 deletions(-) diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts index a495dc21ba..c1952f2086 100644 --- a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts @@ -1,5 +1,5 @@ import { Injectable, Logger } from '@nestjs/common'; -import { join } from 'path'; +import { join, parse } from 'path'; import * as fs from 'fs-extra'; import { CustomTutorial } from 'src/modules/custom-tutorial/models/custom-tutorial'; import { @@ -10,11 +10,100 @@ import { import { plainToClass } from 'class-transformer'; const MANIFEST_FILE = 'manifest.json'; +const SYS_MANIFEST_FILE = '_manifest.json'; @Injectable() export class CustomTutorialManifestProvider { private logger = new Logger('CustomTutorialManifestProvider'); + /** + * Auto generate system manifest json (_manifest.json) + * @param path + * @private + */ + private async generateManifestFile(path: string): Promise { + try { + const manifest = await this.generateManifestEntry(path, '/'); + + await fs.writeFile(SYS_MANIFEST_FILE, JSON.stringify(manifest), 'utf8'); + + return manifest; + } catch (e) { + this.logger.warn('Unable to automatically generate manifest file', e); + return null; + } + } + + /** + * Discover all .md files and folders and generate manifest based on it + * Manifest labels will be created based on files and folders names + * For files [.md] will be excluded + * All folders and files which starts from "_" and "." will be excluded also + * @param path + * @param relativePath + * @private + */ + private async generateManifestEntry(path: string, relativePath: string = '/'): Promise { + const manifest = []; + const entries = await fs.readdir(path); + + for (let i = 0; i < entries.length; i += 1) { + const entry = entries[i]; + + if (entry.startsWith('.') || entry.startsWith('_')) { + // eslint-disable-next-line no-continue + continue; + } + + const isDirectory = (await fs.lstat(join(path, entry))).isDirectory(); + + const { name, ext } = parse(entry); + + if (isDirectory) { + manifest.push({ + id: entry, + label: name, + type: CustomTutorialManifestType.Group, + args: { + initialIsOpen: true, + }, + children: await this.generateManifestEntry(join(path, entry), join(relativePath, entry)), + }); + } else if (ext === '.md') { + manifest.push({ + id: entry, + label: name, + type: CustomTutorialManifestType.InternalLink, + args: { + path: join(relativePath, entry), + }, + }); + } + } + + return manifest; + } + + private async getManifestJsonFile(path): Promise { + try { + return JSON.parse( + await fs.readFile(join(path, MANIFEST_FILE), 'utf8'), + ); + } catch (e) { + this.logger.warn('Unable to get manifest for tutorial'); + } + + try { + return JSON.parse( + await fs.readFile(join(path, SYS_MANIFEST_FILE), 'utf8'), + ); + } catch (e) { + this.logger.warn('Unable to get _manifest for tutorial'); + } + + return await this.generateManifestFile(path) as any; + } + /** * Try to get and parse manifest.json * In case of any error will not throw an error but return null @@ -24,9 +113,7 @@ export class CustomTutorialManifestProvider { */ public async getManifestJson(path: string): Promise { try { - const manifestJson = JSON.parse( - await fs.readFile(join(path, MANIFEST_FILE), 'utf8'), - ); + const manifestJson = await this.getManifestJsonFile(path); const model = plainToClass(CustomTutorialManifest, manifestJson as [], { excludeExtraneousValues: true }); From 75adfa09ad42642ae63e547b768a23f568d55e05 Mon Sep 17 00:00:00 2001 From: Artem Date: Thu, 2 Mar 2023 13:35:03 +0200 Subject: [PATCH 4/4] fix label + fix _manifest.json path + change download links --- redisinsight/api/config/default.ts | 4 ++-- .../src/modules/custom-tutorial/custom-tutorial.service.ts | 2 +- .../providers/custom-tutorial.manifest.provider.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/redisinsight/api/config/default.ts b/redisinsight/api/config/default.ts index d5d9e96992..e19f3c5c95 100644 --- a/redisinsight/api/config/default.ts +++ b/redisinsight/api/config/default.ts @@ -110,14 +110,14 @@ export default { }, guides: { updateUrl: process.env.GUIDES_UPDATE_URL - || 'https://github.com/RedisInsight/Guides/releases/download/release', + || 'https://github.com/RedisInsight/Guides/releases/download/2.x.x', zip: process.env.GUIDES_ZIP || dataZipFileName, buildInfo: process.env.GUIDES_CHECKSUM || buildInfoFileName, devMode: !!process.env.GUIDES_DEV_PATH, }, tutorials: { updateUrl: process.env.TUTORIALS_UPDATE_URL - || 'https://github.com/RedisInsight/Tutorials/releases/download/release', + || 'https://github.com/RedisInsight/Tutorials/releases/download/2.x.x', zip: process.env.TUTORIALS_ZIP || dataZipFileName, buildInfo: process.env.TUTORIALS_CHECKSUM || buildInfoFileName, devMode: !!process.env.TUTORIALS_DEV_PATH, diff --git a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts index 356c0b0f51..e38c530890 100644 --- a/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts +++ b/redisinsight/api/src/modules/custom-tutorial/custom-tutorial.service.ts @@ -93,7 +93,7 @@ export class CustomTutorialService { { type: CustomTutorialManifestType.Group, id: 'custom-tutorials', - label: 'My Tutorials', + label: 'MY TUTORIALS', _actions: [CustomTutorialActions.CREATE], args: { withBorder: true, diff --git a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts index c1952f2086..5cb288a45c 100644 --- a/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts +++ b/redisinsight/api/src/modules/custom-tutorial/providers/custom-tutorial.manifest.provider.ts @@ -25,7 +25,7 @@ export class CustomTutorialManifestProvider { try { const manifest = await this.generateManifestEntry(path, '/'); - await fs.writeFile(SYS_MANIFEST_FILE, JSON.stringify(manifest), 'utf8'); + await fs.writeFile(join(path, SYS_MANIFEST_FILE), JSON.stringify(manifest), 'utf8'); return manifest; } catch (e) {