보일러플레이트로 create-my-app 패키지 만들기

se·2023년 3월 16일
1

개발 환경을 구축하는건 공부와 노력이 꽤 필요한 일이다. 엄청난 삽질이 쌓여 이정도면 괜찮다싶은 세팅이 머릿속에 생겼지만, 자주 만져본 설정 파일들에 익숙해졌고 대부분의 구조는 yarn create vite로 만들다보니 셋업하는게 비효율적이라는 생각을 못했었다.

최근에 스터디와 테오의 스프린트, 그리고 이런저런 과제와 개인 연습용 프로젝트들을 하면서 단기간에 여러번의 비슷한 작업을 반복했다. 필요한 것들을 하나하나 챙기는 데에 시간이 꽤 걸린다는걸 느꼈고 휴먼 에러도 종종 있었다.

그래서 드디어 보일러 플레이트를 만들었다. 만든김에 터미널 커맨드로만 셋업할 수 있으면 더 좋겠다는 생각이 들었다. 매번 create-vite 사용하면서도 어떻게 굴러가는지 몰랐는데 궁금하기도 해서 만들어봤다.

create 커맨드의 정체

create <package>하면 패키지매니저가 알아서 해당 패키지에 맞는 개발 환경을 구성해줄리는 없다. --help 옵션으로 이 커맨드가 뭔지 알아봤다.

create-se-app [main●●] % npm create --help
Create a package.json file

