import chalk from 'chalk'; import {execSync} from 'child_process'; import {copyFileSync, existsSync, lstatSync, PathLike, readdirSync, readFileSync} from 'fs'; import {join, resolve, sep} from 'path'; import {satisfies, valid} from 'semver'; import {isDeepStrictEqual} from 'util'; import {parse, stringify} from 'yaml'; /** * 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: any; /** * Expected dependencies */ dependencies: string[]; /** * Expected dev dependencies */ devDependencies: string[]; /** * Expected files */ files: string[]; /** * Expected licenses */ licenses: string[]; /** * Expected NYC configuration */ nycConfiguration: any; /** * Expected scripts */ scripts: { [k: string]: string; }; } /** * Wrapper for console.info that outputs every argument in cyan * * @param args Arguments to output */ export function consoleInfo(...args: string[]): void { args.forEach((arg) => { /* tslint:disable-next-line:no-console */ console.info('\n' + chalk.cyan(arg)); }); } /** * Wrapper for console.warn that outputs every argument in red * * @param args Arguments to output */ export function consoleWarn(...args: string[]): void { args.forEach((arg) => { const lines = arg.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 args Arguments to output */ export function consoleLog(...args: string[]): void { args.forEach((arg) => { /* tslint:disable-next-line:no-console */ console.log('\n' + chalk.green.bold(arg)); }); } /** * Check dependencies are installed * * @param rules Rules for check * @param packageJson package.json to check dependencies in */ export function checkDependencies(rules: Rules, packageJson: any): void { 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' && valid(version) && !satisfies(installedVersion, version) ) { consoleWarn( `Version '${installedVersion}' of dependency '${name} does not satisfy constraint '${version}'.`, ); } } for (const devDependency of rules.devDependencies) { const [name, version] = devDependency.split(':'); const installedVersion = packageJson.dependencies[name]; if ( typeof packageJson.dependencies === 'undefined' || typeof packageJson.dependencies[name] === 'undefined' && typeof packageJson.devDependencies === 'undefined' || typeof packageJson.devDependencies[name] === 'undefined' ) { consoleWarn(`Dev dependency '${name}' is missing. Please install with 'npm install --save-exact --save-dev ${name}'.`); } else if ( typeof version !== 'undefined' && valid(version) && !satisfies(installedVersion, version) ) { consoleWarn( `Version '${installedVersion}' of dev dependency '${name} does not satisfy constraint '${version}'.`, ); } } } /** * Check that configuration files are extended * * @param path Path, where configuration files are located */ export function checkConfigurationFilesAreExtended(path: string): void { // check if configuration files are extended ['tsconfig.json', 'tslint.json'].forEach((file) => { const fileToCheck = resolve(path, 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 === 'tslint.json' && Array.isArray(configFile.extends) && configFile.extends.indexOf(expectedPath) >= 0 ); if (!configFileExtended) { consoleWarn(`File '${fileToCheck}' should extend '${expectedPath}'! Example: ${readFileSync(resolve(__dirname, '..', 'templates', 'template-' + file))}`); } } }); } /** * Check needed files * * @param rules Rules for check * @param path Path to files to check * @param replaceFlag Whether or not to replace files * @return Whether or not overwrite is suggested */ export function checkNeededFiles(rules: Rules, path: string, replaceFlag: boolean): boolean { let suggestOverwrite = false; // copy needed files rules.files.forEach((file) => { let destinationFile = file; // remove templates directory for destination files if (destinationFile.indexOf('templates') === 0) { destinationFile = destinationFile.split(sep).slice(1).join(sep); file = join('templates', `template-${destinationFile}`); } const source = resolve(__dirname, '..', file); const destination = resolve(path, 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; let unignoresDocs = false; for (const ignoredPattern of ignoredPatterns) { if (ignoredPattern === '/*') { ignoresEverything = true; } if (ignoredPattern === '!docs') { unignoresDocs = true; } } if (!ignoresEverything) { consoleWarn(`'.npmignore' should have '/*' as first pattern to ignore everything.`); suggestOverwrite = true; } if (unignoresDocs) { consoleWarn(`'.npmignore' contains '!docs' and thus the package will contain the documentation. 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: any): void { // check if license is one of the expected ones if (rules.licenses.indexOf(packageJson.license) === -1) { 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 * @return Whether or not package.json was changed and if overwrite is suggested */ export function checkNYCConfiguration(rules: Rules, packageJson: any, 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).indexOf('nyc') >= 0) { 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 * @return Whether or not the package.json was changed */ export function checkScripts(rules: Rules, packageJson: any, replaceFlag: boolean): boolean { let packageJsonChanged = false; // check if scripts is a map if (typeof packageJson.scripts !== 'object') { packageJson.scripts = {}; packageJsonChanged = true; } Object.keys(rules.scripts).forEach((scriptName) => { 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: any): void { const execBuffer = execSync(`git --git-dir=${path}/.git --work-tree=${path} log --format=\'%aN\' | sort -u`); for (let author of execBuffer.toString().split('\n')) { author = author.trim(); if (author === '') { continue; } let authorIsAttributed = false; authorIsAttributed = authorIsAttributed || (typeof packageJson.author === 'string' && packageJson.author.indexOf(author) >= 0) || (Array.isArray(packageJson.contributors) && packageJson.contributors.find((contributor: string) => { return contributor.indexOf(author) >= 0; })); if (!authorIsAttributed) { consoleWarn(`'${author}' should be attributed as author or contributor.`); } } } /** * Check CI config * * @param rules Rules for check * @param path Path to CI config */ export function checkCIConfig(rules: Rules, path: string): void { // check CI config if it exists const pathToCiConfig = resolve(path, '.gitlab-ci.yml'); if (existsSync(pathToCiConfig)) { // read CI config const buffer = readFileSync(pathToCiConfig); try { const ciConfig = parse(buffer.toString()); // check entries for (const entry in rules.ciConfig) { if (!rules.ciConfig.hasOwnProperty(entry)) { continue; } if (!isDeepStrictEqual(rules.ciConfig[entry], ciConfig[entry])) { const completeEntry: any = {}; completeEntry[entry] = rules.ciConfig[entry]; consoleWarn(`Entry '${entry}' in '${pathToCiConfig}' is incorrect. Expected value is: ${stringify(completeEntry)}`); } } } catch (error) { consoleWarn(`Could not parse ${pathToCiConfig} because of '${error.message}'. Please ensure consistency of CI config manually. ${stringify(rules.ciConfig)}`); } } } /** * Check copyright years in files * * @param path Path to project root * @param subDir Subordinated directory to examine */ export function checkCopyrightYears(path: PathLike, subDir: PathLike): void { const fileSystemObjects = readdirSync(resolve(path.toString(), subDir.toString())); for (const fileSystemObject of fileSystemObjects) { const fileSystemObjectPath = resolve(path.toString(), subDir.toString(), fileSystemObject); // tslint:disable-next-line:max-line-length const execBuffer = execSync(`git --git-dir=${path}/.git --work-tree=${path} log --oneline --format='%cI' -- ${fileSystemObjectPath}`); const seen: number[] = []; const changedYears = execBuffer .toString() .split('\n') .map((date) => parseInt(date.split('-')[0], 10)) .filter((year) => { if (seen.indexOf(year) >= 0 || !year.toString().match(/[0-9]{4}/)) { return false; } seen.push(year); return true; }) .sort(); const fileStats = lstatSync(fileSystemObjectPath); if (fileStats.isFile()) { const content = readFileSync(fileSystemObjectPath).toString().split('\n'); let copyrightYearsString: string = ''; for (const line of content) { const match = line.match(/^ \* Copyright \(C\) ([0-9\-,\s]*) StApps$/); if (Array.isArray(match) && match.length === 2) { copyrightYearsString = match[1]; } } if (copyrightYearsString === '') { consoleWarn(`Copyright line for file '${fileSystemObjectPath}' could not be found!`); } else { const copyrightYearsWithIntervals = copyrightYearsString.split(',').map((year) => year.trim()); const copyrightYears: number[] = []; for (const copyrightYear of copyrightYearsWithIntervals) { if (copyrightYear.indexOf('-') >= 0) { const [startString, endString] = copyrightYear.split('-'); const start = parseInt(startString.trim(), 10); const end = parseInt(endString.trim(), 10); for (let year = start; year <= end; year++) { copyrightYears.push(year); } } else { copyrightYears.push(parseInt(copyrightYear, 10)); } } for (const copyrightYear of copyrightYears) { const idx = changedYears.indexOf(copyrightYear); if (idx >= 0) { changedYears.splice(idx, 1); } else { // tslint:disable-next-line:max-line-length consoleWarn(`File '${join(subDir.toString(), fileSystemObject)}' wrongly states '${copyrightYear}' as year in the copyright line.`); } } if (changedYears.length > 0) { // tslint:disable-next-line:max-line-length consoleWarn(`File '${join(subDir.toString(), fileSystemObject)}' is missing '${changedYears.join(', ')}' as year(s) in the copyright line.`); } } } else if (fileStats.isDirectory()) { checkCopyrightYears(path, join(subDir.toString(), fileSystemObject)); } } } /** * Get configuration * * @param packageJson package.json to get configuration from */ export function getConfiguration(packageJson: any): 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 = [ 'conventional-changelog-cli', 'tslint', 'typescript:^3.4.0', ]; // files that need to be copied const files = [ '.editorconfig', join('templates', '.gitignore'), join('templates', 'tsconfig.json'), join('templates', 'tslint.json'), ]; // configuration for nyc to add to package.json const 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', ], 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', }; // list of expected licenses const licenses = [ 'AGPL-3.0-only', 'GPL-3.0-only', ]; // expected values in CI config const ciConfig: { [k: string]: any; } = { /* tslint:disable:object-literal-sort-keys */ image: 'registry.gitlab.com/openstapps/projectmanagement/node', cache: { key: '${CI_COMMIT_REF_SLUG}', paths: [ 'node_modules', ], }, build: { stage: 'build', script: [ 'npm run build', ], artifacts: { paths: [ 'lib', ], }, }, audit: { allow_failure: true, except: [ 'schedules', ], script: [ 'npm audit', ], stage: 'test', }, 'scheduled-audit': { only: [ 'schedules', ], script: [ 'npm audit', ], stage: 'test', }, package: { dependencies: [ 'build', ], tags: [ 'secrecy', ], stage: 'deploy', script: [ 'echo "//registry.npmjs.org/:_authToken=$NPM_AUTH_TOKEN" > /root/.npmrc', 'npm publish', ], only: [ '/^v[0-9]+\.[0-9]+\.[0-9]+$/', ], artifacts: { paths: [ 'public', ], }, }, 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) { delete ciConfig[ignoreCiEntry]; } if (configuration.forPackaging) { scripts.prepublishOnly = 'npm ci && npm run build'; files.push( join('templates', '.npmignore'), ); } if (configuration.serverSide) { dependencies.push('@types/node:^10.0.0'); } if (configuration.standardBuild || configuration.hasCli) { scripts.build = 'npm run tslint && 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 src'; } for (const ignoreScript of configuration.ignoreScripts) { consoleInfo(`Ignoring script '${ignoreScript}'.`); delete scripts[ignoreScript]; } return { ciConfig, dependencies, devDependencies, files, licenses, nycConfiguration, scripts, }; }