티스토리 뷰

책/nodejs 교과서

14. CLI 프로그램 만들기

안양사람 2021. 3. 8. 00:50
728x90
SMALL

명령줄 인터페이스(CLI : Command Line Interface)는 콘솔 창을 통해 프로그램을 수행하는 환경을 뜻한다.

이와 반대되는 개념으로는 그래픽 사용자 인터페이스(GUI : Graphic User Interface)가 있다.

간단한 콘솔 명령어 만들기

index.js

#!/usr/bin/env node

 

주석이라 의미 없는 문장이라고 생각할 수도 있지만, 리눅스, 맥 같은 유닉스 기반 운영체제에서는 실행하라는 뜻이다.

package.json

{
  "name": "node-cli",
  "version": "0.0.1",
  "description": "nodejs cli program",
  "main": "index.js",
  "author": "min",
  "license": "ISC",
  "bin": {
    "cli": "./index.js"
  }
}

 

bin 속성이 콘솔 명령어와 해당 명령어를 호출할 때 실행 파일을 설정하는 객체이다. 콘솔 명령어는 cli로, 실행 파일은 방금 생성한 index.js로 지정.

npm i -g(맥이나 리눅스는 sudo 붙여야 할수도??)

 

코드가 업데이트될 때마다 다시 설치할 필요가 없다. bin 속성에 cli 명령어와 indexjs를 연결해두었으므로 cli 명령어가 호출될 때마다 index.js 파일이 실행

 

process.argv로 입력값 배열 만들기

index.js

#!/usr/bin/env node
console.log('hello', process.argv);

$ cli one two three
hello [
  'C:\\Program Files\\nodejs\\node.exe',
  'C:\\Users\\User\\AppData\\Roaming\\npm\\node_modules\\node-cli\\index.js',
  'one',
  'two',
  'three'
]

 

index.js

#!/usr/bin/env node
const readline = require('readline');

const rl = readline.createInterface({
  input: process.stdin,
  output: process.stdout,
});

console.clear();
const answerCallback = (answer) => {
  if (answer === 'y') {
    console.log('감사합니다!');
    rl.close();
  } else if (answer === 'n') {
    console.log('죄송합니다!');
    rl.close();
  } else {
    console.clear();
    console.log('y 또는 n만 입력하세요.');
    rl.question('예제가 재미있습니까? (y/n) ', answerCallback);
  }
};

rl.question('예제가 재미있습니까? (y/n) ', answerCallback);

 

좀 더 실용적인 예제

CLI 프로그램 명령어를 입력하면 기본적인 html 또는 익스프레스 라우터 파일 템플릿을 만들어주는 코드

cli html main ./public/html 같은 명령어를 입력하면 public 폴더 안에 있는 html 폴더에 main.html이 생기는 식이다

이 방식의 단점은 사용자가 명령어와 명령어 옵션 순서를 모두 외우고 있어야 한다는 것이다. 더 쉽게 단계별로 질문하게 하자.

template.js

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const readline = require('readline');

let rl;
let type = process.argv[2];
let name = process.argv[3];
let directory = process.argv[4] || '.';

