본문 바로가기

프로젝트 개발일지/warrr-ui 디자인 시스템

[warrr-ui 디자인 시스템 개발기] commander를 활용한 init, add cli 실습

일단, 저번 글에서도 봤겠지만, 우리 디자인 시스템은 cli 관련 기능을 제공하기로 했다.

그 중에서 나는 init과 add cli 명령어 실습을 해봤다.

 

shadcn에는 init, add, diff 이렇게 세 가지 명령어가 있다.

간단하게 설명하자면, init 명령어에서는 사용하는 공통 의존성 등의 설치 여부를 판단해 설치해주는 기본 작업을 진행한다.

add 명령어에서는 사용자가 컴포넌트를 선택하면 해당 컴포넌트 관련 의존성을 설치하고 해당 컴포넌트 자체 파일을 사용자가 지정한 경로에 생성해준다.

diff 명령어는 사용자가 사용하고 있던 각 컴포넌트 파일과 업데이트된 파일이 있는지 확인하고 변경 사항이 있다면 이를 정리해서 알려주는 명령어이다.

 

사실, 이 실습을 처음 진행하려 했을 때는 add 명령어를 메인으로 두고 진행하려고 하였으나, add를 본격적으로 진행하려고 하니 init이 없어서는 안 되겠다는 생각이 들어서 init까지 진행하게 되었다.

하지만, 추후에는 diff 명령어도 꼭 실습으로 알아보는 시간이 있어야겠다고 느꼈는데, 컴포넌트별로 패키징을 하기 위해서는 버전 관리 등의 측면에서 없어서는 안 되는 기능 중 하나라고 느꼈다.

추후 좀 더 조사하고 알아보는 시간을 가질 예정이다.

 

init 명령어

내가 작성한 init 명령어 주요 로직은 다음과 같다.

1. panda css 설치 여부 판단 후 설치가 되어있지 않다면 설치해준다.

2. panda init 여부 판단 후 아직 되어있지 않다면 init 해준다.

 

해당 코드는 아래 링크에서 확인할 수 있다.

https://github.com/ghdtjgus76/design-system-cli/blob/main/packages/cli/src/commands/init.ts

 

아래 코드는 init 명령어를 생성하는 코드이다.

option으로는 현재 디렉토리인 cwd, 컴포넌트를 추가할 경로인 path, 그리고 action 부분에서는 명령어에서 작업할 내용을 나타낸다.

export const init = program
  .name("init")
  .description("initialize your project and install dependencies")
  .option(
    "-c, --cwd <cwd>",
    "the working directory. defaults to the current directory.",
    process.cwd()
  )
  .option(
    "-p, --path <path>",
    "the path to add the component to.",
    process.cwd()
  )
  .action(
  	// ...
  )

 

이때, 나는 zod 라이브러리를 활용해서 타입 유효성 검증을 진행했다.

아래 스키마는 init 명령어의 option으로 받는 cwd, path에 대한 타입을 검사해주는 것으로 이해하면 된다.

// command option 유효성 검증을 위한 스키마
// init command에서는 현재 디렉토리인 cwd, 컴포넌트를 넣을 경로인 path만 사용
// 두 option의 기본 값은 현재 디렉토리
const initOptionSchema = z.object({
  cwd: z.string(),
  path: z.string(),
});

 

이제 이 init 명령어에서는 어떤 작업을 하나 알아보자.

// 사용자가 입력한 옵션을 스키마를 사용해 유효성 검증 후 파싱
const options = initOptionSchema.parse(opts);
// 사용자가 입력한 현재 작업 디렉토리를 절대 경로로 변환
const cwd = path.resolve(options.cwd);

if (!existsSync(options.path) || !existsSync(cwd)) {
  console.error(`The path does not exist. Please try again.`);
  process.exit(1);
}

일단, 위에서 정의한 스키마를 이용해서 타입 유효성 검증 후 파싱하고 이 값을 options에 담아둔다.

