Build You Own CLI EP.2

มาสร้างเครื่องมือ Command-Line (CLI) ของคุณเองกันต่อใน EP.2 บทความนี้ผมจะมาใช้ `degit` เพื่อดึงโปรเจกต์จาก GitHub และการใช้ `kleur` เพื่อเพิ่มสีสันให้ CLI ให้ดูสวยงามและใช้งานง่ายยิ่งขึ้น

Avatar Takai
14/07/2025

ใน EP.1 เราได้ปูพื้นฐานการสร้าง CLI, การติดตั้ง Dependencies ที่จำเป็น, และสร้างคำสั่งย่อย django ที่สามารถรับ Input จากผู้ใช้ผ่าน inquirer พร้อมแสดง Spinner จำลองจาก ora กันไปแล้ว

สำหรับใน EP.2 นี้ เราจะมาทำในส่วนที่เป็นหัวใจหลักกันจริงๆ นั่นคือ การดึงโค้ดโปรเจกต์ต้นแบบ (Template) จาก GitHub Repository ที่เราเตรียมไว้ และปิดท้ายด้วยการ เพิ่มสีสันให้กับข้อความ เพื่อให้ CLI ของเราดูสวยงามและเป็นมิตรกับผู้ใช้มากขึ้นครับ

การทำคำสั่ง Generate Project

ก่อนที่เราจะเริ่มเขียนโค้ดกัน เราต้องมีโปรเจกต์ต้นแบบที่เก็บไว้บน GitHub ก่อน เพื่อให้ CLI ของเราไปดึงมาใช้งานได้ครับ

1. ขั้นตอนก่อนจะเริ่มเพิ่มโค้ดใน CLI

  1. ให้เข้าไปสร้าง Repository แบบ Public ในบัญชี GitHub ของคุณ (ในตัวอย่างจะใช้ชื่อว่า Template-Django-Test)
  2. ให้สร้าง Branch เอาไว้ 2 Branch ชื่อ original-version และ second-version
  3. ใน Branch original-version ให้สร้างไฟล์อะไรก็ได้ไว้เป็นไฟล์ตั้งต้น เช่น test.txt
  4. ใน Branch second-version ให้สร้างโฟลเดอร์และไฟล์ภายในโฟล์เดอร์ที่สร้าง เช่น app/test.txt

เมื่อเตรียม Repository เสร็จแล้ว ก็ถึงเวลามาแก้ไขโค้ดใน test.command.ts ของเรากันครับ!

2. การสร้างฟังก์ชันตรวจสอบและจัดการ Path (Validators)

เพื่อให้ CLI ของเราทำงานได้อย่างมีประสิทธิภาพและป้องกันข้อผิดพลาดจาก User Input เราควรสร้างฟังก์ชันสำหรับตรวจสอบความถูกต้องของข้อมูล (Validation) และจัดการกับ Path ของไฟล์กันก่อนครับ

สร้างโฟลเดอร์ใหม่ชื่อ utils ภายใน src และสร้างไฟล์ validators.ts ข้างใน (src/utils/validators.ts) จากนั้นใส่โค้ดต่อไปนี้ลงไป:

// src/utils/validators.ts
import path from "path";
import klear from "kleur";

// ตรวจสอบชื่อโปรเจกต์
export const validateProjectName = (name: string): boolean | string => {
  if (!name) return klear.red("Project name cannot be empty.");
  if (!/^[a-zA-Z0-9_]+$/.test(name))
    return klear.red(
      "Project name can only contain letters, numbers, and underscores.",
    );
  return true;
};

// ตรวจสอบ Path ปลายทาง
export const validateDestinationPath = (input: string): boolean | string => {
  if (!input) return true; // อนุญาตให้เว้นว่างได้
  const resolvedPath = path.resolve(input);
  if (!path.isAbsolute(resolvedPath)) {
    return klear.red("Please provide an absolute path.");
  }
  return true;
};

