프로그래밍 언어/NODE JS

commander, inquirer 사용하기(2)

· 코딩마이데이

commander가 기본적으로 제공하는 옵션인 -v와 -h를 입력해보겠습니다.

$ cli -v
$ cli -h
$ npx cli template -h
$ npx cli template

 

명령어에 -h 옵션을 붙이면 명령어 설명서가 나옵니다. 조금 전에 usage나 name, description, option 메서드에 적었던 설명이 그대로 표시됩니다.

cli template 명령어를 입력했을 때는 필수 요소인 <type>을 빠뜨렸으므로 에러를 표시합니다. 이렇게 설명과 에러 검증을 자동으로 해줘서 편리합니다.

이제 실제로 동작하는 코드를 작성해보겠습니다. template.js의 코드를 대부분 가져옵니다.

#!/usr/bin/env node
const { program } = require("commander");
const fs = require("fs");
const path = require("path");
const inquirer = require("inquirer");
const chalk = require("chalk");

const htmlTemplate = `
<!DOCTYPE html>
  <html>
  <head>
    <meta chart="utf-8" />
    <title>Template</title>
  </head>
  <body>
    <h1>Hello</h1>
    <p>CLI</p>
  </body>
</html>
`;

const routerTemplate = `
const express = require('express');
const router = express.Router();
 
router.get('/', (req, res, next) => {
   try {
     res.send('ok');
   } catch (error) {
     console.error(error);
     next(error);
   }
});
 
module.exports = router;
`;

const exist = (dir) => {
  try {
    fs.accessSync(
      dir,
      fs.constants.F_OK | fs.constants.R_OK | fs.constants.W_OK,
    );
    return true;
  } catch (e) {
    return false;
  }
};

const mkdirp = (dir) => {
  const dirname = path
    .relative(".", path.normalize(dir))
    .split(path.sep)
    .filter((p) => !!p);
  dirname.forEach((d, idx) => {
    const pathBuilder = dirname.slice(0, idx + 1).join(path.sep);
    if (!exist(pathBuilder)) {
      fs.mkdirSync(pathBuilder);
    }
  });
};

const makeTemplate = (type, name, directory) => {
  mkdirp(directory);
  if (type === "html") {
    const pathToFile = path.join(directory, `${name}.html`);
    if (exist(pathToFile)) {
      console.error(chalk.bold.red("이미 해당 파일이 존재합니다"));
    } else {
      fs.writeFileSync(pathToFile, htmlTemplate);
      console.log(chalk.green(pathToFile, "생성 완료"));
    }
  } else if (type === "express-router") {
    const pathToFile = path.join(directory, `${name}.js`);
    if (exist(pathToFile)) {
      console.error(chalk.bold.red("이미 해당 파일이 존재합니다"));
    } else {
      fs.writeFileSync(pathToFile, routerTemplate);
      console.log(chalk.green(pathToFile, "생성 완료"));
    }
  } else {
    console.error(
      chalk.bold.red("html 또는 express-router 둘 중 하나를 입력하세요."),
    );
  }
};

program.version("0.0.1", "-v, --version").name("cli");

program
  .command("template <type>")
  .usage("<type> --filename [filename] --path [path]")
  .description("템플릿을 생성합니다.")
  .alias("tmpl")
  .option("-f, --filename [filename]", "파일명을 입력하세요.", "index")
  .option("-d, --directory [path]", "생성 경로를 입력하세요", ".")
  .action((type, options) => {
    makeTemplate(type, options.filename, options.directory);
  });

program.command("*", { noHelp: true }).action(() => {
  console.log("해당 명령어를 찾을 수 있습니다.");
  program.help();
});

program.parse(process.argv);

 

콘솔에서 실행해보면 됩니다. template.js 프로그램과 명령어만 다를 뿐 동일하게 동작합니다.

$ cli template html -d public/html -f new
$ cli copy
$ cli

 

