【若川视野 x 源码共读】第6期 | update-notifier 检测 npm 包是否更新

81 阅读2分钟

1、作用

以非入侵的方式通知cli应用程序更新包。

2、原因

  1. 美化终端输出信息,描绘边框。

  2. 我们可以学会怎么去开启子进程

  3. 给一个node方法作为传参的几种方式

  4. 通过configstore这个库我们能够持久化存储一些信息

  5. 通过latest-version获取对应pkg包的最新版本信息

3、源码

'use strict';
const updateNotifier = require('.');

// Run: $ node example

// 第一次必须运行此文件两次
// 因为它不会在第一次运行时报告更新
//如果你想使用,必须确保你使用的是老版本

updateNotifier({
  pkg: {
    name: 'public-ip',
    version: '0.9.2'
  },
  updateCheckInterval: 0
}).notify();
"use strict";
//spawn方法创建一个子进程来执行特定命令,用法与execFile方法类似,
//但是没有回调函数,只能通过监听事件,来获取运行结果。
//它属于异步执行,适用于子进程长时间运行的情况。
const { spawn } = require("child_process");
const path = require("path");
const { format } = require("util");
const importLazy = require("import-lazy")(require);

const configstore = importLazy("configstore");
const chalk = importLazy("chalk");
//语义化版本
const semver = importLazy("semver");
//semver-diff:获取两个semver版本的diff类型
const semverDiff = importLazy("semver-diff");
const latestVersion = importLazy("latest-version");
const isNpm = importLazy("is-npm");
//检查您的软件包是否已全局安装
const isInstalledGlobally = importLazy("is-installed-globally");
//检查您的yarn包是否已全局安装
const isYarnGlobal = importLazy("is-yarn-global");
const hasYarn = importLazy("has-yarn");
//boxen:在终端中创建框
const boxen = importLazy("boxen");
const xdgBasedir = importLazy("xdg-basedir");
const isCi = importLazy("is-ci");
const pupa = importLazy("pupa");

const ONE_DAY = 1000 * 60 * 60 * 24;

class UpdateNotifier {
  //实例化对象
  constructor(options = {}) {
    this.options = options;
    options.pkg = options.pkg || {};
    options.distTag = options.distTag || "latest";
    
    //减少pkg到基本的关键:退回到已弃用选项
    // TODO:将来的时间点移除已弃用的选项
    options.pkg = {
      name: options.pkg.name || options.packageName,
      version: options.pkg.version || options.packageVersion,
    };
    
    // 必要字段检测
    if (!options.pkg.name || !options.pkg.version) {
      throw new Error("pkg.name and pkg.version required");
    }
    
    this.packageName = options.pkg.name;
    this.packageVersion = options.pkg.version;
    // 检查周期 单位是ms
    this.updateCheckInterval =
      typeof options.updateCheckInterval === "number"
      ? options.updateCheckInterval
    : ONE_DAY;
    this.disabled =
      //获取process检查是否禁用更新
      "NO_UPDATE_NOTIFIER" in process.env ||
      process.env.NODE_ENV === "test" ||
      process.argv.includes("--no-update-notifier") ||
      isCi(); //isCi的作用是如果当前环境是持续集成服务器,则返回true
    //判断是否在Npm脚本中通知
    this.shouldNotifyInNpmScript = options.shouldNotifyInNpmScript;
    
    if (!this.disabled) {
      try {
        //ConfigStore的作用是轻松加载和保留配置,而无需考虑在哪里以及如何
        const ConfigStore = configstore();
        this.config = new ConfigStore(`update-notifier-${this.packageName}`, {
          optOut: false,
          // 初始化当前时间,因此第一次仅检查
          //在设置间隔之后,所以不要马上打扰用户
          lastUpdateCheck: Date.now(),
        });
      } catch {
        //期望出现错误代码 EACCES 或 EPERM 控制台显示错误
        const message =
              chalk().yellow(format(" %s update check failed ", options.pkg.name)) +
              format(
                "\n Try running with %s or get access ",
                chalk().cyan("sudo")
              ) +
              "\n to the local update config store via \n" +
              chalk().cyan(
                format(
                  " sudo chown -R $USER:$(id -gn $USER) %s ",
                  xdgBasedir().config
                )
              );
        
        //退出程序
        process.on("exit", () => {
          console.error(boxen()(message, { align: "center" }));
        });
      }
    }
  }
  
  
  