// แปลง Path ที่รับเข้ามาให้เป็น Path เต็ม
export const resolvePath = (input: string): string => {
  if (!input) return process.cwd(); // ถ้าเว้นว่าง ให้ใช้ Directory ปัจจุบัน
  const resolvedPath = path.resolve(input);
  return resolvedPath;
};
  • validateProjectName: ฟังก์ชันนี้จะเช็กว่าชื่อโปรเจกต์ที่ผู้ใช้ป้อนเข้ามานั้นไม่เป็นค่าว่าง และต้องประกอบด้วยตัวอักษรภาษาอังกฤษ, ตัวเลข, หรือเครื่องหมาย _ เท่านั้น
  • validateDestinationPath: ใช้สำหรับตรวจสอบ Path ปลายทางที่ผู้ใช้กรอก โดยอนุญาตให้เว้นว่างได้ (ซึ่งหมายถึงการใช้โฟลเดอร์ปัจจุบัน) หากมีการกรอก จะต้องเป็น Absolute Path เท่านั้น
  • resolvePath: ทำหน้าที่แปลง Path ที่ผู้ใช้กรอก (ไม่ว่าจะเป็น Relative หรือ Absolute) ให้กลายเป็น Path แบบเต็ม (Absolute Path)

3. แก้ไข django.command.ts เพื่อเชื่อมต่อกับ Git

ตอนนี้เราจะเปลี่ยนจากการจำลองการทำงานด้วย setTimeout มาเป็นการใช้ degit เพื่อดึงโปรเจกต์จริงๆ กันครับ

// src/commands/django.command.ts
import { Command } from "commander";
import consola from "consola";
import degit from "degit";
import klear from "kleur"; // เพิ่มเข้ามา
import inquirer from "inquirer";
import ora from "ora";
import {
  resolvePath,
  validateDestinationPath,
  validateProjectName,
} from "../utils/validators"; // เพิ่มเข้ามา

// URL ของ Git Repo ที่เราสร้างไว้
const DJANGO_PUBLIC_GIT_REPO = "git@github.com:.../....git";

const PROJECT_TYPE_CHOICES = [
  {
    name: "Original Project",
    value: "original",
  },
  {
    name: "Second Project",
    value: "second",
  },
];

// ฟังก์ชันสำหรับเลือกว่าจะดึงโค้ดจาก Branch ไหน
const determineGit = (type: string): string => {
  let repo = DJANGO_PUBLIC_GIT_REPO;
  switch (type) {
    case "original":
      repo += "#original-version"; // #ตามด้วยชื่อ Branch
      break;
    case "second":
      repo += "#second-version";
    default:
      repo = DJANGO_PUBLIC_GIT_REPO; // ถ้าไม่ตรงเลยให้ใช้ repo หลัก (main branch)
      break;
  }
  return repo;
};
  • kleur: เรา import kleur เข้ามาเพื่อเตรียมใช้ในการเพิ่มสีสันให้กับข้อความ
  • validators: เราจะ import ฟังก์ชันสำหรับตรวจสอบความถูกต้องของข้อมูลเข้ามา (แม้เราจะยังไม่ได้สร้างไฟล์ แต่เป็นการเตรียมโครงสร้างไว้)
  • DJANGO_PUBLIC_GIT_REPO: สร้างตัวแปรสำหรับเก็บ URL ของ Git repository เพื่อให้ง่ายต่อการแก้ไขในอนาคต (อย่าลืมเปลี่ยนเป็น Repo ของคุณเอง!)
  • determineGit(type): คือฟังก์ชันที่เราสร้างขึ้นมาใหม่เพื่อสร้าง URL ที่ถูกต้องสำหรับ degit โดยการนำชื่อ Branch มาต่อท้าย URL หลักในรูปแบบ #<branch_name>

4. อัปเกรด action ให้ใช้งานได้จริง

ตอนนี้เราจะมาแก้ไขโค้ดในส่วนของ .action() ให้ครบสมบูรณ์กันครับ โดยเพิ่มการรับ Input, การตรวจสอบความถูกต้อง ของ Input และเรียกใช้ degit

// src/commands/django.command.ts

// ... (ส่วนโค้ดด้านบนเหมือนเดิม) ...

