나만의 CLI 프로그램 만들어 npm 배포하기 (feat. npm 환경에 기여하기)

2020. 11. 4. 10:49web tech

javascript, Node.js를 이용해서 CLI(Command Line Interface) 프로그램을 개발해보려고 합니다.

git 이나 npm 모두 git clone , git add 같이 커맨드 창에서 명령어로 실행하는 것이 모두 CLI 프로그램 입니다.

 

npm 모듈을 전역적으로 설치할 수 있고, 터미널 명령을 이요해 시스템 어느 곳에서나 설치된 모듈을 참조할 수 있습니다.

npm에서 어떤 모듈을 설치하면(window에서 설치한다고 가정) \user\AppData/Roaming/npm 에 전역 모듈을 설치하고 package.json은 해당 모듈을 처리하고 bin 필드를 찾습니다. 

 

* AppData는 윈도우OS에서 프로그램의 데이터 및 설정 내용을 저장하기 위해 존재하는 디렉토리

 

* bin 필드는 사용자가 해당 명령을 실행할 때 실행해야하는 모듈의 파일인 객체.

  bin 필드가 안 비어 있으면 npm은 사용자가 해당 명령을 사용하도록 npm 폴더 내에 필요 파일을 만듭니다!

 

 

디렉토리 구조

cliproject
|-- bin \ cliproject
|-- lib \
|-- dist \
|-- package.json

bin 폴더에는 확장자가 없이 보통 실행할 커맨드 이름과 프로젝트의 이름과 동일하게 만든다(일반적으로).

.cmd 확장자 파일도 이전 파일과 함께 생성되어 node.js에서만 .js 파일이 실행되도록 한다. 

 

 


두 json파일의 key값을 비교 판별해주는 cli 프로그램을 만들어보겠습니다.

 

npm init 후 위의 디렉토리 구조로 폴더 생성!

/* json-key-discriminator 이란 cli 프로그램을 만들예정입니다! */
mkdir json-key-discriminator
cd json-key-discriminator

/* npm 초기화 */
npm init

mkdir bin
mkdir .gitignore
mkdir .npmignore

 생성된 프로젝트로 들어가 npm 초기화 한 후에 위에서 설명한 bin 폴더와 git에서 무시하는 .gitignore과 npm에서 무시하는 .npmignore을 생성합니다.

 

bin 폴더에는 모든 실행 파일이 들어가 있어야 합니다. (CLI가 구동되는 코드.)

json-key-discriminator이란 이름으로 만들어야 하지만 커맨드라인에서 사용하기에 길어서 앞 글자를 따서 jkd 로 생성하겠습니다.

$ jkd --help
$ jkd -f 어쩌구 -s 저쩌구

위와 같이 약어로 커맨드 명령어 이름으로 지정되도록 하겠습니다.

 

** 주의 **

실제 프로그램을 구동하는 명령어는 

$ node ./dist/cli.js 

입니다.

 

jkd 라는 명령어로 실행시키기 위해서는 모듈을 전역적으로 설치해야 합니다.

npm install -g PACKAGE_NAME (저는 json-key-discriminator) 

이나 

npm install -g ./

해당 명령어로 npm에 전역 설치를 해줘야 npm registry에서 모듈을 사용할 수 있게 게시됩니다!

이제 전역 설치후  터미널에서 jkd 라고 명령을 자유롭게 실행할 수 있습니다.

 

 


CLI 개발 시 많이 사용하는 라이브러리

 

1. commander

 process argv에 직접 커맨드 명령어를 담아서 실행시킬 수 있지만 commander이라는 cli 생성 라이브러리를 만들면 편리하게 개발 할 수 있습니다.

 

2. chalk

  커맨드에 색칠을 해주는 라이브러리. console로 찍으면 하이라이트 할 수 있습니다.

 

3. jest

 굳이 jest를 쓰지 않아도 되지만, 개발 시 테스트를 많이 해보는 게 좋을 텐데, jest를 이용해서 단위테스트를 진행했습니다.

npm install commander
npm install chalk
npm install jest

 

* node.jsprocess 모듈

정말 간단한 라이브러리지만 node.js의 모듈을 몇 가지 사용해야 했습니다. 그 중 커맨드라인에 사용자의 입력을 시스템이 받아야하는데 node.js의 모듈인 process를 사용합니다.

process 모듈은 실행 중인 프로세스에 대한 정보를 포함하는 변수를 제공합니다.

