前言

在这篇文章中,我们将学习谷歌的zx库提供了什么,以及我们如何使用它来用Node.js编写shell脚本。然后,我们将学习如何通过构建一个命令行工具来使用zx的功能,帮助我们为新的Node.js项目引导配置。

编写Shell脚本的问题

创建一个由Bash或者zsh执行的shell脚本,是自动化重复任务的好方法。Node.js似乎是编写shell脚本的理想选择,因为它为我们提供了许多核心模块,并允许我们导入任何我们选择的库。它还允许我们访问JavaScript提供的语言特性和内置函数。

如果你尝试编写运行在Node.js中的shell脚本,你会发现这没有你想象中的那么顺利。你需要为子进程编写特殊的处理程序,注意转义命令行参数,然后最终与stdout(标准输出)和stderr(标准错误)打交道。这不是特别直观,而且会使shell脚本变得相当笨拙。

Bash shell脚本语言是编写shell脚本的普遍选择。不需要编写代码来处理子进程,而且它有内置的语言特性来处理stdoutstderr。但是用Bash编写shell脚本也不是那么容易。语法可能相当混乱,使得它实现逻辑,或者处理诸如提示用户输入的事情非常困难。

谷歌的zx库有助于让使用Node.js编写的shell脚本变得高效和舒适。

前置条件

往下阅读之前,有几个前置条件需要遵循:

  • 理想情况下,你应该熟悉JavaScript和Node.js的基础知识。
  • 你需要适应在终端中运行命令。
  • 你需要安装Node.js >= v14.13.1

本文中的所有代码都可以从GitHub上获得。

zx如何运作

Google的zx提供了创建子进程的函数,以及处理这些进程的stdoutstderr的函数。我们将使用的主要函数是$函数。下面是它的一个实际例子:

import { $ } from "zx";

await $`ls`;

下面是执行上述代码的输出:

$ ls
bootstrap-tool
hello-world
node_modules
package.json
README.md
typescript

上面的例子中的JavaScript语法可能看起来有点古怪。它使用了一种叫做带标签的模板字符串的语言特性。它在功能上与编写await $("ls")相同。

谷歌的zx提供了其他几个实用功能,使编写shell脚本更容易。比如:

  • cd()。允许我们更改当前工作目录。
  • question()。这是Node.js readline模块的包装器。它使提示用户输入变得简单明了。

除了zx提供的实用功能外,它还为我们提供了几个流行的库,比如:

  • chalk。这个库允许我们为脚本的输出添加颜色。
  • minimist。一个解析命令行参数的库。然后它们在argv对象下被暴露出来。
  • fetch。Fetch API的Node.js实现。我们可以用它来进行HTTP请求。
  • fs-extra。一个暴露Node.js核心fs模块的库,以及一些额外的方法,使其更容易与文件系统一起工作。

现在我们知道了zx给了我们什么,让我们用它创建第一个shell脚本。

zx如何使用

首先,我们先创建一个新项目:

mkdir zx-shell-scripts
cd zx-shell-scripts npm init --yes

然后安装zx库:

npm install --save-dev zx

注意:zx的文档建议用npm全局安装该库。通过将其安装为我们项目的本地依赖,我们可以确保zx总是被安装,并控制shell脚本使用的版本。

顶级await

为了在Node.js中使用顶级await,也就是await位于async函数的外部,我们需要在ES模块的模式下编写代码,该模式支持顶级await

我们可以通过在package.json中添加"type": "module"来表明项目中的所有模块都是ES模块。或者我们可以将单个脚本的文件扩展名设置为.mjs。在本文的例子中,我们将使用.mjs文件扩展名。

运行命令并捕获输出

创建一个新脚本,将其命名为hello-world.mjs。我们将添加一个Shebang行,它告诉操作系统(OS)的内核要用node程序运行该脚本:

#! /usr/bin/env node

然后,我们添加一些代码,使用zx来运行命令。

在下面的代码中,我们运行命令执行ls程序。ls程序将列出当前工作目录(脚本所在的目录)中的文件。我们将从命令的进程中捕获标准输出,将其存储在一个变量中,然后打印到终端:

// hello-world.mjs

import { $ } from "zx";

const output = (await $`ls`).stdout;

console.log(output);