export const DjangoCommand = new Command("django")
  .alias("gdj")
  .description("Generate Django project structure")
  .action(async () => {
    try {
      // ใช้ klear เพื่อเพิ่มสีสันและความหนา
      consola.info(
        klear.bold().blue("Welcome to the Django Project Generator!"),
      );

      const args = await inquirer.prompt([
        {
          type: "list",
          name: "type",
          message: "Select project type:",
          default: "original",
          choices: PROJECT_TYPE_CHOICES,
        },
        {
          type: "input",
          name: "name",
          message: "Enter the project name:",
          default: "my_django_project",
          validate: validateProjectName, // เพิ่มการตรวจสอบ
        },
        {
          type: "input",
          name: "destination",
          message:
            "Enter the destination path (leave empty for current directory):",
          default: "",
          validate: validateDestinationPath, // เพิ่มการตรวจสอบ
        },
      ]);

      const spinner = ora("Cloning project template...").start();

      const { type, name, destination } = args;

      // หา Path ที่จะติดตั้งโปรเจกต์
      const resolvedDestination = destination
        ? resolvePath(destination)
        : process.cwd();
      const projectPath = `${resolvedDestination}/${name}`;

      // เลือก Repo + Branch จาก Input ของผู้ใช้
      const gitRepo = determineGit(type);

      // สั่งให้ degit ทำงาน
      const emitter = degit(gitRepo, {
        cache: false,
        force: true,
        verbose: true,
      });

      await emitter.clone(projectPath);

      spinner.stop(); // หยุด Spinner เมื่อทำงานเสร็จ

      // แสดงผลลัพธ์สุดท้ายพร้อมสีสันสวยงาม ✨
      consola.success(
        klear
          .bold()
          .green(
            `Project ${klear.blue(name)} created successfully at ${klear.blue(projectPath)}!`,
          ),
      );
    } catch (error) {
      // จัดการ Error ที่อาจเกิดขึ้น
      consola.error(
        klear
          .bold()
          .red("An error occurred while generating the Django project:"),
        error,
      );
      process.exit(1);
    }
  });
  • inquirer :
    • เราเพิ่มค่า destination เพื่อให้ผู้ใช้กำหนดได้ว่าจะสร้างโปรเจกต์ไว้ที่โฟลเดอร์ไหน
    • เราเพิ่ม validate เข้าไปในคำถาม name และ destination เพื่อเรียกใช้ฟังก์ชันตรวจสอบความถูกต้องของข้อมูลที่ผู้ใช้ป้อน (เช่น ชื่อโปรเจกต์ต้องไม่มีอักขระพิเศษ)
  • Path Resolution:
    • resolvedDestination: หากผู้ใช้ป้อน destination มา เราจะใช้ path นั้น แต่ถ้าเว้นว่างไว้ จะใช้โฟลเดอร์ปัจจุบัน (process.cwd()) เป็นที่ตั้ง
    • projectPath: คือ path เต็มๆ ที่จะใช้สร้างโปรเจกต์
  • **degit:
    • const emitter = degit(...): เป็นการสร้าง instance ของ degit พร้อมระบุ repo และ option ต่างๆ
    • await emitter.clone(projectPath): คือคำสั่งที่ให้ degit เริ่มทำการโคลนโปรเจกต์มายัง path ที่เรากำหนดไว้ ซึ่งเป็น asynchronous operation
  • การใช้ kleur**: เราได้นำ kleur มาใช้ตกแต่งข้อความที่แสดงผลผ่าน consola ในส่วนต่างๆ เช่น
    • klear.bold().blue(...): ทำให้ข้อความต้อนรับเป็นสีฟ้าตัวหนา
    • klear.bold().green(...): แสดงข้อความเมื่อทำสำเร็จเป็นสีเขียวตัวหนา
    • klear.bold().red(...): แสดงข้อความเมื่อเกิดข้อผิดพลาดเป็นสีแดงตัวหนา

บทสรุป

ในที่สุด CLI ของเราก็มีฟังก์ชันที่เป็นหัวใจสำคัญแล้วครับ ใน EP.2 นี้ เราได้เรียนรู้วิธีใช้ degit เพื่อดึงโปรเจกต์ต้นแบบจาก GitHub Repository ตาม Branch ที่ผู้ใช้เลือก พร้อมทั้งสร้างฟังก์ชัน validators เพื่อตรวจสอบข้อมูลจากผู้ใช้ให้ถูกต้องและป้องกันข้อผิดพลาดที่อาจเกิดขึ้น

นอกจากนี้ เรายังได้ใช้ kleur มาช่วยเพิ่มสีสันให้กับข้อความ ทำให้ CLI ของเราดูเป็นมืออาชีพ สวยงาม และสื่อสารกับผู้ใช้ได้ชัดเจนยิ่งขึ้น ไม่ว่าจะเป็นข้อความต้อนรับ, ข้อความแจ้งเตือนเมื่อสำเร็จ, หรือข้อความเมื่อเกิดข้อผิดพลาด

ใน EP. ถัดไป เราจะมาต่อยอด CLI ของเราให้สมบูรณ์ขึ้นไปอีกขั้น ด้วยการเพิ่มคำสั่งใหม่ๆ และเตรียมความพร้อมสำหรับการนำไปเผยแพร่ (Publish) เพื่อให้คนอื่นสามารถดาวน์โหลดไปใช้งานได้จริง อย่าลืมมาติดตามกันนะครับ