그리고, 현재 작업 디렉토리를 절대 경로로 반환하고, path나 cwd가 존재하지 않는 디렉토리인지 판단하는 로직이 따라온다.

 

이후에는 이제 panda css 설치 여부 및 init 여부를 확인하는 로직이 나온다.

// 현재 디렉토리부터 package.json 파일이 있는 경로를 찾음
// 만약 현재 디렉토리를 찾아도 없다면 부모 디렉토리로 이동해서 탐색
// 루트 디렉토리에도 없다면 null 반환
const packageJsonPath = getNearestPackageJson(options.path);
// 현재 디렉토리부터 package.json 파일이 있는 경로를 찾음
// 만약 현재 디렉토리를 찾아도 없다면 부모 디렉토리로 이동해서 탐색
// 루트 디렉토리에도 없다면 null 반환
export const getNearestPackageJson = (cwd) => {
  let currentDir = cwd;

  while (true) {
    const packageJsonPath = path.join(currentDir, "package.json");
		
    // 현재 디렉토리에 package.json이 있는지 판단
    if (existsSync(packageJsonPath)) {
      return packageJsonPath;
    }

    // 부모 디렉토리도 탐색
    const parentDir = path.resolve(currentDir, "..");
	
    // 루트 디렉토리에 도착 시 루프에서 나옴
    if (parentDir === currentDir) {
      break;
    }

    currentDir = parentDir;
  }

  // package.json을 찾지 못한 경우
  return null;
};

 

panda css와 panda init 여부를 판단하기 위해서는 사용자가 입력한 경로에서 가장 가까운 package.json 경로를 찾아야 한다.

그래서 위 로직에서는 현재 디렉토리부터 부모 디렉토리로 올라가면서 package.json 경로를 찾아서 반환하는 함수인 getNearestPackageJson 함수를 작성했고, 이를 활용해서 경로를 찾는다.

 

이제 이 package.json 경로가 있다면 다음 로직을 진행할 수가 있다.

panda css 설치 여부를 판단하기 위해서, package.json 경로의 부모 디렉토리까지 경로 + node_modules/@pandacss/dev/package.json이라는 경로가 존재하는지 확인한다.

그래서 설치되어 있다면 이후 panda init 과정으로 넘어가고, 설치되어있지 않다면 설치 후 panda init 과정으로 넘어가게 된다.

// package.json 파일이 있는 경로를 찾았다면
if (packageJsonPath) {
  // 사용하고 있는 패키지 매니저를 찾음
  const packageManager = await getPackageManager(cwd);

  // 위에서 찾은 package.json 경로에서 디렉토리 경로만 가져와서
  // node_modules에서 @pandacss/dev.package.json 파일 경로를 
  // pandaCssPath에 저장
  const pandaCssPath = path.join(
    path.dirname(packageJsonPath),
    "node_modules",
    "@pandacss",
    "dev",
    "package.json"
  );

  // 만약 pandaCssPath가 존재한다면
  // @pandacss/dev가 설치된 것
  // 아래는 설치가 되지 않은 경우
  if (!existsSync(pandaCssPath)) {
    console.log("You need to install '@pandacss/dev' to use this command");
				
    // @pandacss/dev를 설치하는 로직
    // 설치가 끝나고 나면 panda init을 실행시킴
    const installSpinner = ora(`Installing... @pandacss/dev\n`).start();
    installDependencies(
      packageManager,
      ["@pandacss/dev"],
      options.path,
      async () => {
        installSpinner.succeed(`@pandacss/dev installed successfully.\n`);

        runInitPandacss(packageJsonPath, packageManager, cwd);
      }
    );
  } else {
    // pandaCssPath가 존재하므로
	// panda init 단계로 넘어감
    runInitPandacss(packageJsonPath, packageManager, cwd);
   }
 }

 

getPackageManager 함수는 사용자가 쓰고 있는 패키지 매니저를 찾는 함수이다.