注意:zx文档建议把/usr/bin/env zx放在我们脚本的shebang行中,但我们用/usr/bin/env node代替。这是因为我们已经安装zx,并作为项目的本地依赖。然后我们明确地从zx包中导入我们想要使用的函数和对象。这有助于明确我们脚本中使用的依赖来自哪里。

我们使用chmod来让脚本可执行:

chmod u+x hello-world.mjs

运行项目:

./hello-world.mjs

可以看到如下输出:

$ ls
hello-world.mjs
node_modules
package.json
package-lock.json
README.md
hello-world.mjs
node_modules
package.json
package-lock.json
README.md

你会注意到:

  • 我们运行的命令(ls)被包含在输出中。
  • 命令的输出显示两次。
  • 在输出的末尾多了一个新行。

zx默认以verbose模式运行。它将输出你传递给$函数的命令,同时也输出该命令的标准输出。我们可以通过在运行ls命令前加入以下一行代码来改变这种行为:

$.verbose = false;

大多数命令行程序,如ls,会在其输出的结尾处输出一个新行字符,以使输出在终端中更易读。这对可读性有好处,但由于我们要将输出存储在一个变量中,我们不希望有这个额外的新行。我们可以用JavaScript String#trim()函数把它去掉:

- const output = (await $`ls`).stdout;
+ const output = (await $`ls`).stdout.trim();

再次运行脚本,结果看起来好很多:

hello-world.mjs
node_modules
package.json
package-lock.json

引入TypeScript

如果我们想在TypeScript中编写使用zx的shell脚本,有几个微小的区别我们需要加以说明。

注意:TypeScript编译器提供了大量的配置选项,允许我们调整它如何编译我们的TypeScript代码。考虑到这一点,下面的TypeScript配置和代码是为了在大多数TypeScript版本下工作。

首先,安装需要运行TypeScript代码的依赖:

npm install --save-dev typescript ts-node

ts-node包提供了一个TypeScript执行引擎,让我们能够转译和运行TypeScript代码。

需要创建tsconfig.json文件包含下面的配置:

{
"compilerOptions": {
"target": "es2017",
"module": "commonjs"
}
}

创建新的脚本,并命名为hello-world-typescript.ts。首先,添加Shebang行,告诉OS内核使用ts-node程序来运行我们的脚本:

#! ./node_modules/.bin/ts-node

为了在我们的TypeScript代码中使用await关键字,我们需要把它包装在一个立即调用函数表达式(IIFE)中,正如zx文档所建议的那样:

// hello-world-typescript.ts

import { $ } from "zx";

void (async function () {
await $`ls`;
})();

然后需要让脚本可执行:

chmod u+x hello-world-typescript.ts

运行脚本:

./hello-world-typescript.ts

可以看到下面的输出:

$ ls
hello-world-typescript.ts
node_modules
package.json
package-lock.json
README.md
tsconfig.json

在TypeScript中用zx编写脚本与使用JavaScript相似,但需要对我们的代码进行一些额外的配置和包装。

构建项目启动工具

现在我们已经学会了用谷歌的zx编写shell脚本的基本知识,我们要用它来构建一个工具。这个工具将自动创建一个通常很耗时的过程:为一个新的Node.js项目的配置提供引导。

我们将创建一个交互式shell脚本,提示用户输入。它还将使用zx内置的chalk库,以不同的颜色高亮输出,并提供一个友好的用户体验。我们的shell脚本还将安装新项目所需的npm包,所以它已经准备好让我们立即开始开发。

准备开始

首先创建一个名为bootstrap-tool.mjs的新文件,并添加shebang行。我们还将从zx包中导入我们要使用的函数和模块,以及Node.js核心path模块:

#! /usr/bin/env node

// bootstrap-tool.mjs

import { $, argv, cd, chalk, fs, question } from "zx";

import path from "path";

与我们之前创建的脚本一样,我们要使我们的新脚本可执行:

chmod u+x bootstrap-tool.mjs

我们还将定义一个辅助函数,用红色文本输出一个错误信息,并以错误退出代码1退出Node.js进程:

function exitWithError(errorMessage) {
console.error(chalk.red(errorMessage));
process.exit(1);
}

当我们需要处理一个错误时,我们将通过我们的shell脚本在各个地方使用这个辅助函数。

检查依赖