  async fetchInfo() {
    const { distTag } = this.options;
    const latest = await latestVersion()(this.packageName, {
      version: distTag,
    });
    
    return {
      latest,
      current: this.packageVersion,
      type: semverDiff()(this.packageVersion, latest) || distTag,
      name: this.packageName,
    };
  }
  
  notify(options) {
    const suppressForNpm = !this.shouldNotifyInNpmScript && isNpm().isNpmOrYarn;
    if (
      //判断它是否在终端(terminal)终端环境中执行
      !process.stdout.isTTY ||
      suppressForNpm ||
      !this.update ||
      //语义化对比
      !semver().gt(this.update.latest, this.update.current)
    ) {
      return this;
    }
    
    options = {
      isGlobal: isInstalledGlobally(),
      isYarnGlobal: isYarnGlobal()(),
      ...options,
    };
    
    let installCommand;
    if (options.isYarnGlobal) {
      installCommand = `yarn global add ${this.packageName}`;
    } else if (options.isGlobal) {
      installCommand = `npm i -g ${this.packageName}`;
    } else if (hasYarn()()) {
      installCommand = `yarn add ${this.packageName}`;
    } else {
      installCommand = `npm i ${this.packageName}`;
    }
    
    const defaultTemplate =
          "Update available " +
          chalk().dim("{currentVersion}") +
          chalk().reset(" → ") +
          chalk().green("{latestVersion}") +
          " \nRun " +
          chalk().cyan("{updateCommand}") +
          " to update";
    
    const template = options.message || defaultTemplate;
    
    options.boxenOptions = options.boxenOptions || {
      padding: 1,
      margin: 1,
      align: "center",
      borderColor: "yellow",
      borderStyle: "round",
    };
    
    const message = boxen()(
      pupa()(template, {
        packageName: this.packageName,
        currentVersion: this.update.current,
        latestVersion: this.update.latest,
        updateCommand: installCommand,
      }),
      options.boxenOptions
    );
    
    if (options.defer === false) {
      console.error(message);
    } else {
      process.on("exit", () => {
        console.error(message);
      });
      
      process.on("SIGINT", () => {
        console.error("");
        process.exit();
      });
    }
    
    return this;
  }
}

module.exports = (options) => {
  
  //检查更新
  updateNotifier.check();
  return updateNotifier;
};

module.exports.UpdateNotifier = UpdateNotifier;

1、实例化对象

//实例化对象 包名通过ConfigStore进行持久化存储,顺便把最后一次检查的时间更新成现在

const updateNotifier = new UpdateNotifier(options);

constructor(options = {}) {
  this.options = options;
  options.pkg = options.pkg || {};
  options.distTag = options.distTag || "latest";
  
  //减少pkg到基本的关键:退回到已弃用选项
  // TODO:将来的时间点移除已弃用的选项
  options.pkg = {
    name: options.pkg.name || options.packageName,
    version: options.pkg.version || options.packageVersion,
  };
  
  // 必要字段检测
  if (!options.pkg.name || !options.pkg.version) {
    throw new Error("pkg.name and pkg.version required");
  }
  
  this.packageName = options.pkg.name;
  this.packageVersion = options.pkg.version;
  // 检查周期 单位是ms
  this.updateCheckInterval =
    typeof options.updateCheckInterval === "number"
    ? options.updateCheckInterval
  : ONE_DAY;
  this.disabled =
    //获取process检查是否禁用更新
    "NO_UPDATE_NOTIFIER" in process.env ||
    process.env.NODE_ENV === "test" ||
    process.argv.includes("--no-update-notifier") ||
    isCi(); //isCi的作用是如果当前环境是持续集成服务器,则返回true
  this.shouldNotifyInNpmScript = options.shouldNotifyInNpmScript;
  
  if (!this.disabled) {
    try {
      //ConfigStore的作用是轻松加载和保留配置,而无需考虑在哪里以及如何
      const ConfigStore = configstore();
      this.config = new ConfigStore(`update-notifier-${this.packageName}`, {
        optOut: false,
        // 初始化当前时间,因此第一次仅检查
        //在设置间隔之后,所以不要马上打扰用户
        lastUpdateCheck: Date.now(),
      });
    } catch {
      //期望出现错误代码 EACCES 或 EPERM 控制台显示错误
      const message =
            chalk().yellow(format(" %s update check failed ", options.pkg.name)) +
            format(
              "\n Try running with %s or get access ",
              chalk().cyan("sudo")
            ) +
            "\n to the local update config store via \n" +
            chalk().cyan(
              format(
                " sudo chown -R $USER:$(id -gn $USER) %s ",
                xdgBasedir().config
              )
            );
      
      //退出程序
      process.on("exit", () => {
        console.error(boxen()(message, { align: "center" }));
      });
    }
  }
	}

