让 CLI 更友好:在 npm 包里同时支持“命令行传参”与“交互式对话传参”

包地址:www.npmjs.com/package/@hs... (第一次发包踩坑记录:名字冲突、bin 路径写错、publish 了好几次才成功......名字就此将错就错了 😅)

一、需求背景

当我们把一段脚本封装成全局 npm 包(如 get-dir-tree)时,用户可能处于两种完全不同的使用场景:

  1. CI / 自动化脚本:参数一次性给足,无人值守。
  2. 终端随手敲:用户只记得包名,参数记不清,甚至不知道有哪些选项。

如果 CLI 仅支持"一次性传参",体验会过于僵硬;如果强制进入问答,又会让自动化脚本无法运行。最佳实践是"两者同时支持": 有参 → 直接执行;无参 → 友好对话补全。

二、运行示例

场景 A:一次性给足

$ get-dir-tree ./src 2 tree.md .git,node_modules,dist

场景 B:啥都没给,进入对话

$ get-dir-tree ? 请输入目标目录路径(默认为当前目录): ? 请输入应遍历的最大深度......

三、核心代码

javascript 复制代码
const fs = require('fs');
const path = require('path');
const readline = require('readline');

// 显示帮助信息
function showHelp() {
  console.log(`
    Usage: get-dir-tree [dirPath] [shouldDeepTraverse] [outputPath] [ignorePatterns]

    Options:
      dirPath            目标目录路径,默认为当前目录。
      shouldDeepTraverse 控制遍历深度,0表示全部遍历,1表示只获取当前层,其他正整数表示对应层数,默认为1。
      outputPath         输出文件路径,默认不保存到文件。
      ignorePatterns     过滤规则,默认过滤.git,node_modules。
      -h, --help         显示帮助信息。

    Examples:
      get-dir-tree ./ 1 
      get-dir-tree ./ 2 outputPath
      get-dir-tree ./ 0 outputPath
      get-dir-tree -h (or --help) for help
  `);
}

// 创建readline接口实例
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

// 解析命令行参数并开始对话
async function startDialogue() {
  // 如果有参数且不是帮助标志,则直接使用这些参数
  const args = process.argv.slice(2);
  if (args.includes('-h') || args.includes('--help')) {
    showHelp();
    process.exit(0);
  }

  let dirPath = args[0];
  let shouldDeepTraverse = args[1];
  let outputPath = args[2];
  // 设置忽略模式
  let ignorePatterns = args[3];

  // 如果缺少参数,则通过对话方式获取
  if (!dirPath) {
    dirPath = await askQuestion('请输入目标目录路径(默认为当前目录):') || './';
  }
  if (shouldDeepTraverse === undefined) {
    shouldDeepTraverse = await askQuestion('请输入应遍历的最大深度(0为不限制,但是文件过多或有一些特殊字符的文件可能会出现报错,1为仅当前层,默认为1):') || '1';
    shouldDeepTraverse = shouldDeepTraverse.toLowerCase() === '0' ? Infinity : parseInt(shouldDeepTraverse, 10) || 1;
  }
  if (outputPath === undefined) {
    let fileName = await askQuestion('请输入输出文件名称(留空则不保存到文件):')
    outputPath = fileName ? fileName + '.md' : null;
  }
  if (ignorePatterns === undefined) {
    let filterList = await askQuestion('请输入忽略规则(多个规则用逗号分隔),默认过滤.git,node_modules:')
    ignorePatterns = filterList ? filterList?.split(/[,,]/) : ['.git', 'node_modules'];
  }
  rl.close(); // 关闭readline接口

四、获取文件目录树完整代码如下

JavaScript 复制代码
const fs = require('fs');
const path = require('path');
const readline = require('readline');

// 显示帮助信息
function showHelp() {
  console.log(`
    Usage: get-dir-tree [dirPath] [shouldDeepTraverse] [outputPath] [ignorePatterns]

    Options:
      dirPath            目标目录路径,默认为当前目录。
      shouldDeepTraverse 控制遍历深度,0表示全部遍历,1表示只获取当前层,其他正整数表示对应层数,默认为1。
      outputPath         输出文件路径,默认不保存到文件。
      ignorePatterns     过滤规则,默认过滤.git,node_modules。
      -h, --help         显示帮助信息。

    Examples:
      get-dir-tree ./ 1 
      get-dir-tree ./ 2 outputPath
      get-dir-tree ./ 0 outputPath
      get-dir-tree -h (or --help) for help
  `);
}

// 创建readline接口实例
const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout
});