我们要创建的工具需要使用三个不同程序来运行命令:gitnodenpx。我们可以使用which库来帮助我们检查这些程序是否已经安装并可以使用。

首先,我们需要安装which

npm install --save-dev which

然后引入它:

import which from "which";

然后创建一个使用它的checkRequiredProgramsExist函数:

async function checkRequiredProgramsExist(programs) {
try {
for (let program of programs) {
await which(program);
}
} catch (error) {
exitWithError(`Error: Required command ${error.message}`);
}
}

上面的函数接受一个程序名称的数组。它循环遍历数组,对每个程序调用which函数。如果which找到了程序的路径,它将返回该程序。否则,如果该程序找不到,它将抛出一个错误。如果有任何程序找不到,我们就调用exitWithError辅助函数来显示一个错误信息并停止运行脚本。

我们现在可以添加一个对checkRequiredProgramsExist的调用,以检查我们的工具所依赖的程序是否可用:

await checkRequiredProgramsExist(["git", "node", "npx"]);

添加目标目录选项

由于我们正在构建的工具将帮助我们启动新的Node.js项目,因此我们希望在项目的目录中运行我们添加的任何命令。我们现在要给脚本添加一个 --directory命令行参数。

zx内置了minimist包,它能够解析传递给脚本的任何命令行参数。这些被解析的命令行参数被zx包作为argv提供:

让我们为名为directory的命令行参数添加一个检查:

let targetDirectory = argv.directory;
if (!targetDirectory) {
exitWithError("Error: You must specify the --directory argument");
}

如果directory参数被传递给了我们的脚本,我们要检查它是否是已经存在的目录的路径。我们将使用fs-extra提供的fs.pathExists方法:

targetDirectory = path.resolve(targetDirectory);

if (!(await fs.pathExists(targetDirectory))) {
exitWithError(`Error: Target directory '${targetDirectory}' does not exist`);
}

如果目标路径存在,我们将使用zx提供的cd函数来切换当前的工作目录:

cd(targetDirectory);

如果我们现在在没有--directory参数的情况下运行脚本,我们应该会收到一个错误:

$ ./bootstrap-tool.mjs

Error: You must specify the --directory argument

检查全局Git设置

稍后,我们将在项目目录下初始化一个新的 Git 仓库,但首先我们要检查 Git 是否有它需要的配置。我们要确保提交会被GitHub等代码托管服务正确归类。

为了做到这一点,这里创建一个getGlobalGitSettingValue函数。它将运行 git config命令来检索Git配置设置的值:

async function getGlobalGitSettingValue(settingName) {
$.verbose = false; let settingValue = "";
try {
settingValue = (
await $`git config --global --get ${settingName}`
).stdout.trim();
} catch (error) {
// Ignore process output
} $.verbose = true; return settingValue;
}

你会注意到,我们正在关闭zx默认设置的verbose模式。这意味着,当我们运行git config命令时,该命令和它发送到标准输出的任何内容都不会被显示。我们在函数的结尾处将verbose模式重新打开,这样我们就不会影响到我们稍后在脚本中添加的任何其他命令。

现在我们添加checkGlobalGitSettings函数,该函数接收Git设置名称组成的数组。它将循环遍历每个设置名称,并将其传递给getGlobalGitSettingValue函数以检索其值。如果设置没有值,将显示警告信息:

async function checkGlobalGitSettings(settingsToCheck) {
for (let settingName of settingsToCheck) {
const settingValue = await getGlobalGitSettingValue(settingName);
if (!settingValue) {
console.warn(
chalk.yellow(`Warning: Global git setting '${settingName}' is not set.`)
);
}
}
}

让我们给checkGlobalGitSettings添加一个调用,检查user.nameuser.email的Git设置是否已经被设置:

await checkGlobalGitSettings(["user.name", "user.email"]);

初始化Git仓库

我们可以通过添加以下命令在项目目录下初始化一个新的 Git 仓库:

await $`git init`;

生成package.json

每个Node.js项目都需要package.json文件。这是我们为项目定义元数据的地方,指定项目所依赖的包,以及添加实用的脚本。

在我们为项目生成package.json文件之前,我们要创建几个辅助函数。第一个是readPackageJson函数,它将从项目目录中读取package.json文件:

async function readPackageJson(directory) {
const packageJsonFilepath = `${directory}/package.json`; return await fs.readJSON(packageJsonFilepath);
}

