#!/usr/bin/env node import { execSync, spawn } from 'node:child_process'; import { promises as fs, readdirSync, statSync } from 'node:fs'; import { extname, join, relative, resolve } from 'node:path'; import process from 'node:process'; const FIX_MODE_ENABLED = process.argv.includes('--fix'); const USE_NPX = process.argv.includes('--from-npx'); const SHELLCHECK_EXE_NAME = 'shellcheck'; const SHELLCHECK_OPTIONS = ['--shell=sh', '--severity=info', '--enable=all']; if (FIX_MODE_ENABLED) SHELLCHECK_OPTIONS.push('--format=diff'); else if (process.env.GITHUB_ACTIONS) SHELLCHECK_OPTIONS.push('--format=json'); const SPAWN_OPTIONS = { cwd: null, shell: false, stdio: ['pipe', 'pipe', 'inherit'], }; function* findScriptFilesRecursively(dirPath) { const entries = readdirSync(dirPath, { withFileTypes: true }); for (const entry of entries) { const path = join(dirPath, entry.name); if ( entry.isDirectory() && entry.name !== 'build' && entry.name !== 'changelogs' && entry.name !== 'deps' && entry.name !== 'fixtures' && entry.name !== 'gyp' && entry.name !== 'inspector_protocol' && entry.name !== 'node_modules' && entry.name !== 'out' && entry.name !== 'tmp' ) { yield* findScriptFilesRecursively(path); } else if (entry.isFile() && extname(entry.name) === '.sh') { yield path; } } } const expectedHashBang = Buffer.from('#!/bin/sh\n'); async function hasInvalidHashBang(fd) { const { length } = expectedHashBang; const actual = Buffer.allocUnsafe(length); await fd.read(actual, 0, length, 0); return Buffer.compare(actual, expectedHashBang); } async function checkFiles(...files) { const flags = FIX_MODE_ENABLED ? 'r+' : 'r'; await Promise.all( files.map(async (file) => { const fd = await fs.open(file, flags); if (await hasInvalidHashBang(fd)) { if (FIX_MODE_ENABLED) { const file = await fd.readFile(); const fileContent = file[0] === '#'.charCodeAt() ? file.subarray(file.indexOf('\n') + 1) : file; const toWrite = Buffer.concat([expectedHashBang, fileContent]); await fd.truncate(toWrite.length); await fd.write(toWrite, 0, toWrite.length, 0); } else { if (!process.exitCode) process.exitCode = 1; console.error( (process.env.GITHUB_ACTIONS ? `::error file=${file},line=1,col=1::` : 'Fixable with --fix: ') + `Invalid hashbang for ${file} (expected /bin/sh).`, ); } } await fd.close(); }), ); const stdout = await new Promise((resolve, reject) => { const SHELLCHECK_EXE = process.env.SHELLCHECK || execSync('command -v ' + (USE_NPX ? 'npx' : SHELLCHECK_EXE_NAME)) .toString() .trim(); const NPX_OPTIONS = USE_NPX ? [SHELLCHECK_EXE_NAME] : []; const shellcheck = spawn( SHELLCHECK_EXE, [ ...NPX_OPTIONS, ...SHELLCHECK_OPTIONS, ...(FIX_MODE_ENABLED ? files.map((filePath) => relative(SPAWN_OPTIONS.cwd, filePath)) : files), ], SPAWN_OPTIONS, ); shellcheck.once('error', reject); let json = ''; let childProcess = shellcheck; if (FIX_MODE_ENABLED) { const GIT_EXE = process.env.GIT || execSync('command -v git').toString().trim(); const gitApply = spawn(GIT_EXE, ['apply'], SPAWN_OPTIONS); shellcheck.stdout.pipe(gitApply.stdin); shellcheck.once('exit', (code) => { if (!process.exitCode && code) process.exitCode = code; }); gitApply.stdout.pipe(process.stdout); gitApply.once('error', reject); childProcess = gitApply; } else if (process.env.GITHUB_ACTIONS) { shellcheck.stdout.on('data', (chunk) => { json += chunk; }); } else { shellcheck.stdout.pipe(process.stdout); } childProcess.once('exit', (code) => { if (!process.exitCode && code) process.exitCode = code; resolve(json); }); }); if (!FIX_MODE_ENABLED && process.env.GITHUB_ACTIONS) { const data = JSON.parse(stdout); for (const { file, line, column, message } of data) { console.error( `::error file=${file},line=${line},col=${column}::${file}:${line}:${column}: ${message}`, ); } } } const USAGE_STR = `Usage: ${process.argv[1]} [--fix] [--from-npx]\n` + '\n' + 'Environment variables:\n' + ' - SHELLCHECK: absolute path to `shellcheck`. If not provided, the\n' + ' script will use the result of `command -v shellcheck`, or\n' + ' `$(command -v npx) shellcheck` if the flag `--from-npx` is provided\n' + ' (may require an internet connection).\n' + ' - GIT: absolute path to `git`. If not provided, the \n' + ' script will use the result of `command -v git`.\n'; if ( process.argv.length < 3 || process.argv.includes('-h') || process.argv.includes('--help') ) { console.log(USAGE_STR); } else { console.log('Running Shell scripts checker...'); const entryPoint = resolve(process.argv[2]); const stats = statSync(entryPoint, { throwIfNoEntry: false }); const onError = (e) => { console.log(USAGE_STR); console.error(e); process.exitCode = 1; }; if (stats?.isDirectory()) { SPAWN_OPTIONS.cwd = entryPoint; checkFiles(...findScriptFilesRecursively(entryPoint)).catch(onError); } else if (stats?.isFile()) { SPAWN_OPTIONS.cwd = process.cwd(); checkFiles(entryPoint).catch(onError); } else { onError(new Error('You must provide a valid directory or file path. ' + `Received '${process.argv[2]}'.`)); } }