2021-01-05 17:25:15 +02:00
import * as fs from 'fs-extra' ;
import * as path from 'path' ;
import * as process from 'process' ;
2021-01-06 22:23:23 +02:00
import validatePluginId from '@joplin/lib/services/plugins/utils/validatePluginId' ;
2021-01-11 14:42:11 +02:00
import markdownUtils , { MarkdownTableHeader , MarkdownTableRow } from '@joplin/lib/markdownUtils' ;
2021-01-18 16:37:27 +02:00
import { execCommand2 , resolveRelativePathWithinDir , gitPullTry , gitRepoCleanTry , gitRepoClean } from '@joplin/tools/tool-utils.js' ;
2021-01-05 17:25:15 +02:00
interface NpmPackage {
name : string ;
version : string ;
date : Date ;
}
2021-01-12 17:29:08 +02:00
function stripOffPackageOrg ( name : string ) : string {
const n = name . split ( '/' ) ;
if ( n [ 0 ] [ 0 ] === '@' ) n . splice ( 0 , 1 ) ;
return n . join ( '/' ) ;
}
function isJoplinPluginPackage ( pack : any ) : boolean {
if ( ! pack . keywords || ! pack . keywords . includes ( 'joplin-plugin' ) ) return false ;
if ( stripOffPackageOrg ( pack . name ) . indexOf ( 'joplin-plugin' ) !== 0 ) return false ;
return true ;
}
2021-01-05 17:25:15 +02:00
function pluginInfoFromSearchResults ( results : any [ ] ) : NpmPackage [ ] {
const output : NpmPackage [ ] = [ ] ;
for ( const r of results ) {
2021-01-12 17:29:08 +02:00
if ( ! isJoplinPluginPackage ( r ) ) continue ;
2021-01-05 17:25:15 +02:00
output . push ( {
name : r.name ,
version : r.version ,
date : new Date ( r . date ) ,
} ) ;
}
return output ;
}
async function checkPluginRepository ( dirPath : string ) {
if ( ! ( await fs . pathExists ( dirPath ) ) ) throw new Error ( ` No plugin repository at: ${ dirPath } ` ) ;
if ( ! ( await fs . pathExists ( ` ${ dirPath } /.git ` ) ) ) throw new Error ( ` Directory is not a Git repository: ${ dirPath } ` ) ;
2021-01-06 21:43:05 +02:00
const previousDir = process . cwd ( ) ;
process . chdir ( dirPath ) ;
2021-01-18 16:37:27 +02:00
await gitRepoCleanTry ( ) ;
2021-01-06 21:43:05 +02:00
await gitPullTry ( ) ;
process . chdir ( previousDir ) ;
2021-01-05 17:25:15 +02:00
}
2021-01-06 21:43:05 +02:00
async function readJsonFile ( manifestPath : string , defaultValue : any = null ) : Promise < any > {
if ( ! ( await fs . pathExists ( manifestPath ) ) ) {
if ( defaultValue === null ) throw new Error ( ` No such file: ${ manifestPath } ` ) ;
return defaultValue ;
}
2021-01-05 17:25:15 +02:00
const content = await fs . readFile ( manifestPath , 'utf8' ) ;
return JSON . parse ( content ) ;
}
2021-01-12 17:29:08 +02:00
function caseInsensitiveFindManifest ( manifests : any , manifestId : string ) : any {
for ( const id of Object . keys ( manifests ) ) {
if ( id . toLowerCase ( ) === manifestId . toLowerCase ( ) ) return manifests [ id ] ;
}
return null ;
}
async function extractPluginFilesFromPackage ( existingManifests : any , workDir : string , packageName : string , destDir : string ) : Promise < any > {
2021-01-05 17:25:15 +02:00
const previousDir = process . cwd ( ) ;
process . chdir ( workDir ) ;
2021-01-18 16:37:27 +02:00
await execCommand2 ( ` npm install ${ packageName } --save --ignore-scripts ` , { showOutput : false } ) ;
2021-01-05 17:25:15 +02:00
const pluginDir = resolveRelativePathWithinDir ( workDir , 'node_modules' , packageName , 'publish' ) ;
const files = await fs . readdir ( pluginDir ) ;
2021-01-18 16:37:27 +02:00
const manifestFilePath = path . resolve ( pluginDir , files . find ( ( f : any ) = > path . extname ( f ) === '.json' ) ) ;
const pluginFilePath = path . resolve ( pluginDir , files . find ( ( f : any ) = > path . extname ( f ) === '.jpl' ) ) ;
2021-01-05 17:25:15 +02:00
if ( ! ( await fs . pathExists ( manifestFilePath ) ) ) throw new Error ( ` Could not find manifest file at ${ manifestFilePath } ` ) ;
if ( ! ( await fs . pathExists ( pluginFilePath ) ) ) throw new Error ( ` Could not find plugin file at ${ pluginFilePath } ` ) ;
2021-01-06 22:23:23 +02:00
// At this point, we need to check the manifest ID as it's used in various
// places including as directory name and object key in manifests.json, so
// it needs to be correct. It's mostly for security reasons. The other
// manifest properties are checked when the plugin is loaded into the app.
2021-01-06 21:43:05 +02:00
const manifest = await readJsonFile ( manifestFilePath ) ;
2021-01-06 22:23:23 +02:00
validatePluginId ( manifest . id ) ;
2021-01-06 21:43:05 +02:00
manifest . _npm_package_name = packageName ;
// If there's already a plugin with this ID published under a different
// package name, we skip it. Otherwise it would allow anyone to overwrite
// someone else plugin just by using the same ID. So the first plugin with
// this ID that was originally added is kept.
2021-01-12 17:29:08 +02:00
//
// We need case insensitive match because the filesystem might be case
// insensitive too.
const originalManifest = caseInsensitiveFindManifest ( existingManifests , manifest . id ) ;
2021-01-06 21:43:05 +02:00
if ( originalManifest && originalManifest . _npm_package_name !== packageName ) {
throw new Error ( ` Plugin " ${ manifest . id } " from npm package " ${ packageName } " has already been published under npm package " ${ originalManifest . _npm_package_name } ". Plugin from package " ${ packageName } " will not be imported. ` ) ;
}
2021-01-05 17:25:15 +02:00
2021-01-06 21:43:05 +02:00
const pluginDestDir = resolveRelativePathWithinDir ( destDir , manifest . id ) ;
2021-01-05 17:25:15 +02:00
await fs . mkdirp ( pluginDestDir ) ;
2021-01-06 21:43:05 +02:00
await fs . writeFile ( path . resolve ( pluginDestDir , 'manifest.json' ) , JSON . stringify ( manifest , null , '\t' ) , 'utf8' ) ;
2021-01-05 17:25:15 +02:00
await fs . copy ( pluginFilePath , path . resolve ( pluginDestDir , 'plugin.jpl' ) ) ;
process . chdir ( previousDir ) ;
return manifest ;
}
2021-01-11 14:42:11 +02:00
async function updateReadme ( readmePath : string , manifests : any ) {
const rows : MarkdownTableRow [ ] = [ ] ;
for ( const pluginId in manifests ) {
rows . push ( manifests [ pluginId ] ) ;
}
const headers : MarkdownTableHeader [ ] = [
{
name : 'homepage_url' ,
2021-01-11 14:46:21 +02:00
label : ' ' ,
2021-01-11 14:42:11 +02:00
filter : ( value : string ) = > {
return ` [🏠]( ${ markdownUtils . escapeLinkUrl ( value ) } ) ` ;
} ,
} ,
{
name : 'name' ,
label : 'Name' ,
} ,
{
name : 'version' ,
label : 'Version' ,
} ,
{
name : 'description' ,
label : 'Description' ,
} ,
{
name : 'author' ,
label : 'Author' ,
} ,
] ;
2021-01-11 14:46:21 +02:00
rows . sort ( ( a : any , b : any ) = > {
return a . name . toLowerCase ( ) < b . name . toLowerCase ( ) ? - 1 : + 1 ;
} ) ;
2021-01-11 14:42:11 +02:00
const mdTable = markdownUtils . createMarkdownTable ( headers , rows ) ;
const tableRegex = /<!-- PLUGIN_LIST -->([^]*)<!-- PLUGIN_LIST -->/ ;
2021-01-18 16:37:27 +02:00
const content = await fs . pathExists ( readmePath ) ? await fs . readFile ( readmePath , 'utf8' ) : '<!-- PLUGIN_LIST -->\n<!-- PLUGIN_LIST -->' ;
2021-01-11 14:42:11 +02:00
const newContent = content . replace ( tableRegex , ` <!-- PLUGIN_LIST --> \ n ${ mdTable } \ n<!-- PLUGIN_LIST --> ` ) ;
await fs . writeFile ( readmePath , newContent , 'utf8' ) ;
}
2021-01-18 16:37:27 +02:00
interface CommandBuildArgs {
pluginRepoDir : string ;
}
enum ProcessingActionType {
Add = 1 ,
Update = 2 ,
}
function commitMessage ( actionType : ProcessingActionType , npmPackage : NpmPackage ) : string {
const output : string [ ] = [ ] ;
if ( actionType === ProcessingActionType . Add ) {
output . push ( 'New' ) ;
} else {
output . push ( 'Update' ) ;
}
output . push ( ` ${ npmPackage . name } @ ${ npmPackage . version } ` ) ;
return output . join ( ': ' ) ;
}
async function processNpmPackage ( npmPackage : NpmPackage , repoDir : string ) {
2021-01-05 17:25:15 +02:00
const tempDir = ` ${ repoDir } /temp ` ;
2021-01-06 21:43:05 +02:00
const pluginManifestsPath = path . resolve ( repoDir , 'manifests.json' ) ;
2021-01-12 17:29:08 +02:00
const obsoleteManifestsPath = path . resolve ( repoDir , 'obsoletes.json' ) ;
2021-01-06 21:43:05 +02:00
const errorsPath = path . resolve ( repoDir , 'errors.json' ) ;
2021-01-05 17:25:15 +02:00
await fs . mkdirp ( tempDir ) ;
2021-01-06 21:43:05 +02:00
const originalPluginManifests = await readJsonFile ( pluginManifestsPath , { } ) ;
2021-01-12 17:29:08 +02:00
const obsoleteManifests = await readJsonFile ( obsoleteManifestsPath , { } ) ;
const existingManifests = {
. . . originalPluginManifests ,
. . . obsoleteManifests ,
} ;
2021-01-06 21:43:05 +02:00
2021-01-05 17:25:15 +02:00
const packageTempDir = ` ${ tempDir } /packages ` ;
await fs . mkdirp ( packageTempDir ) ;
2021-01-18 16:37:27 +02:00
const previousDir = process . cwd ( ) ;
2021-01-05 17:25:15 +02:00
process . chdir ( packageTempDir ) ;
2021-01-18 16:37:27 +02:00
await execCommand2 ( 'npm init --yes --loglevel silent' , { quiet : true } ) ;
2021-01-05 17:25:15 +02:00
2021-01-18 16:37:27 +02:00
const errors : any = await readJsonFile ( errorsPath , { } ) ;
delete errors [ npmPackage . name ] ;
2021-01-06 21:43:05 +02:00
2021-01-18 16:37:27 +02:00
let actionType : ProcessingActionType = ProcessingActionType . Update ;
2021-01-06 21:43:05 +02:00
let manifests : any = { } ;
2021-01-18 16:37:27 +02:00
try {
const destDir = ` ${ repoDir } /plugins/ ` ;
const manifest = await extractPluginFilesFromPackage ( existingManifests , packageTempDir , npmPackage . name , destDir ) ;
if ( ! existingManifests [ manifest . id ] ) {
actionType = ProcessingActionType . Add ;
2021-01-06 21:43:05 +02:00
}
2021-01-18 16:37:27 +02:00
if ( ! obsoleteManifests [ manifest . id ] ) manifests [ manifest . id ] = manifest ;
} catch ( error ) {
console . error ( error ) ;
errors [ npmPackage . name ] = error . message || '' ;
2021-01-05 17:25:15 +02:00
}
2021-01-06 21:43:05 +02:00
// We preserve the original manifests so that if a plugin has been removed
// from npm, we still keep it. It's also a security feature - it means that
// if a plugin is removed from npm, it's not possible to highjack it by
// creating a new npm package with the same plugin ID.
manifests = {
. . . originalPluginManifests ,
. . . manifests ,
} ;
await fs . writeFile ( pluginManifestsPath , JSON . stringify ( manifests , null , '\t' ) , 'utf8' ) ;
2021-01-18 16:37:27 +02:00
if ( Object . keys ( errors ) . length ) {
await fs . writeFile ( errorsPath , JSON . stringify ( errors , null , '\t' ) , 'utf8' ) ;
2021-01-06 21:43:05 +02:00
} else {
await fs . remove ( errorsPath ) ;
}
2021-01-05 17:25:15 +02:00
2021-01-11 14:42:11 +02:00
await updateReadme ( ` ${ repoDir } /README.md ` , manifests ) ;
2021-01-18 16:37:27 +02:00
process . chdir ( previousDir ) ;
2021-01-05 17:25:15 +02:00
await fs . remove ( tempDir ) ;
2021-01-18 16:37:27 +02:00
process . chdir ( repoDir ) ;
if ( ! ( await gitRepoClean ( ) ) ) {
await execCommand2 ( 'git add -A' , { showOutput : false } ) ;
await execCommand2 ( [ 'git' , 'commit' , '-m' , commitMessage ( actionType , npmPackage ) ] , { showOutput : false } ) ;
} else {
console . info ( 'Nothing to commit' ) ;
}
}
async function commandBuild ( args : CommandBuildArgs ) {
const repoDir = args . pluginRepoDir ;
await checkPluginRepository ( repoDir ) ;
const searchResults = ( await execCommand2 ( 'npm search joplin-plugin --searchlimit 5000 --json' , { showOutput : false } ) ) . trim ( ) ;
const npmPackages = pluginInfoFromSearchResults ( JSON . parse ( searchResults ) ) ;
for ( const npmPackage of npmPackages ) {
await processNpmPackage ( npmPackage , repoDir ) ;
}
await execCommand2 ( 'git push' ) ;
}
async function main() {
const scriptName : string = 'plugin-repo-cli' ;
const commands : Record < string , Function > = {
build : commandBuild ,
} ;
let selectedCommand : string = '' ;
let selectedCommandArgs : string = '' ;
function setSelectedCommand ( name : string , args : any ) {
selectedCommand = name ;
selectedCommandArgs = args ;
}
require ( 'yargs' )
. scriptName ( scriptName )
. usage ( '$0 <cmd> [args]' )
. command ( 'build <plugin-repo-dir>' , 'Build the plugin repository' , ( yargs : any ) = > {
yargs . positional ( 'plugin-repo-dir' , {
type : 'string' ,
describe : 'Directory where the plugin repository is located' ,
} ) ;
} , ( args : any ) = > setSelectedCommand ( 'build' , args ) )
. help ( )
. argv ;
if ( ! selectedCommand ) {
console . error ( ` Please provide a command name or type \` ${ scriptName } --help \` for help ` ) ;
process . exit ( 1 ) ;
}
if ( ! commands [ selectedCommand ] ) {
console . error ( ` No such command: ${ selectedCommand } ` ) ;
process . exit ( 1 ) ;
}
await commands [ selectedCommand ] ( selectedCommandArgs ) ;
2021-01-05 17:25:15 +02:00
}
main ( ) . catch ( ( error ) = > {
console . error ( 'Fatal error' ) ;
console . error ( error ) ;
process . exit ( 1 ) ;
} ) ;