• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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