然后我们将创建一个writePackageJson函数,我们可以用它来向项目的package.json文件写入更改:

async function writePackageJson(directory, contents) {
const packageJsonFilepath = `${directory}/package.json`; await fs.writeJSON(packageJsonFilepath, contents, { spaces: 2 });
}

我们在上面的函数中使用的fs.readJSONfs.writeJSON方法是由fs-extra库提供的。

在定义了package.json辅助函数后,我们可以开始考虑package.json文件的内容。

Node.js支持两种模块类型:

  • CommonJS Modules (CJS)。使用module.exports来导出函数和对象,在另一个模块中使用require()加载它们。
  • ECMAScript Modules (ESM)。使用export来导出函数和对象,在另一个模块中使用import加载它们。

Node.js生态系统正在逐步采用ES模块,这在客户端JavaScript中是很常见的。当事情处于过渡阶段时,我们需要决定我们的Node.js项目默认使用CJS模块还是ESM模块。让我们创建一个promptForModuleSystem函数,询问这个新项目应该使用哪种模块类型:

async function promptForModuleSystem(moduleSystems) {
const moduleSystem = await question(
`Which Node.js module system do you want to use? (${moduleSystems.join(
" or "
)}) `,
{
choices: moduleSystems,
}
); return moduleSystem;
}

上面函数使用的question函数由zx提供。

现在我们将创建一个getNodeModuleSystem函数,以调用 promptForModuleSystem函数。它将检查所输入的值是否有效。如果不是,它将再次询问:

async function getNodeModuleSystem() {
const moduleSystems = ["module", "commonjs"];
const selectedModuleSystem = await promptForModuleSystem(moduleSystems); const isValidModuleSystem = moduleSystems.includes(selectedModuleSystem);
if (!isValidModuleSystem) {
console.error(
chalk.red(
`Error: Module system must be either '${moduleSystems.join(
"' or '"
)}'\n`
)
); return await getNodeModuleSystem();
} return selectedModuleSystem;
}

现在我们可以通过运行npm init命令生成我们项目的package.json文件:

await $`npm init --yes`;

然后我们将使用readPackageJson辅助函数来读取新创建的package.json文件。我们将询问项目应该使用哪个模块系统,并将其设置为packageJson对象中的type属性值,然后将其写回到项目的package.json文件中:

const packageJson = await readPackageJson(targetDirectory);
const selectedModuleSystem = await getNodeModuleSystem(); packageJson.type = selectedModuleSystem; await writePackageJson(targetDirectory, packageJson);

提示:当你用--yes标志运行npm init时,要想在package.json中获得合理的默认值,请确保你设置了npminit-*配置设置

安装所需项目依赖

为了使运行我们的启动工具后能够轻松地开始项目开发,我们将创建一个 promptForPackages函数,询问要安装哪些npm包:

async function promptForPackages() {
let packagesToInstall = await question(
"Which npm packages do you want to install for this project? "
); packagesToInstall = packagesToInstall
.trim()
.split(" ")
.filter((pkg) => pkg); return packagesToInstall;
}

为了防止我们在输入包名时出现错别字,我们将创建一个identifyInvalidNpmPackages函数。这个函数将接受一个npm包名数组,然后运行npm view命令来检查它们是否存在:

async function identifyInvalidNpmPackages(packages) {
$.verbose = false; let invalidPackages = [];
for (const pkg of packages) {
try {
await $`npm view ${pkg}`;
} catch (error) {
invalidPackages.push(pkg);
}
} $.verbose = true; return invalidPackages;
}

让我们创建一个getPackagesToInstall函数,使用我们刚刚创建的两个函数:

async function getPackagesToInstall() {
const packagesToInstall = await promptForPackages();
const invalidPackages = await identifyInvalidNpmPackages(packagesToInstall); const allPackagesExist = invalidPackages.length === 0;
if (!allPackagesExist) {
console.error(
chalk.red(
`Error: The following packages do not exist on npm: ${invalidPackages.join(
", "
)}\n`
)
); return await getPackagesToInstall();
} return packagesToInstall;
}

如果有软件包名称不正确,上面的函数将显示一个错误,然后再次询问要安装的软件包。

一旦我们得到需要安装的有效包列表,就可以使用npm install命令来安装它们:

