【源码阅读-ni】npm,yarn,pnpm各种包管理工具,如何自动管理?

先说一下,这篇文章的由来。

前几天看到若川的推文,介绍一个39行小工具 install-pkg,可以解决包管理工具的命令管理功能。

看完之后感觉很实用,故想把该功能集成到自己的脚手架里面去,因此特意去看了一下源码。(我看的是ni库,是同一个作者,核心代码也是一样的)

先看一下这个库解决了一个什么问题?

不同项目,可能用的包管理软件不同,直接粗暴的用npm可能会有问题,比如依赖的版本,关系不正确,比如产生多个lock文件。

这个库就是根据项目的lock文件,自动匹配对应的命令脚本。

那么这是如何实现的呢?(先理源码思路)

1.如何确认项目使用的管理工具

源码中有一个detect方法,就是用来确认管理工具的。

typescript 复制代码
export async function detect({
  autoInstall,
  programmatic,
  cwd,
}: DetectOptions = {}) {
  let agent: Agent | null = null;
  let version: string | null = null;

  const lockPath = await findUp(Object.keys(LOCKS), { cwd });
  let packageJsonPath: string | undefined;

  if (lockPath) packageJsonPath = path.resolve(lockPath, "../package.json");
  else packageJsonPath = await findUp("package.json", { cwd });

  // read `packageManager` field in package.json
  if (packageJsonPath && fs.existsSync(packageJsonPath)) {
    try {
      const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
      if (typeof pkg.packageManager === "string") {
        const [name, ver] = pkg.packageManager.split("@");
        version = ver;
        if (name === "yarn" && Number.parseInt(ver) > 1) agent = "yarn@berry";
        else if (name === "pnpm" && Number.parseInt(ver) < 7) agent = "pnpm@6";
        else if (name in AGENTS) agent = name;
        else if (!programmatic)
          console.warn("[ni] Unknown packageManager:", pkg.packageManager);
      }
    } catch {}
  }

  // detect based on lock
  if (!agent && lockPath) agent = LOCKS[path.basename(lockPath)];

  // auto install
  if (agent && !cmdExists(agent.split("@")[0]) && !programmatic) {
    if (!autoInstall) {
      console.warn(
        `[ni] Detected ${agent} but it doesn't seem to be installed.\n`
      );

      if (process.env.CI) process.exit(1);

      const link = terminalLink(agent, INSTALL_PAGE[agent]);
      const { tryInstall } = await prompts({
        name: "tryInstall",
        type: "confirm",
        message: `Would you like to globally install ${link}?`,
      });
      if (!tryInstall) process.exit(1);
    }

    await execaCommand(`npm i -g ${agent}${version ? `@${version}` : ""}`, {
      stdio: "inherit",
      cwd,
    });
  }

  return agent;
}

第一步,先匹配对应目录下是哪种lock文件。

ini 复制代码
  const lockPath = await findUp(Object.keys(LOCKS), { cwd });

ps(根据文件名查找路径用的是find-up库)

第二步,如果package.json中指定了packageManager,则使用packageManager中的配置,否则就是lock为准。

第三步,判断命令是否存在,如果不存在,则进行自动安装

ps(terminalLink方法是在命令行上创建一个可点击的链接。prompts创建一个交互界面,选择是否尝试安装。execaCommand为执行命令方法)

如何匹配命令

比如最常见的安装命令,我们需要根据不同的工具执行不同的命令。

核心的代码是getCommand函数。

ps(ni -v 获取到的args参数是["-v"])

typescript 复制代码
export function getCommand(
  agent: Agent,
  command: Command,
  args: string[] = []
) {
  if (!(agent in AGENTS)) throw new Error(`Unsupported agent "${agent}"`);

  const c = AGENTS[agent][command];

  if (typeof c === "function") return c(args);

  if (!c) throw new UnsupportedCommand({ agent, command });

  const quote = (arg: string) =>
    !arg.startsWith("--") && arg.includes(" ") ? JSON.stringify(arg) : arg;

  return c.replace("{0}", args.map(quote).join(" ")).trim();
}

在源码中,已经将各个工具能用到的命令都枚举出来了。(包括安装,移除,升级,全局,npx)