// 패키지 매니저를 찾는 로직
export const getPackageManager = async (targetDir) => {
  const packageManager = await detect({ programmatic: true, cwd: targetDir });

  if (packageManager === "yarn@berry") return "yarn";
  if (packageManager === "pnpm@6") return "pnpm";
  if (packageManager === "bun") return "bun";

  return packageManager ?? "npm";
};

 

installDependencies 함수는 위에서 받아둔 패키지 매니저를 활용해서 의존성 설치 후 성공 시 onSuccess 콜백 함수를 실행시킨다.

// 인자로 받은 의존성들을 현재 디렉토리에 설치하고
// 성공 시 onSuccess 함수를 실행시키는 함수
export const installDependencies = async (
  packageManager,
  dependencies,
  cwd,
  onSuccess
) => {
  try {
    if (dependencies?.length) {
      await execa(
        packageManager,
        [packageManager === "npm" ? "install" : "add", ...dependencies],
        {
          cwd,
        }
      );
    }

    if (typeof onSuccess === "function") {
      onSuccess();
    }
  } catch (error) {
    console.error("Error installing dependencies:", error);
  }
};

 

아래 runInitPandacss 함수는 panda init 명령어를 실행시키는 함수이다.

아래 링크에서도 확인할 수 있듯이 panda init 이후에는 styled-system 폴더가 생성된다.

https://github.com/ghdtjgus76/panda-css/commit/e3e45b6fb7d096c3f74de498029efd5649a73b35

 

그래서, styled-system 경로가 존재하는지로 panda init 여부를 판단하고, init이 되어있지 않다면 init까지 해주게 된다.

// panda init 명령어를 실행시키는 함수
// panda init 후에는 styled-system 폴더가 생성됨
// 따라서 styled-system이 존재하는지 여부로 init 여부 판단
export const runInitPandacss = async (packageJsonPath, packageManager, cwd) => {
  // styled-system 폴더는 package.json 파일과 동일한 디렉토리에 저장됨
  // 따라서 아래와 같이 경로 정의
  const styledSystemPath = path.join(
    path.dirname(packageJsonPath),
    "styled-system"
  );

  // styled-system 폴더가 있다면
  // 즉, panda init을 이미 완료한 상태
  if (existsSync(styledSystemPath)) {
    console.log(
      `${chalk.green(
        "Success!"
      )} Project initialization completed. You may now add components.`
    );
    process.exit(1);
  } else {
  // styled-system 폴더가 없다면
  // panda init을 해야 하는 상황
  console.log("You need to run 'panda init' to use this command");

  // 아래는 panda init을 해주는 로직
  const initSpinner = ora(`Running panda init...`).start();
  try {
    await execa(packageManager, ["panda", "init"], { cwd });

    initSpinner.succeed(`panda init runned successfully.\n`);

    console.log(
      `${chalk.green(
          "Success!"
      )} Project initialization completed. You may now add components.`
    );
      process.exit(1);
    } catch (error) {
      console.error("Error running panda init", error);
    }
  }
};

 

여기까지가 init 함수 로직이다.

 

아래는 init 명령어를 실행했을 때 실제로 동작하는 영상이다.

 

add 명령어

이번에는 add 명령어이다.

 

add 명령어 주요 로직은 다음과 같다.

1. init 여부 (panda css 설치 및 panda init) 판단 후 아직 init하지 않은 경우 경고문 출력 후 실행을 종료한다.

2. 사용자가 선택한 컴포넌트를 설치해준다.

 

해당 코드는 다음 링크에 있다.

https://github.com/ghdtjgus76/design-system-cli/blob/main/packages/cli/src/commands/add.ts

 

아래 코드는 add 명령을 생성하는 코드이다.

init과 같이 option을 받는데, 현재 디렉토리인 cwd, 전체 컴포넌트를 다운로드 받을지 여부에 대한 all 옵션, 컴포넌트 설치 경로를 나타내는 path 이렇게 세 옵션을 받는다.