const packagesToInstall = await getPackagesToInstall();
const havePackagesToInstall = packagesToInstall.length > 0;
if (havePackagesToInstall) {
await $`npm install ${packagesToInstall}`;
}

为工具生成配置

创建项目配置是我们用项目启动工具自动完成的最佳事项。首先,让我们添加一个命令来生成一个.gitignore文件,这样我们就不会意外地提交我们不希望在Git仓库中出现的文件:

await $`npx gitignore node`;

上面的命令使用gitignore包,从GitHub的gitignore模板中拉取Node.js的.gitignore文件。

为了生成我们的EditorConfigPrettierESLint配置文件,我们将使用一个叫做Mrm的命令行工具。

全局安装我们需要的mrm依赖项:

npm install --global mrm mrm-task-editorconfig mrm-task-prettier mrm-task-eslint

然后添加mrm命令行生成配置文件:

await $`npx mrm editorconfig`;
await $`npx mrm prettier`;
await $`npx mrm eslint`;

Mrm负责生成配置文件,以及安装所需的npm包。它还提供了大量的配置选项,允许我们调整生成的配置文件以符合我们的个人偏好。

生成README

我们可以使用我们的readPackageJson辅助函数,从项目的package.json文件中读取项目名称。然后我们可以生成一个基本的Markdown格式的README,并将其写入README.md文件中:

const { name: projectName } = await readPackageJson(targetDirectory);
const readmeContents = `# ${projectName} ...
`; await fs.writeFile(`${targetDirectory}/README.md`, readmeContents);

在上面的函数中,我们正在使用fs-extra暴露的fs.writeFile的promise变量。

提交项目骨架

最后,是时候提交我们用git创建的项目骨架了:

await $`git add .`;
await $`git commit -m "Add project skeleton"`;

然后我们将显示一条消息,确认我们的新项目已经成功启动:

console.log(
chalk.green(
`\n️ The project ${projectName} has been successfully bootstrapped!\n`
)
);
console.log(chalk.green(`Add a git remote and push your changes.`));

启动新项目

现在我们可以使用我们创建的工具来启动一个新的项目:

mkdir new-project

./bootstrap-tool.mjs --directory new-project

并观看我们所做的一切。

总结

在这篇文章中,我们已经学会了如何在Node.js中借助Google的zx库来创建强大的shell脚本。我们使用了它提供的实用功能和库来创建一个灵活的命令行工具。

到目前为止,我们所构建的工具只是一个开始。这里有一些功能点子,你可能想尝试自己添加:

  • 自动创建目标目录。如果目标目录还不存在,则提示用户并询问他们是否想要为他们创建该目录。
  • 开源卫生。问问用户他们是否在创建一个将是开源的项目。如果是的话,运行命令来生成许可证贡献者文件。
  • 自动创建GitHub上的仓库。添加使用GitHub CLI的命令,在GitHub上创建一个远程仓库。一旦用Git提交了初始骨架,新项目就可以被推送到这个仓库。

本文中的所有代码都可以在GitHub上找到。

以上就是本文的所有内容。如果对你有所帮助,欢迎点赞、收藏、转发~