// 解析命令行参数并开始对话
async function startDialogue() {
  // 如果有参数且不是帮助标志,则直接使用这些参数
  const args = process.argv.slice(2);
  if (args.includes('-h') || args.includes('--help')) {
    showHelp();
    process.exit(0);
  }

  let dirPath = args[0];
  let shouldDeepTraverse = args[1];
  let outputPath = args[2];
  // 设置忽略模式
  let ignorePatterns = args[3];

  // 如果缺少参数,则通过对话方式获取
  if (!dirPath) {
    dirPath = await askQuestion('请输入目标目录路径(默认为当前目录):') || './';
  }
  if (shouldDeepTraverse === undefined) {
    shouldDeepTraverse = await askQuestion('请输入应遍历的最大深度(0为不限制,但是文件过多或有一些特殊字符的文件可能会出现报错,1为仅当前层,默认为1):') || '1';
    shouldDeepTraverse = shouldDeepTraverse.toLowerCase() === '0' ? Infinity : parseInt(shouldDeepTraverse, 10) || 1;
  }
  if (outputPath === undefined) {
    let fileName = await askQuestion('请输入输出文件名称(留空则不保存到文件):')
    outputPath = fileName ? fileName + '.md' : null;
  }
  if (ignorePatterns === undefined) {
    let filterList = await askQuestion('请输入忽略规则(多个规则用逗号分隔),默认过滤.git,node_modules:')
    ignorePatterns = filterList ? filterList?.split(/[,,]/) : ['.git', 'node_modules'];
  }
  rl.close(); // 关闭readline接口



  // 打印或保存结果
  const result = dirPath + '\n' + treeToMarkdown(dirPath, '', true, ignorePatterns, shouldDeepTraverse);

  if (outputPath) {
    let text = "```bash\n" + result + "\n```";
    fs.writeFileSync(outputPath, text);
    console.log(`目录结构已保存至: ${outputPath}`);
  } else {
    console.log(result);
  }
}

// 提问函数
function askQuestion(question) {
  return new Promise(resolve => {
    // 禁用快速编辑模式
    process.stdin.setRawMode(false);
    rl.question(question, (answer) => {
      // 恢复快速编辑模式
      process.stdin.setRawMode(true);
      resolve(answer);
    });
  });
}

// 树状结构转换为Markdown格式函数
function treeToMarkdown(dirPath, prefix = '', isLast = true, ignorePatterns = [], depth = Infinity, currentDepth = 0) {
  let markdown = '';
  try {
    if (currentDepth >= depth) return ''; // 达到最大深度时停止递归

    const entries = fs.readdirSync(dirPath, { withFileTypes: true });
    entries.sort((a, b) => b.isDirectory() - a.isDirectory() || a.name.localeCompare(b.name));

    entries.forEach((entry, index) => {
      if (isIgnored(entry.name, ignorePatterns)) return;

      const isEntryLast = index === entries.length - 1;
      const newPrefix = isEntryLast ? '└── ' : '├── ';
      const entryName = entry.isDirectory() ? entry.name + '/' : entry.name;
      markdown += `${prefix}${newPrefix}${entryName}\n`;

      if (entry.isDirectory()) { // 检查是否继续遍历子目录
        const nextPrefix = isEntryLast ? '    ' : '│   ';
        markdown += treeToMarkdown(path.join(dirPath, entry.name), prefix + nextPrefix, isEntryLast, ignorePatterns, depth, currentDepth + 1);
      } else if (entry.isSymbolicLink()) {
        try {
          const resolvedPath = fs.readlinkSync(path.join(dirPath, entry.name));
          markdown += `${prefix}    → ${resolvedPath}\n`;
        } catch (err) {
          console.error(`无法解析符号链接: ${entry.name}`);
        }
      }
    });
  } catch (err) {
    console.error(`读取目录时出错: ${dirPath}`, err);
  }

  return markdown;
}

// 忽略规则函数
function isIgnored(entryName, ignorePatterns) {
  return ignorePatterns.some(pattern => entryName.startsWith(pattern));
}

startDialogue();

五、一键安装体验

shell 复制代码
npm i @hsk766187397/get-tree -g

六、第一次发包的踩坑小结

  1. 名字冲突:npm 上 get-tree、dir-tree 全被占,只能加前缀。
  2. package.json 的 bin 字段必须指向最终可执行文件,开发阶段忘改路径,全局安装后报 command not found。
  3. 发布前务必 npm link 本地试跑。

至此,一个"零配置也能用、传参就能跑"的目录树 CLI 就打包完毕。 欢迎 npm i -g @hsk766187397/get-tree 体验!

相关推荐
Mintopia8 小时前
🐋 用 Docker 驯服 Next.js —— 一场前端与底层的浪漫邂逅
前端·javascript·全栈
Mintopia8 小时前
物联网数据驱动 AIGC:Web 端设备状态预测的技术实现
前端·javascript·aigc
一个W牛8 小时前
报文比对工具(xml和sop)
xml·前端·javascript
鸡吃丸子8 小时前
浏览器是如何运作的?深入解析从输入URL到页面渲染的完整过程
前端
作业逆流成河8 小时前
🔥 enum-plus 3.0:介绍一个天花板级的前端枚举库
前端·javascript·前端框架
爱喝水的小周8 小时前
《UniApp 页面导航跳转全解笔记》
前端·uni-app
蒜香拿铁8 小时前
Angular【组件】
前端·javascript·angular.js
ByteCraze8 小时前
一文讲透 npm 包版本管理规范
前端·arcgis·npm
梵得儿SHI9 小时前
Vue 模板语法深度解析:从文本插值到 HTML 渲染的核心逻辑
前端·vue.js·html·模板语法·文本插值·v-text指令·v-html指令