옵션들은 순서를 바꿔서 입력해도 됩니다. -d public/html -f new나 -f new -d public/html이나 똑같급니다. cli copy처럼 미리 등록하지 않은 명령어를 사용하면 * 와일드카드 명령어가 실행됩니다.

commander에서 알아두어야 할 것은 기번 명렁어(cli)는 * 명령어에 해당하지 않는다는 것입니다. 이 명령어에 동작을 추가하려면 * 명령어를 없애고 일반 action에서 매개변수에 따라 분기 처리해야 합니다.

commander를 사용하더라도 여전히 명령어를 외워야 합니다. 설명서도 제공하고 옵션 순서도 바꿀 수 있지만, 불편한 것이 사실입니다. 따라서 inquirer로 cli 명령어를 사용할 때 사용자와 상호작용할 수 있도록 만들어봅시다.

#!/usr/bin/env node
const { program } = require("commander");
const fs = require("fs");
const path = require("path");
const inquirer = require("inquirer");
const chalk = require("chalk");

const htmlTemplate = `
<!DOCTYPE html>
  <html>
  <head>
    <meta chart="utf-8" />
    <title>Template</title>
  </head>
  <body>
    <h1>Hello</h1>
    <p>CLI</p>
  </body>
</html>
`;

const routerTemplate = `
const express = require('express');
const router = express.Router();
 
router.get('/', (req, res, next) => {
   try {
     res.send('ok');
   } catch (error) {
     console.error(error);
     next(error);
   }
});
 
module.exports = router;
`;

const exist = (dir) => {
  try {
    fs.accessSync(
      dir,
      fs.constants.F_OK | fs.constants.R_OK | fs.constants.W_OK,
    );
    return true;
  } catch (e) {
    return false;
  }
};

const mkdirp = (dir) => {
  const dirname = path
    .relative(".", path.normalize(dir))
    .split(path.sep)
    .filter((p) => !!p);
  dirname.forEach((d, idx) => {
    const pathBuilder = dirname.slice(0, idx + 1).join(path.sep);
    if (!exist(pathBuilder)) {
      fs.mkdirSync(pathBuilder);
    }
  });
};

const makeTemplate = (type, name, directory) => {
  mkdirp(directory);
  if (type === "html") {
    const pathToFile = path.join(directory, `${name}.html`);
    if (exist(pathToFile)) {
      console.error(chalk.bold.red("이미 해당 파일이 존재합니다"));
    } else {
      fs.writeFileSync(pathToFile, htmlTemplate);
      console.log(chalk.green(pathToFile, "생성 완료"));
    }
  } else if (type === "express-router") {
    const pathToFile = path.join(directory, `${name}.js`);
    if (exist(pathToFile)) {
      console.error(chalk.bold.red("이미 해당 파일이 존재합니다"));
    } else {
      fs.writeFileSync(pathToFile, routerTemplate);
      console.log(chalk.green(pathToFile, "생성 완료"));
    }
  } else {
    console.error(
      chalk.bold.red("html 또는 express-router 둘 중 하나를 입력하세요."),
    );
  }
};

program.version("0.0.1", "-v, --version").name("cli");

program
  .command("template <type>")
  .usage("<type> --filename [filename] --path [path]")
  .description("템플릿을 생성합니다.")
  .alias("tmpl")
  .option("-f, --filename [filename]", "파일명을 입력하세요.", "index")
  .option("-d, --directory [path]", "생성 경로를 입력하세요", ".")
  .action((type, options) => {
    makeTemplate(type, options.filename, options.directory);
  });

