Node.js 교과서 : 14. CLI 프로그램 만들기

전지연·2025년 12월 23일

GDGoC_2025

목록 보기
15/16
post-thumbnail

CLI(Command Line Interface) : 콘솔 창을 통해 프로그램을 수행하는 환경

노드는 서버가 아니고, JavaScript를 브라우저 외 환경에서 돌릴 수 있게 해주는 런타임
근데 지금까지는 서버로만 만들었음
-> 터미널 명령어 기반으로 동작하는 노드 프로그램 생성 실습

CLI 프로그램 만들기

node, npm 명령어 - 노드 설치해야만 사용 가능
nodemon, rimraf와 같은 명령어는 해당 패키지를 npm을 통해 전역 설치하여 콘솔에서 명령어로 사용 가능

package.json

{
  "name": "node-cli",
  "version": "0.0.1",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "keywords": [],
  "author": "Zerocho",
  "license": "ISC",
  "description": "nodejs cli program",
  "bin" : {
    "cli": "./index.js"
  }
}
  • bin : 콘솔 명령어 - 실행 파일 설정
    • cli 명령어와 index.js 파일 연결

index.js

#!/usr/bin/env node
console.log("Hello CLI!", process.argv);
  • 첫 줄 주석 : 이 경로에 등록된 node명령어로 이 파일 실행
    (윈도 운영체제에서는 단순 주석 처리)
  • process.argv : 명령어 옵션 목록 배열로 표시

현재 패키지 전역 설치

npm i -g
  • 맥이나 리눅스에선 sudo를 앞에 붙여서 다시 생성
  • 전역 설치 -> node_modules 폴더가 생기지 x

콘솔 결과 확인

  • 콘솔에서 npx cli로 확인한 결과

    두 명령어는 각각 node와 index.js의 경로

  • 콘솔에서 npx cli에 명령어 붙여 호출

    • 이건 node index.js 하고 뒤에 one two three four를 붙인 결과와 같음

사용자로부터의 입력 - index.js 수정

readline : 사용자로부터의 입력 받는 모듈

#!/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);
  • createInterface 메서드로 rl객체 생성
    • 콘솔 입/출력 담당 스트림 사용(stdin, stdout)
  • rl.close() : y/n이 아닌 다른 값이 온 경우 입력값을 다시 받아야 함
    -> readline 종료 함수를 answer이 y/n인 경우의 함수 안에 포함시킨다.
  • console.clear() : 콘솔 내용 삭제

실행하면 다음과 같다.

html/router 파일 템플릿을 만들어주는 프로그램

예제
1. 명령어 입력

npx cli html main ./public/html
  1. public/html 안에 main.html 생성

templete.js

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