그리고 components라는 인자를 받는데, 이는 다중 입력이 가능하도록 설정하여 한 번에 여러 컴포넌트를 추가할 수 있도록 하였다.

export const add = program
  .name("add")
  .description("add a component to your project")
  .argument("[components...]", "the components to add")
  .option(
    "-c, --cwd <cwd>",
    "the working directory. defaults to the current directory.",
    process.cwd()
  )
  .option("-a, --all", "add all available components", false)
  .option(
    "-p, --path <path>",
    "the path to add the component to.",
    process.cwd()
  )
  .action(async (components, opts) => {
	// ... 
  }
// command option 유효성 검증을 위한 스키마
// add command에서는 현재 디렉토리인 cwd, 
// 모든 컴포넌트를 선택할지의 여부를 나타내는 all,
// 컴포넌트를 설치할 경로인 path만 사용
// cwd, path option의 기본 값은 현재 디렉토리
// all option의 기본 값은 false
const addOptionsSchema = z.object({
  components: z.array(z.string()).optional(),
  cwd: z.string(),
  all: z.boolean(),
  path: z.string(),
});

 

init과 동일하게 스키마를 사용해서 유효성 검증 후 존재하는 경로인지 파악하는 기본적인 작업 후 init 실행 여부를 판단하게 된다.

// 사용자가 입력한 옵션을 스키마를 사용해 유효성 검증 후 파싱
const options = addOptionsSchema.parse({
  components,
  ...opts,
});
		
// 사용자가 입력한 경로를 절대 경로로 변환
const cwd = path.resolve(options.cwd);

if (!existsSync(options.path) || !existsSync(cwd)) {
  console.error(`The path does not exist. Please try again.`);
  process.exit(1);
}

// init 명령어 실행 여부 판단
// 실행하지 않은 경우 경고문을 출력하고 실행 종료
// 실행한 후라면 다음 로직 수행
isInitialized(options.path);
// init command 실행 여부를 판단하는 함수
// @pandacss/dev 미설치거나
// panda init 미실행한 경우를 판단함
export const isInitialized = (cwd) => {
  const packageJsonPath = getNearestPackageJson(cwd);

  if (packageJsonPath) {
    const pandaCssPath = path.join(
      path.dirname(packageJsonPath),
      "node_modules",
      "@pandacss",
      "dev",
      "package.json"
    );

    const styledSystemPath = path.join(
      path.dirname(packageJsonPath),
      "styled-system"
    );

    if (!existsSync(pandaCssPath) || !existsSync(styledSystemPath)) {
      console.error(
        `Configuration is missing. Please run ${chalk.green(`init`)} first.`
      );
      process.exit(1);
    }
  } else {
    console.error(
      "node_modules or package.json not found in the current directory or its parent directories"
    );
    process.exit(1);
  }
};

 

isInitialized 함수 자체 구현은 init 내부 구현과 유사하다.

panda css 설치 여부와 styled system 경로, 즉 init 여부를 판단해서 둘 중 하나라도 되어 있지 않다면 프로세스를 종료하는 코드를 작성해두었다.

 

이후, init이 되어있는 경우라면, 컴포넌트 관련 작업을 시작하게 된다.

 

shadcn 페이지 상에 올라와있는 모든 컴포넌트 레지스트리를 불러온 후, 사용자가 선택한 컴포넌트를 가져오게 된다.

이때, 인자로 받은 components 목록을 이용하게 된다.

    // 모든 컴포넌트 레지스트리를 불러옴
    const registryInfo = await getRegistryInfo();

    // 사용자가 선택한 컴포넌트를 가져옴
    let selectedComponents = options.all
      ? registryInfo.map((info) => info.name)
      : options.components;

    // 사용자가 option으로 컴포넌트를 따로 입력하지 않았고
    // all option도 선택하지 않았다면
    // 프롬프트를 이용해서 입력 받음
    if (!options.components?.length && !options.all) {
      // 다중 선택 가능하도록 설정
      // 스페이스, A, 엔터를 치면 바로 option에 반영됨
      const { components } = await prompts({
        type: "multiselect",
        name: "components",
        message: "Which components would you like to add?",
        hint: "Space to select. A to toggle all. Enter to submit.",
        choices: registryInfo.map((info) => ({
        title: info.name,
        value: info.name,
        selected: options.components?.includes(info.name),
      })),
    });

  selectedComponents = components;
}
// shadcn 페이지에서 전체 컴포넌트 레지스트리 정보를 가져오는 함수
export const getRegistryInfo = async () => {
  try {
    const data = await fetch(`https://ui.shadcn.com/registry`);

    if (data.status === 404) {
      return null;
    }

    return data.json();
  } catch (error) {
    console.error(`Error reading registry`, error);
    return null;
  }
};

 

이제, 사용자가 선택한 컴포넌트들을 하나씩 쭉 순회하면서 의존성, 파일 경로 등 기본 정보들을 가져와서 해당 컴포넌트 파일을 생성 후 내용을 작성해준다.

또, 이때 해당 컴포넌트 관련 의존성도 함께 설치해주게 된다.

// 사용자가 선택한 컴포넌트들을 하나씩 쭉 순회
selectedComponents?.forEach(async (component) => {
  // 각 컴포넌트별로 정보들을 가져옴
  const componentInfo = await getComponentInfo(component);

  if (!componentInfo) {
    console.error(`Error Finding ${component} component.`);
    process.exit(1);
  }

  const spinner = ora(`Installing... ${component}`).start();
			
  // 컴포넌트 레지스트리에서 가져온 name, dependency, file content 등을
  // 변수로 저장
  const file = componentInfo.files[0];
  const dir = path.join(options.path, "components", "ui");
  const { content: fileContent, name: fileName } = file;
  const filePath = path.join(dir, fileName);
  const dependencies = componentInfo.dependencies;

  // 사용 중인 패키지 매니저를 불러옴
  const packageManager = await getPackageManager(cwd);

  // 만약 위에서 설정한 디렉토리가 없다면
  if (!existsSync(dir)) {
    // 디렉토리를 만든 후 
    mkdir(dir, { recursive: true }, async (error) => {
      if (error) {
        console.error(`Error creating directory ${dir}:`, error);
        process.exit(1);
      }
					
    // 위에서 가져온 file content를 file path라는 경로에 씀
    writeFileWithContent(filePath, fileContent);
    // 컴포넌트별 의존성도 설치함
    installDependencies(
      packageManager,
      dependencies,
      options.path,
      () => {
        spinner.succeed(`${component} installed successfully.`);
      }
    );
  });
} else {
  // 위에서 설정한 디렉토리가 있다면 바로 file content를 file path라는 경로에 씀
  writeFileWithContent(filePath, fileContent);
  // 컴포넌트별 의존성도 설치함
  installDependencies(packageManager, dependencies, options.path, () => {
    spinner.succeed(`${component} installed successfully.`);
  });
}
// shadcn 페이지에서 각 컴포넌트별 레지스트리 정보를 가져오는 함수
export const getComponentInfo = async (component) => {
  try {
    const data = await fetch(
      `https://ui.shadcn.com/registry/styles/default/${component}.json`
    );

    if (data.status === 404) {
      return null;
    }

    return data.json();
  } catch (error) {
    console.error(`Error reading ${component} component`, error);
    return null;
  }
};
// 지정된 경로에 파일 내용을 쓰는 함수
export const writeFileWithContent = (filePath, fileContent) => {
  writeFile(filePath, fileContent, (error) => {
    if (error) {
      console.error(`Error writing file ${filePath}:`, error);
    }
  });
};

 

아래 영상은 add 명령어를 실행시켰을 때의 실제 동작 영상이다.

 

참고 자료

https://github.com/shadcn-ui/ui

https://panda-css.com/docs/installation/cli

https://github.com/chakra-ui/panda/blob/main/packages/cli/src/cli-main.ts