1#!/usr/bin/env node 2'use strict'; 3 4const { execSync, spawn } = require('child_process'); 5const { promises: fs, readdirSync, statSync } = require('fs'); 6const { extname, join, relative, resolve } = require('path'); 7 8const FIX_MODE_ENABLED = process.argv.includes('--fix'); 9const USE_NPX = process.argv.includes('--from-npx'); 10 11const SHELLCHECK_EXE_NAME = 'shellcheck'; 12const SHELLCHECK_OPTIONS = ['--shell=sh', '--severity=info', '--enable=all']; 13if (FIX_MODE_ENABLED) SHELLCHECK_OPTIONS.push('--format=diff'); 14else if (process.env.GITHUB_ACTIONS) SHELLCHECK_OPTIONS.push('--format=json'); 15 16const SPAWN_OPTIONS = { 17 cwd: null, 18 shell: false, 19 stdio: ['pipe', 'pipe', 'inherit'], 20}; 21 22function* findScriptFilesRecursively(dirPath) { 23 const entries = readdirSync(dirPath, { withFileTypes: true }); 24 25 for (const entry of entries) { 26 const path = join(dirPath, entry.name); 27 28 if ( 29 entry.isDirectory() && 30 entry.name !== 'build' && 31 entry.name !== 'changelogs' && 32 entry.name !== 'deps' && 33 entry.name !== 'fixtures' && 34 entry.name !== 'gyp' && 35 entry.name !== 'inspector_protocol' && 36 entry.name !== 'node_modules' && 37 entry.name !== 'out' && 38 entry.name !== 'tmp' 39 ) { 40 yield* findScriptFilesRecursively(path); 41 } else if (entry.isFile() && extname(entry.name) === '.sh') { 42 yield path; 43 } 44 } 45} 46 47const expectedHashBang = Buffer.from('#!/bin/sh\n'); 48async function hasInvalidHashBang(fd) { 49 const { length } = expectedHashBang; 50 51 const actual = Buffer.allocUnsafe(length); 52 await fd.read(actual, 0, length, 0); 53 54 return Buffer.compare(actual, expectedHashBang); 55} 56 57async function checkFiles(...files) { 58 const flags = FIX_MODE_ENABLED ? 'r+' : 'r'; 59 await Promise.all( 60 files.map(async (file) => { 61 const fd = await fs.open(file, flags); 62 if (await hasInvalidHashBang(fd)) { 63 if (FIX_MODE_ENABLED) { 64 const file = await fd.readFile(); 65 66 const fileContent = 67 file[0] === '#'.charCodeAt() ? 68 file.subarray(file.indexOf('\n') + 1) : 69 file; 70 71 const toWrite = Buffer.concat([expectedHashBang, fileContent]); 72 await fd.truncate(toWrite.length); 73 await fd.write(toWrite, 0, toWrite.length, 0); 74 } else { 75 if (!process.exitCode) process.exitCode = 1; 76 console.error( 77 (process.env.GITHUB_ACTIONS ? 78 `::error file=${file},line=1,col=1::` : 79 'Fixable with --fix: ') + 80 `Invalid hashbang for ${file} (expected /bin/sh).` 81 ); 82 } 83 } 84 await fd.close(); 85 }) 86 ); 87 88 const stdout = await new Promise((resolve, reject) => { 89 const SHELLCHECK_EXE = 90 process.env.SHELLCHECK || 91 execSync('command -v ' + (USE_NPX ? 'npx' : SHELLCHECK_EXE_NAME)) 92 .toString() 93 .trim(); 94 const NPX_OPTIONS = USE_NPX ? [SHELLCHECK_EXE_NAME] : []; 95 96 const shellcheck = spawn( 97 SHELLCHECK_EXE, 98 [ 99 ...NPX_OPTIONS, 100 ...SHELLCHECK_OPTIONS, 101 ...(FIX_MODE_ENABLED ? 102 files.map((filePath) => relative(SPAWN_OPTIONS.cwd, filePath)) : 103 files), 104 ], 105 SPAWN_OPTIONS 106 ); 107 shellcheck.once('error', reject); 108 109 let json = ''; 110 let childProcess = shellcheck; 111 if (FIX_MODE_ENABLED) { 112 const GIT_EXE = 113 process.env.GIT || execSync('command -v git').toString().trim(); 114 115 const gitApply = spawn(GIT_EXE, ['apply'], SPAWN_OPTIONS); 116 shellcheck.stdout.pipe(gitApply.stdin); 117 shellcheck.once('exit', (code) => { 118 if (!process.exitCode && code) process.exitCode = code; 119 }); 120 gitApply.stdout.pipe(process.stdout); 121 122 gitApply.once('error', reject); 123 childProcess = gitApply; 124 } else if (process.env.GITHUB_ACTIONS) { 125 shellcheck.stdout.on('data', (chunk) => { 126 json += chunk; 127 }); 128 } else { 129 shellcheck.stdout.pipe(process.stdout); 130 } 131 childProcess.once('exit', (code) => { 132 if (!process.exitCode && code) process.exitCode = code; 133 resolve(json); 134 }); 135 }); 136 137 if (!FIX_MODE_ENABLED && process.env.GITHUB_ACTIONS) { 138 const data = JSON.parse(stdout); 139 for (const { file, line, column, message } of data) { 140 console.error( 141 `::error file=${file},line=${line},col=${column}::${file}:${line}:${column}: ${message}` 142 ); 143 } 144 } 145} 146 147const USAGE_STR = 148 `Usage: ${process.argv[1]} <path> [--fix] [--from-npx]\n` + 149 '\n' + 150 'Environment variables:\n' + 151 ' - SHELLCHECK: absolute path to `shellcheck`. If not provided, the\n' + 152 ' script will use the result of `command -v shellcheck`, or\n' + 153 ' `$(command -v npx) shellcheck` if the flag `--from-npx` is provided\n' + 154 ' (may require an internet connection).\n' + 155 ' - GIT: absolute path to `git`. If not provided, the \n' + 156 ' script will use the result of `command -v git`.\n'; 157 158if ( 159 process.argv.length < 3 || 160 process.argv.includes('-h') || 161 process.argv.includes('--help') 162) { 163 console.log(USAGE_STR); 164} else { 165 console.log('Running Shell scripts checker...'); 166 const entryPoint = resolve(process.argv[2]); 167 const stats = statSync(entryPoint, { throwIfNoEntry: false }); 168 169 function onError(e) { 170 console.log(USAGE_STR); 171 console.error(e); 172 process.exitCode = 1; 173 } 174 if (stats?.isDirectory()) { 175 SPAWN_OPTIONS.cwd = entryPoint; 176 checkFiles(...findScriptFilesRecursively(entryPoint)).catch(onError); 177 } else if (stats?.isFile()) { 178 SPAWN_OPTIONS.cwd = process.cwd(); 179 checkFiles(entryPoint).catch(onError); 180 } else { 181 onError(new Error('You must provide a valid directory or file path. ' + 182 `Received '${process.argv[2]}'.`)); 183 } 184} 185