实例化结果:

2、第一次检查更新

check() {
  //没有本地存储或者禁用更新的情况下,那么就不执行了 
  if (!this.config || this.config.get("optOut") || this.disabled) {
    return;
  }
  
  //将之前存进去的update对象来更新,第一次获得的update是空
  this.update = this.config.get("update");
  
  if (this.update) {
    // Use the real latest version instead of the cached one
    this.update.current = this.packageVersion;
    
    // Clear cached information
    this.config.delete("update");
  }
  
  // Only check for updates on a set interval
  if (
    Date.now() - this.config.get("lastUpdateCheck") <
    this.updateCheckInterval
  ) {
    return;
  }
  
  //spawn开启一个子进程,运行check.js,然后把options作为调用check.js的命令行参数
  spawn(
    process.execPath,
    [path.join(__dirname, "check.js"), JSON.stringify(this.options)],
    {
      detached: true,
      stdio: "ignore",
    }
  ).unref();
	}

3、通知

notify(options) {
  const suppressForNpm = !this.shouldNotifyInNpmScript && isNpm().isNpmOrYarn;
  if (
    //判断它是否在终端(terminal)终端环境中执行
    !process.stdout.isTTY ||
    suppressForNpm ||
    //第一次因为update为空,直接返回了
    !this.update ||
    //语义化对比
    !semver().gt(this.update.latest, this.update.current)
  ) {
    return this;
  }
  .....
  return this;
}
}

4、第二次执行,进入check.js文件

updateNotifier = new updateNotifier.UpdateNotifier(options);

(async () => {
  // 30s后退出程序
  setTimeout(process.exit, 1000 * 30);
  //获得更新的内容
  const update = await updateNotifier.fetchInfo();
  
  // 设置最后更新时间
  updateNotifier.config.set("lastUpdateCheck", Date.now());
  //版本差别 设置更新
  if (update.type && update.type !== "latest") {
    updateNotifier.config.set("update", update);
  }
  
  //终止子进程
  // 否则,根据 Node.js 文档,子进程将永远运行
  process.exit();
})().catch((error) => {
  console.error(error);
  process.exit(1);
});
	async fetchInfo() {
		const { distTag } = this.options;
		//获得最新的版本
		const latest = await latestVersion()(this.packageName, {
			version: distTag,
		});

		return {
			//包名、最新版本、当前版本、版本差别的信息
			latest,
			current: this.packageVersion,
			type: semverDiff()(this.packageVersion, latest) || distTag,
			name: this.packageName,
		};
	}
  

5、第二次通知

