diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/README.md b/README.md new file mode 100644 index 0000000..d800929 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# SAU/CAL Scaffold + +This is meant to be a command to scaffold plugins, themes, projects with the command line. + +_This package is inspired by [@wordpress/scripts](https://www.npmjs.com/package/@wordpress/scripts)._ diff --git a/bin/sc-scaffold.js b/bin/sc-scaffold.js index 908ba84..d99d7c0 100755 --- a/bin/sc-scaffold.js +++ b/bin/sc-scaffold.js @@ -1 +1,10 @@ #!/usr/bin/env node + +/** + * Internal dependencies + */ +const { getNodeArgsFromCLI, spawnScript } = require( '../utils' ); + +const { scriptName, scriptArgs, nodeArgs } = getNodeArgsFromCLI(); + +spawnScript( scriptName, scriptArgs, nodeArgs ); diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 44f6f67..0000000 --- a/package-lock.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "sc-scaffold", - "version": "1.0.0", - "lockfileVersion": 1 -} diff --git a/package.json b/package.json index a680859..dcfb222 100644 --- a/package.json +++ b/package.json @@ -9,5 +9,36 @@ "scaffold": "sc-scaffold" }, "author": "SAU/CAL", - "license": "GPL-2.0-or-later" + "license": "GPL-2.0-or-later", + "keywords": [ + "wordpress", + "scripts" + ], + "homepage": "https://github.com/saucal/scaffold", + "repository": { + "type": "git", + "url": "https://github.com/saucal/scaffold.git" + }, + "bugs": { + "url": "https://github.com/saucal/scaffold/issues" + }, + "engines": { + "node": ">=12", + "npm": ">=6.9" + }, + "files": [ + "bin", + "config", + "scripts", + "utils" + ], + "publishConfig": { + "access": "public" + }, + "dependencies": { + "cross-spawn": "^5.1.0", + "minimist": "^1.2.5", + "read-pkg-up": "^1.0.1", + "resolve-bin": "^0.4.0" + } } diff --git a/utils/cli.js b/utils/cli.js new file mode 100644 index 0000000..31a8e19 --- /dev/null +++ b/utils/cli.js @@ -0,0 +1,99 @@ +/** + * External dependencies + */ +const minimist = require( 'minimist' ); +const spawn = require( 'cross-spawn' ); + +/** + * Internal dependencies + */ +const { fromScriptsRoot, hasScriptFile, getScripts } = require( './file' ); +const { exit, getArgsFromCLI } = require( './process' ); + +const getArgFromCLI = ( arg ) => { + for ( const cliArg of getArgsFromCLI() ) { + const [ name, value ] = cliArg.split( '=' ); + if ( name === arg ) { + return value || null; + } + } +}; + +const hasArgInCLI = ( arg ) => getArgFromCLI( arg ) !== undefined; + +const getFileArgsFromCLI = () => minimist( getArgsFromCLI() )._; + +const getNodeArgsFromCLI = () => { + const args = getArgsFromCLI(); + const scripts = getScripts(); + const scriptIndex = args.findIndex( ( arg ) => scripts.includes( arg ) ); + return { + nodeArgs: args.slice( 0, scriptIndex ), + scriptName: args[ scriptIndex ], + scriptArgs: args.slice( scriptIndex + 1 ), + }; +}; + +const hasFileArgInCLI = () => getFileArgsFromCLI().length > 0; + +const handleSignal = ( signal ) => { + if ( signal === 'SIGKILL' ) { + // eslint-disable-next-line no-console + console.log( + 'The script failed because the process exited too early. ' + + 'This probably means the system ran out of memory or someone called ' + + '`kill -9` on the process.' + ); + } else if ( signal === 'SIGTERM' ) { + // eslint-disable-next-line no-console + console.log( + 'The script failed because the process exited too early. ' + + 'Someone might have called `kill` or `killall`, or the system could ' + + 'be shutting down.' + ); + } + exit( 1 ); +}; + +const spawnScript = ( scriptName, args = [], nodeArgs = [] ) => { + if ( ! scriptName ) { + // eslint-disable-next-line no-console + console.log( 'Script name is missing.' ); + exit( 1 ); + } + + if ( ! hasScriptFile( scriptName ) ) { + // eslint-disable-next-line no-console + console.log( + 'Unknown script "' + + scriptName + + '". ' + + 'Perhaps you need to update the package?' + ); + exit( 1 ); + } + + const { signal, status } = spawn.sync( + 'node', + [ ...nodeArgs, fromScriptsRoot( scriptName ), ...args ], + { + stdio: 'inherit', + } + ); + + if ( signal ) { + handleSignal( signal ); + } + + exit( status ); +}; + +module.exports = { + getArgFromCLI, + getArgsFromCLI, + getFileArgsFromCLI, + getNodeArgsFromCLI, + hasArgInCLI, + hasFileArgInCLI, + spawnScript, +}; diff --git a/utils/config.js b/utils/config.js new file mode 100644 index 0000000..ee86620 --- /dev/null +++ b/utils/config.js @@ -0,0 +1,147 @@ +/** + * External dependencies + */ +const { basename } = require( 'path' ); + +/** + * Internal dependencies + */ +const { + getArgsFromCLI, + getFileArgsFromCLI, + hasArgInCLI, + hasFileArgInCLI, +} = require( './cli' ); +const { fromConfigRoot, fromProjectRoot, hasProjectFile } = require( './file' ); +const { hasPackageProp } = require( './package' ); + +// See https://babeljs.io/docs/en/config-files#configuration-file-types +const hasBabelConfig = () => + hasProjectFile( '.babelrc.js' ) || + hasProjectFile( '.babelrc.json' ) || + hasProjectFile( 'babel.config.js' ) || + hasProjectFile( 'babel.config.json' ) || + hasProjectFile( '.babelrc' ) || + hasPackageProp( 'babel' ); + +/** + * Returns path to a Jest configuration which should be provided as the explicit + * configuration when there is none available for discovery by Jest in the + * project environment. Returns undefined if Jest should be allowed to discover + * an available configuration. + * + * This can be used in cases where multiple possible configurations are + * supported. Since Jest will only discover `jest.config.js`, or `jest` package + * directive, such custom configurations must be specified explicitly. + * + * @param {"e2e"|"unit"} suffix Suffix of configuration file to accept. + * + * @return {string=} Override or fallback configuration file path. + */ +function getJestOverrideConfigFile( suffix ) { + if ( hasArgInCLI( '-c' ) || hasArgInCLI( '--config' ) ) { + return; + } + + if ( hasProjectFile( `jest-${ suffix }.config.js` ) ) { + return fromProjectRoot( `jest-${ suffix }.config.js` ); + } + + if ( ! hasJestConfig() ) { + return fromConfigRoot( `jest-${ suffix }.config.js` ); + } +} + +const hasJestConfig = () => + hasProjectFile( 'jest.config.js' ) || + hasProjectFile( 'jest.config.json' ) || + hasPackageProp( 'jest' ); + +// See https://prettier.io/docs/en/configuration.html. +const hasPrettierConfig = () => + hasProjectFile( '.prettierrc.js' ) || + hasProjectFile( '.prettierrc.json' ) || + hasProjectFile( '.prettierrc.toml' ) || + hasProjectFile( '.prettierrc.yaml' ) || + hasProjectFile( '.prettierrc.yml' ) || + hasProjectFile( 'prettier.config.js' ) || + hasProjectFile( '.prettierrc' ) || + hasPackageProp( 'prettier' ); + +const hasWebpackConfig = () => + hasArgInCLI( '--config' ) || + hasProjectFile( 'webpack.config.js' ) || + hasProjectFile( 'webpack.config.babel.js' ); + +// See https://github.com/michael-ciniawsky/postcss-load-config#usage (used by postcss-loader). +const hasPostCSSConfig = () => + hasProjectFile( 'postcss.config.js' ) || + hasProjectFile( '.postcssrc' ) || + hasProjectFile( '.postcssrc.json' ) || + hasProjectFile( '.postcssrc.yaml' ) || + hasProjectFile( '.postcssrc.yml' ) || + hasProjectFile( '.postcssrc.js' ) || + hasPackageProp( 'postcss' ); + +/** + * Converts CLI arguments to the format which webpack understands. + * + * @see https://webpack.js.org/api/cli/#usage-with-config-file + * + * @return {Array} The list of CLI arguments to pass to webpack CLI. + */ +const getWebpackArgs = () => { + // Gets all args from CLI without those prefixed with `--webpack`. + let webpackArgs = getArgsFromCLI( [ '--webpack' ] ); + + const hasWebpackOutputOption = + hasArgInCLI( '-o' ) || hasArgInCLI( '--output' ); + if ( hasFileArgInCLI() && ! hasWebpackOutputOption ) { + /** + * Converts a path to the entry format supported by webpack, e.g.: + * `./entry-one.js` -> `entry-one=./entry-one.js` + * `entry-two.js` -> `entry-two=./entry-two.js` + * + * @param {string} path The path provided. + * + * @return {string} The entry format supported by webpack. + */ + const pathToEntry = ( path ) => { + const entry = basename( path, '.js' ); + + if ( ! path.startsWith( './' ) ) { + path = './' + path; + } + + return [ entry, path ].join( '=' ); + }; + + // The following handles the support for multiple entry points in webpack, e.g.: + // `wp-scripts build one.js custom=./two.js` -> `webpack one=./one.js custom=./two.js` + webpackArgs = webpackArgs.map( ( cliArg ) => { + if ( + getFileArgsFromCLI().includes( cliArg ) && + ! cliArg.includes( '=' ) + ) { + return pathToEntry( cliArg ); + } + + return cliArg; + } ); + } + + if ( ! hasWebpackConfig() ) { + webpackArgs.push( '--config', fromConfigRoot( 'webpack.config.js' ) ); + } + + return webpackArgs; +}; + +module.exports = { + getWebpackArgs, + hasBabelConfig, + getJestOverrideConfigFile, + hasJestConfig, + hasPrettierConfig, + hasPostCSSConfig, +}; diff --git a/utils/file.js b/utils/file.js new file mode 100644 index 0000000..6e57182 --- /dev/null +++ b/utils/file.js @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +const { existsSync, readdirSync } = require( 'fs' ); +const path = require( 'path' ); + +/** + * Internal dependencies + */ +const { getPackagePath } = require( './package' ); + +const fromProjectRoot = ( fileName ) => + path.join( path.dirname( getPackagePath() ), fileName ); + +const hasProjectFile = ( fileName ) => + existsSync( fromProjectRoot( fileName ) ); + +const fromConfigRoot = ( fileName ) => + path.join( path.dirname( __dirname ), 'config', fileName ); + +const fromScriptsRoot = ( scriptName ) => + path.join( path.dirname( __dirname ), 'scripts', `${ scriptName }.js` ); + +const hasScriptFile = ( scriptName ) => + existsSync( fromScriptsRoot( scriptName ) ); + +const getScripts = () => + readdirSync( path.join( path.dirname( __dirname ), 'scripts' ) ) + .filter( ( f ) => path.extname( f ) === '.js' ) + .map( ( f ) => path.basename( f, '.js' ) ); + +module.exports = { + fromProjectRoot, + fromConfigRoot, + fromScriptsRoot, + getScripts, + hasProjectFile, + hasScriptFile, +}; diff --git a/utils/index.js b/utils/index.js new file mode 100644 index 0000000..edb728d --- /dev/null +++ b/utils/index.js @@ -0,0 +1,43 @@ +/** + * Internal dependencies + */ +const { + getArgFromCLI, + getArgsFromCLI, + getFileArgsFromCLI, + getNodeArgsFromCLI, + hasArgInCLI, + hasFileArgInCLI, + spawnScript, +} = require( './cli' ); +const { + getWebpackArgs, + hasBabelConfig, + getJestOverrideConfigFile, + hasJestConfig, + hasPrettierConfig, + hasPostCSSConfig, +} = require( './config' ); +const { fromProjectRoot, fromConfigRoot, hasProjectFile } = require( './file' ); +const { getPackageProp, hasPackageProp } = require( './package' ); + +module.exports = { + fromProjectRoot, + fromConfigRoot, + getArgFromCLI, + getArgsFromCLI, + getFileArgsFromCLI, + getJestOverrideConfigFile, + getNodeArgsFromCLI, + getPackageProp, + getWebpackArgs, + hasArgInCLI, + hasBabelConfig, + hasFileArgInCLI, + hasJestConfig, + hasPackageProp, + hasPostCSSConfig, + hasPrettierConfig, + hasProjectFile, + spawnScript, +}; diff --git a/utils/package.js b/utils/package.js new file mode 100644 index 0000000..1ad275e --- /dev/null +++ b/utils/package.js @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +const { realpathSync } = require( 'fs' ); +const { sync: readPkgUp } = require( 'read-pkg-up' ); + +/** + * Internal dependencies + */ +const { getCurrentWorkingDirectory } = require( './process' ); + +const { pkg, path: pkgPath } = readPkgUp( { + cwd: realpathSync( getCurrentWorkingDirectory() ), +} ); + +const getPackagePath = () => pkgPath; + +const getPackageProp = ( prop ) => pkg && pkg[ prop ]; + +const hasPackageProp = ( prop ) => pkg && pkg.hasOwnProperty( prop ); + +module.exports = { + getPackagePath, + getPackageProp, + hasPackageProp, +}; diff --git a/utils/process.js b/utils/process.js new file mode 100644 index 0000000..de07d36 --- /dev/null +++ b/utils/process.js @@ -0,0 +1,17 @@ +const getArgsFromCLI = ( excludePrefixes ) => { + const args = process.argv.slice( 2 ); + if ( excludePrefixes ) { + return args.filter( ( arg ) => { + return ! excludePrefixes.some( ( prefix ) => + arg.startsWith( prefix ) + ); + } ); + } + return args; +}; + +module.exports = { + exit: process.exit, + getArgsFromCLI, + getCurrentWorkingDirectory: process.cwd, +}; diff --git a/utils/test/index.js b/utils/test/index.js new file mode 100644 index 0000000..988b76d --- /dev/null +++ b/utils/test/index.js @@ -0,0 +1,274 @@ +/** + * External dependencies + */ +import crossSpawn from 'cross-spawn'; + +/** + * Internal dependencies + */ +import { + hasArgInCLI, + hasProjectFile, + getJestOverrideConfigFile, + spawnScript, +} from '../'; +import { + getPackagePath as getPackagePathMock, + hasPackageProp as hasPackagePropMock, +} from '../package'; +import { + exit as exitMock, + getArgsFromCLI as getArgsFromCLIMock, +} from '../process'; +import { + hasProjectFile as hasProjectFileMock, + fromProjectRoot as fromProjectRootMock, + fromConfigRoot as fromConfigRootMock, +} from '../file'; + +jest.mock( '../package', () => { + const module = jest.requireActual( '../package' ); + + jest.spyOn( module, 'getPackagePath' ); + jest.spyOn( module, 'hasPackageProp' ); + + return module; +} ); +jest.mock( '../process', () => { + const module = jest.requireActual( '../process' ); + + jest.spyOn( module, 'exit' ); + jest.spyOn( module, 'getArgsFromCLI' ); + + return module; +} ); +jest.mock( '../file', () => { + const module = jest.requireActual( '../file' ); + + jest.spyOn( module, 'hasProjectFile' ); + jest.spyOn( module, 'fromProjectRoot' ); + jest.spyOn( module, 'fromConfigRoot' ); + + return module; +} ); + +describe( 'utils', () => { + const crossSpawnMock = jest.spyOn( crossSpawn, 'sync' ); + + describe( 'hasArgInCLI', () => { + beforeAll( () => { + getArgsFromCLIMock.mockReturnValue( [ + '-a', + '--b', + '--config=test', + ] ); + } ); + + afterAll( () => { + getArgsFromCLIMock.mockReset(); + } ); + + test( 'should return false when no args passed', () => { + getArgsFromCLIMock.mockReturnValueOnce( [] ); + + expect( hasArgInCLI( '--no-args' ) ).toBe( false ); + } ); + + test( 'should return false when checking for unrecognized arg', () => { + expect( hasArgInCLI( '--non-existent' ) ).toBe( false ); + } ); + + test( 'should return true when CLI arg found', () => { + expect( hasArgInCLI( '-a' ) ).toBe( true ); + expect( hasArgInCLI( '--b' ) ).toBe( true ); + expect( hasArgInCLI( '--config' ) ).toBe( true ); + } ); + } ); + + describe( 'hasProjectFile', () => { + test( 'should return false for the current directory and unknown file', () => { + getPackagePathMock.mockReturnValueOnce( __dirname ); + + expect( hasProjectFile( 'unknown-file.name' ) ).toBe( false ); + } ); + + test( 'should return true for the current directory and this file', () => { + getPackagePathMock.mockReturnValueOnce( __dirname ); + + expect( hasProjectFile( 'index.js' ) ).toBe( true ); + } ); + } ); + + describe( 'getJestOverrideConfigFile', () => { + beforeEach( () => { + getArgsFromCLIMock.mockReturnValue( [] ); + hasPackagePropMock.mockReturnValue( false ); + hasProjectFileMock.mockReturnValue( false ); + fromProjectRootMock.mockImplementation( ( path ) => '/p/' + path ); + fromConfigRootMock.mockImplementation( ( path ) => '/c/' + path ); + } ); + + afterEach( () => { + getArgsFromCLIMock.mockReset(); + hasPackagePropMock.mockReset(); + hasProjectFileMock.mockReset(); + fromProjectRootMock.mockReset(); + fromConfigRootMock.mockReset(); + } ); + + it( 'should return undefined if --config flag is present', () => { + getArgsFromCLIMock.mockReturnValue( [ '--config=test' ] ); + + expect( getJestOverrideConfigFile( 'e2e' ) ).toBe( undefined ); + } ); + + it( 'should return undefined if -c flag is present', () => { + getArgsFromCLIMock.mockReturnValue( [ '-c=test' ] ); + + expect( getJestOverrideConfigFile( 'e2e' ) ).toBe( undefined ); + } ); + + it( 'should return variant project configuration if present', () => { + hasProjectFileMock.mockImplementation( + ( file ) => file === 'jest-e2e.config.js' + ); + + expect( getJestOverrideConfigFile( 'e2e' ) ).toBe( + '/p/jest-e2e.config.js' + ); + } ); + + it( 'should return undefined if jest.config.js available', () => { + hasProjectFileMock.mockImplementation( + ( file ) => file === 'jest.config.js' + ); + + expect( getJestOverrideConfigFile( 'e2e' ) ).toBe( undefined ); + } ); + + it( 'should return undefined if jest.config.json available', () => { + hasProjectFileMock.mockImplementation( + ( file ) => file === 'jest.config.json' + ); + + expect( getJestOverrideConfigFile( 'e2e' ) ).toBe( undefined ); + } ); + + it( 'should return undefined if jest package directive specified', () => { + hasPackagePropMock.mockImplementation( + ( prop ) => prop === 'jest' + ); + + expect( getJestOverrideConfigFile( 'e2e' ) ).toBe( undefined ); + } ); + + it( 'should return default configuration if nothing available', () => { + expect( getJestOverrideConfigFile( 'e2e' ) ).toBe( + '/c/jest-e2e.config.js' + ); + + expect( getJestOverrideConfigFile( 'unit' ) ).toBe( + '/c/jest-unit.config.js' + ); + } ); + } ); + + describe( 'spawnScript', () => { + const scriptName = 'test-unit-js'; + + beforeAll( () => { + exitMock.mockImplementation( ( code ) => { + throw new Error( `Exit code: ${ code }.` ); + } ); + } ); + + afterAll( () => { + exitMock.mockReset(); + } ); + + test( 'should exit when no script name provided', () => { + expect( () => spawnScript() ).toThrow( 'Exit code: 1.' ); + expect( console ).toHaveLoggedWith( 'Script name is missing.' ); + } ); + + test( 'should exit when an unknown script name provided', () => { + expect( () => spawnScript( 'unknown-script' ) ).toThrow( + 'Exit code: 1.' + ); + expect( console ).toHaveLoggedWith( + 'Unknown script "unknown-script". Perhaps you need to update the package?' + ); + } ); + + test( 'should exit when the script failed because of SIGKILL signal', () => { + crossSpawnMock.mockReturnValueOnce( { signal: 'SIGKILL' } ); + + expect( () => spawnScript( scriptName ) ).toThrow( + 'Exit code: 1.' + ); + expect( console ).toHaveLogged(); + } ); + + test( 'should exit when the script failed because of SIGTERM signal', () => { + crossSpawnMock.mockReturnValueOnce( { signal: 'SIGTERM' } ); + + expect( () => spawnScript( scriptName ) ).toThrow( + 'Exit code: 1.' + ); + expect( console ).toHaveLogged(); + } ); + + test( 'should pass inspect args to node', () => { + crossSpawnMock.mockReturnValueOnce( { status: 0 } ); + + expect( () => + spawnScript( scriptName, [], [ '--inspect-brk' ] ) + ).toThrow( 'Exit code: 0.' ); + expect( crossSpawnMock ).toHaveBeenCalledWith( + 'node', + [ '--inspect-brk', expect.stringContaining( scriptName ) ], + { stdio: 'inherit' } + ); + } ); + + test( 'should pass script args to the script', () => { + crossSpawnMock.mockReturnValueOnce( { status: 0 } ); + + expect( () => + spawnScript( scriptName, [ '--runInBand' ] ) + ).toThrow( 'Exit code: 0.' ); + expect( crossSpawnMock ).toHaveBeenCalledWith( + 'node', + [ expect.stringContaining( scriptName ), '--runInBand' ], + { stdio: 'inherit' } + ); + } ); + + test( 'should finish successfully when the script properly executed', () => { + crossSpawnMock.mockReturnValueOnce( { status: 0 } ); + + expect( () => spawnScript( scriptName ) ).toThrow( + 'Exit code: 0.' + ); + expect( crossSpawnMock ).toHaveBeenCalledWith( + 'node', + [ expect.stringContaining( scriptName ) ], + { stdio: 'inherit' } + ); + } ); + + test( 'should finish successfully when the script properly executed with args', () => { + crossSpawnMock.mockReturnValueOnce( { status: 0 } ); + const args = [ '-a', '--bbb', '-c=ccccc' ]; + + expect( () => spawnScript( scriptName, args ) ).toThrow( + 'Exit code: 0.' + ); + expect( crossSpawnMock ).toHaveBeenCalledWith( + 'node', + [ expect.stringContaining( scriptName ), ...args ], + { stdio: 'inherit' } + ); + } ); + } ); +} );