process 모듈에 argv(argument vetor)라는 속성에서 실행 매개변수를 배열에 받습니다.

 

// node 실행
$ node

// node의 process 확인
> process

// node의 process의 실행 매개변수 확인
> process.argv
> 

아래와 같이 CLI 프로그램에서 해당 flag(-f)와 flag의 값(firstValue)을 사용자가 입력하면 아래 그림 처럼 들어감

$ jdk -f firstValue

CLI프로그램에서 찍으면 실제 이렇게 process객체의 argv에 배열 요소로 하나씩 들어감

첫 번째 값은 default로 들어가는데 node 인터프리터의 경로이고, 그 다음부터는 CLI 명령어 이후 추가 된 공백으로 구분된 텍스트들이 요소로 들어갑니다. ( jdk , -f , firstValue ,  추가적인 옵션이랑 값들이 더 올 수 있습니다! )

 

 

 


 

json-key-discriminator 만들기 ( 두 JSON 파일의 key값을 구별해주는 CLI프로그램)

 

우선, 저는 require로 모듈을 가져오는 게 좀 보기 싫어서 babel로 트랜스파일해서 사용하도록 했습니다! 

 

npm install -D @babel/cli @babel/core @babel/preset-env

프로젝트 root에 .babelrc를 만들어 presets 설정을 해주었습니다

 

이제 바벨을 이용해 import 구문을 사용할 수 있습니다. 기본적으로 빌드하면 생성되는 /dist의 실행 파일에서 프로그램을 구동시켜줍니다!! ( babel에 대한 주제는 아니므로 자세한 설명 없이 넘어가겠습니다 )

 

 

프로젝트 디렉토리 & package.json


 

json-key-discriminator의 전체 디렉토리 구조

전체 디렉토리 구조는 다음과 같고 꽤나 심플한 코드 입니다.

 

exam

|-- exam.json

|-- lang-en.json

|-- lang-kr.json

|-- test.json

exam은 제가 임의로 지정한 폴더. 테스트랑 json파일들을 넣은 폴더들.. 편하신 대로 네이밍하시면 됩니다

 

test

|-- unit

|---- cli.test.js

 실제 두 json파일의 키 값을 비교하는 로직을 테스트하는 unit test코드 입니다. 보통은 기능 단위로 쪼개서 파일을 나눠주는 게 좋은데 cli라는 파일로 한 번에 넣은 게 좀 아쉬운 점이라고 생각합니다. 

extract.js -> extract.test.js

validation.js -> validation.test.js 

 이런 식으로 명확하게 쪼개서 기능 단위로 파일을 쪼개는 게 더 좋았을 것 같네요! 간단한 기능이라 나누지 않았는데 묵직한 기능들이 많아지면 쪼개는 게 좋지 않을까 합니다!!

 

dist

babel로 빌드하면 현재 src의 있는 파일들( index.js , cli.js )가 빌드되어 그대로 생성됩니다! 

require보다 import 구문이 편리해서 사용한 것 밖에 없는데...최신 JS문법을 많이 사용하실 거면 babel을 더 공부하셔서 

 

 

package.json

 

bin : package.json의 bin속성 주면, 실행할 수 있는 패키지를 만들 수 있다.

그러면, 패키지 설치 시 npm은 bin 항목에 기술된 파일의 링크를 가져오게 된다.

 

license : 라이센스를 지정합니다. 패키지 사용을 허용하는 방법과 제한 사항을 알 수 있게 합니다.

 

repository :  실제 프로젝트 코드의 주소를 지정합니다. 보통? git 주소로 나타내고, npm 패키지에 해당 주소로 

나오게 됩니다.

 

더 자세하고 다양한 속성들이 있는데 , HEROPYT Tech 에서 더 자세한 속성을 넣어서 풍부하게 설정할 수 있으니 참고하면 좋겠습니다! ( front end 참고할 좋은 글들이 많이 있습니다! )

 

 

프로젝트 구동 코드 


디렉토리에서 실제 구동하는 코드는 /dist/cli.js 코드 입니다.

import 구문이라던가 사용해서 실제 동작하기 위해서는 build한 파일에서 실행시켜야 합니다.

 

시나리오

npm run build 

--> 빌드하면 /dist/cli.js , /dist/index.js라 빌드 되고

--> /dist/cli.js 를 다음과 같이 실행 :   $ node ./dist/cli.js -f lang-en -s lang-kr -d exam 

 

 