const htmlTemplate = `
<!DOCTYPE html>
<html>
  <head>
    <meta charset="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 = () => { // 템플릿 생성 함수
  mkdirp(directory);
  if (type === 'html') {
    const pathToFile = path.join(directory, `${name}.html`);
    if (exist(pathToFile)) {
      console.error('이미 해당 파일이 존재합니다');
    } else {
      fs.writeFileSync(pathToFile, htmlTemplate);
      console.log(pathToFile, '생성 완료');
    }
  } else if (type === 'express-router') {
    const pathToFile = path.join(directory, `${name}.js`);
    if (exist(pathToFile)) {
      console.error('이미 해당 파일이 존재합니다');
    } else {
      fs.writeFileSync(pathToFile, routerTemplate);
      console.log(pathToFile, '생성 완료');
    }
  } else {
    console.error('html 또는 express-router 둘 중 하나를 입력하세요.');
  }
};

const dirAnswer = (answer) => { // 경로 설정
  directory = (answer && answer.trim()) || '.';
  rl.close();
  makeTemplate();
};

const nameAnswer = (answer) => { // 파일명 설정
  if (!answer || !answer.trim()) {
    console.clear();
    console.log('name을 반드시 입력하셔야 합니다.');
    return rl.question('파일명을 설정하세요. ', nameAnswer);
  }
  name = answer;
  return rl.question('저장할 경로를 설정하세요.(설정하지 않으면 현재경로) ', dirAnswer);
};

const typeAnswer = (answer) => { // 템플릿 종류 설정
  if (answer !== 'html' && answer !== 'express-router') {
    console.clear();
    console.log('html 또는 express-router만 지원합니다.');
    return rl.question('어떤 템플릿이 필요하십니까? ', typeAnswer);
  }
  type = answer;
  return rl.question('파일명을 설정하세요. ', nameAnswer);
};

const program = () => {
  if (!type || !name) {
    rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout,
    });
    console.clear();
    rl.question('어떤 템플릿이 필요하십니까? ', typeAnswer);
  } else {
    makeTemplate();
  }
};

program(); // 프로그램 실행부

 

CLI 프로그램 삭제 방법

npm rm -g node-cli

commander, inquirer 사용하기

위 방식대로 충분히 CLI 프로그램을 만들 수 있지만 ,상당히 손이 많이 간다. npm에는 CLI 프로그램을 위한 라이브러리가 많이 준비되어 있다. 대표적인 것으로 yargs, commander, meow가 있다.

이 책에서는 commander 사용. CLI 프로그램과 사용자 간의 사옿자굥을 돕는 inquirer 패키지, 콘솔 텍스트에 스타일을 추가하는 chalk 패키지도 함께 사용

 

npm i commander inquirer chalk

 

command.js

#!/usr/bin/env node
const { program } = require('commander');

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) => {
    console.log(type, options.filename, options.directory);
  });

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

program.parse(process.argv);

version: 프로그램의 버전을 설정. 첫번째 인수로 버전, 두번째 인수로 버전을 보여줄 옵션. 여러 개인 경우 쉼표

usage: 명령어의 사용법을 설정. 사용법은 명령어에 도움 옵션(-h 또는 --help)을 붙였을 때 나타나는 설명서에 표시. [options]라고 되어 있는데, []는 필수가 아닌 선택이라는 뜻.

name: 명령어의 이름을 넣는다.

command: 명령어를 설정하는 메서드. cli template html가능.(<>는 필수) *은 와일드카드 명령어로, 나머지 모든 명령어

description: 명령어에 대한 설명

alias: 명령어의 별칭을 설정할 수 있다. tmpl이므로 cli template html 대신 cli tmpl html

option: 명령어에 대한 부가적인 옵션 설정. 첫번째 인수가 옵션 명령어, 두번째 인수가 옵션에 대한 설명. 마지막 인수는 옵션 기본값. 옵션을 입력하지 않았을 경우 자동으로 기본값이 적용. 옵션 이름으로 name은 위의 name 메서드와 충돌할 위험이 있으니 사용하지 말자

requireOption: option과 같은 역할을 하지만 필수로 입력해야 하는 옵션을 지정할 때 사용

action: 명령어에 대한 실제 동작을 정의하는 메서드. <type> 같은 필수 요소나 옵션들을 매개변수로 가져올 수 있다.

help: 설명서를 보여주는 옵션. -h나 --help 옵션으로 설명서를 볼 수도 있지만, 이 메서드를 사용해 프로그래밍적으로 표시할 수도 있다.

parse: program 객체의 마지막에 붙이는 메서드. process.argv를 인수로 받아서 명령어와 옵션을 파싱한다.

 

command.js

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

const htmlTemplate = `
<!DOCTYPE html>
<html>
  <head>
    <meta charset="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('이미 해당 파일이 존재합니다');
    } else {
      fs.writeFileSync(pathToFile, htmlTemplate);
      console.log(pathToFile, '생성 완료');
    }
  } else if (type === 'express-router') {
    const pathToFile = path.join(directory, `${name}.js`);
    if (exist(pathToFile)) {
      console.error('이미 해당 파일이 존재합니다');
    } else {
      fs.writeFileSync(pathToFile, routerTemplate);
      console.log(pathToFile, '생성 완료');
    }
  } else {
    console.error('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);

 

이거방 순서 바꿔도 돼

User@DESKTOP-VOH9EIA MINGW64 ~/Desktop/nodebook/node-cli
$ cli template html -d public/html -f new
public\html\new.html 생성 완료

User@DESKTOP-VOH9EIA MINGW64 ~/Desktop/nodebook/node-cli
$ cli template html -f hi -d public/html
public\html\hi.html 생성 완료

User@DESKTOP-VOH9EIA MINGW64 ~/Desktop/nodebook/node-cli
$ cli
Usage: cli [options] [command]

Options:
  -v, --version                   output the version number
  -h, --help                      display help for command

Commands:
  template|tmpl [options] <type>  템플릿을 생성합니다.
  help [command]                  display help for command

User@DESKTOP-VOH9EIA MINGW64 ~/Desktop/nodebook/node-cli
$ cli copy
해당 명령어를 찾을 수 없습니다.
Usage: cli [options] [command]

Options:
  -v, --version                   output the version number
  -h, --help                      display help for command

Commands:
  template|tmpl [options] <type>  템플릿을 생성합니다.
  help [command]                  display help for command

 

이렇게 하면 여전히 명령어를 외워햐 돼. 설명서도 제공하고 옵션 순서도 바꿀 수 있지만, 불편해..

따라서 inquirer로 cli 명령어를 사용할 때 사용자와 상호작용할 수 있게 만들자

 

command.js

const inquirer = require('inquirer');

program.action((cmd, args) => {
  if (args) {
    console.log('해당 명령어를 찾을 수 없습니다.');
    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('터미널을 종료합니다.');
        }
      });
  }
});

 

