2020-09-28 07:52:18 +03:00
/ * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
* Copyright ( c ) Microsoft Corporation . All rights reserved .
* Licensed under the Source EULA . See License . txt in the project root for license information .
* -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- * /
'use strict' ;
2021-07-13 21:37:17 +03:00
import * as path from 'path' ;
import * as url from 'url' ;
import * as fs from 'fs' ;
import got from 'got' ;
2022-05-25 02:07:57 +03:00
const ROOT _DIR = path . join ( path . dirname ( url . fileURLToPath ( import . meta . url ) ) , '..' ) ;
const MICROSOFT _SQLOPS _DOWNLOADPAGE = 'Microsoft.SQLOps.DownloadPage' ;
const MICROSOFT _VISUALSTUDIO _SERVICES _VSIXPACKAGE = 'Microsoft.VisualStudio.Services.VSIXPackage' ;
2020-09-28 07:52:18 +03:00
/ * *
* This file is for validating the extension gallery files to ensure that they adhere to the expected schema defined in
* https : //github.com/microsoft/azuredatastudio/blob/main/src/vs/platform/extensionManagement/common/extensionGalleryService.ts#L64
*
* It would be ideal to use the actual parsing logic ADS uses for more thorough validation and automatic updates if the schema ever
* changes , but given how unlikely that is this is fine for now .
*
* Note that while most checks are falsy checks some specifically check for undefined when an empty string or 0 is
* an expected value .
*
* You can run this manually from the command line with :
* node scripts / validateGalleries . js
* /
/ * *
* Validate the extension gallery json according to
* interface IRawGalleryQueryResult {
* results : {
* extensions : IRawGalleryExtension [ ] ;
* resultMetadata : {
* metadataType : string ;
* metadataItems : {
* name : string ;
* count : number ;
* } [ ] ;
* } [ ]
* } [ ] ;
* }
* /
2022-05-25 02:07:57 +03:00
async function validateExtensionGallery ( galleryFilePath ) {
2020-09-28 07:52:18 +03:00
let galleryJson ;
try {
2022-05-25 02:07:57 +03:00
galleryJson = JSON . parse ( fs . readFileSync ( galleryFilePath ) . toString ( ) ) ;
2020-09-28 07:52:18 +03:00
} catch ( err ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` Unable to parse extension gallery file ${ galleryFilePath } : ${ err } ` ) ;
2020-09-28 07:52:18 +03:00
}
if ( ! galleryJson . results || ! galleryJson . results [ 0 ] ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - results invalid ` ) ;
2020-09-28 07:52:18 +03:00
}
2022-05-25 02:07:57 +03:00
await validateResults ( galleryFilePath , galleryJson . results [ 0 ] ) ;
2020-09-28 07:52:18 +03:00
}
/ * *
* Validate results blob according to
* {
* extensions : IRawGalleryExtension [ ] ;
* resultMetadata : {
* metadataType : string ;
* metadataItems : {
* name : string ;
* count : number ;
* } [ ] ;
* } [ ]
* }
* /
2022-05-25 02:07:57 +03:00
async function validateResults ( galleryFilePath , resultsJson ) {
2020-09-28 07:52:18 +03:00
if ( ! resultsJson . extensions || ! resultsJson . extensions . length ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - No extensions \n ${ JSON . stringify ( resultsJson ) } ` )
2020-09-28 07:52:18 +03:00
}
2021-07-13 21:37:17 +03:00
for ( const extension of resultsJson . extensions ) {
2022-05-25 02:07:57 +03:00
await validateExtension ( galleryFilePath , extension ) ;
2021-07-13 21:37:17 +03:00
}
2020-09-28 07:52:18 +03:00
if ( ! resultsJson . resultMetadata || ! resultsJson . resultMetadata . length ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - No resultMetadata \n ${ JSON . stringify ( resultsJson ) } ` )
2020-09-28 07:52:18 +03:00
}
2022-05-25 02:07:57 +03:00
resultsJson . resultMetadata . forEach ( resultMetadata => validateResultMetadata ( galleryFilePath , resultsJson . extensions . length , resultMetadata ) ) ;
2020-09-28 07:52:18 +03:00
}
/ * *
* Validate extension blob according to
* interface IRawGalleryExtension {
* extensionId : string ;
* extensionName : string ;
* displayName : string ;
* shortDescription : string ;
* publisher : { displayName : string , publisherId : string , publisherName : string ; } ;
* versions : IRawGalleryExtensionVersion [ ] ;
* statistics : IRawGalleryExtensionStatistics [ ] ;
* flags : string ;
* }
* /
2022-05-25 02:07:57 +03:00
async function validateExtension ( galleryFilePath , extensionJson ) {
2020-09-28 07:52:18 +03:00
if ( ! extensionJson . extensionId ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - No extensionId \n ${ JSON . stringify ( extensionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
let extensionName = extensionJson . extensionName ;
if ( ! extensionName ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - No extensionName \n ${ JSON . stringify ( extensionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
if ( ! extensionJson . displayName ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - No displayName \n ${ JSON . stringify ( extensionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
if ( ! extensionJson . shortDescription ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - No shortDescription \n ${ JSON . stringify ( extensionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
if ( ! extensionJson . publisher || ! extensionJson . publisher . displayName || ! extensionJson . publisher . publisherId || ! extensionJson . publisher . publisherName ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - Invalid publisher \n ${ JSON . stringify ( extensionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
if ( ! extensionJson . versions || ! extensionJson . versions . length ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - Invalid versions \n ${ JSON . stringify ( extensionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
2021-07-13 21:37:17 +03:00
for ( const version of extensionJson . versions ) {
2022-05-25 02:07:57 +03:00
await validateVersion ( galleryFilePath , extensionName , version ) ;
2021-07-13 21:37:17 +03:00
}
2020-09-28 07:52:18 +03:00
if ( ! extensionJson . statistics || extensionJson . statistics . length === undefined ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - Invalid statistics \n ${ JSON . stringify ( extensionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
2022-05-25 02:07:57 +03:00
extensionJson . statistics . forEach ( statistics => validateExtensionStatistics ( galleryFilePath , extensionName , statistics ) ) ;
2020-09-28 07:52:18 +03:00
if ( extensionJson . flags === undefined ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - No flags \n ${ JSON . stringify ( extensionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
}
/ * *
* Validate an extension statistics blob according to
*
* interface IRawGalleryExtensionStatistics {
* statisticName : string ;
* value : number ;
* }
* /
2022-05-25 02:07:57 +03:00
function validateExtensionStatistics ( galleryFilePath , extensionName , extensionStatisticsJson ) {
2020-09-28 07:52:18 +03:00
if ( ! extensionStatisticsJson . statisticName ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - Invalid statisticName \n ${ JSON . stringify ( extensionStatisticsJson ) } ` )
2020-09-28 07:52:18 +03:00
}
if ( extensionStatisticsJson . value === undefined ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - Invalid value \n ${ JSON . stringify ( extensionStatisticsJson ) } ` )
2020-09-28 07:52:18 +03:00
}
}
/ * *
* Validate an extension version blob according to
* interface IRawGalleryExtensionVersion {
* version : string ;
* lastUpdated : string ;
* assetUri : string ;
* fallbackAssetUri : string ;
* files : IRawGalleryExtensionFile [ ] ;
* properties ? : IRawGalleryExtensionProperty [ ] ;
* }
* /
2022-05-25 02:07:57 +03:00
async function validateVersion ( galleryFilePath , extensionName , extensionVersionJson ) {
2020-09-28 07:52:18 +03:00
if ( ! extensionVersionJson . version ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - No version \n ${ JSON . stringify ( extensionVersionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
if ( extensionVersionJson . lastUpdated === undefined ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - No last updated \n ${ JSON . stringify ( extensionVersionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
2022-02-28 20:42:38 +03:00
if ( ( new Date ( extensionVersionJson . lastUpdated ) ) . toString ( ) === 'Invalid Date' ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - Last updated value ' ${ extensionVersionJson . lastUpdated } ' is invalid. It must be in the format MM/DD/YYYY \n ${ JSON . stringify ( extensionVersionJson ) } ` )
2022-02-28 20:42:38 +03:00
}
2020-09-28 07:52:18 +03:00
if ( extensionVersionJson . assetUri === undefined ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - No asset URI \n ${ JSON . stringify ( extensionVersionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
if ( ! extensionVersionJson . fallbackAssetUri ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - No fallbackAssetUri \n ${ JSON . stringify ( extensionVersionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
if ( ! extensionVersionJson . files || ! extensionVersionJson . files [ 0 ] ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - Invalid version files \n ${ JSON . stringify ( extensionVersionJson ) } ` )
2020-09-28 07:52:18 +03:00
}
2021-07-13 21:37:17 +03:00
2022-05-25 02:07:57 +03:00
validateHasRequiredAssets ( galleryFilePath , extensionName , extensionVersionJson . files ) ;
2021-07-13 21:37:17 +03:00
for ( const file of extensionVersionJson . files ) {
2022-05-25 02:07:57 +03:00
await validateExtensionFile ( galleryFilePath , extensionName , file ) ;
2021-07-13 21:37:17 +03:00
}
2020-09-28 07:52:18 +03:00
if ( extensionVersionJson . properties && extensionVersionJson . properties . length ) {
2022-05-25 02:07:57 +03:00
extensionVersionJson . properties . forEach ( property => validateExtensionProperty ( galleryFilePath , extensionName , property ) ) ;
2021-03-31 23:31:49 +03:00
const azdataEngineVersion = extensionVersionJson . properties . find ( property => property . key === 'Microsoft.AzDataEngine' && ( property . value . startsWith ( '>=' ) || property . value === '*' ) )
if ( ! azdataEngineVersion ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - No valid Microsoft.AzdataEngine property found. Value must be either * or >=x.x.x where x.x.x is the minimum Azure Data Studio version the extension requires \n ${ JSON . stringify ( extensionVersionJson . properties ) } ` )
2021-03-31 23:31:49 +03:00
}
2021-04-01 01:59:46 +03:00
const vscodeEngineVersion = extensionVersionJson . properties . find ( property => property . key === 'Microsoft.VisualStudio.Code.Engine' ) ;
if ( vscodeEngineVersion && vscodeEngineVersion . value . startsWith ( '>=' ) && azdataEngineVersion . value . startsWith ( '>=' ) ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - Both Microsoft.AzDataEngine and Microsoft.VisualStudio.Code.Engine should not have minimum versions. Each Azure Data Studio version is tied to a specific VS Code version and so having both is redundant. ` )
2021-04-01 01:59:46 +03:00
}
2021-03-31 23:31:49 +03:00
} else {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - No properties, extensions must have an AzDataEngine version defined ` )
2020-09-28 07:52:18 +03:00
}
}
2021-07-13 21:37:17 +03:00
/ * *
* Validates that an extension version has the expected files for displaying in the gallery .
* There are some existing 3 rd party extensions that don 't have all the files, but that' s ok for now .
* Going forward all new extensions should provide these files .
* /
2022-05-25 02:07:57 +03:00
function validateHasRequiredAssets ( galleryFilePath , extensionName , filesJson ) {
2021-07-13 21:37:17 +03:00
// VSIXPackage or DownloadPage
2022-05-25 02:07:57 +03:00
const vsixFile = filesJson . find ( file => file . assetType === MICROSOFT _VISUALSTUDIO _SERVICES _VSIXPACKAGE ) ;
const downloadPageFile = filesJson . find ( file => file . assetType === MICROSOFT _SQLOPS _DOWNLOADPAGE ) ;
2021-08-11 20:28:09 +03:00
if ( vsixFile && downloadPageFile ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - Can not have both VSIXPackage and DownloadPage file ` ) ;
2021-07-13 21:37:17 +03:00
} else if ( ! vsixFile && ! downloadPageFile ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - Must have file with either VSIXPackage or DownloadPage assetType ` ) ;
2021-07-13 21:37:17 +03:00
}
// Icon
const iconFile = filesJson . find ( file => file . assetType === 'Microsoft.VisualStudio.Services.Icons.Default' ) ;
const noIconExtensions = [ 'poor-sql-formatter' , 'qpi' ] ; // Not all 3rd party extensions have icons so allow existing ones to pass for now
2021-08-11 20:28:09 +03:00
if ( ! iconFile && noIconExtensions . find ( ext => ext === extensionName ) === undefined ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - Must have an icon file ` ) ;
2021-07-13 21:37:17 +03:00
}
// Details
const detailsFile = filesJson . find ( file => file . assetType === 'Microsoft.VisualStudio.Services.Content.Details' ) ;
2021-08-11 20:28:09 +03:00
if ( ! detailsFile ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - Must have a details file (README) ` ) ;
2021-07-13 21:37:17 +03:00
}
// Manifest
const noManifestExtensions = [ 'plan-explorer' , 'sql-prompt' ] ; // Not all 3rd party extensions have manifests so allow existing ones to pass for now
const manifestFile = filesJson . find ( file => file . assetType === 'Microsoft.VisualStudio.Code.Manifest' ) ;
2021-08-11 20:28:09 +03:00
if ( ! manifestFile && noManifestExtensions . find ( ext => ext === extensionName ) === undefined ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - Must have a manifest file (package.json) ` ) ;
2021-07-13 21:37:17 +03:00
}
// License
const noLicenseExtensions = [ 'sp_executesqlToSQL' , 'simple-data-scripter' , 'db-snapshot-creator' ] ; // Not all 3rd party extensions have license files to link to so allow existing ones to pass for now
const licenseFile = filesJson . find ( file => file . assetType === 'Microsoft.VisualStudio.Services.Content.License' ) ;
2021-08-11 20:28:09 +03:00
if ( ! licenseFile && noLicenseExtensions . find ( ext => ext === extensionName ) === undefined ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - Must have a license file ` ) ;
2021-07-13 21:37:17 +03:00
}
}
2020-09-28 07:52:18 +03:00
/ * *
* Validate an extension property blob according to
* interface IRawGalleryExtensionProperty {
* key : string ;
* value : string ;
* }
* /
2022-05-25 02:07:57 +03:00
function validateExtensionProperty ( galleryFilePath , extensionName , extensionPropertyJson ) {
2020-09-28 07:52:18 +03:00
if ( ! extensionPropertyJson . key ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - No key \n ${ JSON . stringify ( extensionPropertyJson ) } ` )
2020-09-28 07:52:18 +03:00
}
if ( extensionPropertyJson . value === undefined ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - No value \n ${ JSON . stringify ( extensionPropertyJson ) } ` )
2020-09-28 07:52:18 +03:00
}
}
2022-02-25 22:30:37 +03:00
// The set of asset types that are required to be hosted by either us or Github due to potential CORS issues loading
// content in ADS from other sources.
const hostedAssetTypes = new Set ( [
2022-05-25 02:07:57 +03:00
MICROSOFT _VISUALSTUDIO _SERVICES _VSIXPACKAGE ,
2022-02-25 22:30:37 +03:00
'Microsoft.VisualStudio.Services.Icons.Default' ,
'Microsoft.VisualStudio.Services.Content.Details' ,
'Microsoft.VisualStudio.Services.Content.Changelog' ,
'Microsoft.VisualStudio.Code.Manifest' ] ) ;
2022-02-26 03:56:39 +03:00
const allowedHosts = [
'https://sqlopsextensions.blob.core.windows.net/' ,
'https://dsct.blob.core.windows.net/' ,
'https://raw.githubusercontent.com/'
] ;
2020-09-28 07:52:18 +03:00
/ * *
* Validate an extension file blob according to
* interface IRawGalleryExtensionFile {
* assetType : string ;
* source : string ;
* }
2021-07-13 21:37:17 +03:00
* Will also validate that the source URL provided is valid .
2020-09-28 07:52:18 +03:00
* /
2022-05-25 02:07:57 +03:00
async function validateExtensionFile ( galleryFilePath , extensionName , extensionFileJson ) {
2020-09-28 07:52:18 +03:00
if ( ! extensionFileJson . assetType ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - No assetType \n ${ JSON . stringify ( extensionFileJson ) } ` )
2020-09-28 07:52:18 +03:00
}
if ( ! extensionFileJson . source ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - No source \n ${ JSON . stringify ( extensionFileJson ) } ` )
2020-09-28 07:52:18 +03:00
}
2022-02-25 22:30:37 +03:00
// Waka-time link is hitting rate limit for the download link so just ignore this one for now.
2022-05-25 02:07:57 +03:00
if ( extensionName === 'vscode-wakatime' && extensionFileJson . assetType === MICROSOFT _SQLOPS _DOWNLOADPAGE ) {
2021-08-11 20:28:09 +03:00
return ;
}
2022-02-26 03:56:39 +03:00
if ( hostedAssetTypes . has ( extensionFileJson . assetType ) && ! allowedHosts . find ( host => extensionFileJson . source . startsWith ( host ) ) ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - The asset ${ extensionFileJson . source } ( ${ extensionFileJson . assetType } ) is required to be hosted either on Github or by the Azure Data Studio team. If the asset is hosted on Github it must use a https://raw.githubusercontent.com/ URL. If the asset cannot be hosted on Github then please reply in the PR with links to the assets and a team member will handle moving them. ` ) ;
2022-02-25 22:30:37 +03:00
}
2021-07-13 21:37:17 +03:00
// Validate the source URL
try {
const response = await got ( extensionFileJson . source ) ;
if ( response . statusCode !== 200 ) {
throw new Error ( ` ${ response . statusCode } : ${ response . statusMessage } ` ) ;
}
} catch ( err ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - ${ extensionName } - Error fetching ${ extensionFileJson . assetType } with URL ${ extensionFileJson . source } . ${ err } ` ) ;
2021-07-13 21:37:17 +03:00
}
2020-09-28 07:52:18 +03:00
}
/ * *
* Validate a result metadata blob according to
* {
* metadataType : string ;
* metadataItems : {
* name : string ;
* count : number ;
* }
* /
2022-05-25 02:07:57 +03:00
function validateResultMetadata ( galleryFilePath , extensionCount , resultMetadataJson ) {
2020-09-28 07:52:18 +03:00
if ( ! resultMetadataJson . metadataType ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - No metadataType \n ${ JSON . stringify ( resultMetadataJson ) } ` )
2020-09-28 07:52:18 +03:00
}
if ( ! resultMetadataJson . metadataItems || ! resultMetadataJson . metadataItems . length ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - Invalid metadataItems \n ${ JSON . stringify ( resultMetadataJson ) } ` )
2020-09-28 07:52:18 +03:00
}
resultMetadataJson . metadataItems . forEach ( metadataItem => {
if ( ! metadataItem . name ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - No name \n ${ JSON . stringify ( metadataItem ) } ` )
2020-09-28 07:52:18 +03:00
}
if ( metadataItem . count === undefined ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - No count \n ${ JSON . stringify ( metadataItem ) } ` )
2020-09-28 07:52:18 +03:00
}
// Extra check here for validating that the total count of extensions is correct
if ( metadataItem . name === 'TotalCount' && metadataItem . count !== extensionCount ) {
2022-05-25 02:07:57 +03:00
throw new Error ( ` ${ galleryFilePath } - Invalid TotalCount, this needs to be updated if adding/removing a new extension. Actual count : ${ extensionCount } \n ${ JSON . stringify ( metadataItem ) } ` )
2020-09-28 07:52:18 +03:00
}
} )
}
2021-07-13 21:37:17 +03:00
await Promise . all ( [
2022-05-25 02:07:57 +03:00
validateExtensionGallery ( path . join ( ROOT _DIR , 'extensionsGallery.json' ) ) ,
validateExtensionGallery ( path . join ( ROOT _DIR , 'extensionsGallery-insider.json' ) )
2021-07-13 21:37:17 +03:00
] ) ;