- 本文参加了由 公众号@若川视野 发起的每周源码共读活动, 点击了解详情一起参与。
- 这是源码共读的第20期,链接:www.yuque.com/ruochuan12/...
先说一下,这篇文章的由来。
前几天看到若川的推文,介绍一个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里面做匹配。
总结一下
虽然是个小工具,实现的功能也比较简单。
但是里面的代码实现细节,处理方式还是能学到很多东西。
如果看完有收获,欢迎点赞、评论、分享支持。你的支持和肯定,是我写作的动力。