readline 모듈을 사용할 때보다 간단해졌어. 매개변수로 cmd와 args가 들어오는데, 첫번째 매개변수인 cmd에는 명령어에 대한 전체적인 내용이 들어있고 두번째 매개변수인 args에는 cli 명령어 다음에 오는 인수가 들어 있다. 

만약 명령어가 cli copy면 ['copy']가 들어 있고, 명령어가 cli면 undefined가 들어 있다. 따라서 args 값의 유무로 cli를 입력했는지 입력하지 않았는지를 구별할 수 있다.

inquirer 객체는 prompt라는 메서드를 가지고 있다. 이 메서드는 인수로 질문 목록을 받고, 프로미스를 통해 답변(answers 객체)을 반환한다.

type: 질문의 종류. input, checkbox, list, password, confirm 등이 있다

name : 질문의 이름. 나중에 답변 객체가 속성명으로 질문의 이름을, 속성값으로 질문의 답을 가지게 된다.

message: 사용자에게 표시되는 문자열. 여기에 실제 질문을 적자

choices: type이 checkbox, list 등인 경우 선택지를 넣는 곳. 배열로

default: 답을 적지 않았을 경우 기본값

 

버전에 따라 달라 "commander": "^5.0.0",

 

마지막으로 chalk 패키지 사용. 터미널에 색과 스타일을 추가

#!/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 charset="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);

 

사용법 간단. chalk 객체의 메서드들로 문자열을 감싸면 된다. 직접 색을 지정하고 싶다면 rgb 메서드나 hex 메서드를 사용 ex) chalk.rgb(12,34,56)(텍스트) 또는 chalk.hex('#123456')(텍스트)

 

스스로 해보기

파일을 복사하는 명령어 만들어보기

경로를 지정하면 하위의 모든 폴더와 파일을 지우는 명령어 만들어보기(rimraf 패키지 참조)

데이터베이스와 연동하여 가계부 만들어보기(시퀄라이즈를 세팅하고 나서 실행 파일 첫 부분에 sequelize.sync로 데이터베이스를 연동한 후 명령어를 수행하면 된다.)

728x90
LIST
댓글
공지사항