如何使用zx编写shell脚本的更多相关文章

  1. 编写shell脚本遇到的问题

    运行shell脚本提示“syntax error near unexpected token for((i=0;i<$length;i++))”: 原因是因为Linux下的换行符是 \n 而你在 ...

  2. 在windows下编写shell脚本

    注意两点: 1.第一行:#!/bin/bash 2.将文档格式转换为unix,因为在windows下编写shell脚本回车符是\n\r,而linux下的回车符是\n,所以在linux下运行脚本的时候, ...

  3. linux 的基本操作(编写shell 脚本)

    终于到shell 脚本这章了,在以前笔者卖了好多关子说shell脚本怎么怎么重要,确实shell脚本在linux系统管理员的运维工作中非常非常重要.下面笔者就带你正式进入shell脚本的世界吧. 到现 ...

  4. 编写Shell脚本的最佳实践

    编写Shell脚本的最佳实践 http://kb.cnblogs.com/page/574767/ 需要记住的 代码有注释 #!/bin/bash # Written by steven # Name ...

  5. python编写shell脚本详细讲解

    python编写shell脚本详细讲解 那,python可以做shell脚本吗? 首先介绍一个函数: os.system(command) 这个函数可以调用shell运行命令行command并且返回它 ...

  6. CentOS下编写shell脚本来监控MySQL主从复制的教程

    这篇文章主要介绍了在CentOS系统下编写shell脚本来监控主从复制的教程,文中举了两个发现故障后再次执行复制命令的例子,需要的朋友可以参考下 目的:定时监控MySQL主从数据库是否同步,如果不同步 ...

  7. [转帖]编写shell脚本所需的语法和示例

    编写shell脚本所需的语法和示例 https://blog.csdn.net/CSDN___LYY/article/details/100584638 在说什么是shell脚本之前,先说说什么是sh ...

  8. linux环境下编写shell脚本实现启动停止tomcat服务

    第一步:以管理员的身份进入控制台,在指定目录下新建一个shell脚本,我这里命名为tomcat.sh 第二步:编写shell脚本 #!/bin/bash tomcat_home=/usr/tomcat ...

  9. 编写shell脚本实现对虚拟机cpu、内存、磁盘监控机制

    一.安装Vmware,并通过镜像安装centos7. 二.安装xshell(可以不装,可以直接在虚拟机中直接进行以下步骤) 三.安装mail 一般Linux发送报警邮件通过本地邮箱或外部邮箱服务器,这 ...

  10. 编写shell脚本的规范

    目录 编写shell脚本的一些规范 解释器 添加脚本版本和注释功能 添加脚本调试 变量命名 全局变量和局部变量 命名规范 函数命名 脚本命名 函数 引用模块或文件 脚本日志 配置文件 其他 编写she ...

随机推荐

  1. KingbaseES R6 单节点数据库异机恢复案例

    数据库运行硬件或系统环境如果发生了不可恢复的故障,这时只能采用异机恢复的方式恢复数据库.以下通过例子介绍异机恢复的过程. 一.硬件环境 192.168.237.101 数据库运行源主机 192.168 ...

  2. Golang实现set

    背景 Golang语言本身未实现set,但是实现了map golang的map是一种无序的键值对的集合,其中键是唯一的 而set是键的不重复的集合,因此可以用map来实现set Empty 由于map ...

  3. MasaFramework -- 锁与分布式锁

    前言 什么是锁?什么是分布式锁?它们之间有什么样的关系? 什么是锁 加锁(lock)是2018年公布的计算机科学技术名词,是指将控制变量置位,控制共享资源不能被其他线程访问.通过加锁,可以确保在同一时 ...

  4. 第六章:Django 综合篇 - 9:序列化 serializers

    Django的序列化工具让你可以将Django的模型'翻译'成其它格式的数据.通常情况下,这种其它格式的数据是基于文本的,并且用于数据交换\传输过程. 一.序列化数据 Django为我们提供了一个强大 ...

  5. 6.Ceph 基础篇 - CephFS 文件系统

    文章转载自:https://mp.weixin.qq.com/s?__biz=MzI1MDgwNzQ1MQ==&mid=2247485294&idx=1&sn=e9039504 ...

  6. 分步骤讲解Deployment故障排除

    背景假设 当你希望在Kubernetes中部署应用程序时,你通常会定义三个组件: 一个Deployment - 这是一份用于创建你的应用程序的Pod副本的"食谱": 一个Servi ...

  7. Alertmanager高可用

    为了提升Promthues的服务可用性,通常用户会部署两个或者两个以上的Promthus Server,它们具有完全相同的配置包括Job配置,以及告警配置等.当某一个Prometheus Server ...

  8. k8s 中 Pod 的控制器

    k8s 中 Pod 的控制器 前言 Replication Controller ReplicaSet Deployment 更新 Deployment 回滚 deployment StatefulS ...

  9. JavaWeb505错误,IDEA版问题解决

    问题描述: 在学习JavaWeb的过程中,使用JSP文件转至servlet文件的过程中,发现无论如何都无法打开文件 JSP文件代码 <%@page contentType="text/ ...

  10. 洛谷P2627 [USACO11OPEN]Mowing the Lawn G (单调队列优化DP)

    一道单调队列优化DP的入门题. f[i]表示到第i头牛时获得的最大效率. 状态转移方程:f[i]=max(f[j-1]-sum[j])+sum[i] ,i-k<=j<=i.j的意义表示断点 ...