根据agent,以及command可以匹配到具体的命令,然后再通过替换占位符的方式,拼接出完整的命令。

如何开始源码调试

现在我们大致知道了这个工具实现的功能,以及如何实现的。

那我们再来详细的看看源码是怎么写的,也可以调试一下。

第1步: 克隆代码,安装依赖

第2步, 执行pnpm run build && pnpm run stub

打包用的是unbuild,stub类似监听。

第3步, 找入口文件开始调试。

ni的入口是bin下面的ni.mjs文件。

javascript 复制代码
#!/usr/bin/env node
'use strict'
import '../dist/ni.mjs'

引用的是dist里面的,那肯定是打包出来的文件,所以我们再找一下打包配置。

javascript 复制代码
import { basename } from 'node:path'
import { defineBuildConfig } from 'unbuild'
import fg from 'fast-glob'

export default defineBuildConfig({
  entries: [
    ...fg.sync('src/commands/*.ts').map(i => ({
      input: i.slice(0, -3),
      name: basename(i).slice(0, -3),
    })),
  ],
  clean: true,
  declaration: true,
  rollup: {
    emitCJS: true,
    inlineDependencies: true,
  },
})

这下总算是找到了,在src/commands/

javascript 复制代码
import { parseNi } from '../parse'
import { runCli } from '../runner'

runCli(parseNi)

源码中还实现了哪些功能?

源码其实就执行了一个方法,runCli-> run。

typescript 复制代码
export async function run(
  fn: Runner,
  args: string[],
  options: DetectOptions = {}
) {
  console.log(args);
  const debug = args.includes(DEBUG_SIGN);
  console.log("debug", debug, args);
  if (debug) remove(args, DEBUG_SIGN);

  let cwd = options.cwd ?? process.cwd();
  if (args[0] === "-C") {
    cwd = resolve(cwd, args[1]);
    args.splice(0, 2);
  }
  if (
    args.length === 1 &&
    (args[0]?.toLowerCase() === "-v" || args[0] === "--version")
  ) {
    const getCmd = (a: Agent) =>
      agents.includes(a) ? getCommand(a, "agent", ["-v"]) : `${a} -v`;
    const getV = (a: string, o?: ExecaOptions) => {
      return execaCommand(getCmd(a as Agent), o)
        .then((e) => e.stdout)
        .then((e) => (e.startsWith("v") ? e : `v${e}`));
    };

    const globalAgentPromise = getGlobalAgent();
    const globalAgentVersionPromise = globalAgentPromise.then(getV);
    const agentPromise = detect({ ...options, cwd }).then((a) => a || "");
    const agentVersionPromise = agentPromise.then((a) => a && getV(a, { cwd }));
    const nodeVersionPromise = getV("node", { cwd });

    console.log(`@antfu/ni  ${c.cyan(`v${version}`)}`);
    console.log(`node       ${c.green(await nodeVersionPromise)}`);
    const [agent, agentVersion] = await Promise.all([
      agentPromise,
      agentVersionPromise,
    ]);
    if (agent) console.log(`${agent.padEnd(10)} ${c.blue(agentVersion)}`);
    else console.log("agent      no lock file");
    const [globalAgent, globalAgentVersion] = await Promise.all([
      globalAgentPromise,
      globalAgentVersionPromise,
    ]);
    console.log(
      `${`${globalAgent} -g`.padEnd(10)} ${c.blue(globalAgentVersion)}`
    );
    return;
  }

  if (args.length === 1 && (args[0] === "--version" || args[0] === "-v")) {
    console.log(`@antfu/ni v${version}`);
    return;
  }

  if (args.length === 1 && ["-h", "--help"].includes(args[0])) {
    const dash = c.dim("-");
    console.log(
      c.green(c.bold("@antfu/ni")) +
        c.dim(` use the right package manager v${version}\n`)
    );
    console.log(`ni    ${dash}  install`);
    console.log(`nr    ${dash}  run`);
    console.log(`nlx   ${dash}  execute`);
    console.log(`nu    ${dash}  upgrade`);
    console.log(`nun   ${dash}  uninstall`);
    console.log(`nci   ${dash}  clean install`);
    console.log(`na    ${dash}  agent alias`);
    console.log(`ni -v ${dash}  show used agent`);
    console.log(
      c.yellow("\ncheck https://github.com/antfu/ni for more documentation.")
    );
    return;
  }

  let command = await getCliCommand(fn, args, options, cwd);
  console.log("command", command);
  if (!command) return;

  const voltaPrefix = getVoltaPrefix();
  if (voltaPrefix) command = voltaPrefix.concat(" ").concat(command);

  if (debug) {
    console.log(command);
    return;
  }

  await execaCommand(command, { stdio: "inherit", encoding: "utf-8", cwd });
}

