/* eslint-disable unicorn/prefer-module */ /* * Copyright (C) 2018, 2019 StApps * This program is free software: you can redistribute it and/or modify it * under the terms of the GNU General Public License as published by the Free * Software Foundation, version 3. * * This program is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with * this program. If not, see . */ import chalk from 'chalk'; import {execSync} from 'child_process'; import {copyFileSync, existsSync, lstatSync, PathLike, readdirSync, readFileSync} from 'fs'; import path from 'path'; import {satisfies, valid} from 'semver'; import {isDeepStrictEqual} from 'util'; import {parse, stringify} from 'yaml'; interface NYCConfiguration { [prop: string]: boolean | number | string[]; } interface PackageJSONPerson { /** * Email of the author */ email?: string; /** * Name of the author */ name: string; /** * URL of the author */ url?: string; } interface PackageJSON { /** * Author of the package */ author: string | PackageJSONPerson; /** * Contributors of the package */ contributors: Array; /** * Dependencies */ dependencies?: { [dependency: string]: string; }; /** * Development dependencies */ devDependencies?: { [devDependency: string]: string; }; /** * License */ license: string; /** * NYC configuration */ nyc: NYCConfiguration; /** * Openstapps configuration */ openstappsConfiguration: Configuration; /** * Scripts */ scripts: { [name: string]: string; }; } /** * Configuration for the configuration check */ export interface Configuration { /** * Whether or not the project is meant to be packaged */ forPackaging: boolean; /** * Whether or not the project has a CLI */ hasCli: boolean; /** * A list of CI entries to ignore while checking */ ignoreCiEntries: string[]; /** * A list of script names to ignore while checking */ ignoreScripts: string[]; /** * Whether or not the project is meant to be executed server side */ serverSide: boolean; /** * Whether or not the standard build procedure is meant to be used */ standardBuild: boolean; /** * Whether or not the standard documentation procedure is meant to be used */ standardDocumentation: boolean; } /** * Rules for the configuration check */ export interface Rules { /** * Expected CI config */ ciConfig: {[k: string]: string | object}; /** * Expected dependencies */ dependencies: string[]; /** * Expected dev dependencies */ devDependencies: string[]; /** * Expected files */ files: string[]; /** * Expected licenses */ licenses: string[]; /** * Expected NYC configuration */ nycConfiguration: NYCConfiguration; /** * Expected scripts */ scripts: {[k: string]: string}; } /** * Wrapper for console.info that outputs every argument in cyan * * @param arguments_ Arguments to output */ export function consoleInfo(...arguments_: string[]): void { for (const argument of arguments_) { /* tslint:disable-next-line:no-console */ console.info(`\n${chalk.cyan(argument)}`); } } /** * Wrapper for console.warn that outputs every argument in red * * @param arguments_ Arguments to output */ export function consoleWarn(...arguments_: string[]): void { for (const argument of arguments_) { const lines = argument.split('\n'); /* tslint:disable-next-line:no-console */ console.warn(`\n${chalk.red.bold(lines[0])}`); for (const line of lines.slice(1)) { /* tslint:disable-next-line:no-console */ console.info(line); } } } /** * Wrapper for console.log that outputs every argument in green * * @param arguments_ Arguments to output */ export function consoleLog(...arguments_: string[]): void { for (const argument of arguments_) { /* tslint:disable-next-line:no-console */ console.log(`\n${chalk.green.bold(argument)}`); } } /** * Check dependencies are installed * * @param rules Rules for check * @param packageJson package.json to check dependencies in */ export function checkDependencies(rules: Rules, packageJson: PackageJSON): void { if (typeof packageJson.dependencies === 'object') { for (const dependency of rules.dependencies) { const [name, version] = dependency.split(':'); const installedVersion = packageJson.dependencies[name]; if ( typeof packageJson.dependencies === 'undefined' || typeof packageJson.dependencies[name] === 'undefined' ) { consoleWarn(`Dependency '${name}' is missing. Please install with 'npm install --save-exact ${name}'.`); } else if ( typeof version !== 'undefined' && typeof valid(version) === 'string' && !satisfies(installedVersion, version) ) { consoleWarn( `Version '${installedVersion}' of dependency '${name} does not satisfy constraint '${version}'.`, ); } } } if (typeof packageJson.devDependencies === 'object') { for (const developmentDependency of rules.devDependencies) { const [developmentName, developmentVersion] = developmentDependency.split(':'); let installedVersion = packageJson.devDependencies[developmentName]; if (typeof packageJson.dependencies === 'object') { const [name] = developmentDependency.split(':'); if (typeof packageJson.devDependencies[name] === 'string') { installedVersion = packageJson.dependencies[name]; } } if ( (typeof packageJson.dependencies === 'undefined' || typeof packageJson.dependencies[developmentName] === 'undefined') && (typeof packageJson.devDependencies === 'undefined' || typeof packageJson.devDependencies[developmentName] === 'undefined') ) { consoleWarn(`Dev dependency '${developmentName}' is missing. Please install with 'npm install --save-exact --save-dev ${developmentName}'.`); } else if ( typeof developmentVersion !== 'undefined' && typeof valid(developmentVersion) === 'string' && !satisfies(installedVersion, developmentVersion) ) { consoleWarn( `Version '${installedVersion}' of dev dependency '${developmentName} does not satisfy constraint '${developmentVersion}'.`, ); } } } } /** * Check that configuration files are extended * * @param configPath Path, where configuration files are located */ export function checkConfigurationFilesAreExtended(configPath: string): void { // check if configuration files are extended for (const file of ['tsconfig.json', 'eslintrc.json']) { const fileToCheck = path.resolve(configPath, file); const expectedPath = `./node_modules/@openstapps/configuration/${file}`; if (existsSync(fileToCheck)) { const configFile = JSON.parse(readFileSync(fileToCheck).toString()); const configFileExtended = (typeof configFile.extends === 'string' && configFile.extends === expectedPath) || (file === 'eslintrc.json' && Array.isArray(configFile.extends) && configFile.extends.includes('@openstapps')); if (!configFileExtended) { consoleWarn(`File '${fileToCheck}' should extend '${expectedPath}'! Example: ${readFileSync(path.resolve(__dirname, '..', 'templates', `template-${file}`))}`); } } } } /** * Check needed files * * @param rules Rules for check * @param filePath Path to files to check * @param replaceFlag Whether or not to replace files * @returns Whether or not overwrite is suggested */ export function checkNeededFiles(rules: Rules, filePath: string, replaceFlag: boolean): boolean { let suggestOverwrite = false; // copy needed files for (let file of rules.files) { let destinationFile = file; // remove templates directory for destination files if (destinationFile.indexOf('templates') === 0) { destinationFile = destinationFile.split(path.sep).slice(1).join(path.sep); file = path.join('templates', `template-${destinationFile}`); } const source = path.resolve(__dirname, '..', file); const destination = path.resolve(filePath, destinationFile); // check if file exists or replace flag is set if (!existsSync(destination) || replaceFlag) { copyFileSync(source, destination); consoleInfo(`Copied file '${source}' to '${destination}'.`); } else if (destinationFile === '.npmignore') { const npmIgnore = readFileSync(destination).toString(); const ignoredPatterns = npmIgnore.split('\n'); let ignoresEverything = false; // eslint-disable-next-line unicorn/prevent-abbreviations let includeDocs = false; for (const ignoredPattern of ignoredPatterns) { if (ignoredPattern === '/*') { ignoresEverything = true; } if (ignoredPattern === '!docs') { includeDocs = true; } } if (!ignoresEverything) { consoleWarn(`'.npmignore' should have '/*' as first pattern to ignore everything.`); suggestOverwrite = true; } if (includeDocs) { consoleWarn(`'.npmignore' contains '!docs' and thus the package will contain the documentation. Consider creating a CI job to publish those files, rather than committing this folder to the npm repo. Please double check that this is desired behavior since the docs can become huge: https://gitlab.com/openstapps/configuration/issues/11`); suggestOverwrite = true; } } } return suggestOverwrite; } /** * Check licenses * * @param rules Rules for check * @param packageJson package.json to check license in */ export function checkLicenses(rules: Rules, packageJson: PackageJSON): void { // check if license is one of the expected ones if (!rules.licenses.includes(packageJson.license)) { consoleWarn(`License should be one of '${rules.licenses.join(', ')}'!`); } } /** * Check NYC configuration * * @param rules Rules for check * @param packageJson package.json to check NYC configuration in * @param replaceFlag Whether or not to replace NYC configuration * @returns Whether or not package.json was changed and if overwrite is suggested */ export function checkNYCConfiguration( rules: Rules, packageJson: PackageJSON, replaceFlag: boolean, ): [boolean, boolean] { let packageJsonChanged = false; let suggestOverwrite = false; // check if nyc is a dependency if ( typeof packageJson.devDependencies === 'object' && Object.keys(packageJson.devDependencies).includes('nyc') ) { if (typeof packageJson.nyc === 'undefined' || replaceFlag) { // add NYC configuration packageJson.nyc = rules.nycConfiguration; packageJsonChanged = true; consoleLog(`Added NYC configuration in to 'package.json'.`); } else if (!isDeepStrictEqual(packageJson.nyc, rules.nycConfiguration)) { consoleInfo( `NYC configuration in 'package.json' differs from the proposed one. Please check manually.`, ); suggestOverwrite = true; } } return [packageJsonChanged, suggestOverwrite]; } /** * Check scripts * * @param rules Rules for check * @param packageJson package.json to check scripts in * @param replaceFlag Whether or not to replace scripts * @returns Whether or not the package.json was changed */ export function checkScripts(rules: Rules, packageJson: PackageJSON, replaceFlag: boolean): boolean { let packageJsonChanged = false; // check if scripts is a map if (typeof packageJson.scripts !== 'object') { packageJson.scripts = {}; packageJsonChanged = true; } for (const scriptName in rules.scripts) { if (!rules.scripts.hasOwnProperty(scriptName)) { continue; } const scriptToCheck = packageJson.scripts[scriptName]; // check if script exists if (typeof scriptToCheck === 'undefined' || replaceFlag) { packageJson.scripts[scriptName] = rules.scripts[scriptName]; packageJsonChanged = true; consoleInfo(`Added '${scriptName}' script to 'package.json'.`); } else if (typeof scriptToCheck === 'string' && scriptToCheck !== rules.scripts[scriptName]) { consoleWarn(`Script '${scriptName}' in 'package.json' should be: \"${rules.scripts[scriptName].replace('\n', '\\n')}\" .`); } } return packageJsonChanged; } /** * Check contributors * * @param path Path to directory * @param packageJson package.json to check contributors in */ export function checkContributors(path: PathLike, packageJson: PackageJSON): void { const execBuffer = execSync( `git --git-dir=${path}/.git --work-tree=${path} log --format=\'%aN\' | sort -u`, ); for (let person of execBuffer.toString().split('\n')) { person = person.trim(); if (person === '') { continue; } let authorIsAttributed = false; authorIsAttributed = authorIsAttributed || (typeof packageJson.author === 'string' && packageJson.author.includes(person)) || (Array.isArray(packageJson.contributors) && packageJson.contributors.findIndex(contributor => { return typeof contributor === 'string' && contributor.includes(person); }) >= 0); if (!authorIsAttributed) { consoleWarn(`'${person}' should be attributed as author or contributor.`); } } } /** * Check CI config * * @param rules Rules for check * @param configPath Path to CI config */ export function checkCIConfig(rules: Rules, configPath: string): void { const pathToCiConfig = path.resolve(configPath, '.gitlab-ci.yml'); // check CI config if it exists if (existsSync(pathToCiConfig)) { // read CI config const content = readFileSync(pathToCiConfig).toString(); let ciConfigWithoutTemplates = ''; for (const line of content.split('\n')) { const match = line.trim().match(/^< Number.parseInt(date.split('-')[0], 10)) .filter(year => { const stringYear = year.toString(); if (seen.includes(year) || stringYear.match(/[0-9]{4}/) === null) { return false; } seen.push(year); return true; }) .sort(); changedYears = [changedYears[0], changedYears[changedYears.length - 1]]; changedYears = [...new Set(changedYears)].sort(); const fileStats = lstatSync(fileSystemObjectPath); if (fileStats.isFile()) { if (fileSystemObject.match(/\.ts$/) === null) { continue; } const content = readFileSync(fileSystemObjectPath).toString().split('\n'); let copyrightYearsString = ''; for (const line of content) { const match = line.match(/^ \* Copyright \(C\) ([0-9]{4}-[0-9]{4})|([0-9]{4}) StApps$/m); const expectedMatchLength = 3; if (Array.isArray(match) && match.length === expectedMatchLength) { // tslint:disable-next-line:no-magic-numbers copyrightYearsString = match[1] ?? match[2]; } } if (copyrightYearsString === '') { consoleWarn(`Copyright line for file '${fileSystemObjectPath}' could not be found!`); } else { const copyrightYearsWithIntervals = copyrightYearsString.split('-').map(year => year.trim()); const copyrightYears: number[] = copyrightYearsWithIntervals .map(year => Number.parseInt(year, 10)) .sort(); let copyrightYearNeedsUpdate = false; if (typeof copyrightYears[0] !== 'undefined' && typeof changedYears[0] !== 'undefined') { copyrightYearNeedsUpdate = copyrightYears[0] !== changedYears[0]; } if ( typeof copyrightYears[1] !== 'undefined' && typeof changedYears[1] !== 'undefined' && !copyrightYearNeedsUpdate ) { copyrightYearNeedsUpdate = copyrightYears[1] !== changedYears[1]; } if (copyrightYears.length !== changedYears.length) { copyrightYearNeedsUpdate = true; } if (copyrightYearNeedsUpdate) { // tslint:disable-next-line:max-line-length consoleWarn( `File '${path.join( checkPathFragment.toString(), fileSystemObject, )}' has to specify '${changedYears.join('-')}' as year(s) in the copyright line.`, ); } } } else if (fileStats.isDirectory()) { checkCopyrightYears(projectPath, path.join(checkPathFragment.toString(), fileSystemObject)); } } } /** * Get configuration * * @param packageJson package.json to get configuration from */ export function getConfiguration(packageJson: PackageJSON): Configuration { const defaultConfiguration: Configuration = { forPackaging: true, hasCli: true, ignoreCiEntries: [], ignoreScripts: [], serverSide: true, standardBuild: true, standardDocumentation: true, }; if (typeof packageJson.openstappsConfiguration !== 'undefined') { return { ...defaultConfiguration, ...packageJson.openstappsConfiguration, }; } return defaultConfiguration; } /** * Get rules for check * * @param configuration Configuration for check */ export function getRules(configuration: Configuration): Rules { // expected dependencies const dependencies: string[] = []; // expected dev dependencies const devDependencies = [ '@typescript-eslint/eslint-plugin', '@typescript-eslint/parser', '@openstapps/eslint-config', 'conventional-changelog-cli', 'eslint', 'eslint-config-prettier', 'eslint-plugin-jsdoc', 'eslint-plugin-prettier', 'eslint-plugin-unicorn', 'prettier', 'typescript:4.4.4', ]; // files that need to be copied const files = [ '.editorconfig', '.eslintrc.json', '.eslintignore', path.join('templates', '.gitignore'), path.join('templates', 'tsconfig.json'), ]; // configuration for nyc to add to package.json const nycConfiguration: NYCConfiguration = { 'all': true, 'branches': 95, 'check-coverage': true, 'exclude': ['src/cli.ts'], 'extension': ['.ts'], 'functions': 95, 'include': ['src'], 'lines': 95, 'per-file': true, 'reporter': ['html', 'text-summary'], 'require': ['ts-node/register'], 'statements': 95, }; // expected scripts const scripts: {[k: string]: string} = { /* tslint:disable-next-line:max-line-length */ 'changelog': "conventional-changelog -p angular -i CHANGELOG.md -s -r 0 && git add CHANGELOG.md && git commit -m 'docs: update changelog'", 'check-configuration': 'openstapps-configuration', 'postversion': 'npm run changelog', 'preversion': 'npm run prepublishOnly', 'push': 'git push && git push origin "v$npm_package_version"', 'lint': 'eslint --ext .ts src/', }; // list of expected licenses const licenses = ['AGPL-3.0-only', 'GPL-3.0-only']; // expected values in CI config const ciConfig = { /* tslint:disable:object-literal-sort-keys */ 'image': 'registry.gitlab.com/openstapps/projectmanagement/node', 'before_script': 'npm ci', 'build': { stage: 'build', script: ['npm run build'], artifacts: { paths: ['lib'], }, }, 'audit': { allow_failure: true, except: ['schedules'], script: ['npm audit'], stage: 'audit', }, 'scheduled-audit': { only: ['schedules'], script: ['npm audit --audit-level=high'], stage: 'audit', }, 'package': { dependencies: ['build'], tags: ['secrecy'], stage: 'publish', script: ['echo "//registry.npmjs.org/:_authToken=$NPM_AUTH_TOKEN" > ~/.npmrc', 'npm publish'], only: ['/^v[0-9]+.[0-9]+.[0-9]+$/'], artifacts: { paths: ['lib'], }, }, 'pages': { artifacts: { paths: ['public'], }, only: ['/^v[0-9]+\\.[0-9]+\\.[0-9]+$/'], script: ['npm run documentation', 'mv docs public'], stage: 'deploy', }, /* tslint:enable */ }; for (const ignoreCiEntry of configuration.ignoreCiEntries) { // @ts-expect-error can't be used to index delete ciConfig[ignoreCiEntry]; } if (configuration.forPackaging) { scripts.prepublishOnly = 'npm ci && npm run build'; files.push(path.join('templates', '.npmignore')); } else { // @ts-expect-error can't be used to index delete ciConfig[`package`]; } if (configuration.serverSide) { dependencies.push('@types/node:^10.0.0'); } if (configuration.standardBuild || configuration.hasCli) { scripts.build = 'npm run lint && npm run compile'; scripts.compile = 'rimraf lib && tsc'; devDependencies.push('rimraf'); if (configuration.hasCli) { devDependencies.push('prepend-file-cli'); scripts.compile += " && prepend lib/cli.js '#!/usr/bin/env node\n'"; } } if (configuration.standardDocumentation) { devDependencies.push('typedoc'); /* tslint:disable-next-line:max-line-length */ scripts.documentation = 'typedoc --includeDeclarations --mode modules --out docs --readme README.md --listInvalidSymbolLinks --entryPointStrategy expand src'; } for (const ignoreScript of configuration.ignoreScripts) { consoleInfo(`Ignoring script '${ignoreScript}'.`); delete scripts[ignoreScript]; } return { ciConfig, dependencies, devDependencies, files, licenses, nycConfiguration, scripts, }; }