notify(options) {
  const suppressForNpm = !this.shouldNotifyInNpmScript && isNpm().isNpmOrYarn;
  if (
    //判断它是否在终端(terminal)终端环境中执行
    !process.stdout.isTTY ||
    suppressForNpm ||
    !this.update ||
    //语义化对比
    !semver().gt(this.update.latest, this.update.current)
  ) {
    return this;
  }
  
  options = {
    isGlobal: isInstalledGlobally(),
    isYarnGlobal: isYarnGlobal()(),
    ...options,
  };
  
  let installCommand;
  //运行命令
  if (options.isYarnGlobal) {
    installCommand = `yarn global add ${this.packageName}`;
  } else if (options.isGlobal) {
    installCommand = `npm i -g ${this.packageName}`;
  } else if (hasYarn()()) {
    installCommand = `yarn add ${this.packageName}`;
  } else {
    installCommand = `npm i ${this.packageName}`;
  }
  //设置cmd 提示内容
  const defaultTemplate =
        "Update available " +
        chalk().dim("{currentVersion}") +
        chalk().reset(" → ") +
        chalk().green("{latestVersion}") +
        " \nRun " +
        chalk().cyan("{updateCommand}") +
        " to update";
  
  const template = options.message || defaultTemplate;
  //样式
  options.boxenOptions = options.boxenOptions || {
    padding: 1,
    margin: 1,
    align: "center",
    borderColor: "yellow",
    borderStyle: "round",
  };
  
  const message = boxen()(
    pupa()(template, {
      packageName: this.packageName,
      currentVersion: this.update.current,
      latestVersion: this.update.latest,
      updateCommand: installCommand,
    }),
    options.boxenOptions
  );
  
  if (options.defer === false) {
    console.error(message);
  } else {
    process.on("exit", () => {
      console.error(message);
    });
    
    process.on("SIGINT", () => {
      console.error("");
      process.exit();
    });
  }
  
  return this;
	}

6、最后输出

"use strict";
//spawn方法创建一个子进程来执行特定命令,用法与execFile方法类似,
//但是没有回调函数,只能通过监听事件,来获取运行结果。
//它属于异步执行,适用于子进程长时间运行的情况。
const { spawn } = require("child_process");
const path = require("path");
const { format } = require("util");
const importLazy = require("import-lazy")(require);

const configstore = importLazy("configstore");
const chalk = importLazy("chalk");
//语义化版本
const semver = importLazy("semver");
//semver-diff:获取两个semver版本的diff类型
const semverDiff = importLazy("semver-diff");
//获取最新版本的 npm 包
const latestVersion = importLazy("latest-version");
const isNpm = importLazy("is-npm");
//检查您的软件包是否已全局安装
const isInstalledGlobally = importLazy("is-installed-globally");
//检查您的yarn包是否已全局安装
const isYarnGlobal = importLazy("is-yarn-global");
const hasYarn = importLazy("has-yarn");
//boxen:在终端中创建框
const boxen = importLazy("boxen");
const xdgBasedir = importLazy("xdg-basedir");
const isCi = importLazy("is-ci");
const pupa = importLazy("pupa");

const ONE_DAY = 1000 * 60 * 60 * 24;

class UpdateNotifier {
  constructor(options = {}) {
    this.options = options;
    options.pkg = options.pkg || {};
    options.distTag = options.distTag || "latest";
    
    //减少pkg到基本的关键:退回到已弃用选项
    // TODO:将来的时间点移除已弃用的选项
    options.pkg = {
      name: options.pkg.name || options.packageName,
      version: options.pkg.version || options.packageVersion,
    };
    
    // 必要字段检测
    if (!options.pkg.name || !options.pkg.version) {
      throw new Error("pkg.name and pkg.version required");
    }
    
    this.packageName = options.pkg.name;
    this.packageVersion = options.pkg.version;
    // 检查周期 单位是ms
    this.updateCheckInterval =
      typeof options.updateCheckInterval === "number"
      ? options.updateCheckInterval
    : ONE_DAY;
    this.disabled =
      //获取process检查是否禁用更新
      "NO_UPDATE_NOTIFIER" in process.env ||
      process.env.NODE_ENV === "test" ||
      process.argv.includes("--no-update-notifier") ||
      isCi(); //isCi的作用是如果当前环境是持续集成服务器,则返回true
    this.shouldNotifyInNpmScript = options.shouldNotifyInNpmScript;
    
    if (!this.disabled) {
      try {
        //ConfigStore的作用是轻松加载和保留配置,而无需考虑在哪里以及如何
        const ConfigStore = configstore();
        this.config = new ConfigStore(`update-notifier-${this.packageName}`, {
          optOut: false,
          // 初始化当前时间,因此第一次仅检查
          //在设置间隔之后,所以不要马上打扰用户
          lastUpdateCheck: Date.now(),
        });
      } catch {
        //期望出现错误代码 EACCES 或 EPERM 控制台显示错误
        const message =
              chalk().yellow(format(" %s update check failed ", options.pkg.name)) +
              format(
                "\n Try running with %s or get access ",
                chalk().cyan("sudo")
              ) +
              "\n to the local update config store via \n" +
              chalk().cyan(
                format(
                  " sudo chown -R $USER:$(id -gn $USER) %s ",
                  xdgBasedir().config
                )
              );
        
        //退出程序
        process.on("exit", () => {
          console.error(boxen()(message, { align: "center" }));
        });
      }
    }
  }
  