ps(console是我本地调试加的)

1.判断是否是debug模式

ini 复制代码
const DEBUG_SIGN = "?";
const debug = args.includes(DEBUG_SIGN);

如果是debug模式则只输出命令,不执行。

lua 复制代码
if (debug) {
    console.log(command);
    return;
  }

ps(?是特殊字符,需要写成ni "?")

2.是否是-v,-h

脚手架基本都有的两个参数。

而且不仅仅只是输出工具版本,而是把node,npm,以及当前工具版本都输出出来了。

ps(查找某个工具是否安装,用的是which库)

3.生成完整命令并执行(重点)

ini 复制代码
  let command = await getCliCommand(fn, args, options, cwd);
  console.log("command", command);
  if (!command) return;

  const voltaPrefix = getVoltaPrefix();
  if (voltaPrefix) command = voltaPrefix.concat(" ").concat(command);

  if (debug) {
    console.log(command);
    return;
  }

  await execaCommand(command, { stdio: "inherit", encoding: "utf-8", cwd });

getCliCommand跟execaCommand我们之前已经介绍过了。

那这个fn是啥呢?

fn就是入口那里传入的parseNi。

javascript 复制代码
export const parseNi = <Runner>((agent, args, ctx) => {
  // bun use `-d` instead of `-D`, #90
  if (agent === "bun") args = args.map((i) => (i === "-D" ? "-d" : i));

  if (args.includes("-g"))
    return getCommand(agent, "global", exclude(args, "-g"));

  if (args.includes("--frozen-if-present")) {
    args = exclude(args, "--frozen-if-present");
    return getCommand(agent, ctx?.hasLock ? "frozen" : "install", args);
  }

  if (args.includes("--frozen"))
    return getCommand(agent, "frozen", exclude(args, "--frozen"));

  if (args.length === 0 || args.every((i) => i.startsWith("-")))
    return getCommand(agent, "install", args);

  return getCommand(agent, "add", args);
});

getCommand方法我们已经看过了。

parseNi方法就是对参数做一个处理,比如-g,要变成global,因为要到AGENTS里面做匹配。

总结一下

虽然是个小工具,实现的功能也比较简单。

但是里面的代码实现细节,处理方式还是能学到很多东西。

如果看完有收获,欢迎点赞、评论、分享支持。你的支持和肯定,是我写作的动力

相关推荐
轻口味34 分钟前
【每日学点鸿蒙知识】AVCodec、SmartPerf工具、web组件加载、监听键盘的显示隐藏、Asset Store Kit
前端·华为·harmonyos
alikami37 分钟前
【若依】用 post 请求传 json 格式的数据下载文件
前端·javascript·json
吃杠碰小鸡1 小时前
lodash常用函数
前端·javascript
emoji1111111 小时前
前端对页面数据进行缓存
开发语言·前端·javascript
泰伦闲鱼1 小时前
nestjs:GET REQUEST 缓存问题
服务器·前端·缓存·node.js·nestjs
m0_748250031 小时前
Web 第一次作业 初探html 使用VSCode工具开发
前端·html
一个处女座的程序猿O(∩_∩)O2 小时前
vue3 如何使用 mounted
前端·javascript·vue.js
m0_748235952 小时前
web复习(三)
前端
迷糊的『迷』2 小时前
vue-axios+springboot实现文件流下载
vue.js·spring boot
web135085886352 小时前
uniapp小程序使用webview 嵌套 vue 项目
vue.js·小程序·uni-app