Usage:
npm init <package-spec> (same as `npx <package-spec>)
npm init <@scope> (same as `npx <@scope>/create`)

Options:
[-y|--yes] [-f|--force] [--scope <@scope>]
[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
[-ws|--workspaces] [--no-workspaces-update] [--include-workspace-root]

aliases: create, innit

Run "npm help init" for more info

npm init이랑 같고 package.json을 만든다는 얘기다. 뭐 어떡하라고.. 싶어서 npm help init 해봤더니 더 친절하게 알려줬다.

npm init <initializer> can be used to set up a new or existing npm package.
initializer in this case is 1️⃣ an npm package named create-<initializer>, 2️⃣ which will be installed by npm help npm-exec, 3️⃣ and then have its main bin executed -- presumably creating or updating package.json and running any other initialization-related operations.

그러니까 npm init <initializer>처럼 쓰이는 경우, 로컬이나 리모트 패키지의 커맨드를 실행시키는 커맨드인 npm-exec에 의해 create-<initializer> 패키지의 main bin이 실행된다. 아래와 같은 규칙으로 변환된다고 한다.

The init command is transformed to a corresponding npm exec operation as follows:

•   npm init foo -> npm exec create-foo

•   npm init @usr/foo -> npm exec @usr/create-foo

•   npm init @usr -> npm exec @usr/create

•   npm init @usr@2.0.0 -> npm exec @usr/create@2.0.0

•   npm init @usr/foo@2.0.0 -> npm exec @usr/create-foo@2.0.0

번외로 추상화 레벨이 굉장히 높은 yarn의 설명 ...

Usage: yarn [command] [flags]

Creates new projects from any create-* starter kits.

create-vite

그러면 그 create-<initializer> 패키지는 어떻게 생겼는지 궁금해진다.

레퍼런스들을 읽어보니 이해하는건 어렵지 않았는데, 노드로 작성한 스크립트를 터미널에서 실행시키는거였다. 패키지 커맨드는 아래처럼 매핑되어 있다.

// create-vite
"bin": {
  "create-vite": "index.js",
  "cva": "index.js"
},

// create-react-app
"bin": {
  "create-react-app": "./index.js"
},

https://github.com/vitejs/vite/tree/main/packages/create-vite

https://github.com/facebook/create-react-app/tree/main/packages/create-react-app

index.ts

그래서 자주 사용하는 create-vite의 스크립트 소스를 읽으면서 흐름을 파악해봤다. 코드 양이 많아서 필요한 부분만 발췌했다.

우선 prompt 패키지를 사용해서 터미널 입력을 받는다. 이 부분이 길고 복잡해보이지만 중요한건 입력을 받는 동작이다.

  let result: prompts.Answers<
    'projectName' | 'overwrite' | 'packageName' | 'framework' | 'variant'
  >

  try {
    result = await prompts(
      [
        {
          type: argTargetDir ? null : 'text',
          name: 'projectName',
          message: reset('Project name:'),
          initial: defaultTargetDir,
          onState: (state) => {
            targetDir = formatTargetDir(state.value) || defaultTargetDir
          },
        },
        ...

원래는 아래 사진처럼 프레임워크, 언어와 컴파일러 등의 항목이 있는데 내 템플릿은 스택이 React + TypeScript 고정이라서 프로젝트와 패키지 이름만 남겼다.

개인적인 용도라면 선택지 없이 고정해두는게 편하다. 또는 npm create @usr/foo -> npm exec @usr/create-foo 규칙을 참고해서 네임스페이스와 여러개의 템플릿으로 만드는 것도 좋을 것 같다.

근데 알록달록한 터미널 예뻐보여서.. 기술 스택 늘어난다면 추가도 해보고싶다.

입력이 정상적으로 끝났다면 터미널 입력, 환경변수 등을 사용해 선택된 템플릿, 패키지매니저 정보를 프로세싱한다.

const { framework, overwrite, packageName, variant } = result;

const root = path.join(cwd, targetDir);

let template: string = variant || framework?.name || argTemplate;

const pkgInfo = pkgFromUserAgent(process.env.npm_config_user_agent);
const pkgManager = pkgInfo ? pkgInfo.name : "npm";
const isYarn1 = pkgManager === "yarn" && pkgInfo?.version.startsWith("1.");

그 정보들로 템플릿 디렉토리의 파일들을 타겟 디렉토리로 복사하고,

const templateDir = path.resolve(fileURLToPath(import.meta.url), "../..", `template-${template}`);

const files = fs.readdirSync(templateDir);
for (const file of files.filter((f) => f !== "package.json")) {
  write(file);
}

const pkg = JSON.parse(fs.readFileSync(path.join(templateDir, `package.json`), "utf-8"));

pkg.name = packageName || getProjectName();

write("package.json", JSON.stringify(pkg, null, 2) + "\n");

완료되면 실행 방법을 안내한다.

const cdProjectName = path.relative(cwd, root);
console.log(`\nDone. Now run:\n`);
if (root !== cwd) {
  console.log(`  cd ${cdProjectName.includes(" ") ? `"${cdProjectName}"` : cdProjectName}`);
}
switch (pkgManager) {
  case "yarn":
    console.log("  yarn");
    console.log("  yarn dev");
    break;
  default:
    console.log(`  ${pkgManager} install`);
    console.log(`  ${pkgManager} run dev`);
    break;
}
console.log();

https://github.com/se030/create-se-app/blob/main/src/index.js

이정도 부분만 사용해서 스크립트를 만들었다. 나는 템플릿 디렉토리도 한개이기 때문에 그쪽 파일을 복사하기만 하는 간단한 동작이다.

사실 이것저것 가져온 쪽에 더 가깝지만 ..ㅋㅋ 패키지매니저 관련 고려할 것들이 많은 듯해서 오픈소스를 쓰는게 나아보인다. 작성한 스크립트는 node <스크립트 경로>로 실행시켜 볼 수 있다.


배포하기

Rollup

배포를 위해서는 빌드가 필요하다.

번들링을 통해 외부 패키지 용량도 줄이고, 왠지 써보고 싶은 Rollup 번들러도 사용해 볼 수 있다. Webpack에 비해 가벼운 번들러다.

파일이 하나라서 설정은 복잡하지 않다.

import commonjs from "@rollup/plugin-commonjs";
import resolve from "@rollup/plugin-node-resolve";

export default {
  input: "index.js",
  output: {
    banner: "#!/usr/bin/env node",
    file: "dist/index.mjs",
    format: "esm",
  },
  plugins: [commonjs(), resolve()],
};
  • input, output을 원하는대로 넣어준다. 나는 bin 커맨드를 아래처럼 작성했다.

    "bin": {
      "create-se-app": "dist/index.mjs"
    },

    이 때 output에 대한 templateDir 상대 경로가 input 기준의 경로와 다르다면 스크립트 내용에 수정이 필요하다.

  • 실행 환경을 정의하는 라인인 #!/usr/bin/env node가 번들링 과정에서 사라진다. rollup #235를 참고해 banner로 넣어줬다.

  • @rollup/plugin-node-resolve, @rollup/plugin-commonjs CommonJS를 포함한 의존성 패키지들을 가져온다.

  • 빌드가 끝나면 node dist/index.mjs로 잘 동작하는지 테스트 해본다.


npm publish

여기까지 하고나면 배포는 정말 별거 없다.

package.json 파일에 필요한 정보들을 작성하고 npm publish한 뒤에 이런저런 인증 과정을 거치면 정상적으로 배포된다.

// package.json
{
  "name": "create-se-app",
  "description": "bin script to create a productive scaffold for a web client project",
  "version": "0.0.12",
  "type": "module",
  "bin": {
    "create-se-app": "dist/index.mjs"
  },
  "main": "src/index.js",
  "files": [
    "src",
    "template",
    "dist"
  ],

배포가 끝나면 원했던대로 명령어만으로 프로젝트 셋업이 가능하다. test 디렉토리에 템플릿이 잘 들어가는 것을 확인할 수 있다.


글로벌로 설치도 한번 해봤다. 이게 맘에 든다면 이렇게 쓸 수도 있다. 개인적으로는 손에 안익고, 템플릿이 업데이트 되었을 때 설치된 커맨드도 수동 업데이트 해야하기 때문에 좋은 방식은 아닌 것 같다.

create-se-app [main●] % npm i -g create-se-app

added 1 package, and audited 2 packages in 1s

found 0 vulnerabilities
create-se-app [main●] % create-se-app test



👷 Scaffolding project in /Users/swym/Desktop/temp/create-se-app/test...

✨ Your test is now ready! Enjoy dev with:

  cd test
  npm install
  npm run dev

create-se-app

이렇게 프로젝트 셋업용 패키지가 만들어졌다. 편하게 사용할 수 있는 구조를 커맨드만으로 만들 수 있는건 생각보다 기분 좋은 일이다!

내 template 디렉토리 구조를 간단하게 살펴보면 아래와 같다. 그동안의 프로젝트에서 이것저것 시도해보면서 찾은 나름의 취향이 반영된 best practice다.

  • vite + React + TypeScript 앱에 필요한 디렉토리 구조를 만들고 절대경로와 타입 선언 폴더 설정을 작성했다.

  • 컨벤션을 지킬 수 있게 도와주는 린트 설정 파일과 커밋되는 코드를 린팅해주는 husky hook을 설정했다.

  • github 협업에 필요한 Issue, PR 템플릿과 라벨 자동 생성 Action도 넣었다.

template
├── .github
├── .husky
├── .eslintrc
├── .prettierrc
│
├── index.html
├── package.json
├── public
├── src
│   ├── App.tsx
│   ├── main.tsx
│   ├── @types
│   ├── apis
│   ├── assets
│   ├── components
│   ├── constants
│   ├── hooks
│   ├── pages
│   ├── routes
│   ├── styles
│   └── utils
├── tsconfig.json
├── tsconfig.node.json
├── tsconfig.path.json
└── vite.config.ts

내 보일러플레이트도 배포하고 싶다면 레포를 fork / clone 해서 template 디렉토리와 package.json만 바꾸어 배포할 수도 있고, 포스트 내용을 참고해서 직접 만들어도 된다.

블로그 쓰려고 들어가봤는데 누가 이렇게 받아간거 .. 잘 쓰셨나요 ..?

0개의 댓글