const type = process.argv[2];
const name = process.argv[3];
const directory = process.argv[4] || '.';
const htmlTemplate = `
<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>Templete</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 makeTemplete = () => {
    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 program = () => {
    if (!type || !name) {
        console.error('사용법: cli html|express-router 파일명 [경로]');
    } else {
        makeTemplete();
    }
};

program(); // 실행
  • process.argv의 0번째와 1번째는 각각 node와 index.js의 경로로 설정되어 있음
    -> test, name 등은 2번째부터 인수로 설정
  • htmlTemplate, routerTemplate 내용 작성
  • exist : 폴더 존재 확인 함수
    • fs.accessSync로 파일/폴더 존재하는지 확인
      • F_OK, R_OK, W_OK : 각각 보이는 파일 있는지, 읽기 권한 파일 있는지, 쓰기 권한 파일 있는지 확인
    • 존재하지 않으면 에러 발생하므로 try-catch 문 사용
  • mkdirp : 제공된 경로를 바탕으로 새로운 폴더 생성
    • 입력된 경로의 상위 폴더부터 순서대로 생성
    • fs.mkdirSync : fs모듈의 Sync가 붙은 메서드는 블로킹을 유발해 다른 요청들을 대기하게 함(CLI에서는 웹 서버가 아니므로 큰 문제x)
  • makeTemplete : type가 html인지 express-router인지 if로 구분하여 다르게 처리하여 파일 생성
    • 먼저 exist로 이미 존재하는 파일인지 체크
    • 새로운 파일이라면 writeFileSync를 이용하여 템플릿을 생성
  • program
    • type와 name 있는지 확인
    • makeTemplete() 실행

cli명령어와 templete.js 연결 - package.json 수정

{
  ...
  
  "bin" : {
    "cli": "./template.js"
  }
}

package.json을 수정했으므로 새로 전역 설치 필요
npm i -g

콘솔에 npx cli를 입력하여 실행한다.

실행 함수가 templete.js로 바뀌지 않고 index.js가 계속 나온다면

npm ls -g node-cli


이 경로로 이동하여 cli 명령어를 삭제할 수 있는 명령어를 입력한다.

npx rimraf cli climcmd climps1

이후 다시 npm i -g로 패키지를 설치해 준다.

폴더 생성

npx cli 명령어를 이용해서 디렉터리와 파일을 생성할 수 있다.

npx cli 명령어를 이용해 public/html 파일과 routes/index.js 파일을 생성할 수 있다.

단계적 질문 생성

CLI 프로그램이 사용자가 명령어를 순서대로 사용할 수 있도록 질문을 하도록 코드를 수정한다.

<질문 순서>
1. 어떤 템플릿을 생성할지 설정 (html / express-router)
2. 파일명 설정
3. 저장경로 설정

#!/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 dirAnswer = (answer) => {
    directory = answer?.trim() || '.';
    rl.close();
    makeTemplete();
};

const nameAnswer = (answer) => {
    if (!answer || !answer.trim()) {
        console.clear();
        console.log('name을 반드시 입력하셔야 합니다.');
        return rl.question('파일명(확장자 제외)을 입력하세요: ', nameAnswer);
    }
    name = answer.trim();
    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 {
        makeTemplete();
    }
};

program(); // 실행
  • type, name, directory를 입력받아야 함 -> const를 let으로 수정

추가된 함수의 순서는 실제 실행 순서의 역순이다.

  • disAnswer : 저장경로 설정
    • 입력된 answer 경로를 trim으로 양옆 공백 삭제, 입력 없으면 현재경로
    • disAnswer이 마지막 순서 -> rl.close로 readline을 종료
    • makeTemplate : 입력된 모든 정보로 template을 생성
  • nameAnswer : 파일명 설정
    • 입력값이 비어있으면 nameAnswer 다시 실행
    • 파일명 제대로 입력되면 name에 저장
    • disAnswer 실행
  • typeAnswer : 어떤 템플릿을 생성할지 설정
    • 입력값이 html이나 express-router가 모두 아니라면 typeAnswer 다시 실행
    • 템플릿 제대로 들어오면 type에 저장
    • nameAnswer 실행
  • program :
    • type이나 name을 입력하지 않았을 경우 보완
    • type이나 name 보완되고 나면 makeTemplate 실행

실행하면 결과는 다음과 같다.

CLI 프로그램 삭제 방법
npm 전역 제거 명령어 호출

npm rm -g

또는

npm rm -g node-cli

Commander, Inquirer 사용하기

commander를 사용해 위에서 만든 CLI 프로그램 재연

패키지 설치

commander 9버전, inquirer 8버전을 사용한다.

npm i commander@9 inquirer@8

commander 사용법

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 [directory]', '생성 경로를 입력하세요.', '.')
    .action((type, options, command) => {
        console.log(type, options.filename, options.directory);
    });

program
    .command('*', { noHelp: true })
    .action(() => {
        console.error('알 수 없는 명령어입니다.');
        program.help();
    });

program
    .parse(process.argv);

commander 객체 중 program 객체 호출

program 메서드

  • version : 프로그램 버전 설정
  • usage : 프로그램 사용법 설정
    • <>는 필수
  • name : 명령어 이름
  • command : 명령어 설정
    • 'template type', '*' 이렇게 2개 설정됨
      -> cli template html 이런 식으로 명령어 사용
    • '*'는 나머지 모든 명령어(와일드카드)
  • description : 명령어에 대한 설명
  • alias : 명령어의 별칭
    • template 대신 tmpl 사용 가능
  • option(옵션 명령어, 옵션에 대한 설명, 옵션 기본값) : 명령어에 대한 부가 옵션
    • template에선 파일명(filename)과 경로(directory)를 옵션으로 가짐
    • 옵션 입력하지 않은 경우 기본값 사용
    • name은 충돌 위험이 있어 사용 지양
  • requiredOption : 필수 옵션
  • action : 명령어에 대한 실제 동작을 정의하는 메서드
  • help : 설명어 보여주는 옵션
  • parse : process.argv를 인수로 받아서 명령어와 옵션을 파싱

그리고 package.json의 bin 속성에 새로 만든 command.js 파일을 연결한다.

package.json

{
  "bin" : {
    "cli": "./command.js"
  }
}

명령어로 전역 설치를 진행한다.

npm i -g

commander가 기본적으로 제공하는 -v-h를 테스트하면 다음과 같다.

  • -h : 명령어 설명서
    usage, name, description, option 메서드에 적었던 설명이 그대로 표시
  • template :
    필수 요소인 <type>을 작성하지 않았기 때문에 에러 발생

command.js 수정

template.js에 있는 코드 중 htmlTemplate, routerTemplate, exist(), mkdirp(), makeTemplate()를 가져와서 작성해 준다.

#!/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 [directory]', '생성 경로를 입력하세요.', '.')
    .action((type, options, command) => {
        makeTemplate(type, options.filename, options.directory);    // 수정
    });

program
    .command('*', { noHelp: true })
    .action(() => {
        console.error('알 수 없는 명령어입니다.');
        program.help();
    });

program
    .parse(process.argv);

테스트해보면 다음과 같다.

  • -d public/html-f new는 순서 상관x
  • copy는 등록되지 않은 명령어
    -> 와일드카드(*)명령어 실행

단계적 선택지 생성

CLI 프로그램이 단계적 질문을 하던 것처럼, inquirer 객체를 사용해 프로그램이 사용자에게 단계적 선택지를 제공하도록 만들 수 있다.

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

...

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

program
    .action((options, command) => {
        if(command.args.length !== 0) {
            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(chalk.rgb(128, 128, 128)('터미널을 종료합니다.'));   // 추가
                    }
                });
        }
    })
    .parse(process.argv);
  • 와일드카드('*') 명령어 제거
    이때 등록하지 않은 명령어(copy) 입력되는 경우
    -> copy가 command.args에 등록됨
    -> command.args의 length가 1이 됨
    -> 괄호{}안의 console.log 내용이 출력됨
  • else 안에 있는 prompt가 순서대로 실행됨
    -> then의 answer에 prompt에서 입력한 선택지가 전달
    -> answers.confirm에 true가 입력되면 makeTemplate가 실행됨

프로젝트 마무리

chalk 패키지 : 터미널에 색과 스타일 추가

패키지 설치

npm i chalk@4

command.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 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 [directory]', '생성 경로를 입력하세요.', '.')
    .action((type, options) => {
        makeTemplate(type, options.filename, options.directory);
    });

program
    .action((options, command) => {
        if(command.args.length !== 0) {
            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 패키지 사용법 : chalk 객체의 메서드들로 문자열을 감싼다.
직접 색 지정 -> rgb(128, 128, 128)메서드나 hex(#123fff) 메서드 사용

그 외

  • 배경색 지정 : bgGreen, byYellowbgRgb, bgHex 등 사용
  • 텍스트 스타일 지정 : bold, underline 등 사용
  • 글자색, 배경색, 스타일 동시 지정 : chalk.red.bgBlue, bold(텍스트) 처럼 사용

색상이 적용된 CLI 프로그램의 콘솔은 다음과 같다.

핵심 정리

0개의 댓글