실행 로직

src/index.js

// extract key values
import fs from 'fs';
import path from 'path';
import chalk from 'chalk';

let bothContainArray = [];
let onlyInMain = [];
let onlyInCompare = [];

function extractKeyValues(jsonFile) {
    let keyArray = [];
    for (let key in jsonFile) {
        keyArray.push(key);
    }
    return keyArray;
}

function checkHasFileInFolder(dirJsonFiles, fileName) {
    let addFormatFileName = fileName.concat('.json');
    let check = false;
    for (const el of dirJsonFiles) {
        if (addFormatFileName === el) {
            check = true;
        }
    }
    return check;
}

//  로컬에서 파일 패스까지를 리턴함.
function getDirPath(folder) {
    const root = process.cwd();
    return path.join(root, folder);
}

// 사용자들의 json파일들 array return
function getJsonFiles(dirPath) {
    // json이 있는 dirPath 가져오기. 현재  project는 exam에 json파일들 있음
    const files = fs.readdirSync(dirPath);
    return files;
}

function readFile(dirPath, readFile) {
    const readFilePath = dirPath.concat('\\' + readFile + '.json');
    return JSON.parse(fs.readFileSync(readFilePath).toString('utf-8'));
}

function compareKeyValuesArray(mainArray, compareArray) {
    mainArray.forEach(el => {
        compareArray.includes(el) ? bothContainArray.push(el) : onlyInMain.push(el);
    })
    compareArray.forEach(el => {
        if (!bothContainArray.includes(el)) { onlyInCompare.push(el) };
    })
}

// chalk로 highlight
function printDifference(dirName, mainFileName, compareFileName, mainArray, compareArray) {
    console.log('--- compare two json key ---');
    mainArray.forEach(el => {
        console.log(`it's a key value only in ${dirName}/`.concat(chalk.blue(`${mainFileName}.json`)), chalk.red(el));
    });

    compareArray.forEach(el => {
        console.log(`it's a key value only in ${dirName}/`.concat(chalk.green(`${compareFileName}.json`)), chalk.red(el));
    })
}

function checkHasFolderInRoot(inputFileName) {
    const root = process.cwd();
    const rootFiles = getJsonFiles(root);

    let isRightFileName = false;
    rootFiles.forEach(file => {
        if (file === inputFileName) {
            isRightFileName = true
        }
    })
    return isRightFileName;
}

export function start(dirName, mainFileName, compareFileName) {
    let hasFolderInRoot = checkHasFolderInRoot(dirName);
    if (!hasFolderInRoot) {
        console.error(chalk.red(`${dirName} is not in root... Please enter folder name in root!`))
    } else {
        let dirPath = getDirPath(dirName);
        let dirJsonFiles = getJsonFiles(dirPath);
        let hasMainFileInFolder = checkHasFileInFolder(dirJsonFiles, mainFileName);
        let hasCompareFileInFolder = checkHasFileInFolder(dirJsonFiles, compareFileName);
        let mainArray;
        let compareArray;

        if (!hasMainFileInFolder) {
            console.error(chalk.red(`${dirName}/${mainFileName}.json is not in the folder. Please enter json file name correctly.`))
        } else {
            mainArray = extractKeyValues(readFile(dirPath, mainFileName));
        }
        if (!hasCompareFileInFolder) {
            console.error(chalk.red(`${dirName}/${compareFileName}.json is not in the folder. Please enter json file name correctly.`))
        } else {
            compareArray = extractKeyValues(readFile(dirPath, compareFileName));
        }

        if (hasMainFileInFolder && hasCompareFileInFolder) {
            compareKeyValuesArray(mainArray, compareArray);
            printDifference(dirName, mainFileName, compareFileName, onlyInMain, onlyInCompare);
        }
    }
}

 

 

+ 약간의 코드 설명

(클린 코드로 혼쭐을 내주고 싶으시다면 피드백 주시면 감사히 받겠습니다! 그래도 긴 코드가 아니라 충분히 따라가며 보실 수 있을 거라 생각합니다:) )

 

- process.cwd() 는 현재 디렉토리의 경로를 불러옵니다. 

function getDirPath(folderName) {
    const root = process.cwd();
    return path.join(root, folderName);
}

 

 