  check() {
    if (!this.config || this.config.get("optOut") || this.disabled) {
      return;
    }
    
    this.update = this.config.get("update");
    
    if (this.update) {
      // Use the real latest version instead of the cached one
      this.update.current = this.packageVersion;
      
      // Clear cached information
      this.config.delete("update");
    }
    
    // Only check for updates on a set interval
    if (
      Date.now() - this.config.get("lastUpdateCheck") <
      this.updateCheckInterval
    ) {
      return;
    }
    
    //生成分离的进程,将选项作为环境属性传递
    spawn(
      process.execPath,
      [path.join(__dirname, "check.js"), JSON.stringify(this.options)],
      {
        detached: true,
        stdio: "ignore",
      }
    ).unref();
  }
  
  async fetchInfo() {
    const { distTag } = this.options;
    //获得最新的版本
    const latest = await latestVersion()(this.packageName, {
      version: distTag,
    });
    
    return {
      //包名、最新版本、当前版本、版本差别的信息
      latest,
      current: this.packageVersion,
      type: semverDiff()(this.packageVersion, latest) || distTag,
      name: this.packageName,
    };
  }
  
  notify(options) {
    const suppressForNpm = !this.shouldNotifyInNpmScript && isNpm().isNpmOrYarn;
    if (
      //判断它是否在终端(terminal)终端环境中执行
      !process.stdout.isTTY ||
      suppressForNpm ||
      !this.update ||
      //语义化对比
      !semver().gt(this.update.latest, this.update.current)
    ) {
      return this;
    }
    
    options = {
      isGlobal: isInstalledGlobally(),
      isYarnGlobal: isYarnGlobal()(),
      ...options,
    };
    
    let installCommand;
    if (options.isYarnGlobal) {
      installCommand = `yarn global add ${this.packageName}`;
    } else if (options.isGlobal) {
      installCommand = `npm i -g ${this.packageName}`;
    } else if (hasYarn()()) {
      installCommand = `yarn add ${this.packageName}`;
    } else {
      installCommand = `npm i ${this.packageName}`;
    }
    
    const defaultTemplate =
          "Update available " +
          chalk().dim("{currentVersion}") +
          chalk().reset(" → ") +
          chalk().green("{latestVersion}") +
          " \nRun " +
          chalk().cyan("{updateCommand}") +
          " to update";
    
    const template = options.message || defaultTemplate;
    
    options.boxenOptions = options.boxenOptions || {
      padding: 1,
      margin: 1,
      align: "center",
      borderColor: "yellow",
      borderStyle: "round",
    };
    
    const message = boxen()(
      pupa()(template, {
        packageName: this.packageName,
        currentVersion: this.update.current,
        latestVersion: this.update.latest,
        updateCommand: installCommand,
      }),
      options.boxenOptions
    );
    
    if (options.defer === false) {
      console.error(message);
    } else {
      process.on("exit", () => {
        console.error(message);
      });
      
      process.on("SIGINT", () => {
        console.error("");
        process.exit();
      });
    }
    
    return this;
  }
}

module.exports = (options) => {
  const updateNotifier = new UpdateNotifier(options);
  updateNotifier.check();
  return updateNotifier;
};

module.exports.UpdateNotifier = UpdateNotifier;