program
  .action((cmd, args) => {
    if (args) {
      console.log(chalk.bold.red("해당 명령어를 찾을 수 없습니다."));
      program.help();
    } else {
      inquirer
        .prompt([
          {
            type: "list",
            name: "type",
            message: "템플릿 종류를 선택하세요.",
            choices: ["html", "express-router"],
          },
          {
            type: "input",
            name: "name",
            message: "파일의 이름을 입력하세요.",
            default: "index",
          },
          {
            type: "input",
            name: "directory",
            message: "파일이 위치할 폴더의 경로를 입력하세요.",
            default: ".",
          },
          {
            type: "confirm",
            name: "confirm",
            message: "생성하시겠습니까?",
          },
        ])
        .then((answers) => {
          if (answers.confirm) {
            makeTemplate(answers.type, answers.name, answers.directory);
            console.log(chalk.rgb(128, 128, 128)("터미널을 종료합니다."));
          }
        });
    }
  })
  .parse(process.argv);

 

readline 모듈을 사용할 때는 엄청 복잡했던 코드가 간결해졌습니다(inquirer 패키지도 내부적으 로 readline 모듈을 사용하기는 합니다).* command를 없애고 progran 객체에 바로 action 메서 드를 붙입니다. 매개변수로 Cmd와 args가 들어오는데, 첫 번째 매개변수인 cmd에는 명령어에 대한 전체적인 내용이 들어 있고 두 번째 매개변수인 args에는 cli 명령어 다음에 오는 인수가 들어 있 습니다. 만약 명령어가 cLi Copy면 'copy']가 들어 있고, 명령어가 cl1면 undef ined가 들어 있습 니다. 따라서 args 값의 유무로 cLi를 입력했는지 입력하지 않았는지를 구별할 수 있습니다.
이제 cli를 입력하면 사용자와의 상호작용을 시작합니다. inquirer 패키지로부터 불러온 inquirer 객체는 prompt라는 메서드를 가지고 있습니다. 이 메서드는 인수로 질문 목록을 받고, 프로미스를 통해 답변(answers 객체)을 반환합니다. 질문 객체의 속성을 알아봅시다.

  • type: 질문의 종류입니다. input, checkbox, list, password, Conticm 능이 있습니다. 이 예 제에서는 input(평범한 답변), list(다중 택일), Confirm(Yes 또는 No)과 같은 종류의 질문 을 사용합니다.
  • name : 질문의 이름입니다. 나중에 답변 객체가 속성명으로 질문의 이름을, 속성값으로 질문 의 답을 가지게 됩니다.
  • message : 사용자에게 표시되는 문자열입니다. 여기에 실제 질문을 적으면 됩니다.
  • choices: type이 checkbox, 1ist 등인 경우 선택지를 넣는 곳입니다. 배열로 넣으면 됩니다.
  • default : 답을 적지 않았을 경우 적용되는 기본값입니다.

예제에서는 질문 네 개를 연달아 합니다. 질문 객체 네 개를 배열로 묶어 prompt 메서드의 인수로 제공했습니다. prompt 메서드는 프로미스를 반환하므로 then 메서드를 붙여 답변을 매개변수를 통해 받을 수 있습니다.


콘솔에 명령어를 입력해보면 훨씬 더 풍부한 상호작용을 하는 것을 볼 수 있습니다.

 

$ npx cli

 

list 타입의 질문은 키보드 화살표를 통해 답변을 고를 수 있습니다. 답변 선택기는 choices 속성에 넣어준 것들입니다. 계속해서 나머지 질문을 진행하면 다음과 같은 결과가 나옵니다.

? 템플릿 종류는 선택하세요. html
? 파일의 이름을 입력하세요. new
? 파일이 위치할 폴더의 경로를 입력하세요. public/html
? 생성하시겠습니까? y
이미 해당 파일이 존재합니다.
터미널을 종료합니다.

 

여기에 입력했던 답변들은 answers 객체에 저장되어 프로미스를 통해 반환됩니다. 질문 객체에 넣어줬던 name 속성과 질문의 답변이 각각 키와 값이 됩니다. 예를 들면 첫 번째 질문의 name이 type이므로 answers.type == 'html'이 되는 것입니다.