- fs.readFileSync에 file경로를 넣어주면 ' ..//..//exam//lang-en.json ' 을 버퍼로 받아와서 string으로 변환 후(utf-8로 인코딩 타입으로 변화) 다시 JSON으로 파싱해서 리턴합니다!  

function readFile(dirPath, readFile) {
    const readFilePath = dirPath.concat('\\' + readFile + '.json');
    return JSON.parse(fs.readFileSync(readFilePath).toString('utf-8'));
}

 

 

조금 민망할 수도 있지만, 실제 파일을 읽어오고, 두 json파일의 키 값을 비교하는 로직은 이게 전부입니다.

node.js 의   fs  모듈과  path  모듈을 불러와 파일을 읽어오고 붙히고 여타 예외 처리를 했습니다. 

 

 

- start()는 프로그램을 트리거(trigger)하는 코드로 모듈로 export 해줘야 다른 파일에서 import 해서 사용할 수 있습니다.

 

start( 검사할 json들이 있는 폴더 이름(root에 있어야 함 ) ,  검사할 첫 번째 json 파일 이름 검사할 두 번째 json 파일 이름 )

 

start 함수에서 위에 정의한 기능들을 수행합니다. start의 인자 값은 CLI 프로그램에서 받을 값들입니다.

(혼동이 있을 수 있는데..  네이밍을 mainFileName은 first ,  compareFileName은 second 로 CLI에서 flag를 줬습니다..헷갈리셨다면 죄송합니다.. )

 

$ jkd -d exam -f lang-en -s lang-kr

이렇게 CLI를 실행하면 start( exam , lang-en , lang-kr ) 이렇게 들어가서 로직을 수행합니다!

chalk 되어 파랑색 빨강색 초록색으로 보여집니다.

 

 

다음으로 commander.js를 이용해서 CLI 프로그램을 정의하겠습니다.

 

src/cli.js

"use strict"
import program from 'commander';
import { start } from "./index";

program.on('--help', () => {
    console.log('                  json-key-discriminator CLI');
    console.log('                  Usage: jkd [Option]');
    console.log('                  Option: ');
    console.log('                      -h:  Display this help message');
    console.log('                      -d:  Name of the folder in the project directory');
    console.log('                      -f:  first name of json file');
    console.log('                      -s:  second name of json file');
})

program
    .version('1.0.0')
    .description('this is a simple JSON key value discriminator.')
    .option('-d , --dir <type>', 'directory folder name (required)')
    .option('-f ,--first <type>', 'first json name - main file (required)')
    .option('-s ,--second <type>', 'second json name - compare file (required)')
    .action((options) => {
        start(options.dir, options.first, options.second)
    })

// 해당되는 command가 없을 경우 실행되는 command
program.command('*', { noHelp: true }).action(() => {
    console.log('cannot find commander.');
    program.help();
})

program.parse(process.argv);

CLI를 구성하는 코드 입니다. program 이란 이름으로 commander 라이브러리를 불러와서 사용합니다.

 

보통의 CLI 프로그램은 어떻게 사용할 지 알려주는 --help 플래그가 있습니다. 

program.on('--help', () => {
    console.log('                  json-key-discriminator CLI');
    console.log('                  Usage: jkd [Option]');
    console.log('                  Option: ');
    console.log('                      -h:  Display this help message');
    console.log('                      -d:  Name of the folder in the project directory');
    console.log('                      -f:  first name of json file');
    console.log('                      -s:  second name of json file');
})

--help 기본적으로 -h 는 제공되는데 program.on('플래그' , callback) 으로 해당 플래그(flag)를 입력 받으면

사용자들에게 사용법과 option들을 제공받습니다. 최대한 자세하게 적어 주도록 권고하는데...조금 더 사용성 좋게 수정하는 게 좋을 것 같네요.

 

program
    .version('1.0.0')
    .description('this is a simple JSON key value discriminator.')
    .option('-d , --dir <type>', 'directory folder name (required)')
    .option('-f ,--first <type>', 'first json name - main file (required)')
    .option('-s ,--second <type>', 'second json name - compare file (required)')
    .action((options) => {
        start(options.dir, options.first, options.second)
    })

 

 

 

program에서 이제 여러 option을 직접 추가 해주어야 합니다.

.version .option   현재 프로그램의 버전 정보 추가

.description => 현재 프로그램에 대한 설명을 추가할 수 있습니다.

.option => 사용자들이 CLI에서 입력을 하면 어느 flag가 어떤 값인 지 알아야 합니다. 

보통 

.option( '-f  , --flag' , '해당 flag에 대한 설명' )

.option(' -f --flag <type>' , '해당 flag에 대한 설명')

.option(' -f --flag [type]' , '해당 flag에 대한 설명')

 

- : 단축옵션

-- : 옵션

<>: 필수 값

[] : 선택 값


.action => 이제 까지는 사용자가 명령어를 이해하는 데 도움이 되는 부분만 추가해줬다면 실제 명령이 사용될 때 

실행되는 로직이 있어야 합니다. .action으로 실행될 로직을 넣어줄 수 있습니다.

실행 함수에서 options라는 인자로 flag의 옵션의 값을 받을 수 있습니다. 

 

options.dir 은 --dir <type>의 값을 가져옵니다.

jkd -d test 

위의 -d , --dir의 <type> 은 test로 가져오게 되는 것입니다.

마찬가지로 options.first / options.second 는 -f, --first의 공백 후 다음 값과 -s , --second의 공백 후 다음 값으로 받습니다.

 

저는 ./src/index.js에서 export한 start()를 import 해서 action에서 실행시켜주었습니다! 

그럼 파라미터 값들을 사용자가 플래그에 맞게 작성해주면 해당 값들을 받아서 프로그램을 구동시킵니다.👌

 

 


 

npm에 배포 전 테스트

 

npm에 배포 하지 전에 local에서 테스트를 할 수 있습니다.

$ cd ..
$ mkdir jkd-test
$ cd jkd-test
$ npm init

// jkd-test에서 만든 라이브러리 설치!
$ npm install ../json-key-discriminator

 jkd-test라는 폴더를 만들어 npm init 후 json-key-discriminator를 설치해서 테스트 해볼 수 있습니다.

 


 

npm에 배포하기

 

자 이제 드디어! npm에 배포해보겠습니다👏

npm에 배포하는 게 거창하고 무서워 보였는데.. 생각보다 엄청 빨르고 손 쉽게 된다는 걸 알았습니다. (물론 최대한 검증하고 안정성을 확보한 코드를.. 올려야 하겠지만요..🤣)

 

우선  www.npmjs.com/ npm사이트에 들어가 로그인을 계정이 없다면 생성해 줍니다!

 

다음 생성된 계정으로 커맨드창에서 로그인 후 배포해보겠습니다. 

npm login

배포하려는 앱 루트에서 npm login하면 Username / Password / Eamil 을 입력하라고 합니다. 

방금 회원가입 및 로그인 한 계정을 입력하면 로그인 완료됬음을 알리는 라인이 나옵니다.

 

그리고 

npm publish

npm publish 하면 package.json의 정보로 npm에 배포됩니다. 라이브러리 이름은! npm에서 유일해야 합니다.

npm에 json-key-discriminator가 있다면 package.json의 "name"에서 다름 이름으로 바꿔줘야 합니다!!

 


npm 재배포(버전 업 후 배포)

 

추가적으로 잘못되는 부분을 npm 배포 후 발견하거나 유지 보수할 부분이 생기게 됩니다.

그렇다면

 package.json 에서

{  "version" : "1.0.0"}

에서 version을 "1.0.1" 이런 식으로 자기가 생각한 버전에 맞춰 수정한 후 

npm login 후 npm publish 해서 배포해주면 version이 바뀌었다면 수정된 코드로 재배포 되고 version이 그대로면

수정된 코드로 배포되지 않습니다.🤷‍♂️

 

 

 

 

<참고>

medium.com/jspoint/creating-cli-executable-global-npm-module-5ef734febe32

 

Creating CLI Executable global npm module

JavaScript has quickly became the most popular language on the planet and it is growing like anything. After ES6 and ES7, JavaScript…

medium.com

medium.com/jspoint/making-cli-app-with-ease-using-commander-js-and-inquirer-js-f3bbd52977ac

 

Making CLI app with ease using commander.js and Inquirer.js

In my previous post about Making CLI Application in Node.js (I am going to call it previous blog)which you can find it at …

medium.com

heropy.blog/2019/01/31/node-js-npm-module-publish/

 

내 NPM 패키지(모듈) 배포하기

개발을 위해 npm install xxx로 설치하는 모듈이 많아지면서 자주 사용하는 나의 코드들도 같은 방법으로 제공하고 싶었죠.하지만 ‘코드 복붙’이 더 쉬우니 차일피일 ...

heropy.blog