From 45cc9906f3087ceffd2ca0882c46485faa429851 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Sat, 16 Sep 2023 21:22:14 +0900 Subject: [PATCH 01/27] =?UTF-8?q?=F0=9F=9B=A0=20Refactoring=20Code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - import pg modules --- lib/node-postgres/.env.development.local | 7 + lib/node-postgres/.env.production.local | 7 + lib/node-postgres/.env.test.local | 7 + lib/node-postgres/.swcrc | 1 - lib/node-postgres/src/app.ts | 6 - lib/node-postgres/src/config/index.ts | 2 +- lib/node-postgres/src/database/index.ts | 8 +- lib/node-postgres/src/database/init.sql | 119 ++--------------- .../src/middlewares/auth.middleware.ts | 16 ++- lib/node-postgres/src/models/users.model.ts | 9 -- lib/node-postgres/src/routes/auth.route.ts | 4 +- lib/node-postgres/src/routes/users.route.ts | 4 +- .../src/services/auth.service.ts | 81 +++++++++--- .../src/services/users.service.ts | 124 +++++++++++++++--- lib/node-postgres/src/test/auth.test.ts | 11 +- lib/node-postgres/src/test/users.test.ts | 31 ++--- lib/node-postgres/tsconfig.json | 1 - 17 files changed, 248 insertions(+), 190 deletions(-) delete mode 100644 lib/node-postgres/src/models/users.model.ts diff --git a/lib/node-postgres/.env.development.local b/lib/node-postgres/.env.development.local index dc6fced..0e2aea0 100644 --- a/lib/node-postgres/.env.development.local +++ b/lib/node-postgres/.env.development.local @@ -11,3 +11,10 @@ LOG_DIR = ../logs # CORS ORIGIN = * CREDENTIALS = true + +# DATABASE +POSTGRES_USER = root +POSTGRES_PASSWORD = password +POSTGRES_HOST = localhost +POSTGRES_PORT = 5432 +POSTGRES_DB = dev diff --git a/lib/node-postgres/.env.production.local b/lib/node-postgres/.env.production.local index dad9936..25130f1 100644 --- a/lib/node-postgres/.env.production.local +++ b/lib/node-postgres/.env.production.local @@ -11,3 +11,10 @@ LOG_DIR = ../logs # CORS ORIGIN = your.domain.com CREDENTIALS = true + +# DATABASE +POSTGRES_USER = root +POSTGRES_PASSWORD = password +POSTGRES_HOST = pg +POSTGRES_PORT = 5432 +POSTGRES_DB = dev diff --git a/lib/node-postgres/.env.test.local b/lib/node-postgres/.env.test.local index dc6fced..b0437c1 100644 --- a/lib/node-postgres/.env.test.local +++ b/lib/node-postgres/.env.test.local @@ -11,3 +11,10 @@ LOG_DIR = ../logs # CORS ORIGIN = * CREDENTIALS = true + +# DATABASE +POSTGRES_USER = root +POSTGRES_PASSWORD = password +POSTGRES_HOST = pg +POSTGRES_PORT = 5432 +POSTGRES_DB = dev diff --git a/lib/node-postgres/.swcrc b/lib/node-postgres/.swcrc index 95a2c95..3f93da5 100644 --- a/lib/node-postgres/.swcrc +++ b/lib/node-postgres/.swcrc @@ -28,7 +28,6 @@ "@exceptions/*": ["exceptions/*"], "@interfaces/*": ["interfaces/*"], "@middlewares/*": ["middlewares/*"], - "@models/*": ["models/*"], "@services/*": ["services/*"], "@utils/*": ["utils/*"] } diff --git a/lib/node-postgres/src/app.ts b/lib/node-postgres/src/app.ts index 91e24da..d5c6fc3 100644 --- a/lib/node-postgres/src/app.ts +++ b/lib/node-postgres/src/app.ts @@ -9,7 +9,6 @@ import morgan from 'morgan'; import swaggerJSDoc from 'swagger-jsdoc'; import swaggerUi from 'swagger-ui-express'; import { NODE_ENV, PORT, LOG_FORMAT, ORIGIN, CREDENTIALS } from '@config'; -import { client } from '@database'; import { Routes } from '@interfaces/routes.interface'; import { ErrorMiddleware } from '@middlewares/error.middleware'; import { logger, stream } from '@utils/logger'; @@ -24,7 +23,6 @@ export class App { this.env = NODE_ENV || 'development'; this.port = PORT || 3000; - this.connectToDatabase(); this.initializeMiddlewares(); this.initializeRoutes(routes); this.initializeSwagger(); @@ -44,10 +42,6 @@ export class App { return this.app; } - private async connectToDatabase() { - await client.connect(); - } - private initializeMiddlewares() { this.app.use(morgan(LOG_FORMAT, { stream })); this.app.use(cors({ origin: ORIGIN, credentials: CREDENTIALS })); diff --git a/lib/node-postgres/src/config/index.ts b/lib/node-postgres/src/config/index.ts index 71d0f29..80991e1 100644 --- a/lib/node-postgres/src/config/index.ts +++ b/lib/node-postgres/src/config/index.ts @@ -3,4 +3,4 @@ config({ path: `.env.${process.env.NODE_ENV || 'development'}.local` }); export const CREDENTIALS = process.env.CREDENTIALS === 'true'; export const { NODE_ENV, PORT, SECRET_KEY, LOG_FORMAT, LOG_DIR, ORIGIN } = process.env; -export const { DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_DATABASE } = process.env; +export const { POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB } = process.env; diff --git a/lib/node-postgres/src/database/index.ts b/lib/node-postgres/src/database/index.ts index f76d913..12922e8 100644 --- a/lib/node-postgres/src/database/index.ts +++ b/lib/node-postgres/src/database/index.ts @@ -1,6 +1,10 @@ import { Client } from 'pg'; -import { DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_DATABASE } from '@config'; +import { POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB } from '@config'; export const client = new Client({ - connectionString: `postgres://${DB_USER}:${DB_PASSWORD}@${DB_HOST}:${DB_PORT}/${DB_DATABASE}`, + connectionString: `postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}`, }); + +client.connect(); + +export default client; diff --git a/lib/node-postgres/src/database/init.sql b/lib/node-postgres/src/database/init.sql index f9440c5..79ff110 100644 --- a/lib/node-postgres/src/database/init.sql +++ b/lib/node-postgres/src/database/init.sql @@ -1,110 +1,13 @@ --- TABLE 존재할 경우 삭제 -DROP TABLE IF EXISTS teachers cascade; -DROP TABLE IF EXISTS classes cascade; -DROP TABLE IF EXISTS students cascade; -DROP TABLE IF EXISTS classes_category cascade; -DROP TABLE IF EXISTS enrolment cascade; - --- ============ --- 강사 테이블 --- ============ --- 강사 테이블 생성 -CREATE TABLE teachers( - "teacherId" SERIAL PRIMARY KEY, - -- 강사 ID - "teacherName" VARCHAR(32) NOT NULL, - -- 강사명 - "teacherAbout" VARCHAR(48) -- 강사 정보 -); --- 강사 데이터 생성 -INSERT INTO teachers( - "teacherId", - "teacherName", - "teacherAbout" - ) -VALUES (1, '조현영', '웹 개발 강사'), - (2, '개복치개발자', '안드로이드 코틀린 강사'), - (3, '이고잉', '생활코딩 운영진 겸 강사'), - (4, '김태원', '파이썬 알고리즘 강사'), - (5, '윤재성', 'Kotiln 기반 안드로이드 강사'), - (6, '조훈', '쿠버네티스 강사'), - (7, 'Rookiss', '얼리언 엔진 전문 강사'), - (8, '유용한IT학습', 'SQLD 자격증 취득 강사'), - (9, '김태민', '쿠버네티스 강사'), - (10, '큰돌', '알고리즘 강사'); -SELECT SETVAL( - '"teachers_teacherId_seq"', - ( - SELECT max("teacherId") - FROM teachers - ) - ); --- =================== --- 강의 카테고리 테이블 --- =================== --- 강의 카테고리 테이블 생성 -CREATE TABLE classes_category( - "categoryId" SERIAL PRIMARY KEY, - -- 카테고리 ID - "categoryName" VARCHAR(32) UNIQUE NOT NULL -- 카테고리 명 -); --- 강의 카테고리 데이터 생성 -INSERT INTO classes_category("categoryId", "categoryName") -VALUES (1, '웹'), - (2, '앱'), - (3, '게임'), - (4, '알고리즘'), - (5, '인프라'), - (6, '데이터베이스'); -SELECT SETVAL( - '"classes_category_categoryId_seq"', - ( - SELECT max("categoryId") - FROM classes_category - ) - ); --- ============ --- 강의 테이블 --- ============ --- 강의 테이블 생성 -CREATE TABLE classes( - "classId" SERIAL PRIMARY KEY, - -- 강의 ID - "className" VARCHAR(32) UNIQUE NOT NULL, - -- 강의명 - "classPrice" INTEGER NOT NULL DEFAULT 0, - -- 가격 - "introduce" TEXT, - -- 강의 소개 - "active" BOOLEAN NOT NULL DEFAULT false, - -- 강의 활성화 (true: 공개, false, 비공개) +-- If Exists Table Drop +DROP TABLE IF EXISTS users cascade; +-- ================ +-- TABLE [users] +-- ================ +-- create users table +CREATE TABLE users( + "id" SERIAL PRIMARY KEY, + "email" VARCHAR(32) UNIQUE NOT NULL, + "password" VARCHAR(48) NOT NULL, "createdAt" TIMESTAMP WITHOUT TIME ZONE DEFAULT(NOW() AT TIME ZONE 'utc'), - -- 강의 생성 일자 - "updatedAt" TIMESTAMP WITHOUT TIME ZONE, - -- 강의 수정 일자 - "teacherId" INTEGER REFERENCES teachers("teacherId") ON DELETE CASCADE, - -- 강사 ID - "categoryId" INTEGER REFERENCES classes_category("categoryId") ON DELETE CASCADE -- 강사 ID -); --- ============== --- 수강생 테이블 --- ============== --- 수강생 테이블 생성 -CREATE TABLE students( - "studentId" SERIAL PRIMARY KEY, - -- 수강생 ID - "email" VARCHAR(48) UNIQUE NOT NULL, - -- 수강생 이메일 - "nickname" VARCHAR(32) NOT NULL -- 수강생 닉네임 -); --- =============== --- 수강신청 테이블 --- =============== --- 수강신청 테이블 생성 -CREATE TABLE enrolment( - "classId" INTEGER REFERENCES classes("classId") ON DELETE CASCADE, - -- 강의 ID - "studentId" INTEGER REFERENCES students("studentId") ON DELETE CASCADE, - -- 수강생 ID - "applicationAt" TIMESTAMP WITHOUT TIME ZONE DEFAULT(NOW() AT TIME ZONE 'utc') -- 신청 일자 + "updatedAt" TIMESTAMP WITHOUT TIME ZONE ); \ No newline at end of file diff --git a/lib/node-postgres/src/middlewares/auth.middleware.ts b/lib/node-postgres/src/middlewares/auth.middleware.ts index c311410..9eff334 100644 --- a/lib/node-postgres/src/middlewares/auth.middleware.ts +++ b/lib/node-postgres/src/middlewares/auth.middleware.ts @@ -1,9 +1,9 @@ import { NextFunction, Response } from 'express'; import { verify } from 'jsonwebtoken'; import { SECRET_KEY } from '@config'; +import pg from '@database'; import { HttpException } from '@exceptions/httpException'; import { DataStoredInToken, RequestWithUser } from '@interfaces/auth.interface'; -import { UserModel } from '@models/users.model'; const getAuthorization = req => { const coockie = req.cookies['Authorization']; @@ -21,10 +21,18 @@ export const AuthMiddleware = async (req: RequestWithUser, res: Response, next: if (Authorization) { const { id } = (await verify(Authorization, SECRET_KEY)) as DataStoredInToken; - const findUser = UserModel.find(user => user.id === id); + const { rows, rowCount } = await pg.query(` + SELECT + "email", + "password" + FROM + users + WHERE + "id" = $1 + `, id); - if (findUser) { - req.user = findUser; + if (rowCount) { + req.user = rows[0]; next(); } else { next(new HttpException(401, 'Wrong authentication token')); diff --git a/lib/node-postgres/src/models/users.model.ts b/lib/node-postgres/src/models/users.model.ts deleted file mode 100644 index d5138d8..0000000 --- a/lib/node-postgres/src/models/users.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { User } from '@interfaces/users.interface'; - -// password: password -export const UserModel: User[] = [ - { id: 1, email: 'example1@email.com', password: '$2b$10$TBEfaCe1oo.2jfkBDWcj/usBj4oECsW2wOoDXpCa2IH9xqCpEK/hC' }, - { id: 2, email: 'example2@email.com', password: '$2b$10$TBEfaCe1oo.2jfkBDWcj/usBj4oECsW2wOoDXpCa2IH9xqCpEK/hC' }, - { id: 3, email: 'example3@email.com', password: '$2b$10$TBEfaCe1oo.2jfkBDWcj/usBj4oECsW2wOoDXpCa2IH9xqCpEK/hC' }, - { id: 4, email: 'example4@email.com', password: '$2b$10$TBEfaCe1oo.2jfkBDWcj/usBj4oECsW2wOoDXpCa2IH9xqCpEK/hC' }, -]; diff --git a/lib/node-postgres/src/routes/auth.route.ts b/lib/node-postgres/src/routes/auth.route.ts index 1025094..1a18f73 100644 --- a/lib/node-postgres/src/routes/auth.route.ts +++ b/lib/node-postgres/src/routes/auth.route.ts @@ -14,8 +14,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post('/signup', ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post('/login', ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post('/signup', ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post('/login', ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post('/logout', AuthMiddleware, this.auth.logOut); } } diff --git a/lib/node-postgres/src/routes/users.route.ts b/lib/node-postgres/src/routes/users.route.ts index 8f097f5..b750b9f 100644 --- a/lib/node-postgres/src/routes/users.route.ts +++ b/lib/node-postgres/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id(\\d+)`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id(\\d+)`, this.user.deleteUser); } } diff --git a/lib/node-postgres/src/services/auth.service.ts b/lib/node-postgres/src/services/auth.service.ts index ffd0d98..336a0c2 100644 --- a/lib/node-postgres/src/services/auth.service.ts +++ b/lib/node-postgres/src/services/auth.service.ts @@ -2,10 +2,10 @@ import { hash, compare } from 'bcrypt'; import { sign } from 'jsonwebtoken'; import { Service } from 'typedi'; import { SECRET_KEY } from '@config'; +import pg from '@database'; import { HttpException } from '@exceptions/httpException'; import { DataStoredInToken, TokenData } from '@interfaces/auth.interface'; import { User } from '@interfaces/users.interface'; -import { UserModel } from '@models/users.model'; const createToken = (user: User): TokenData => { const dataStoredInToken: DataStoredInToken = { id: user.id }; @@ -21,32 +21,83 @@ const createCookie = (tokenData: TokenData): string => { @Service() export class AuthService { public async signup(userData: User): Promise { - const findUser: User = UserModel.find(user => user.email === userData.email); - if (findUser) throw new HttpException(409, `This email ${userData.email} already exists`); + const { email, password } = userData; - const hashedPassword = await hash(userData.password, 10); - const createUserData: User = { id: UserModel.length + 1, ...userData, password: hashedPassword }; + const { rows: findUser } = await pg.query( + ` + SELECT EXISTS( + SELECT + "email" + FROM + users + WHERE + "email" = $1 + )`, + [email], + ); + if (findUser[0].exists) throw new HttpException(409, `This email ${userData.email} already exists`); - return createUserData; + const hashedPassword = await hash(password, 10); + const { rows: signUpUserData } = await pg.query( + ` + INSERT INTO + users( + "email", + "password" + ) + VALUES ($1, $2) + RETURNING "email", "password" + `, + [email, hashedPassword], + ); + + return signUpUserData[0]; } public async login(userData: User): Promise<{ cookie: string; findUser: User }> { - const findUser: User = UserModel.find(user => user.email === userData.email); - if (!findUser) throw new HttpException(409, `This email ${userData.email} was not found`); + const { email, password } = userData; + + const { rows, rowCount } = await pg.query( + ` + SELECT + "email", + "password" + FROM + users + WHERE + "email" = $1 + `, + [email], + ); + if (!rowCount) throw new HttpException(409, `This email ${email} was not found`); - const isPasswordMatching: boolean = await compare(userData.password, findUser.password); + const isPasswordMatching: boolean = await compare(password, rows[0].password); if (!isPasswordMatching) throw new HttpException(409, "You're password not matching"); - const tokenData = createToken(findUser); + const tokenData = createToken(rows[0]); const cookie = createCookie(tokenData); - - return { cookie, findUser }; + return { cookie, findUser: rows[0] }; } public async logout(userData: User): Promise { - const findUser: User = UserModel.find(user => user.email === userData.email && user.password === userData.password); - if (!findUser) throw new HttpException(409, "User doesn't exist"); + const { email, password } = userData; + + const { rows, rowCount } = await pg.query( + ` + SELECT + "email", + "password" + FROM + users + WHERE + "email" = $1 + AND + "password" = $2 + `, + [email, password], + ); + if (!rowCount) throw new HttpException(409, "User doesn't exist"); - return findUser; + return rows[0]; } } diff --git a/lib/node-postgres/src/services/users.service.ts b/lib/node-postgres/src/services/users.service.ts index 7e3d924..ea3edf4 100644 --- a/lib/node-postgres/src/services/users.service.ts +++ b/lib/node-postgres/src/services/users.service.ts @@ -1,51 +1,133 @@ import { hash } from 'bcrypt'; import { Service } from 'typedi'; +import pg from '@database'; import { HttpException } from '@exceptions/httpException'; import { User } from '@interfaces/users.interface'; -import { UserModel } from '@models/users.model'; @Service() export class UserService { public async findAllUser(): Promise { - const users: User[] = UserModel; - return users; + const { rows } = await pg.query(` + SELECT + * + FROM + users + `); + return rows; } public async findUserById(userId: number): Promise { - const findUser: User = UserModel.find(user => user.id === userId); - if (!findUser) throw new HttpException(409, "User doesn't exist"); + const { rows, rowCount } = await pg.query( + ` + SELECT + * + FROM + users + WHERE + id = $1 + `, + [userId], + ); + if (!rowCount) throw new HttpException(409, "User doesn't exist"); - return findUser; + return rows[0]; } public async createUser(userData: User): Promise { - const findUser: User = UserModel.find(user => user.email === userData.email); - if (findUser) throw new HttpException(409, `This email ${userData.email} already exists`); + const { email, password } = userData; - const hashedPassword = await hash(userData.password, 10); - const createUserData: User = { id: UserModel.length + 1, ...userData, password: hashedPassword }; + const { rows } = await pg.query( + ` + SELECT EXISTS( + SELECT + "email" + FROM + users + WHERE + "email" = $1 + )`, + [email], + ); + if (rows[0].exists) throw new HttpException(409, `This email ${email} already exists`); - return createUserData; + const hashedPassword = await hash(password, 10); + const { rows: createUserData } = await pg.query( + ` + INSERT INTO + users( + "email", + "password" + ) + VALUES ($1, $2) + RETURNING "email", "password" + `, + [email, hashedPassword], + ); + + return createUserData[0]; } public async updateUser(userId: number, userData: User): Promise { - const findUser: User = UserModel.find(user => user.id === userId); - if (!findUser) throw new HttpException(409, "User doesn't exist"); + const { rows: findUser } = await pg.query( + ` + SELECT EXISTS( + SELECT + "id" + FROM + users + WHERE + "id" = $1 + )`, + [userId], + ); + if (findUser[0].exists) throw new HttpException(409, "User doesn't exist"); - const hashedPassword = await hash(userData.password, 10); - const updateUserData: User[] = UserModel.map((user: User) => { - if (user.id === findUser.id) user = { id: userId, ...userData, password: hashedPassword }; - return user; - }); + const { email, password } = userData; + const hashedPassword = await hash(password, 10); + const { rows: updateUserData } = await pg.query( + ` + UPDATE + users + SET + "email" = $2, + "password" = $3 + WHERE + "id" = $1 + RETURNING "email", "password" + `, + [userId, email, hashedPassword], + ); return updateUserData; } public async deleteUser(userId: number): Promise { - const findUser: User = UserModel.find(user => user.id === userId); - if (!findUser) throw new HttpException(409, "User doesn't exist"); + const { rows: findUser } = await pg.query( + ` + SELECT EXISTS( + SELECT + "id" + FROM + users + WHERE + "id" = $1 + )`, + [userId], + ); + if (findUser[0].exists) throw new HttpException(409, "User doesn't exist"); + + const { rows: deleteUserData } = await pg.query( + ` + DELETE + FROM + users + WHERE + id = $1 + RETURNING "email", "password" + `, + [userId], + ); - const deleteUserData: User[] = UserModel.filter(user => user.id !== findUser.id); return deleteUserData; } } diff --git a/lib/node-postgres/src/test/auth.test.ts b/lib/node-postgres/src/test/auth.test.ts index db251a0..3b6a92b 100644 --- a/lib/node-postgres/src/test/auth.test.ts +++ b/lib/node-postgres/src/test/auth.test.ts @@ -1,15 +1,17 @@ import request from 'supertest'; import { App } from '@/app'; +import pg from '@database'; import { CreateUserDto } from '@dtos/users.dto'; import { AuthRoute } from '@routes/auth.route'; afterAll(async () => { await new Promise(resolve => setTimeout(() => resolve(), 500)); + pg.end(); }); describe('Testing Auth', () => { describe('[POST] /signup', () => { - it('response should have the Create userData', () => { + it('response should have the Create userData', async () => { const userData: CreateUserDto = { email: 'example@email.com', password: 'password', @@ -17,7 +19,10 @@ describe('Testing Auth', () => { const authRoute = new AuthRoute(); const app = new App([authRoute]); - return request(app.getServer()).post('/signup').send(userData); + return await request(app.getServer()) + .post('/signup') + .send(userData) + .expect(201); }); }); @@ -31,7 +36,7 @@ describe('Testing Auth', () => { const authRoute = new AuthRoute(); const app = new App([authRoute]); - return request(app.getServer()) + return await request(app.getServer()) .post('/login') .send(userData) .expect('Set-Cookie', /^Authorization=.+/); diff --git a/lib/node-postgres/src/test/users.test.ts b/lib/node-postgres/src/test/users.test.ts index cf6ac8e..ec03422 100644 --- a/lib/node-postgres/src/test/users.test.ts +++ b/lib/node-postgres/src/test/users.test.ts @@ -1,33 +1,35 @@ import request from 'supertest'; -import App from '@/app'; +import { App } from '@/app'; +import pg from '@database'; import { CreateUserDto } from '@dtos/users.dto'; -import { User } from '@interfaces/users.interface'; -import { UserModel } from '@models/users.model'; import { UserRoute } from '@routes/users.route'; afterAll(async () => { await new Promise(resolve => setTimeout(() => resolve(), 500)); + pg.end(); }); describe('Testing Users', () => { describe('[GET] /users', () => { - it('response statusCode 200 / findAll', () => { - const findUser: User[] = UserModel; + it('response statusCode 200 / findAll', async () => { const usersRoute = new UserRoute(); const app = new App([usersRoute]); - return request(app.getServer()).get(`${usersRoute.path}`).expect(200, { data: findUser, message: 'findAll' }); + return await request(app.getServer()).get(`${usersRoute.path}`).expect(200); }); }); describe('[GET] /users/:id', () => { - it('response statusCode 200 / findOne', () => { - const userId = 1; - const findUser: User = UserModel.find(user => user.id === userId); + it('response statusCode 200 / findOne', async () => { const usersRoute = new UserRoute(); const app = new App([usersRoute]); - return request(app.getServer()).get(`${usersRoute.path}/${userId}`).expect(200, { data: findUser, message: 'findOne' }); + return await request(app.getServer()) + .get(`${usersRoute.path}`) + .query({ + userId: 1, + }) + .expect(200); }); }); @@ -40,7 +42,7 @@ describe('Testing Users', () => { const usersRoute = new UserRoute(); const app = new App([usersRoute]); - return request(app.getServer()).post(`${usersRoute.path}`).send(userData).expect(201); + return await request(app.getServer()).post(`${usersRoute.path}`).send(userData).expect(201); }); }); @@ -54,18 +56,17 @@ describe('Testing Users', () => { const usersRoute = new UserRoute(); const app = new App([usersRoute]); - return request(app.getServer()).put(`${usersRoute.path}/${userId}`).send(userData).expect(200); + return await request(app.getServer()).put(`${usersRoute.path}/${userId}`).send(userData).expect(200); }); }); describe('[DELETE] /users/:id', () => { - it('response statusCode 200 / deleted', () => { + it('response statusCode 200 / deleted', async () => { const userId = 1; - const deleteUser: User[] = UserModel.filter(user => user.id !== userId); const usersRoute = new UserRoute(); const app = new App([usersRoute]); - return request(app.getServer()).delete(`${usersRoute.path}/${userId}`).expect(200, { data: deleteUser, message: 'deleted' }); + return await request(app.getServer()).delete(`${usersRoute.path}/${userId}`).expect(200); }); }); }); diff --git a/lib/node-postgres/tsconfig.json b/lib/node-postgres/tsconfig.json index 937ff08..c1224b3 100644 --- a/lib/node-postgres/tsconfig.json +++ b/lib/node-postgres/tsconfig.json @@ -29,7 +29,6 @@ "@exceptions/*": ["exceptions/*"], "@interfaces/*": ["interfaces/*"], "@middlewares/*": ["middlewares/*"], - "@models/*": ["models/*"], "@routes/*": ["routes/*"], "@services/*": ["services/*"], "@utils/*": ["utils/*"] From 620e81f30779b970baa11daf6851068ccc7086f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Sat, 16 Sep 2023 21:23:52 +0900 Subject: [PATCH 02/27] =?UTF-8?q?=F0=9F=90=9E=20Fix=20Bug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pg container, an error occurs that the environment key - #218 --- lib/typeorm/.env.development.local | 10 +++++----- lib/typeorm/.env.production.local | 10 +++++----- lib/typeorm/.env.test.local | 10 +++++----- lib/typeorm/docker-compose.yml | 16 ++++++++-------- lib/typeorm/src/config/index.ts | 2 +- lib/typeorm/src/database/index.ts | 12 ++++++------ 6 files changed, 30 insertions(+), 30 deletions(-) diff --git a/lib/typeorm/.env.development.local b/lib/typeorm/.env.development.local index 1307310..175c08c 100644 --- a/lib/typeorm/.env.development.local +++ b/lib/typeorm/.env.development.local @@ -2,11 +2,11 @@ PORT = 3000 # DATABASE -DB_USER = root -DB_PASSWORD = password -DB_HOST = localhost -DB_PORT = 5432 -DB_DATABASE = dev +POSTGRES_USER = root +POSTGRES_PASSWORD = password +POSTGRES_HOST = localhost +POSTGRES_PORT = 5432 +POSTGRES_DATABASE = dev # TOKEN SECRET_KEY = secretKey diff --git a/lib/typeorm/.env.production.local b/lib/typeorm/.env.production.local index aa8022c..cc10459 100644 --- a/lib/typeorm/.env.production.local +++ b/lib/typeorm/.env.production.local @@ -2,11 +2,11 @@ PORT = 3000 # DATABASE -DB_USER = root -DB_PASSWORD = password -DB_HOST = localhost -DB_PORT = 5432 -DB_DATABASE = dev +POSTGRES_USER = root +POSTGRES_PASSWORD = password +POSTGRES_HOST = localhost +POSTGRES_PORT = 5432 +POSTGRES_DATABASE = dev # TOKEN SECRET_KEY = secretKey diff --git a/lib/typeorm/.env.test.local b/lib/typeorm/.env.test.local index 1307310..175c08c 100644 --- a/lib/typeorm/.env.test.local +++ b/lib/typeorm/.env.test.local @@ -2,11 +2,11 @@ PORT = 3000 # DATABASE -DB_USER = root -DB_PASSWORD = password -DB_HOST = localhost -DB_PORT = 5432 -DB_DATABASE = dev +POSTGRES_USER = root +POSTGRES_PASSWORD = password +POSTGRES_HOST = localhost +POSTGRES_PORT = 5432 +POSTGRES_DATABASE = dev # TOKEN SECRET_KEY = secretKey diff --git a/lib/typeorm/docker-compose.yml b/lib/typeorm/docker-compose.yml index 70e2311..74acd93 100644 --- a/lib/typeorm/docker-compose.yml +++ b/lib/typeorm/docker-compose.yml @@ -20,11 +20,11 @@ services: ports: - "3000:3000" environment: - DB_USER: root - DB_PASSWORD: password - DB_HOST: pg - DB_PORT: 5432 - DB_DATABASE: dev + POSTGRES_USER: root + POSTGRES_PASSWORD: password + POSTGRES_HOST: pg + POSTGRES_PORT: 5432 + POSTGRES_DATABASE: dev volumes: - ./:/app - /app/node_modules @@ -40,9 +40,9 @@ services: container_name: pg image: postgres:14.5-alpine environment: - DB_USER: root - DB_PASSWORD: password - DB_DATABASE: dev + POSTGRES_USER: root + POSTGRES_PASSWORD: password + POSTGRES_DB: dev ports: - "5432:5432" networks: diff --git a/lib/typeorm/src/config/index.ts b/lib/typeorm/src/config/index.ts index 71d0f29..8dae560 100644 --- a/lib/typeorm/src/config/index.ts +++ b/lib/typeorm/src/config/index.ts @@ -3,4 +3,4 @@ config({ path: `.env.${process.env.NODE_ENV || 'development'}.local` }); export const CREDENTIALS = process.env.CREDENTIALS === 'true'; export const { NODE_ENV, PORT, SECRET_KEY, LOG_FORMAT, LOG_DIR, ORIGIN } = process.env; -export const { DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_DATABASE } = process.env; +export const { POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DATABASE } = process.env; diff --git a/lib/typeorm/src/database/index.ts b/lib/typeorm/src/database/index.ts index a92ce87..f9e9ac4 100644 --- a/lib/typeorm/src/database/index.ts +++ b/lib/typeorm/src/database/index.ts @@ -1,15 +1,15 @@ import { join } from 'path'; import { createConnection, ConnectionOptions } from 'typeorm'; -import { DB_USER, DB_PASSWORD, DB_HOST, DB_PORT, DB_DATABASE } from '@config'; +import { POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DATABASE } from '@config'; export const dbConnection = async () => { const dbConfig: ConnectionOptions = { type: 'postgres', - username: DB_USER, - password: DB_PASSWORD, - host: DB_HOST, - port: Number(DB_PORT), - database: DB_DATABASE, + username: POSTGRES_USER, + password: POSTGRES_PASSWORD, + host: POSTGRES_HOST, + port: +POSTGRES_PORT, + database: POSTGRES_DATABASE, synchronize: true, logging: false, entities: [join(__dirname, '../**/*.entity{.ts,.js}')], From 73698413ec2a0dab3bf47ac1052db7075cbca78c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Sat, 16 Sep 2023 21:25:36 +0900 Subject: [PATCH 03/27] =?UTF-8?q?=F0=9F=8C=BC=20Update=20Version=20-=20v10?= =?UTF-8?q?.1.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 632be1e..b863807 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typescript-express-starter", - "version": "10.0.1", + "version": "10.1.1", "description": "Quick and Easy TypeScript Express Starter", "author": "AGUMON ", "license": "MIT", From 2839d6c18314d0b3e6f00b56ec464349863775a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Wed, 4 Oct 2023 19:38:46 +0900 Subject: [PATCH 04/27] =?UTF-8?q?=F0=9F=9B=A0=20Refactoring=20Code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ValidationMiddleware Parameter --- lib/mikro-orm/src/routes/auth.route.ts | 4 ++-- lib/mikro-orm/src/routes/users.route.ts | 4 ++-- lib/mongoose/src/routes/auth.route.ts | 4 ++-- lib/mongoose/src/routes/users.route.ts | 4 ++-- lib/prisma/src/routes/auth.route.ts | 4 ++-- lib/prisma/src/routes/users.route.ts | 4 ++-- lib/routing-controllers/package.json | 2 +- lib/routing-controllers/src/controllers/auth.controller.ts | 4 ++-- lib/routing-controllers/src/controllers/users.controller.ts | 4 ++-- lib/sequelize/src/routes/auth.route.ts | 4 ++-- lib/sequelize/src/routes/users.route.ts | 4 ++-- lib/typegoose/src/routes/auth.route.ts | 4 ++-- lib/typegoose/src/routes/users.route.ts | 4 ++-- lib/typeorm/src/routes/auth.route.ts | 4 ++-- lib/typeorm/src/routes/users.route.ts | 4 ++-- 15 files changed, 29 insertions(+), 29 deletions(-) diff --git a/lib/mikro-orm/src/routes/auth.route.ts b/lib/mikro-orm/src/routes/auth.route.ts index 118af5e..a32969f 100644 --- a/lib/mikro-orm/src/routes/auth.route.ts +++ b/lib/mikro-orm/src/routes/auth.route.ts @@ -15,8 +15,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post(`${this.path}signup`, ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post(`${this.path}login`, ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post(`${this.path}signup`, ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post(`${this.path}login`, ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post(`${this.path}logout`, AuthMiddleware, this.auth.logOut); } } diff --git a/lib/mikro-orm/src/routes/users.route.ts b/lib/mikro-orm/src/routes/users.route.ts index c56a39b..957f192 100644 --- a/lib/mikro-orm/src/routes/users.route.ts +++ b/lib/mikro-orm/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id`, this.user.deleteUser); } } diff --git a/lib/mongoose/src/routes/auth.route.ts b/lib/mongoose/src/routes/auth.route.ts index 118af5e..a32969f 100644 --- a/lib/mongoose/src/routes/auth.route.ts +++ b/lib/mongoose/src/routes/auth.route.ts @@ -15,8 +15,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post(`${this.path}signup`, ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post(`${this.path}login`, ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post(`${this.path}signup`, ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post(`${this.path}login`, ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post(`${this.path}logout`, AuthMiddleware, this.auth.logOut); } } diff --git a/lib/mongoose/src/routes/users.route.ts b/lib/mongoose/src/routes/users.route.ts index c56a39b..957f192 100644 --- a/lib/mongoose/src/routes/users.route.ts +++ b/lib/mongoose/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id`, this.user.deleteUser); } } diff --git a/lib/prisma/src/routes/auth.route.ts b/lib/prisma/src/routes/auth.route.ts index 118af5e..a32969f 100644 --- a/lib/prisma/src/routes/auth.route.ts +++ b/lib/prisma/src/routes/auth.route.ts @@ -15,8 +15,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post(`${this.path}signup`, ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post(`${this.path}login`, ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post(`${this.path}signup`, ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post(`${this.path}login`, ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post(`${this.path}logout`, AuthMiddleware, this.auth.logOut); } } diff --git a/lib/prisma/src/routes/users.route.ts b/lib/prisma/src/routes/users.route.ts index 8f097f5..b750b9f 100644 --- a/lib/prisma/src/routes/users.route.ts +++ b/lib/prisma/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id(\\d+)`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id(\\d+)`, this.user.deleteUser); } } diff --git a/lib/routing-controllers/package.json b/lib/routing-controllers/package.json index cbdb85d..b066800 100644 --- a/lib/routing-controllers/package.json +++ b/lib/routing-controllers/package.json @@ -10,7 +10,7 @@ "build": "swc src -d dist --source-maps --copy-files", "build:tsc": "tsc && tsc-alias", "test": "jest --forceExit --detectOpenHandles", - "lint": "eslint --ignore-path .gitignore --ext .ts src/", + "lint": "eslint --ignore-path .gitignore --ext .ts src", "lint:fix": "npm run lint -- --fix", "deploy:prod": "npm run build && pm2 start ecosystem.config.js --only prod", "deploy:dev": "pm2 start ecosystem.config.js --only dev" diff --git a/lib/routing-controllers/src/controllers/auth.controller.ts b/lib/routing-controllers/src/controllers/auth.controller.ts index bd4133e..7bac911 100644 --- a/lib/routing-controllers/src/controllers/auth.controller.ts +++ b/lib/routing-controllers/src/controllers/auth.controller.ts @@ -13,7 +13,7 @@ export class AuthController { public auth = Container.get(AuthService); @Post('/signup') - @UseBefore(ValidationMiddleware(CreateUserDto, 'body')) + @UseBefore(ValidationMiddleware(CreateUserDto)) @HttpCode(201) async signUp(@Body() userData: User) { const signUpUserData: User = await this.auth.signup(userData); @@ -21,7 +21,7 @@ export class AuthController { } @Post('/login') - @UseBefore(ValidationMiddleware(CreateUserDto, 'body')) + @UseBefore(ValidationMiddleware(CreateUserDto)) async logIn(@Res() res: Response, @Body() userData: User) { const { cookie, findUser } = await this.auth.login(userData); diff --git a/lib/routing-controllers/src/controllers/users.controller.ts b/lib/routing-controllers/src/controllers/users.controller.ts index 4a9ce7a..8a7084f 100644 --- a/lib/routing-controllers/src/controllers/users.controller.ts +++ b/lib/routing-controllers/src/controllers/users.controller.ts @@ -27,7 +27,7 @@ export class UserController { @Post('/users') @HttpCode(201) - @UseBefore(ValidationMiddleware(CreateUserDto, 'body')) + @UseBefore(ValidationMiddleware(CreateUserDto)) @OpenAPI({ summary: 'Create a new user' }) async createUser(@Body() userData: User) { const createUserData: User = await this.user.createUser(userData); @@ -35,7 +35,7 @@ export class UserController { } @Put('/users/:id') - @UseBefore(ValidationMiddleware(CreateUserDto, 'body', true)) + @UseBefore(ValidationMiddleware(CreateUserDto, true)) @OpenAPI({ summary: 'Update a user' }) async updateUser(@Param('id') userId: number, @Body() userData: User) { const updateUserData: User[] = await this.user.updateUser(userId, userData); diff --git a/lib/sequelize/src/routes/auth.route.ts b/lib/sequelize/src/routes/auth.route.ts index 1025094..1a18f73 100644 --- a/lib/sequelize/src/routes/auth.route.ts +++ b/lib/sequelize/src/routes/auth.route.ts @@ -14,8 +14,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post('/signup', ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post('/login', ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post('/signup', ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post('/login', ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post('/logout', AuthMiddleware, this.auth.logOut); } } diff --git a/lib/sequelize/src/routes/users.route.ts b/lib/sequelize/src/routes/users.route.ts index 8f097f5..b750b9f 100644 --- a/lib/sequelize/src/routes/users.route.ts +++ b/lib/sequelize/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id(\\d+)`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id(\\d+)`, this.user.deleteUser); } } diff --git a/lib/typegoose/src/routes/auth.route.ts b/lib/typegoose/src/routes/auth.route.ts index 1025094..1a18f73 100644 --- a/lib/typegoose/src/routes/auth.route.ts +++ b/lib/typegoose/src/routes/auth.route.ts @@ -14,8 +14,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post('/signup', ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post('/login', ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post('/signup', ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post('/login', ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post('/logout', AuthMiddleware, this.auth.logOut); } } diff --git a/lib/typegoose/src/routes/users.route.ts b/lib/typegoose/src/routes/users.route.ts index c56a39b..957f192 100644 --- a/lib/typegoose/src/routes/users.route.ts +++ b/lib/typegoose/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id`, this.user.deleteUser); } } diff --git a/lib/typeorm/src/routes/auth.route.ts b/lib/typeorm/src/routes/auth.route.ts index 1025094..1a18f73 100644 --- a/lib/typeorm/src/routes/auth.route.ts +++ b/lib/typeorm/src/routes/auth.route.ts @@ -14,8 +14,8 @@ export class AuthRoute implements Routes { } private initializeRoutes() { - this.router.post('/signup', ValidationMiddleware(CreateUserDto, 'body'), this.auth.signUp); - this.router.post('/login', ValidationMiddleware(CreateUserDto, 'body'), this.auth.logIn); + this.router.post('/signup', ValidationMiddleware(CreateUserDto), this.auth.signUp); + this.router.post('/login', ValidationMiddleware(CreateUserDto), this.auth.logIn); this.router.post('/logout', AuthMiddleware, this.auth.logOut); } } diff --git a/lib/typeorm/src/routes/users.route.ts b/lib/typeorm/src/routes/users.route.ts index 8f097f5..b750b9f 100644 --- a/lib/typeorm/src/routes/users.route.ts +++ b/lib/typeorm/src/routes/users.route.ts @@ -16,8 +16,8 @@ export class UserRoute implements Routes { private initializeRoutes() { this.router.get(`${this.path}`, this.user.getUsers); this.router.get(`${this.path}/:id(\\d+)`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto, 'body'), this.user.createUser); - this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, 'body', true), this.user.updateUser); + this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); + this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(CreateUserDto, true), this.user.updateUser); this.router.delete(`${this.path}/:id(\\d+)`, this.user.deleteUser); } } From 042fc6a9ea562a081a7404c4e648cc415eb36a32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Wed, 4 Oct 2023 19:39:31 +0900 Subject: [PATCH 05/27] =?UTF-8?q?=F0=9F=8C=BC=20Update=20Version=20-=20v10?= =?UTF-8?q?.2.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b863807..11fa391 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typescript-express-starter", - "version": "10.1.1", + "version": "10.2.1", "description": "Quick and Easy TypeScript Express Starter", "author": "AGUMON ", "license": "MIT", From 12169bcd84c746017d7201d3d7c762485834fc92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Wed, 9 Jul 2025 21:34:29 +0900 Subject: [PATCH 06/27] refactor: github template --- .github/ISSUE_TEMPLATE/bug-report---.md | 46 ----------------- .github/ISSUE_TEMPLATE/bug_report.yaml | 52 ++++++++++++++++++++ .github/ISSUE_TEMPLATE/feature-request---.md | 23 --------- .github/ISSUE_TEMPLATE/feature_request.yaml | 35 +++++++++++++ .github/ISSUE_TEMPLATE/qna.yaml | 35 +++++++++++++ .github/ISSUE_TEMPLATE/question---.md | 15 ------ .github/PULL_REQUEST_TEMPLATE.md | 40 +++++---------- .github/workflows/publish.yml | 18 +++---- .npmignore | 9 +++- 9 files changed, 151 insertions(+), 122 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug-report---.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yaml delete mode 100644 .github/ISSUE_TEMPLATE/feature-request---.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yaml create mode 100644 .github/ISSUE_TEMPLATE/qna.yaml delete mode 100644 .github/ISSUE_TEMPLATE/question---.md diff --git a/.github/ISSUE_TEMPLATE/bug-report---.md b/.github/ISSUE_TEMPLATE/bug-report---.md deleted file mode 100644 index 2d741f8..0000000 --- a/.github/ISSUE_TEMPLATE/bug-report---.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -name: "Bug Report(버그 보고서) \U0001F41B" -about: "Create a report to help us improve" -title: "" -labels: "\U0001F41E Bug" -assignees: "ljlm0402" ---- - -### Describe the Bug (버그 설명) - - - -### Version to Reproduce (현재 사용한 버전) - - - -### Steps to Reproduce (재현 순서) - - - -### Expected Behavior (예상 동작) - - - -### Actual Behavior (실제 동작) - - - -### Additional Context (추가 사항) - - - -### Capture screen (캡쳐 화면) - - diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..b9a430c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,52 @@ +name: "\U0001F41B Bug Report" +description: Report a bug or unexpected behavior. +title: "[Bug] " +labels: [bug] +assignees: "ljlm0402" +body: + - type: textarea + id: what-happened + attributes: + label: Description + description: Please describe the bug in detail. + placeholder: ex) The app crashes when clicking the button. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to Reproduce + description: List the steps to reproduce the bug. + placeholder: | + 1. Open the app + 2. Click the X button + 3. See the error + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What did you expect to happen? + placeholder: ex) The screen should change normally. + validations: + required: true + - type: textarea + id: screenshots + attributes: + label: Screenshots / Logs + description: Attach screenshots or logs if available. + - type: input + id: env + attributes: + label: Environment + description: "OS, browser, version, etc. (ex: Windows 11, Chrome 124)" + validations: + required: false + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I have searched for existing issues. + - label: I have checked the relevant documentation. diff --git a/.github/ISSUE_TEMPLATE/feature-request---.md b/.github/ISSUE_TEMPLATE/feature-request---.md deleted file mode 100644 index f73f982..0000000 --- a/.github/ISSUE_TEMPLATE/feature-request---.md +++ /dev/null @@ -1,23 +0,0 @@ ---- -name: "Feature Request(기능 요청서) \U0001F4A1" -about: "Suggest an idea for this project" -title: "" -labels: "\U000026A1 Feature" -assignees: "ljlm0402" ---- - -### Motivation (새로운 기능 설명) - - - -### Proposed Solution (기능을 통해 얻고자 하는 솔루션) - - - -### Alternatives (제안 된 솔루션이 더 나은 이유) - - - -### Additional Context (추가 사항) - - diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..e8b43f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,35 @@ +name: "\U0001F4A1 Feature Request" +description: Suggest a new feature or improvement. +title: "[Feature] " +labels: [enhancement] +assignees: "ljlm0402" +body: + - type: textarea + id: summary + attributes: + label: Summary + description: Briefly describe the feature or improvement. + placeholder: ex) Add notification feature for users + validations: + required: true + - type: textarea + id: detail + attributes: + label: Details + description: Describe the feature or improvement in detail. + placeholder: ex) Send email notifications when users log in successfully. + validations: + required: true + - type: textarea + id: reason + attributes: + label: Reason / Benefit + description: Why is this needed? What benefits are expected? + placeholder: ex) Reduce churn by notifying users of key events. + validations: + required: false + - type: textarea + id: etc + attributes: + label: Additional Context + description: Any references, links, or related screenshots. diff --git a/.github/ISSUE_TEMPLATE/qna.yaml b/.github/ISSUE_TEMPLATE/qna.yaml new file mode 100644 index 0000000..a69c784 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/qna.yaml @@ -0,0 +1,35 @@ +name: "\U00002753 Q&A" +description: Ask a question or request clarification. +title: "[Q&A] " +labels: [question] +assignees: "ljlm0402" +body: + - type: textarea + id: question + attributes: + label: Question + description: Please describe your question or what you need help with. + placeholder: ex) How can I customize the login page UI? + validations: + required: true + - type: textarea + id: attempts + attributes: + label: What have you tried? + description: Describe any attempts, research, or steps you've already taken to solve the issue. + placeholder: ex) I checked the documentation and tried modifying `login.scss`, but it had no effect. + validations: + required: false + - type: textarea + id: expected + attributes: + label: What kind of answer would be helpful? + description: Let us know what kind of information or solution you are looking for. + placeholder: ex) Sample code or documentation reference would be helpful. + validations: + required: false + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other details, screenshots, or references that may help. diff --git a/.github/ISSUE_TEMPLATE/question---.md b/.github/ISSUE_TEMPLATE/question---.md deleted file mode 100644 index 454e916..0000000 --- a/.github/ISSUE_TEMPLATE/question---.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -name: "Question(문의) \U00002753" -about: "Ask a question about this project" -title: "" -labels: "\U00002754 Question" -assignees: "ljlm0402" ---- - -### Summary (요약) - - - -### Additional Details (추가적인 세부 사항) - - diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5c9c67a..6ee0c62 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,36 +1,22 @@ -## Content (작업 내용) 📝 +## 📌 Summary - - + -### Screenshot for work content (작업 내용 스크린샷) 📸 +## 💡 Reason for Change - - + -## Links (링크) 🔗 +## ✅ Checklist -- Issue Links (이슈 링크) +- [ ] I have followed the code/documentation style guidelines. +- [ ] I have tested the relevant functionality. +- [ ] This change does not negatively affect existing features. +- [ ] I have linked related issues/commits/documents below if necessary. -- API Links (API 스팩 문서) +## 🔗 Related Issues (optional) -- Development Links (개발 문서) +- #issue_number -- Document Links (기획 문서) +## 📝 Additional Notes -- Design Links (디자인 문서) - -## Etc (기타 사항) 🔖 - - - - -## Check List (체크 사항) ✅ - - - - -- [ ] Issue (이슈) -- [ ] Tests (테스트) -- [ ] Ready to be merged (병합 준비 완료) -- [ ] Added myself to contributors table (기여자 추가) + diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 4efe235..837b2a3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,26 +1,24 @@ name: npm publish on: - pull_request: - branches: - - master - paths: - - package.json + push: + tags: + - "v*.*.*" jobs: build: runs-on: ubuntu-latest steps: - - name: ⬇️ Checkout source code - uses: actions/checkout@v2 + - name: ⬇️ Checkout Repository + uses: actions/checkout@v3 - name: ⎔ Setup nodejs - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: - node-version: 16 + node-version: lts/* registry-url: https://registry.npmjs.org/ - - name: 🏗 Publish npm + - name: 🏗 Publish to npm run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH_TOKEN }} diff --git a/.npmignore b/.npmignore index 26f5c15..9143bde 100644 --- a/.npmignore +++ b/.npmignore @@ -1 +1,8 @@ -.github +.vscode/ +.github/ +.eslintrc +eslint.config.js +.prettierrc +.prettierignore +pnpm-lock.yaml +*.log From f7b29a1ff66e651d34833ff4e2bbdc47a5b922d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Mon, 14 Jul 2025 23:27:22 +0900 Subject: [PATCH 07/27] =?UTF-8?q?starter.js=20=EA=B0=9C=EC=84=A0:=20CLI=20?= =?UTF-8?q?=ED=98=84=EB=8C=80=ED=99=94,=20devtool=EB=B3=84=20=EC=84=A4?= =?UTF-8?q?=EC=B9=98/=EC=BD=94=EB=93=9C=ED=8C=A8=EC=B9=98=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @clack/prompts, ora를 사용해 CLI 사용자 경험을 대폭 개선했습니다. - 여러 개발 도구(ESLint, Prettier, Husky, PM2, Docker, Swagger 등)를 멀티 선택하여 설치/설정 가능하도록 리팩토링했습니다. - 각 devtool별로 의존성 구분(devDependencies/ dependencies), scripts 자동 추가, Swagger는 AST 기반 안전 코드 패치 적용. - Node 버전 및 패키지 매니저 글로벌 설치 여부 사전 체크, 에러 및 가이드 메시지 개선. - git init 및 첫 커밋 옵션 제공. ⚠️ 주의: 구조와 실행 플로우가 일부 변경되어 기존 워크플로와 호환되지 않을 수 있습니다. --- .eslintignore | 16 + .eslintrc | 18 + .prettierrc | 8 + CONTRIBUTORS.md | 69 ---- bin/cli.js | 18 - bin/starter.js | 350 ++++++++++++++++++ devtools/docker/.dockerignore | 27 ++ devtools/docker/Dockerfile.dev | 13 + devtools/docker/Dockerfile.prod | 22 ++ .../docker}/docker-compose.yml | 18 +- devtools/github/.github/workflows/ci.yml | 24 ++ {lib/default => devtools/husky}/.huskyrc | 0 devtools/husky/.lintstagedrc.json | 3 + devtools/pm2/ecosystem.config.js | 47 +++ devtools/prettier/.eslintignore | 16 + devtools/prettier/.eslintrc | 18 + devtools/prettier/.prettierrc | 8 + {lib/default => devtools/swc}/.swcrc | 6 +- package.json | 27 +- 19 files changed, 603 insertions(+), 105 deletions(-) create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 .prettierrc delete mode 100644 CONTRIBUTORS.md delete mode 100644 bin/cli.js create mode 100644 bin/starter.js create mode 100644 devtools/docker/.dockerignore create mode 100644 devtools/docker/Dockerfile.dev create mode 100644 devtools/docker/Dockerfile.prod rename {lib/default => devtools/docker}/docker-compose.yml (61%) create mode 100644 devtools/github/.github/workflows/ci.yml rename {lib/default => devtools/husky}/.huskyrc (100%) create mode 100644 devtools/husky/.lintstagedrc.json create mode 100644 devtools/pm2/ecosystem.config.js create mode 100644 devtools/prettier/.eslintignore create mode 100644 devtools/prettier/.eslintrc create mode 100644 devtools/prettier/.prettierrc rename {lib/default => devtools/swc}/.swcrc (88%) diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..cce063c --- /dev/null +++ b/.eslintignore @@ -0,0 +1,16 @@ +# 빌드 산출물 +dist/ +coverage/ +logs/ +node_modules/ + +# 설정 파일 +*.config.js +*.config.ts + +# 환경파일 등 +.env +.env.* + +# 기타 +!.eslintrc.js diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..5847758 --- /dev/null +++ b/.eslintrc @@ -0,0 +1,18 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": ["plugin:@typescript-eslint/recommended", "prettier"], + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module" + }, + "env": { + "node": true, + "es2022": true + }, + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-var-requires": "off" + } +} diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..1f67368 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 150, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "arrowParens": "avoid" +} diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md deleted file mode 100644 index 789d7b5..0000000 --- a/CONTRIBUTORS.md +++ /dev/null @@ -1,69 +0,0 @@ -- Jeongwon Kim [https://github.com/swtpumpkin](https://github.com/swtpumpkin) - -- João Silva [https://github.com/joaopms](https://github.com/joaopms) - -- BitYoungjae [https://github.com/BitYoungjae](https://github.com/BitYoungjae) - -- Paolo Tagliani [https://github.com/pablosproject](https://github.com/pablosproject) - -- Lloyd Park [https://github.com/yeondam88](https://github.com/yeondam88) - -- strama4 [https://github.com/strama4](https://github.com/strama4) - -- sonbyungjun [https://github.com/sonbyungjun](https://github.com/sonbyungjun) - -- Sean Maxwell [https://github.com/seanpmaxwell](https://github.com/seanpmaxwell) - -- Ed Guy [https://github.com/edguy3](https://github.com/edguy3) - -- Malavan [https://github.com/malavancs](https://github.com/malavancs) - -- Jon Gallant [https://github.com/jongio](https://github.com/jongio) - -- Kuba Rozkwitalski [https://github.com/kubarozkwitalski](https://github.com/kubarozkwitalski) - -- Craig Harman [https://github.com/craigharman](https://github.com/craigharman) - -- Edward Teixeira Dias Junior [https://github.com/edward-teixeira](https://github.com/edward-teixeira) - -- n2ptune [https://github.com/n2ptune](https://github.com/n2ptune) - -- michael r [https://github.com/alanmynah](https://github.com/alanmynah) - -- Benjamin [https://github.com/benjaminudoh10](https://github.com/benjaminudoh10) - -- Amrik Singh [https://github.com/AmrikSD](https://github.com/AmrikSD) - -- oricc [https://github.com/oricc](https://github.com/oricc) - -- Dustin Newbold [https://github.com/dustinnewbold](https://github.com/dustinnewbold) - -- WhatIfWeDigDeeper [https://github.com/WhatIfWeDigDeeper](https://github.com/WhatIfWeDigDeeper) - -- David Stewart [https://github.com/davidjmstewart](https://github.com/davidjmstewart) - -- JagTheFriend [JagTheFriend](https://github.com/JagTheFriend) - -- Tamzid Karim [Tamzid Karim](https://github.com/tamzidkarim) - -- Andrija Milojević [https://github.com/andrija29](https://github.com/andrija29) - -- Engjell Avdiu [https://github.com/engjellavdiu](https://github.com/engjellavdiu) - -- Florian Mifsud [https://github.com/florianmifsud](https://github.com/florianmifsud) - -- Rashid [https://github.com/rashidmya](https://github.com/rashidmya) - -- JoshuaOLoduca [https://github.com/JoshuaOLoduca](https://github.com/JoshuaOLoduca) - -- Markus Laubscher [https://github.com/markuslaubscher](https://github.com/markuslaubscher) - -- coder-palak [https://github.com/coder-palak](https://github.com/coder-palak) - -- H2RO [https://github.com/primary-1](https://github.com/primary-1) - -- Emmanuel Yeboah [https://github.com/noelzappy](https://github.com/noelzappy) - -- Jonathan Felicity [https://github.com/jonathanfelicity](https://github.com/jonathanfelicity) - -- Hichem Fantar [https://github.com/hichemfantar](https://github.com/hichemfantar) diff --git a/bin/cli.js b/bin/cli.js deleted file mode 100644 index 453ea9e..0000000 --- a/bin/cli.js +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env node - -/***************************************************************** - * TypeScript Express Starter - * 2019.12.18 ~ 🎮 - * Made By AGUMON 🦖 - * https://github.com/ljlm0402/typescript-express-starter - *****************************************************************/ - -const path = require("path"); -const starter = require("../lib/starter"); -const destination = getDest(process.argv[2]); - -function getDest(destFolder = "typescript-express-starter") { - return path.join(process.cwd(), destFolder); -} - -starter(destination); diff --git a/bin/starter.js b/bin/starter.js new file mode 100644 index 0000000..f9f9434 --- /dev/null +++ b/bin/starter.js @@ -0,0 +1,350 @@ +#!/usr/bin/env node + +/***************************************************************** + * TYPESCRIPT-EXPRESS-STARTER - Quick and Easy TypeScript Express Starter + * (c) 2020-present AGUMON (https://github.com/ljlm0402/typescript-express-starter) + * + * MIT License + * + * Made with ❤️ by AGUMON 🦖 + *****************************************************************/ + +import { select, multiselect, text, isCancel, intro, outro, cancel, note, confirm } from '@clack/prompts'; +import chalk from 'chalk'; +import ora from 'ora'; +import path from 'path'; +import fs from 'fs-extra'; +import { execa } from 'execa'; +import editJsonFile from 'edit-json-file'; +import { fileURLToPath } from 'url'; +import recast from 'recast'; +import * as tsParser from 'recast/parsers/typescript.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const templatesDir = path.join(__dirname, '../templates'); +const devtoolsDir = path.join(__dirname, '../devtools'); + +const DEVTOOLS = [ + { + name: 'Prettier & ESLint', + value: 'prettier', + files: ['.prettierrc', '.prettierignore', '.eslintrc', '.eslintignore'], + pkgs: [], + devPkgs: [ + 'prettier', + 'eslint', + '@typescript-eslint/eslint-plugin', + '@typescript-eslint/parser', + 'eslint-config-prettier', + 'eslint-plugin-prettier', + ], + scripts: { + lint: 'eslint --ignore-path .gitignore --ext .ts src/', + 'lint:fix': 'npm run lint -- --fix', + format: 'prettier --check .', + }, + }, + { + name: 'SWC', + value: 'swc', + files: ['.swcrc'], + pkgs: [], + devPkgs: ['@swc/core', '@swc/cli'], + scripts: { + 'build:swc': 'swc src -d dist --source-maps --copy-files', + }, + }, + { + name: 'Docker', + value: 'docker', + files: ['.dockerignore', 'docker-compose.yml', 'Dockerfile.dev', 'Dockerfile.prod'], + pkgs: [], + devPkgs: [], + scripts: {}, + }, + { + name: 'Husky & Lint-Staged', + value: 'husky', + files: ['.huskyrc', 'lint-staged.config.js'], + pkgs: [], + devPkgs: ['husky', 'lint-staged'], + scripts: { prepare: 'husky install' }, + requires: [], + }, + { + name: 'PM2', + value: 'pm2', + files: ['ecosystem.config.js'], + pkgs: [], + devPkgs: ['pm2'], + scripts: { + 'deploy:prod': 'npm run build && pm2 start ecosystem.config.js --only prod', + 'deploy:dev': 'pm2 start ecosystem.config.js --only dev', + }, + }, + { + name: 'GitHub Actions', + value: 'github', + files: ['.github/workflows/ci.yml'], + pkgs: [], + devPkgs: [], + scripts: {}, + }, +]; + +// ========== [공통 함수들] ========== + +// Node 버전 체크 +function checkNodeVersion(min = 18) { + const major = parseInt(process.versions.node.split('.')[0], 10); + if (major < min) { + console.error(chalk.red(`Node.js ${min}+ required. You have ${process.versions.node}.`)); + process.exit(1); + } +} + +// 최신 CLI 버전 체크 (배포용 이름으로 변경 필요!) +async function checkForUpdate(pkgName, localVersion) { + try { + const { stdout } = await execa('npm', ['view', pkgName, 'version']); + const latest = stdout.trim(); + if (latest !== localVersion) { + console.log(chalk.yellow(`🔔 New version available: ${latest} (You are on ${localVersion})\n $ npm i -g ${pkgName}`)); + } + } catch { + /* 무시 */ + } +} + +// 패키지매니저 글로벌 설치여부 +async function checkPkgManagerInstalled(pm) { + try { + await execa(pm, ['--version']); + return true; + } catch { + return false; + } +} + +// 최신 버전 조회 +async function getLatestVersion(pkg) { + try { + const { stdout } = await execa('npm', ['view', pkg, 'version']); + return stdout.trim(); + } catch { + return null; + } +} + +// 도구 간 의존성 자동 해결 +function resolveDependencies(selected) { + const all = new Set(selected); + let changed = true; + while (changed) { + changed = false; + for (const tool of DEVTOOLS) { + if (all.has(tool.value) && tool.requires) { + for (const req of tool.requires) { + if (!all.has(req)) { + all.add(req); + changed = true; + } + } + } + } + } + return Array.from(all); +} + +// 파일 복사 +async function copyDevtoolFiles(devtool, destDir) { + for (const file of devtool.files) { + const src = path.join(devtoolsDir, devtool.value, file); + const dst = path.join(destDir, file); + if (await fs.pathExists(src)) { + await fs.copy(src, dst, { overwrite: true }); + console.log(chalk.gray(` ⎯ ${file} copied.`)); + } + } +} + +// 패키지 설치 (최신버전) +async function installPackages(pkgs, pkgManager, dev = true, destDir = process.cwd()) { + if (!pkgs || pkgs.length === 0) return; + const pkgsWithLatest = []; + for (const pkg of pkgs) { + const version = await getLatestVersion(pkg); + if (version) pkgsWithLatest.push(`${pkg}@${version}`); + else pkgsWithLatest.push(pkg); + } + const installCmd = + pkgManager === 'npm' + ? ['install', dev ? '--save-dev' : '', ...pkgsWithLatest].filter(Boolean) + : pkgManager === 'yarn' + ? ['add', dev ? '--dev' : '', ...pkgsWithLatest].filter(Boolean) + : ['add', dev ? '-D' : '', ...pkgsWithLatest].filter(Boolean); + + await execa(pkgManager, installCmd, { cwd: destDir, stdio: 'inherit' }); +} + +// package.json 수정 (스크립트 추가 등) +async function updatePackageJson(scripts, destDir) { + const pkgPath = path.join(destDir, 'package.json'); + const file = editJsonFile(pkgPath, { autosave: true }); + Object.entries(scripts).forEach(([k, v]) => file.set(`scripts.${k}`, v)); + // Husky 자동 추가 예시 + if (!file.get('scripts.prepare') && fs.existsSync(path.join(destDir, '.huskyrc'))) { + file.set('scripts.prepare', 'husky install'); + } + file.save(); +} + +// 친절한 에러/경고 안내 +function printError(message, suggestion = null) { + console.log(chalk.bgRed.white(' ERROR '), chalk.red(message)); + if (suggestion) { + console.log(chalk.gray('Hint:'), chalk.cyan(suggestion)); + } +} + +// Git init & 첫 커밋 +async function gitInitAndFirstCommit(destDir) { + const doGit = await confirm({ message: 'Initialize git and make first commit?', initial: true }); + if (!doGit) return; + try { + await execa('git', ['init'], { cwd: destDir }); + await execa('git', ['add', '.'], { cwd: destDir }); + await execa('git', ['commit', '-m', 'init'], { cwd: destDir }); + console.log(chalk.green(' ✓ git initialized and first commit made!')); + } catch (e) { + printError('git init/commit failed', 'Check git is installed and accessible.'); + } +} + +// ========== [메인 CLI 실행 흐름] ========== +async function main() { + // 1. Node 버전 체크 + checkNodeVersion(18); + + // 2. CLI 최신버전 안내 (자신의 패키지 이름/버전 직접 입력) + await checkForUpdate('typescript-express-starter', '10.2.2'); + + intro(chalk.cyanBright.bold('✨ TypeScript Express Starter')); + + // 3. 패키지 매니저 선택 + 글로벌 설치 확인 + let pkgManager; + while (true) { + pkgManager = await select({ + message: 'Which package manager do you want to use?', + options: [ + { label: 'npm', value: 'npm' }, + { label: 'yarn', value: 'yarn' }, + { label: 'pnpm', value: 'pnpm' }, + ], + initial: 0, + }); + if (isCancel(pkgManager)) return cancel('Aborted.'); + if (await checkPkgManagerInstalled(pkgManager)) break; + printError(`${pkgManager} is not installed globally! Please install it first.`); + } + note(`Using: ${pkgManager}`); + + // 4. 템플릿 선택 + const templateDirs = (await fs.readdir(templatesDir)).filter(f => fs.statSync(path.join(templatesDir, f)).isDirectory()); + if (templateDirs.length === 0) { + printError('No templates found!'); + return; + } + const template = await select({ + message: 'Choose a template:', + options: templateDirs.map(t => ({ label: t, value: t })), + initial: 0, + }); + if (isCancel(template)) return cancel('Aborted.'); + + // 5. 프로젝트명 (중복체크/덮어쓰기) + let projectName; + let destDir; + while (true) { + projectName = await text({ + message: 'Enter your project name:', + initial: 'my-app', + validate: val => (!val ? 'Project name is required' : undefined), + }); + if (isCancel(projectName)) return cancel('Aborted.'); + destDir = path.resolve(process.cwd(), projectName); + if (await fs.pathExists(destDir)) { + const overwrite = await confirm({ message: `Directory "${projectName}" already exists. Overwrite?` }); + if (overwrite) break; + else continue; + } + break; + } + + // 6. 개발 도구 옵션 선택(멀티) + let devtoolValues = await multiselect({ + message: 'Select additional developer tools:', + options: DEVTOOLS.map(({ name, value }) => ({ label: name, value })), + }); + if (isCancel(devtoolValues)) return cancel('Aborted.'); + devtoolValues = resolveDependencies(devtoolValues); + + // === [진행] === + + // [1] 템플릿 복사 + const spinner = ora('Copying template...').start(); + try { + await fs.copy(path.join(templatesDir, template), destDir, { overwrite: true }); + spinner.succeed('Template copied!'); + } catch (e) { + spinner.fail('Template copy failed!'); + printError(e.message, 'Check templates folder and permissions.'); + return process.exit(1); + } + + // [2] 개발 도구 파일/패키지/스크립트/코드패치 + for (const val of devtoolValues) { + const tool = DEVTOOLS.find(d => d.value === val); + if (!tool) continue; + + spinner.start(`Copying ${tool.name} files...`); + await copyDevtoolFiles(tool, destDir); + spinner.succeed(`${tool.name} files copied!`); + + if (tool.pkgs?.length > 0) { + spinner.start(`Installing ${tool.name} packages (prod)...`); + await installPackages(tool.pkgs, pkgManager, false, destDir); + spinner.succeed(`${tool.name} packages (prod) installed!`); + } + + if (tool.devPkgs?.length > 0) { + spinner.start(`Installing ${tool.name} packages (dev)...`); + await installPackages(tool.devPkgs, pkgManager, true, destDir); + spinner.succeed(`${tool.name} packages (dev) installed!`); + } + + if (Object.keys(tool.scripts).length) { + spinner.start(`Updating scripts for ${tool.name}...`); + await updatePackageJson(tool.scripts, destDir); + spinner.succeed(`${tool.name} scripts updated!`); + } + } + + // [3] 템플릿 기본 패키지 설치 + spinner.start(`Installing base dependencies with ${pkgManager}...`); + await execa(pkgManager, ['install'], { cwd: destDir, stdio: 'inherit' }); + spinner.succeed('Base dependencies installed!'); + + // [4] git 첫 커밋 옵션 + // await gitInitAndFirstCommit(destDir); + + outro(chalk.greenBright('\n🎉 Project setup complete!\n')); + console.log(chalk.cyan(` $ cd ${projectName}`)); + console.log(chalk.cyan(` $ ${pkgManager} run dev\n`)); + console.log(chalk.gray('✨ Happy hacking!\n')); +} + +main().catch(err => { + printError('Unexpected error', err.message); + process.exit(1); +}); diff --git a/devtools/docker/.dockerignore b/devtools/docker/.dockerignore new file mode 100644 index 0000000..a83c01f --- /dev/null +++ b/devtools/docker/.dockerignore @@ -0,0 +1,27 @@ +# 빌드 불필요/민감 파일 차단 +node_modules/ +dist/ +.vscode/ +logs/ +coverage/ + +# 환경설정/시크릿/포매터 +.env +.env.* +.eslintrc* +.eslintignore +.editorconfig +.prettierrc* +.huskyrc +.lintstagedrc* +jest.config.js + +# 도커 파일 자체 +Dockerfile +docker-compose.yml + +# 시스템 +.DS_Store +Thumbs.db +npm-debug.log* +yarn-error.log* diff --git a/devtools/docker/Dockerfile.dev b/devtools/docker/Dockerfile.dev new file mode 100644 index 0000000..c71cc71 --- /dev/null +++ b/devtools/docker/Dockerfile.dev @@ -0,0 +1,13 @@ +FROM node:20-bullseye-slim + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . ./ + +ENV NODE_ENV=development +EXPOSE 3000 + +CMD ["npm", "run", "dev"] diff --git a/devtools/docker/Dockerfile.prod b/devtools/docker/Dockerfile.prod new file mode 100644 index 0000000..927830f --- /dev/null +++ b/devtools/docker/Dockerfile.prod @@ -0,0 +1,22 @@ +# Build stage +FROM node:20-bullseye-slim AS builder +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +# Runtime stage +FROM node:20-bullseye-slim +WORKDIR /app + +ENV NODE_ENV=production + +COPY --from=builder /app/package*.json ./ +COPY --from=builder /app/dist ./dist +COPY --from=builder /app/node_modules ./node_modules + +EXPOSE 3000 +CMD ["npm", "run", "start"] diff --git a/lib/default/docker-compose.yml b/devtools/docker/docker-compose.yml similarity index 61% rename from lib/default/docker-compose.yml rename to devtools/docker/docker-compose.yml index 8138f59..41c567e 100644 --- a/lib/default/docker-compose.yml +++ b/devtools/docker/docker-compose.yml @@ -1,14 +1,16 @@ -version: "3.9" +version: '3.9' services: proxy: container_name: proxy image: nginx:alpine ports: - - "80:80" + - '80:80' volumes: - - ./nginx.conf:/etc/nginx/nginx.conf - restart: "unless-stopped" + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - server + restart: unless-stopped networks: - backend @@ -18,11 +20,13 @@ services: context: ./ dockerfile: Dockerfile.dev ports: - - "3000:3000" + - '3000:3000' volumes: - - ./:/app + - ./:/app:cached - /app/node_modules - restart: 'unless-stopped' + env_file: + - .env + restart: unless-stopped networks: - backend diff --git a/devtools/github/.github/workflows/ci.yml b/devtools/github/.github/workflows/ci.yml new file mode 100644 index 0000000..aa05caf --- /dev/null +++ b/devtools/github/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: Node.js CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run build diff --git a/lib/default/.huskyrc b/devtools/husky/.huskyrc similarity index 100% rename from lib/default/.huskyrc rename to devtools/husky/.huskyrc diff --git a/devtools/husky/.lintstagedrc.json b/devtools/husky/.lintstagedrc.json new file mode 100644 index 0000000..1391e71 --- /dev/null +++ b/devtools/husky/.lintstagedrc.json @@ -0,0 +1,3 @@ +{ + "*.ts": ["npm run lint"] +} diff --git a/devtools/pm2/ecosystem.config.js b/devtools/pm2/ecosystem.config.js new file mode 100644 index 0000000..3a337e0 --- /dev/null +++ b/devtools/pm2/ecosystem.config.js @@ -0,0 +1,47 @@ +module.exports = { + apps: [ + { + name: 'prod', + script: 'dist/server.js', + exec_mode: 'cluster', + instances: 'max', // CPU 코어 수만큼 + autorestart: true, + watch: false, + max_memory_restart: '1G', + output: './logs/access.log', + error: './logs/error.log', + merge_logs: true, + env: { + PORT: 3000, + NODE_ENV: 'production', + }, + }, + { + name: 'dev', + script: 'ts-node', + args: '-r tsconfig-paths/register --transpile-only src/server.ts', + exec_mode: 'fork', // dev는 cluster 사용 X + instances: 1, + autorestart: true, + watch: ['src'], + ignore_watch: ['node_modules', 'logs'], + max_memory_restart: '1G', + output: './logs/access.log', + error: './logs/error.log', + env: { + PORT: 3000, + NODE_ENV: 'development', + }, + }, + ], + deploy: { + production: { + user: 'user', + host: '0.0.0.0', + ref: 'origin/master', + repo: 'git@github.com:repo.git', + path: '/home/user/app', + 'post-deploy': 'npm install && npm run build && pm2 reload ecosystem.config.js --env production', + }, + }, +}; diff --git a/devtools/prettier/.eslintignore b/devtools/prettier/.eslintignore new file mode 100644 index 0000000..cce063c --- /dev/null +++ b/devtools/prettier/.eslintignore @@ -0,0 +1,16 @@ +# 빌드 산출물 +dist/ +coverage/ +logs/ +node_modules/ + +# 설정 파일 +*.config.js +*.config.ts + +# 환경파일 등 +.env +.env.* + +# 기타 +!.eslintrc.js diff --git a/devtools/prettier/.eslintrc b/devtools/prettier/.eslintrc new file mode 100644 index 0000000..ef87e0d --- /dev/null +++ b/devtools/prettier/.eslintrc @@ -0,0 +1,18 @@ +{ + "parser": "@typescript-eslint/parser", + "extends": ["plugin:@typescript-eslint/recommended", "prettier"], + "parserOptions": { + "ecmaVersion": 2022, + "sourceType": "module", + }, + "env": { + "node": true, + "es2022": true, + }, + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/explicit-module-boundary-types": "off", + "@typescript-eslint/ban-types": "off", + "@typescript-eslint/no-var-requires": "off", + }, +} diff --git a/devtools/prettier/.prettierrc b/devtools/prettier/.prettierrc new file mode 100644 index 0000000..1f67368 --- /dev/null +++ b/devtools/prettier/.prettierrc @@ -0,0 +1,8 @@ +{ + "printWidth": 150, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "all", + "semi": true, + "arrowParens": "avoid" +} diff --git a/lib/default/.swcrc b/devtools/swc/.swcrc similarity index 88% rename from lib/default/.swcrc rename to devtools/swc/.swcrc index cf100a9..fb07f8a 100644 --- a/lib/default/.swcrc +++ b/devtools/swc/.swcrc @@ -10,7 +10,7 @@ "legacyDecorator": true, "decoratorMetadata": true }, - "target": "es2017", + "target": "esnext", "externalHelpers": false, "keepClassNames": true, "loose": false, @@ -21,13 +21,13 @@ "baseUrl": "src", "paths": { "@/*": ["*"], - "@config": ["config"], + "@config/*": ["config/*"], "@controllers/*": ["controllers/*"], "@dtos/*": ["dtos/*"], "@exceptions/*": ["exceptions/*"], "@interfaces/*": ["interfaces/*"], "@middlewares/*": ["middlewares/*"], - "@models/*": ["models/*"], + "@repositories/*": ["repositories/*"], "@routes/*": ["routes/*"], "@services/*": ["services/*"], "@utils/*": ["utils/*"] diff --git a/package.json b/package.json index 11fa391..372aa42 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "typescript-express-starter", - "version": "10.2.1", + "version": "10.2.2", "description": "Quick and Easy TypeScript Express Starter", "author": "AGUMON ", "license": "MIT", @@ -29,27 +29,38 @@ "nginx", "swc" ], - "main": "bin/cli.js", + "type": "module", + "main": "bin/starter.js", "bin": { - "typescript-express-starter": "bin/cli.js" + "typescript-express-starter": "bin/starter.js" }, "scripts": { - "start": "node bin/cli.js" + "start": "node bin/starter.js" }, "dependencies": { + "@clack/prompts": "^0.11.0", "chalk": "^4.1.2", - "edit-json-file": "^1.5.0", + "cli-progress": "^3.12.0", + "edit-json-file": "^1.8.1", + "execa": "^9.6.0", + "fs-extra": "^11.3.0", "gitignore": "^0.6.0", "inquirer": "^6.5.2", "ncp": "^2.0.0", - "ora": "^4.0.3" + "ora": "^4.1.1", + "recast": "^0.23.11" + }, + "devDependencies": { + "@babel/parser": "^7.28.0", + "eslint": "^9.30.1", + "prettier": "^3.6.2" }, "publishConfig": { "access": "public" }, "engines": { - "node": ">= 10.13.0", - "npm": ">= 6.11.0" + "node": ">= 16.13.0", + "npm": ">= 9.11.0" }, "repository": { "type": "git", From ad9526eebb212276549bbb2bc257197c2acc0784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Mon, 14 Jul 2025 23:40:43 +0900 Subject: [PATCH 08/27] feat (Default) fixed default template struct change models -> reposigotory DI/DX inject package update prefix use --- lib/default/.dockerignore | 18 --- lib/default/.env.development.local | 13 -- lib/default/.env.production.local | 13 -- lib/default/.env.test.local | 13 -- lib/default/.eslintignore | 1 - lib/default/.eslintrc | 18 --- lib/default/.lintstagedrc.json | 5 - lib/default/.prettierrc | 8 - lib/default/.vscode/launch.json | 35 ---- lib/default/.vscode/settings.json | 6 - lib/default/Dockerfile.dev | 18 --- lib/default/Dockerfile.prod | 18 --- lib/default/ecosystem.config.js | 57 ------- lib/default/nginx.conf | 40 ----- lib/default/nodemon.json | 12 -- lib/default/package.json | 76 --------- lib/default/src/app.ts | 81 ---------- .../src/controllers/auth.controller.ts | 44 ----- .../src/controllers/users.controller.ts | 63 -------- lib/default/src/dtos/users.dto.ts | 20 --- lib/default/src/exceptions/HttpException.ts | 10 -- lib/default/src/http/auth.http | 27 ---- lib/default/src/http/users.http | 34 ---- lib/default/src/interfaces/users.interface.ts | 5 - .../src/middlewares/auth.middleware.ts | 38 ----- .../src/middlewares/error.middleware.ts | 15 -- .../src/middlewares/validation.middleware.ts | 27 ---- lib/default/src/models/users.model.ts | 9 -- lib/default/src/routes/auth.route.ts | 21 --- lib/default/src/routes/users.route.ts | 23 --- lib/default/src/server.ts | 10 -- lib/default/src/services/auth.service.ts | 52 ------ lib/default/src/services/users.service.ts | 51 ------ lib/default/src/test/auth.test.ts | 52 ------ lib/default/src/test/users.test.ts | 62 ------- lib/default/src/utils/validateEnv.ts | 8 - lib/default/swagger.yaml | 123 -------------- {lib => templates}/default/.editorconfig | 3 + templates/default/.env.development.local | 22 +++ templates/default/.env.production.local | 22 +++ templates/default/.env.test.local | 22 +++ {lib => templates}/default/Makefile | 3 + {lib => templates}/default/jest.config.js | 6 +- templates/default/nginx.conf | 17 ++ templates/default/nodemon.json | 7 + templates/default/package.json | 67 ++++++++ templates/default/src/app.ts | 151 ++++++++++++++++++ .../default/src/config/env.ts | 0 templates/default/src/config/validateEnv.ts | 16 ++ .../src/controllers/auth.controller.ts | 51 ++++++ .../src/controllers/users.controller.ts | 59 +++++++ templates/default/src/dtos/users.dto.ts | 22 +++ .../default/src/exceptions/httpException.ts | 14 ++ .../default/src/interfaces/auth.interface.ts | 2 +- .../src/interfaces/routes.interface.ts | 0 .../default/src/interfaces/users.interface.ts | 5 + .../src/middlewares/auth.middleware.ts | 49 ++++++ .../src/middlewares/error.middleware.ts | 19 +++ .../src/middlewares/validation.middleware.ts | 20 +++ .../src/repositories/users.repository.ts | 52 ++++++ templates/default/src/routes/auth.route.ts | 23 +++ templates/default/src/routes/users.route.ts | 24 +++ templates/default/src/server.ts | 39 +++++ .../default/src/services/auth.service.ts | 62 +++++++ .../default/src/services/users.service.ts | 46 ++++++ .../default/src/test/e2e/auth.e2e.spec.ts | 40 +++++ .../default/src/test/e2e/users.e2e.spec.ts | 71 ++++++++ templates/default/src/test/setup.ts | 25 +++ .../test/unit/services/auth.service.spec.ts | 69 ++++++++ .../test/unit/services/users.service.spec.ts | 89 +++++++++++ .../default/src/utils/logger.ts | 37 +++-- templates/default/swagger.yaml | 124 ++++++++++++++ {lib => templates}/default/tsconfig.json | 15 +- 73 files changed, 1269 insertions(+), 1150 deletions(-) delete mode 100644 lib/default/.dockerignore delete mode 100644 lib/default/.env.development.local delete mode 100644 lib/default/.env.production.local delete mode 100644 lib/default/.env.test.local delete mode 100644 lib/default/.eslintignore delete mode 100644 lib/default/.eslintrc delete mode 100644 lib/default/.lintstagedrc.json delete mode 100644 lib/default/.prettierrc delete mode 100644 lib/default/.vscode/launch.json delete mode 100644 lib/default/.vscode/settings.json delete mode 100644 lib/default/Dockerfile.dev delete mode 100644 lib/default/Dockerfile.prod delete mode 100644 lib/default/ecosystem.config.js delete mode 100644 lib/default/nginx.conf delete mode 100644 lib/default/nodemon.json delete mode 100644 lib/default/package.json delete mode 100644 lib/default/src/app.ts delete mode 100644 lib/default/src/controllers/auth.controller.ts delete mode 100644 lib/default/src/controllers/users.controller.ts delete mode 100644 lib/default/src/dtos/users.dto.ts delete mode 100644 lib/default/src/exceptions/HttpException.ts delete mode 100644 lib/default/src/http/auth.http delete mode 100644 lib/default/src/http/users.http delete mode 100644 lib/default/src/interfaces/users.interface.ts delete mode 100644 lib/default/src/middlewares/auth.middleware.ts delete mode 100644 lib/default/src/middlewares/error.middleware.ts delete mode 100644 lib/default/src/middlewares/validation.middleware.ts delete mode 100644 lib/default/src/models/users.model.ts delete mode 100644 lib/default/src/routes/auth.route.ts delete mode 100644 lib/default/src/routes/users.route.ts delete mode 100644 lib/default/src/server.ts delete mode 100644 lib/default/src/services/auth.service.ts delete mode 100644 lib/default/src/services/users.service.ts delete mode 100644 lib/default/src/test/auth.test.ts delete mode 100644 lib/default/src/test/users.test.ts delete mode 100644 lib/default/src/utils/validateEnv.ts delete mode 100644 lib/default/swagger.yaml rename {lib => templates}/default/.editorconfig (78%) create mode 100644 templates/default/.env.development.local create mode 100644 templates/default/.env.production.local create mode 100644 templates/default/.env.test.local rename {lib => templates}/default/Makefile (94%) rename {lib => templates}/default/jest.config.js (57%) create mode 100644 templates/default/nginx.conf create mode 100644 templates/default/nodemon.json create mode 100644 templates/default/package.json create mode 100644 templates/default/src/app.ts rename lib/default/src/config/index.ts => templates/default/src/config/env.ts (100%) create mode 100644 templates/default/src/config/validateEnv.ts create mode 100644 templates/default/src/controllers/auth.controller.ts create mode 100644 templates/default/src/controllers/users.controller.ts create mode 100644 templates/default/src/dtos/users.dto.ts create mode 100644 templates/default/src/exceptions/httpException.ts rename {lib => templates}/default/src/interfaces/auth.interface.ts (92%) rename {lib => templates}/default/src/interfaces/routes.interface.ts (100%) create mode 100644 templates/default/src/interfaces/users.interface.ts create mode 100644 templates/default/src/middlewares/auth.middleware.ts create mode 100644 templates/default/src/middlewares/error.middleware.ts create mode 100644 templates/default/src/middlewares/validation.middleware.ts create mode 100644 templates/default/src/repositories/users.repository.ts create mode 100644 templates/default/src/routes/auth.route.ts create mode 100644 templates/default/src/routes/users.route.ts create mode 100644 templates/default/src/server.ts create mode 100644 templates/default/src/services/auth.service.ts create mode 100644 templates/default/src/services/users.service.ts create mode 100644 templates/default/src/test/e2e/auth.e2e.spec.ts create mode 100644 templates/default/src/test/e2e/users.e2e.spec.ts create mode 100644 templates/default/src/test/setup.ts create mode 100644 templates/default/src/test/unit/services/auth.service.spec.ts create mode 100644 templates/default/src/test/unit/services/users.service.spec.ts rename {lib => templates}/default/src/utils/logger.ts (61%) create mode 100644 templates/default/swagger.yaml rename {lib => templates}/default/tsconfig.json (73%) diff --git a/lib/default/.dockerignore b/lib/default/.dockerignore deleted file mode 100644 index 0b2f116..0000000 --- a/lib/default/.dockerignore +++ /dev/null @@ -1,18 +0,0 @@ -# compiled output -.vscode -/node_modules - -# code formatter -.eslintrc -.eslintignore -.editorconfig -.huskyrc -.lintstagedrc.json -.prettierrc - -# test -jest.config.js - -# docker -Dockerfile -docker-compose.yml diff --git a/lib/default/.env.development.local b/lib/default/.env.development.local deleted file mode 100644 index dc6fced..0000000 --- a/lib/default/.env.development.local +++ /dev/null @@ -1,13 +0,0 @@ -# PORT -PORT = 3000 - -# TOKEN -SECRET_KEY = secretKey - -# LOG -LOG_FORMAT = dev -LOG_DIR = ../logs - -# CORS -ORIGIN = * -CREDENTIALS = true diff --git a/lib/default/.env.production.local b/lib/default/.env.production.local deleted file mode 100644 index dad9936..0000000 --- a/lib/default/.env.production.local +++ /dev/null @@ -1,13 +0,0 @@ -# PORT -PORT = 3000 - -# TOKEN -SECRET_KEY = secretKey - -# LOG -LOG_FORMAT = combined -LOG_DIR = ../logs - -# CORS -ORIGIN = your.domain.com -CREDENTIALS = true diff --git a/lib/default/.env.test.local b/lib/default/.env.test.local deleted file mode 100644 index dc6fced..0000000 --- a/lib/default/.env.test.local +++ /dev/null @@ -1,13 +0,0 @@ -# PORT -PORT = 3000 - -# TOKEN -SECRET_KEY = secretKey - -# LOG -LOG_FORMAT = dev -LOG_DIR = ../logs - -# CORS -ORIGIN = * -CREDENTIALS = true diff --git a/lib/default/.eslintignore b/lib/default/.eslintignore deleted file mode 100644 index 3e22129..0000000 --- a/lib/default/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -/dist \ No newline at end of file diff --git a/lib/default/.eslintrc b/lib/default/.eslintrc deleted file mode 100644 index 206ab05..0000000 --- a/lib/default/.eslintrc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "extends": ["prettier", "plugin:@typescript-eslint/recommended", "plugin:prettier/recommended"], - "parserOptions": { - "ecmaVersion": 2018, - "sourceType": "module" - }, - "rules": { - "@typescript-eslint/explicit-member-accessibility": 0, - "@typescript-eslint/explicit-function-return-type": 0, - "@typescript-eslint/no-parameter-properties": 0, - "@typescript-eslint/interface-name-prefix": 0, - "@typescript-eslint/explicit-module-boundary-types": 0, - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/no-var-requires": "off" - } -} diff --git a/lib/default/.lintstagedrc.json b/lib/default/.lintstagedrc.json deleted file mode 100644 index d2fe776..0000000 --- a/lib/default/.lintstagedrc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "*.ts": [ - "npm run lint" - ] -} \ No newline at end of file diff --git a/lib/default/.prettierrc b/lib/default/.prettierrc deleted file mode 100644 index 93a5aaf..0000000 --- a/lib/default/.prettierrc +++ /dev/null @@ -1,8 +0,0 @@ -{ - "printWidth": 150, - "tabWidth": 2, - "singleQuote": true, - "trailingComma": "all", - "semi": true, - "arrowParens": "avoid" -} \ No newline at end of file diff --git a/lib/default/.vscode/launch.json b/lib/default/.vscode/launch.json deleted file mode 100644 index 00ccfd7..0000000 --- a/lib/default/.vscode/launch.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "version": "0.2.0", - "configurations": [ - { - "type": "node-terminal", - "request": "launch", - "name": "Dev typescript-express-starter", - "command": "npm run dev" - }, - { - "type": "node-terminal", - "request": "launch", - "name": "Start typescript-express-starter", - "command": "npm run start" - }, - { - "type": "node-terminal", - "request": "launch", - "name": "Test typescript-express-starter", - "command": "npm run test" - }, - { - "type": "node-terminal", - "request": "launch", - "name": "Lint typescript-express-starter", - "command": "npm run lint" - }, - { - "type": "node-terminal", - "request": "launch", - "name": "Lint:Fix typescript-express-starter", - "command": "npm run lint:fix" - } - ] -} diff --git a/lib/default/.vscode/settings.json b/lib/default/.vscode/settings.json deleted file mode 100644 index 42ab89c..0000000 --- a/lib/default/.vscode/settings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "editor.codeActionsOnSave": { - "source.fixAll.eslint": true - }, - "editor.formatOnSave": false -} diff --git a/lib/default/Dockerfile.dev b/lib/default/Dockerfile.dev deleted file mode 100644 index 0fbb991..0000000 --- a/lib/default/Dockerfile.dev +++ /dev/null @@ -1,18 +0,0 @@ -# NodeJS Version 16 -FROM node:16.18-buster-slim - -# Copy Dir -COPY . ./app - -# Work to Dir -WORKDIR /app - -# Install Node Package -RUN npm install --legacy-peer-deps - -# Set Env -ENV NODE_ENV development -EXPOSE 3000 - -# Cmd script -CMD ["npm", "run", "dev"] diff --git a/lib/default/Dockerfile.prod b/lib/default/Dockerfile.prod deleted file mode 100644 index 2f81c34..0000000 --- a/lib/default/Dockerfile.prod +++ /dev/null @@ -1,18 +0,0 @@ -# NodeJS Version 16 -FROM node:16.18-buster-slim - -# Copy Dir -COPY . ./app - -# Work to Dir -WORKDIR /app - -# Install Node Package -RUN npm install --legacy-peer-deps - -# Set Env -ENV NODE_ENV production -EXPOSE 3000 - -# Cmd script -CMD ["npm", "run", "start"] diff --git a/lib/default/ecosystem.config.js b/lib/default/ecosystem.config.js deleted file mode 100644 index 94cfa72..0000000 --- a/lib/default/ecosystem.config.js +++ /dev/null @@ -1,57 +0,0 @@ -/** - * @description pm2 configuration file. - * @example - * production mode :: pm2 start ecosystem.config.js --only prod - * development mode :: pm2 start ecosystem.config.js --only dev - */ - module.exports = { - apps: [ - { - name: 'prod', // pm2 start App name - script: 'dist/server.js', - exec_mode: 'cluster', // 'cluster' or 'fork' - instance_var: 'INSTANCE_ID', // instance variable - instances: 2, // pm2 instance count - autorestart: true, // auto restart if process crash - watch: false, // files change automatic restart - ignore_watch: ['node_modules', 'logs'], // ignore files change - max_memory_restart: '1G', // restart if process use more than 1G memory - merge_logs: true, // if true, stdout and stderr will be merged and sent to pm2 log - output: './logs/access.log', // pm2 log file - error: './logs/error.log', // pm2 error log file - env: { // environment variable - PORT: 3000, - NODE_ENV: 'production', - }, - }, - { - name: 'dev', // pm2 start App name - script: 'ts-node', // ts-node - args: '-r tsconfig-paths/register --transpile-only src/server.ts', // ts-node args - exec_mode: 'cluster', // 'cluster' or 'fork' - instance_var: 'INSTANCE_ID', // instance variable - instances: 2, // pm2 instance count - autorestart: true, // auto restart if process crash - watch: false, // files change automatic restart - ignore_watch: ['node_modules', 'logs'], // ignore files change - max_memory_restart: '1G', // restart if process use more than 1G memory - merge_logs: true, // if true, stdout and stderr will be merged and sent to pm2 log - output: './logs/access.log', // pm2 log file - error: './logs/error.log', // pm2 error log file - env: { // environment variable - PORT: 3000, - NODE_ENV: 'development', - }, - }, - ], - deploy: { - production: { - user: 'user', - host: '0.0.0.0', - ref: 'origin/master', - repo: 'git@github.com:repo.git', - path: 'dist/server.js', - 'post-deploy': 'npm install && npm run build && pm2 reload ecosystem.config.js --only prod', - }, - }, -}; diff --git a/lib/default/nginx.conf b/lib/default/nginx.conf deleted file mode 100644 index 7cb62f6..0000000 --- a/lib/default/nginx.conf +++ /dev/null @@ -1,40 +0,0 @@ -user nginx; -worker_processes 1; - -error_log /var/log/nginx/error.log warn; -pid /var/run/nginx.pid; - -events { - worker_connections 1024; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - upstream api-server { - server server:3000; - keepalive 100; - } - - server { - listen 80; - server_name localhost; - - location / { - proxy_http_version 1.1; - proxy_pass http://api-server; - } - - } - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /var/log/nginx/access.log main; - - sendfile on; - keepalive_timeout 65; - include /etc/nginx/conf.d/*.conf; -} diff --git a/lib/default/nodemon.json b/lib/default/nodemon.json deleted file mode 100644 index 9c4580d..0000000 --- a/lib/default/nodemon.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "watch": [ - "src", - ".env" - ], - "ext": "js,ts,json", - "ignore": [ - "src/logs/*", - "src/**/*.{spec,test}.ts" - ], - "exec": "ts-node -r tsconfig-paths/register --transpile-only src/server.ts" -} \ No newline at end of file diff --git a/lib/default/package.json b/lib/default/package.json deleted file mode 100644 index 0b2b639..0000000 --- a/lib/default/package.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "name": "typescript-express-starter", - "version": "0.0.0", - "description": "TypeScript + Express API Server", - "author": "", - "license": "ISC", - "scripts": { - "start": "npm run build && cross-env NODE_ENV=production node dist/server.js", - "dev": "cross-env NODE_ENV=development nodemon", - "build": "swc src -d dist --source-maps --copy-files", - "build:tsc": "tsc && tsc-alias", - "test": "jest --forceExit --detectOpenHandles", - "lint": "eslint --ignore-path .gitignore --ext .ts src", - "lint:fix": "npm run lint -- --fix", - "deploy:prod": "npm run build && pm2 start ecosystem.config.js --only prod", - "deploy:dev": "pm2 start ecosystem.config.js --only dev" - }, - "dependencies": { - "bcrypt": "^5.0.1", - "class-transformer": "^0.5.1", - "class-validator": "^0.13.2", - "compression": "^1.7.4", - "cookie-parser": "^1.4.6", - "cors": "^2.8.5", - "dotenv": "^16.0.1", - "envalid": "^7.3.1", - "express": "^4.18.1", - "helmet": "^5.1.1", - "hpp": "^0.2.3", - "jsonwebtoken": "^8.5.1", - "morgan": "^1.10.0", - "reflect-metadata": "^0.1.13", - "swagger-jsdoc": "^6.2.1", - "swagger-ui-express": "^4.5.0", - "typedi": "^0.10.0", - "winston": "^3.8.1", - "winston-daily-rotate-file": "^4.7.1" - }, - "devDependencies": { - "@swc/cli": "^0.1.57", - "@swc/core": "^1.2.220", - "@types/bcrypt": "^5.0.0", - "@types/compression": "^1.7.2", - "@types/cookie-parser": "^1.4.3", - "@types/cors": "^2.8.12", - "@types/express": "^4.17.13", - "@types/hpp": "^0.2.2", - "@types/jest": "^28.1.6", - "@types/jsonwebtoken": "^8.5.8", - "@types/morgan": "^1.9.3", - "@types/node": "^17.0.45", - "@types/supertest": "^2.0.12", - "@types/swagger-jsdoc": "^6.0.1", - "@types/swagger-ui-express": "^4.1.3", - "@typescript-eslint/eslint-plugin": "^5.29.0", - "@typescript-eslint/parser": "^5.29.0", - "cross-env": "^7.0.3", - "eslint": "^8.20.0", - "eslint-config-prettier": "^8.5.0", - "eslint-plugin-prettier": "^4.2.1", - "husky": "^8.0.1", - "jest": "^28.1.1", - "lint-staged": "^13.0.3", - "node-config": "^0.0.2", - "node-gyp": "^9.1.0", - "nodemon": "^2.0.19", - "pm2": "^5.2.0", - "prettier": "^2.7.1", - "supertest": "^6.2.4", - "ts-jest": "^28.0.7", - "ts-node": "^10.9.1", - "tsc-alias": "^1.7.0", - "tsconfig-paths": "^4.0.0", - "typescript": "^4.7.4" - } -} diff --git a/lib/default/src/app.ts b/lib/default/src/app.ts deleted file mode 100644 index d5c6fc3..0000000 --- a/lib/default/src/app.ts +++ /dev/null @@ -1,81 +0,0 @@ -import 'reflect-metadata'; -import compression from 'compression'; -import cookieParser from 'cookie-parser'; -import cors from 'cors'; -import express from 'express'; -import helmet from 'helmet'; -import hpp from 'hpp'; -import morgan from 'morgan'; -import swaggerJSDoc from 'swagger-jsdoc'; -import swaggerUi from 'swagger-ui-express'; -import { NODE_ENV, PORT, LOG_FORMAT, ORIGIN, CREDENTIALS } from '@config'; -import { Routes } from '@interfaces/routes.interface'; -import { ErrorMiddleware } from '@middlewares/error.middleware'; -import { logger, stream } from '@utils/logger'; - -export class App { - public app: express.Application; - public env: string; - public port: string | number; - - constructor(routes: Routes[]) { - this.app = express(); - this.env = NODE_ENV || 'development'; - this.port = PORT || 3000; - - this.initializeMiddlewares(); - this.initializeRoutes(routes); - this.initializeSwagger(); - this.initializeErrorHandling(); - } - - public listen() { - this.app.listen(this.port, () => { - logger.info(`=================================`); - logger.info(`======= ENV: ${this.env} =======`); - logger.info(`🚀 App listening on the port ${this.port}`); - logger.info(`=================================`); - }); - } - - public getServer() { - return this.app; - } - - private initializeMiddlewares() { - this.app.use(morgan(LOG_FORMAT, { stream })); - this.app.use(cors({ origin: ORIGIN, credentials: CREDENTIALS })); - this.app.use(hpp()); - this.app.use(helmet()); - this.app.use(compression()); - this.app.use(express.json()); - this.app.use(express.urlencoded({ extended: true })); - this.app.use(cookieParser()); - } - - private initializeRoutes(routes: Routes[]) { - routes.forEach(route => { - this.app.use('/', route.router); - }); - } - - private initializeSwagger() { - const options = { - swaggerDefinition: { - info: { - title: 'REST API', - version: '1.0.0', - description: 'Example docs', - }, - }, - apis: ['swagger.yaml'], - }; - - const specs = swaggerJSDoc(options); - this.app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs)); - } - - private initializeErrorHandling() { - this.app.use(ErrorMiddleware); - } -} diff --git a/lib/default/src/controllers/auth.controller.ts b/lib/default/src/controllers/auth.controller.ts deleted file mode 100644 index ab035db..0000000 --- a/lib/default/src/controllers/auth.controller.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextFunction, Request, Response } from 'express'; -import { Container } from 'typedi'; -import { RequestWithUser } from '@interfaces/auth.interface'; -import { User } from '@interfaces/users.interface'; -import { AuthService } from '@services/auth.service'; - -export class AuthController { - public auth = Container.get(AuthService); - - public signUp = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const userData: User = req.body; - const signUpUserData: User = await this.auth.signup(userData); - - res.status(201).json({ data: signUpUserData, message: 'signup' }); - } catch (error) { - next(error); - } - }; - - public logIn = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const userData: User = req.body; - const { cookie, findUser } = await this.auth.login(userData); - - res.setHeader('Set-Cookie', [cookie]); - res.status(200).json({ data: findUser, message: 'login' }); - } catch (error) { - next(error); - } - }; - - public logOut = async (req: RequestWithUser, res: Response, next: NextFunction): Promise => { - try { - const userData: User = req.user; - const logOutUserData: User = await this.auth.logout(userData); - - res.setHeader('Set-Cookie', ['Authorization=; Max-age=0']); - res.status(200).json({ data: logOutUserData, message: 'logout' }); - } catch (error) { - next(error); - } - }; -} diff --git a/lib/default/src/controllers/users.controller.ts b/lib/default/src/controllers/users.controller.ts deleted file mode 100644 index 60f46bf..0000000 --- a/lib/default/src/controllers/users.controller.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { NextFunction, Request, Response } from 'express'; -import { Container } from 'typedi'; -import { User } from '@interfaces/users.interface'; -import { UserService } from '@services/users.service'; - -export class UserController { - public user = Container.get(UserService); - - public getUsers = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const findAllUsersData: User[] = await this.user.findAllUser(); - - res.status(200).json({ data: findAllUsersData, message: 'findAll' }); - } catch (error) { - next(error); - } - }; - - public getUserById = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const userId = Number(req.params.id); - const findOneUserData: User = await this.user.findUserById(userId); - - res.status(200).json({ data: findOneUserData, message: 'findOne' }); - } catch (error) { - next(error); - } - }; - - public createUser = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const userData: User = req.body; - const createUserData: User = await this.user.createUser(userData); - - res.status(201).json({ data: createUserData, message: 'created' }); - } catch (error) { - next(error); - } - }; - - public updateUser = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const userId = Number(req.params.id); - const userData: User = req.body; - const updateUserData: User[] = await this.user.updateUser(userId, userData); - - res.status(200).json({ data: updateUserData, message: 'updated' }); - } catch (error) { - next(error); - } - }; - - public deleteUser = async (req: Request, res: Response, next: NextFunction): Promise => { - try { - const userId = Number(req.params.id); - const deleteUserData: User[] = await this.user.deleteUser(userId); - - res.status(200).json({ data: deleteUserData, message: 'deleted' }); - } catch (error) { - next(error); - } - }; -} diff --git a/lib/default/src/dtos/users.dto.ts b/lib/default/src/dtos/users.dto.ts deleted file mode 100644 index 5bf903b..0000000 --- a/lib/default/src/dtos/users.dto.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IsEmail, IsString, IsNotEmpty, MinLength, MaxLength } from 'class-validator'; - -export class CreateUserDto { - @IsEmail() - public email: string; - - @IsString() - @IsNotEmpty() - @MinLength(9) - @MaxLength(32) - public password: string; -} - -export class UpdateUserDto { - @IsString() - @IsNotEmpty() - @MinLength(9) - @MaxLength(32) - public password: string; -} diff --git a/lib/default/src/exceptions/HttpException.ts b/lib/default/src/exceptions/HttpException.ts deleted file mode 100644 index f0ae6aa..0000000 --- a/lib/default/src/exceptions/HttpException.ts +++ /dev/null @@ -1,10 +0,0 @@ -export class HttpException extends Error { - public status: number; - public message: string; - - constructor(status: number, message: string) { - super(message); - this.status = status; - this.message = message; - } -} diff --git a/lib/default/src/http/auth.http b/lib/default/src/http/auth.http deleted file mode 100644 index 2198991..0000000 --- a/lib/default/src/http/auth.http +++ /dev/null @@ -1,27 +0,0 @@ -# baseURL -@baseURL = http://localhost:3000 - -### -# User Signup -POST {{ baseURL }}/signup -Content-Type: application/json - -{ - "email": "example@email.com", - "password": "password" -} - -### -# User Login -POST {{ baseURL }}/login -Content-Type: application/json - -{ - "email": "example@email.com", - "password": "password" -} - -### -# User Logout -POST {{ baseURL }}/logout -Content-Type: application/json diff --git a/lib/default/src/http/users.http b/lib/default/src/http/users.http deleted file mode 100644 index 13209f2..0000000 --- a/lib/default/src/http/users.http +++ /dev/null @@ -1,34 +0,0 @@ -# baseURL -@baseURL = http://localhost:3000 - -### -# Find All Users -GET {{ baseURL }}/users - -### -# Find User By Id -GET {{ baseURL }}/users/1 - -### -# Create User -POST {{ baseURL }}/users -Content-Type: application/json - -{ - "email": "example@email.com", - "password": "password" -} - -### -# Modify User By Id -PUT {{ baseURL }}/users/1 -Content-Type: application/json - -{ - "email": "example@email.com", - "password": "password" -} - -### -# Delete User By Id -DELETE {{ baseURL }}/users/1 diff --git a/lib/default/src/interfaces/users.interface.ts b/lib/default/src/interfaces/users.interface.ts deleted file mode 100644 index 07bbc28..0000000 --- a/lib/default/src/interfaces/users.interface.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface User { - id?: number; - email?: string; - password: string; -} diff --git a/lib/default/src/middlewares/auth.middleware.ts b/lib/default/src/middlewares/auth.middleware.ts deleted file mode 100644 index c311410..0000000 --- a/lib/default/src/middlewares/auth.middleware.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextFunction, Response } from 'express'; -import { verify } from 'jsonwebtoken'; -import { SECRET_KEY } from '@config'; -import { HttpException } from '@exceptions/httpException'; -import { DataStoredInToken, RequestWithUser } from '@interfaces/auth.interface'; -import { UserModel } from '@models/users.model'; - -const getAuthorization = req => { - const coockie = req.cookies['Authorization']; - if (coockie) return coockie; - - const header = req.header('Authorization'); - if (header) return header.split('Bearer ')[1]; - - return null; -}; - -export const AuthMiddleware = async (req: RequestWithUser, res: Response, next: NextFunction) => { - try { - const Authorization = getAuthorization(req); - - if (Authorization) { - const { id } = (await verify(Authorization, SECRET_KEY)) as DataStoredInToken; - const findUser = UserModel.find(user => user.id === id); - - if (findUser) { - req.user = findUser; - next(); - } else { - next(new HttpException(401, 'Wrong authentication token')); - } - } else { - next(new HttpException(404, 'Authentication token missing')); - } - } catch (error) { - next(new HttpException(401, 'Wrong authentication token')); - } -}; diff --git a/lib/default/src/middlewares/error.middleware.ts b/lib/default/src/middlewares/error.middleware.ts deleted file mode 100644 index 1dbeb17..0000000 --- a/lib/default/src/middlewares/error.middleware.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextFunction, Request, Response } from 'express'; -import { HttpException } from '@exceptions/httpException'; -import { logger } from '@utils/logger'; - -export const ErrorMiddleware = (error: HttpException, req: Request, res: Response, next: NextFunction) => { - try { - const status: number = error.status || 500; - const message: string = error.message || 'Something went wrong'; - - logger.error(`[${req.method}] ${req.path} >> StatusCode:: ${status}, Message:: ${message}`); - res.status(status).json({ message }); - } catch (error) { - next(error); - } -}; diff --git a/lib/default/src/middlewares/validation.middleware.ts b/lib/default/src/middlewares/validation.middleware.ts deleted file mode 100644 index b351e21..0000000 --- a/lib/default/src/middlewares/validation.middleware.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { plainToInstance } from 'class-transformer'; -import { validateOrReject, ValidationError } from 'class-validator'; -import { NextFunction, Request, Response } from 'express'; -import { HttpException } from '@exceptions/httpException'; - -/** - * @name ValidationMiddleware - * @description Allows use of decorator and non-decorator based validation - * @param type dto - * @param skipMissingProperties When skipping missing properties - * @param whitelist Even if your object is an instance of a validation class it can contain additional properties that are not defined - * @param forbidNonWhitelisted If you would rather to have an error thrown when any non-whitelisted properties are present - */ -export const ValidationMiddleware = (type: any, skipMissingProperties = false, whitelist = true, forbidNonWhitelisted = true) => { - return (req: Request, res: Response, next: NextFunction) => { - const dto = plainToInstance(type, req.body); - validateOrReject(dto, { skipMissingProperties, whitelist, forbidNonWhitelisted }) - .then(() => { - req.body = dto; - next(); - }) - .catch((errors: ValidationError[]) => { - const message = errors.map((error: ValidationError) => Object.values(error.constraints)).join(', '); - next(new HttpException(400, message)); - }); - }; -}; diff --git a/lib/default/src/models/users.model.ts b/lib/default/src/models/users.model.ts deleted file mode 100644 index 7f22a20..0000000 --- a/lib/default/src/models/users.model.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { User } from '@interfaces/users.interface'; - -// password: password123456789 -export const UserModel: User[] = [ - { id: 1, email: 'example1@email.com', password: '$2b$10$2YC2ht8x06Yto5VAr08kben8.oxjTPrMn0yJhv8xxSVVltH3bOs4u' }, - { id: 2, email: 'example2@email.com', password: '$2b$10$2YC2ht8x06Yto5VAr08kben8.oxjTPrMn0yJhv8xxSVVltH3bOs4u' }, - { id: 3, email: 'example3@email.com', password: '$2b$10$2YC2ht8x06Yto5VAr08kben8.oxjTPrMn0yJhv8xxSVVltH3bOs4u' }, - { id: 4, email: 'example4@email.com', password: '$2b$10$2YC2ht8x06Yto5VAr08kben8.oxjTPrMn0yJhv8xxSVVltH3bOs4u' }, -]; diff --git a/lib/default/src/routes/auth.route.ts b/lib/default/src/routes/auth.route.ts deleted file mode 100644 index 1a18f73..0000000 --- a/lib/default/src/routes/auth.route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Router } from 'express'; -import { AuthController } from '@controllers/auth.controller'; -import { CreateUserDto } from '@dtos/users.dto'; -import { Routes } from '@interfaces/routes.interface'; -import { AuthMiddleware } from '@middlewares/auth.middleware'; -import { ValidationMiddleware } from '@middlewares/validation.middleware'; - -export class AuthRoute implements Routes { - public router = Router(); - public auth = new AuthController(); - - constructor() { - this.initializeRoutes(); - } - - private initializeRoutes() { - this.router.post('/signup', ValidationMiddleware(CreateUserDto), this.auth.signUp); - this.router.post('/login', ValidationMiddleware(CreateUserDto), this.auth.logIn); - this.router.post('/logout', AuthMiddleware, this.auth.logOut); - } -} diff --git a/lib/default/src/routes/users.route.ts b/lib/default/src/routes/users.route.ts deleted file mode 100644 index ed15c24..0000000 --- a/lib/default/src/routes/users.route.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { Router } from 'express'; -import { UserController } from '@controllers/users.controller'; -import { CreateUserDto, UpdateUserDto } from '@dtos/users.dto'; -import { Routes } from '@interfaces/routes.interface'; -import { ValidationMiddleware } from '@middlewares/validation.middleware'; - -export class UserRoute implements Routes { - public path = '/users'; - public router = Router(); - public user = new UserController(); - - constructor() { - this.initializeRoutes(); - } - - private initializeRoutes() { - this.router.get(`${this.path}`, this.user.getUsers); - this.router.get(`${this.path}/:id(\\d+)`, this.user.getUserById); - this.router.post(`${this.path}`, ValidationMiddleware(CreateUserDto), this.user.createUser); - this.router.put(`${this.path}/:id(\\d+)`, ValidationMiddleware(UpdateUserDto), this.user.updateUser); - this.router.delete(`${this.path}/:id(\\d+)`, this.user.deleteUser); - } -} diff --git a/lib/default/src/server.ts b/lib/default/src/server.ts deleted file mode 100644 index 2362805..0000000 --- a/lib/default/src/server.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { App } from '@/app'; -import { AuthRoute } from '@routes/auth.route'; -import { UserRoute } from '@routes/users.route'; -import { ValidateEnv } from '@utils/validateEnv'; - -ValidateEnv(); - -const app = new App([new UserRoute(), new AuthRoute()]); - -app.listen(); diff --git a/lib/default/src/services/auth.service.ts b/lib/default/src/services/auth.service.ts deleted file mode 100644 index 9710d5e..0000000 --- a/lib/default/src/services/auth.service.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { hash, compare } from 'bcrypt'; -import { sign } from 'jsonwebtoken'; -import { Service } from 'typedi'; -import { SECRET_KEY } from '@config'; -import { HttpException } from '@exceptions/httpException'; -import { DataStoredInToken, TokenData } from '@interfaces/auth.interface'; -import { User } from '@interfaces/users.interface'; -import { UserModel } from '@models/users.model'; - -const createToken = (user: User): TokenData => { - const dataStoredInToken: DataStoredInToken = { id: user.id }; - const expiresIn: number = 60 * 60; - - return { expiresIn, token: sign(dataStoredInToken, SECRET_KEY, { expiresIn }) }; -}; - -const createCookie = (tokenData: TokenData): string => { - return `Authorization=${tokenData.token}; HttpOnly; Max-Age=${tokenData.expiresIn};`; -}; - -@Service() -export class AuthService { - public async signup(userData: User): Promise { - const findUser: User = UserModel.find(user => user.email === userData.email); - if (findUser) throw new HttpException(409, `This email ${userData.email} already exists`); - - const hashedPassword = await hash(userData.password, 10); - const createUserData: User = { ...userData, id: UserModel.length + 1, password: hashedPassword }; - - return createUserData; - } - - public async login(userData: User): Promise<{ cookie: string; findUser: User }> { - const findUser: User = UserModel.find(user => user.email === userData.email); - if (!findUser) throw new HttpException(409, `This email ${userData.email} was not found`); - - const isPasswordMatching: boolean = await compare(userData.password, findUser.password); - if (!isPasswordMatching) throw new HttpException(409, "You're password not matching"); - - const tokenData = createToken(findUser); - const cookie = createCookie(tokenData); - - return { cookie, findUser }; - } - - public async logout(userData: User): Promise { - const findUser: User = UserModel.find(user => user.email === userData.email && user.password === userData.password); - if (!findUser) throw new HttpException(409, "User doesn't exist"); - - return findUser; - } -} diff --git a/lib/default/src/services/users.service.ts b/lib/default/src/services/users.service.ts deleted file mode 100644 index 8d253b8..0000000 --- a/lib/default/src/services/users.service.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { hash } from 'bcrypt'; -import { Service } from 'typedi'; -import { HttpException } from '@exceptions/httpException'; -import { User } from '@interfaces/users.interface'; -import { UserModel } from '@models/users.model'; - -@Service() -export class UserService { - public async findAllUser(): Promise { - const users: User[] = UserModel; - return users; - } - - public async findUserById(userId: number): Promise { - const findUser: User = UserModel.find(user => user.id === userId); - if (!findUser) throw new HttpException(409, "User doesn't exist"); - - return findUser; - } - - public async createUser(userData: User): Promise { - const findUser: User = UserModel.find(user => user.email === userData.email); - if (findUser) throw new HttpException(409, `This email ${userData.email} already exists`); - - const hashedPassword = await hash(userData.password, 10); - const createUserData: User = { ...userData, id: UserModel.length + 1, password: hashedPassword }; - - return createUserData; - } - - public async updateUser(userId: number, userData: User): Promise { - const findUser: User = UserModel.find(user => user.id === userId); - if (!findUser) throw new HttpException(409, "User doesn't exist"); - - const hashedPassword = await hash(userData.password, 10); - const updateUserData: User[] = UserModel.map((user: User) => { - if (user.id === findUser.id) user = { ...userData, id: userId, password: hashedPassword }; - return user; - }); - - return updateUserData; - } - - public async deleteUser(userId: number): Promise { - const findUser: User = UserModel.find(user => user.id === userId); - if (!findUser) throw new HttpException(409, "User doesn't exist"); - - const deleteUserData: User[] = UserModel.filter(user => user.id !== findUser.id); - return deleteUserData; - } -} diff --git a/lib/default/src/test/auth.test.ts b/lib/default/src/test/auth.test.ts deleted file mode 100644 index 11ab020..0000000 --- a/lib/default/src/test/auth.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import request from 'supertest'; -import { App } from '@/app'; -import { User } from '@interfaces/users.interface'; -import { AuthRoute } from '@routes/auth.route'; - -afterAll(async () => { - await new Promise(resolve => setTimeout(() => resolve(), 500)); -}); - -describe('TEST Authorization API', () => { - const route = new AuthRoute(); - const app = new App([route]); - - describe('[POST] /signup', () => { - it('response should have the Create userData', () => { - const userData: User = { - email: 'example@email.com', - password: 'password123456789', - }; - - return request(app.getServer()).post('/signup').send(userData).expect(201); - }); - }); - - describe('[POST] /login', () => { - it('response should have the Set-Cookie header with the Authorization token', () => { - const userData: User = { - email: 'example1@email.com', - password: 'password123456789', - }; - - return request(app.getServer()) - .post('/login') - .send(userData) - .expect('Set-Cookie', /^Authorization=.+/) - .expect(200); - }); - }); - - // error: StatusCode : 404, Message : Authentication token missing - // describe('[POST] /logout', () => { - // it('logout Set-Cookie Authorization=; Max-age=0', () => { - // const route = new AuthRoute() - // const app = new App([route]); - - // return request(app.getServer()) - // .post('/logout') - // .expect('Set-Cookie', /^Authorization=\;/) - // .expect(200); - // }); - // }); -}); diff --git a/lib/default/src/test/users.test.ts b/lib/default/src/test/users.test.ts deleted file mode 100644 index 157dd83..0000000 --- a/lib/default/src/test/users.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import request from 'supertest'; -import { App } from '@/app'; -import { User } from '@interfaces/users.interface'; -import { UserModel } from '@models/users.model'; -import { UserRoute } from '@routes/users.route'; - -afterAll(async () => { - await new Promise(resolve => setTimeout(() => resolve(), 500)); -}); - -describe('TEST Users API', () => { - const route = new UserRoute(); - const app = new App([route]); - - describe('[GET] /users', () => { - it('response statusCode 200 /findAll', () => { - const findUser: User[] = UserModel; - - return request(app.getServer()).get(`${route.path}`).expect(200, { data: findUser, message: 'findAll' }); - }); - }); - - describe('[GET] /users/:id', () => { - it('response statusCode 200 /findOne', () => { - const userId = 1; - const findUser: User = UserModel.find(user => user.id === userId); - - return request(app.getServer()).get(`${route.path}/${userId}`).expect(200, { data: findUser, message: 'findOne' }); - }); - }); - - describe('[POST] /users', () => { - it('response statusCode 201 /created', async () => { - const userData: User = { - email: 'example@email.com', - password: 'password123456789', - }; - - return request(app.getServer()).post(`${route.path}`).send(userData).expect(201); - }); - }); - - describe('[PUT] /users/:id', () => { - it('response statusCode 200 /updated', async () => { - const userId = 1; - const userData: User = { - password: 'password123456789', - }; - - return request(app.getServer()).put(`${route.path}/${userId}`).send(userData).expect(200); - }); - }); - - describe('[DELETE] /users/:id', () => { - it('response statusCode 200 /deleted', () => { - const userId = 1; - const deleteUser: User[] = UserModel.filter(user => user.id !== userId); - - return request(app.getServer()).delete(`${route.path}/${userId}`).expect(200, { data: deleteUser, message: 'deleted' }); - }); - }); -}); diff --git a/lib/default/src/utils/validateEnv.ts b/lib/default/src/utils/validateEnv.ts deleted file mode 100644 index a6f1904..0000000 --- a/lib/default/src/utils/validateEnv.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { cleanEnv, port, str } from 'envalid'; - -export const ValidateEnv = () => { - cleanEnv(process.env, { - NODE_ENV: str(), - PORT: port(), - }); -}; diff --git a/lib/default/swagger.yaml b/lib/default/swagger.yaml deleted file mode 100644 index eebcb31..0000000 --- a/lib/default/swagger.yaml +++ /dev/null @@ -1,123 +0,0 @@ -tags: -- name: users - description: users API - -paths: -# [GET] users - /users: - get: - tags: - - users - summary: Find All Users - responses: - 200: - description: 'OK' - 500: - description: 'Server Error' - -# [POST] users - post: - tags: - - users - summary: Add User - parameters: - - name: body - in: body - description: user Data - required: true - schema: - $ref: '#/definitions/users' - responses: - 201: - description: 'Created' - 400: - description: 'Bad Request' - 409: - description: 'Conflict' - 500: - description: 'Server Error' - -# [GET] users/id - /users/{id}: - get: - tags: - - users - summary: Find User By Id - parameters: - - name: id - in: path - description: User Id - required: true - type: integer - responses: - 200: - description: 'OK' - 409: - description: 'Conflict' - 500: - description: 'Server Error' - -# [PUT] users/id - put: - tags: - - users - summary: Update User By Id - parameters: - - name: id - in: path - description: user Id - required: true - type: integer - - name: body - in: body - description: user Data - required: true - schema: - $ref: '#/definitions/users' - responses: - 200: - description: 'OK' - 400: - description: 'Bad Request' - 409: - description: 'Conflict' - 500: - description: 'Server Error' - -# [DELETE] users/id - delete: - tags: - - users - summary: Delete User By Id - parameters: - - name: id - in: path - description: user Id - required: true - type: integer - responses: - 200: - description: 'OK' - 409: - description: 'Conflict' - 500: - description: 'Server Error' - -# definitions -definitions: - users: - type: object - required: - - email - - password - properties: - email: - type: string - description: user Email - password: - type: string - description: user Password - -schemes: - - https - - http diff --git a/lib/default/.editorconfig b/templates/default/.editorconfig similarity index 78% rename from lib/default/.editorconfig rename to templates/default/.editorconfig index c6c8b36..4a7ea30 100644 --- a/lib/default/.editorconfig +++ b/templates/default/.editorconfig @@ -7,3 +7,6 @@ end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/templates/default/.env.development.local b/templates/default/.env.development.local new file mode 100644 index 0000000..f7ef67f --- /dev/null +++ b/templates/default/.env.development.local @@ -0,0 +1,22 @@ +# APP +PORT=3000 +NODE_ENV=development + +# JWT/토큰 +SECRET_KEY=yourSuperSecretKey + +# LOGGING +LOG_FORMAT=dev +LOG_DIR=../logs + +# CORS +ORIGIN=http://localhost:3000 +CREDENTIALS=true +CORS_ORIGINS=http://localhost:3000,https://yourdomain.com + +# SERVER URL +API_SERVER_URL=https://api.yourdomain.com + +# 추가(실전 활용시) +# SENTRY_DSN= +# REDIS_URL= \ No newline at end of file diff --git a/templates/default/.env.production.local b/templates/default/.env.production.local new file mode 100644 index 0000000..80191f8 --- /dev/null +++ b/templates/default/.env.production.local @@ -0,0 +1,22 @@ +# APP +PORT=3000 +NODE_ENV=production + +# JWT/토큰 +SECRET_KEY=yourSuperSecretKey + +# LOGGING +LOG_FORMAT=combined +LOG_DIR=../logs + +# CORS +ORIGIN=http://localhost:3000 +CREDENTIALS=true +CORS_ORIGINS=http://localhost:3000,https://yourdomain.com + +# SERVER URL +API_SERVER_URL=https://api.yourdomain.com + +# 추가(실전 활용시) +# SENTRY_DSN= +# REDIS_URL= \ No newline at end of file diff --git a/templates/default/.env.test.local b/templates/default/.env.test.local new file mode 100644 index 0000000..e29efdc --- /dev/null +++ b/templates/default/.env.test.local @@ -0,0 +1,22 @@ +# APP +PORT=3000 +NODE_ENV=test + +# JWT/토큰 +SECRET_KEY=yourSuperSecretKey + +# LOGGING +LOG_FORMAT=dev +LOG_DIR=../logs + +# CORS +ORIGIN=http://localhost:3000 +CREDENTIALS=true +CORS_ORIGINS=http://localhost:3000,https://yourdomain.com + +# SERVER URL +API_SERVER_URL=https://api.yourdomain.com + +# 추가(실전 활용시) +# SENTRY_DSN= +# REDIS_URL= diff --git a/lib/default/Makefile b/templates/default/Makefile similarity index 94% rename from lib/default/Makefile rename to templates/default/Makefile index 624834b..12fea01 100644 --- a/lib/default/Makefile +++ b/templates/default/Makefile @@ -40,3 +40,6 @@ clean: ## Clean the images remove: ## Remove the volumes docker volume rm -f ${APP_NAME} + +logs: ## Show container logs + docker logs -f ${APP_NAME} diff --git a/lib/default/jest.config.js b/templates/default/jest.config.js similarity index 57% rename from lib/default/jest.config.js rename to templates/default/jest.config.js index 8edf5ba..cc9ad30 100644 --- a/lib/default/jest.config.js +++ b/templates/default/jest.config.js @@ -1,6 +1,3 @@ -const { pathsToModuleNameMapper } = require('ts-jest'); -const { compilerOptions } = require('./tsconfig.json'); - module.exports = { preset: 'ts-jest', testEnvironment: 'node', @@ -9,4 +6,7 @@ module.exports = { '^.+\\.tsx?$': 'ts-jest', }, moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/src' }), + coverageDirectory: 'coverage', + collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/index.ts'], + testPathIgnorePatterns: ['/node_modules/', '/dist/', '/logs/'], }; diff --git a/templates/default/nginx.conf b/templates/default/nginx.conf new file mode 100644 index 0000000..20aa3e7 --- /dev/null +++ b/templates/default/nginx.conf @@ -0,0 +1,17 @@ + server { + listen 80; + server_name localhost; + + location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_http_version 1.1; + proxy_pass http://api-server; + } + + location /health { + return 200 'ok'; + add_header Content-Type text/plain; + } + } diff --git a/templates/default/nodemon.json b/templates/default/nodemon.json new file mode 100644 index 0000000..1fb5df9 --- /dev/null +++ b/templates/default/nodemon.json @@ -0,0 +1,7 @@ +{ + "watch": ["src", ".env"], + "ext": "js,ts,json", + "ignore": ["logs/*", "**/*.spec.ts", "**/*.test.ts"], + "exec": "ts-node -r tsconfig-paths/register --transpile-only src/server.ts", + "delay": "500" +} diff --git a/templates/default/package.json b/templates/default/package.json new file mode 100644 index 0000000..89c5d7f --- /dev/null +++ b/templates/default/package.json @@ -0,0 +1,67 @@ +{ + "name": "typescript-express-starter", + "version": "0.0.0", + "description": "", + "author": "", + "license": "MIT", + "scripts": { + "start": "npm run build && cross-env NODE_ENV=production node dist/server.js", + "dev": "cross-env NODE_ENV=development nodemon", + "build": "tsc && tsc-alias", + "test": "jest --forceExit --detectOpenHandles", + "test:unit": "jest --testPathPattern=unit", + "test:e2e": "jest --testPathPattern=E2E" + }, + "dependencies": { + "bcryptjs": "^3.0.2", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.2", + "compression": "^1.8.0", + "cookie-parser": "^1.4.7", + "cors": "^2.8.5", + "dotenv": "^17.1.0", + "envalid": "^8.0.0", + "express": "^5.1.0", + "express-rate-limit": "^7.5.1", + "helmet": "^8.1.0", + "hpp": "^0.2.3", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "reflect-metadata": "^0.2.2", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", + "tslib": "^2.8.1", + "typedi": "^0.10.0", + "winston": "^3.17.0", + "winston-daily-rotate-file": "^5.0.0" + }, + "devDependencies": { + "@types/compression": "^1.8.1", + "@types/cookie-parser": "^1.4.9", + "@types/cors": "^2.8.19", + "@types/express": "^5.0.3", + "@types/hpp": "^0.2.6", + "@types/jest": "^30.0.0", + "@types/jsonwebtoken": "^9.0.10", + "@types/morgan": "^1.9.10", + "@types/node": "^20.11.30", + "@types/supertest": "^6.0.3", + "@types/swagger-jsdoc": "^6.0.4", + "@types/swagger-ui-express": "^4.1.8", + "cross-env": "^7.0.3", + "eslint": "^9.30.1", + "jest": "^30.0.4", + "node-gyp": "^11.2.0", + "nodemon": "^3.1.10", + "rimraf": "^6.0.1", + "supertest": "^7.1.3", + "ts-jest": "^29.4.0", + "ts-node": "^10.9.2", + "tsc-alias": "^1.8.16", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.5.3" + }, + "engines": { + "node": ">=18.17.0" + } +} diff --git a/templates/default/src/app.ts b/templates/default/src/app.ts new file mode 100644 index 0000000..d338e9f --- /dev/null +++ b/templates/default/src/app.ts @@ -0,0 +1,151 @@ +import 'reflect-metadata'; +import compression from 'compression'; +import cookieParser from 'cookie-parser'; +import cors from 'cors'; +import express from 'express'; +import helmet from 'helmet'; +import hpp from 'hpp'; +import morgan from 'morgan'; +import swaggerJSDoc from 'swagger-jsdoc'; +import swaggerUi from 'swagger-ui-express'; +import rateLimit from 'express-rate-limit'; +import { NODE_ENV, PORT, LOG_FORMAT, CREDENTIALS } from '@config/env'; +import { Routes } from '@interfaces/routes.interface'; +import { ErrorMiddleware } from '@middlewares/error.middleware'; +import { logger, stream } from '@utils/logger'; + +class App { + public app: express.Application; + public env: string; + public port: string | number; + + constructor(routes: Routes[], apiPrefix = '/api/v1') { + this.app = express(); + this.env = NODE_ENV || 'development'; + this.port = PORT || 3000; + + this.initializeTrustProxy(); + this.initializeMiddlewares(); + this.initializeRoutes(routes, apiPrefix); + this.initializeSwagger(apiPrefix); + this.initializeErrorHandling(); + } + + public listen() { + const server = this.app.listen(this.port, () => { + logger.info(`=================================`); + logger.info(`======= ENV: ${this.env} =======`); + logger.info(`🚀 App listening on the port ${this.port}`); + logger.info(`=================================`); + }); + + return server; + } + + public getServer() { + return this.app; + } + + private initializeTrustProxy() { + // Nginx, Heroku, Cloudflare 등 프록시 환경에서 실IP 추출을 위해 필요 + this.app.set('trust proxy', 1); + } + + private initializeMiddlewares() { + this.app.use( + rateLimit({ + windowMs: 60 * 1000, // 1분 + max: this.env === 'production' ? 100 : 0, // 개발환경에서는 제한 없음 + message: { error: 'Too many requests, please try again later.' }, + keyGenerator: req => req.ip || '', // 신뢰 프록시 하에서 실IP, undefined 방지 + skip: req => this.env !== 'production' || req.ip === '127.0.0.1', + legacyHeaders: false, + standardHeaders: true, + }), + ); + + this.app.use(morgan(LOG_FORMAT || 'dev', { stream })); + + // CORS 화이트리스트를 환경변수에서 관리 + const allowedOrigins = process.env.CORS_ORIGINS?.split(',').map(origin => origin.trim()) || ['http://localhost:3000']; + + this.app.use( + cors({ + origin: (origin, callback) => { + if (!origin || allowedOrigins.includes(origin)) { + callback(null, true); + } else { + callback(new Error('Not allowed by CORS')); + } + }, + credentials: CREDENTIALS, + }), + ); + + this.app.use(hpp()); + this.app.use( + helmet({ + contentSecurityPolicy: + this.env === 'production' + ? { + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + objectSrc: ["'none'"], + upgradeInsecureRequests: [], + }, + } + : false, // 개발 환경에서는 CSP 비활성화 (hot reload 등 편의) + referrerPolicy: { policy: 'no-referrer' }, + }), + ); + this.app.use(compression()); + this.app.use(express.json({ limit: '10mb' })); + this.app.use(express.urlencoded({ extended: true, limit: '10mb' })); + this.app.use(cookieParser()); + } + + private initializeRoutes(routes: Routes[], apiPrefix: string) { + routes.forEach(route => { + this.app.use(apiPrefix, route.router); + }); + } + + private initializeSwagger(apiPrefix: string) { + const options = { + swaggerDefinition: { + openapi: '3.0.0', + info: { + title: 'REST API', + version: '1.0.0', + description: 'Example API Documentation', + }, + servers: [ + { + url: process.env.API_SERVER_URL || `http://localhost:${this.port}${apiPrefix}`, + description: this.env === 'production' ? 'Production server' : 'Local server', + }, + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', + }, + }, + }, + }, + apis: ['swagger.yaml', 'src/controllers/*.ts'], + }; + + const specs = swaggerJSDoc(options); + this.app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(specs)); + } + + private initializeErrorHandling() { + this.app.use(ErrorMiddleware); + } +} + +export default App; diff --git a/lib/default/src/config/index.ts b/templates/default/src/config/env.ts similarity index 100% rename from lib/default/src/config/index.ts rename to templates/default/src/config/env.ts diff --git a/templates/default/src/config/validateEnv.ts b/templates/default/src/config/validateEnv.ts new file mode 100644 index 0000000..907e97a --- /dev/null +++ b/templates/default/src/config/validateEnv.ts @@ -0,0 +1,16 @@ +import { cleanEnv, port, str, bool, url } from 'envalid'; + +export const validateEnv = () => { + cleanEnv(process.env, { + NODE_ENV: str({ choices: ['development', 'production', 'test'] }), + PORT: port(), + SECRET_KEY: str(), + LOG_FORMAT: str(), + LOG_DIR: str(), + ORIGIN: str(), + CREDENTIALS: bool(), + // 옵션 환경변수 예시 + SENTRY_DSN: str({ default: '' }), + REDIS_URL: url({ default: 'redis://localhost:6379' }), + }); +}; diff --git a/templates/default/src/controllers/auth.controller.ts b/templates/default/src/controllers/auth.controller.ts new file mode 100644 index 0000000..77084cc --- /dev/null +++ b/templates/default/src/controllers/auth.controller.ts @@ -0,0 +1,51 @@ +import { Request, Response, NextFunction } from 'express'; +import { Service } from 'typedi'; +import { RequestWithUser } from '@interfaces/auth.interface'; +import { User } from '@interfaces/users.interface'; +import { AuthService } from '@services/auth.service'; + +@Service() +export class AuthController { + constructor(private readonly authService: AuthService) {} + + public signUp = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userData: User = req.body; + const signUpUserData = await this.authService.signup(userData); + res.status(201).json({ data: signUpUserData, message: 'signup' }); + } catch (error) { + next(error); + } + }; + + public logIn = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const loginData: User = req.body; + const { cookie, user } = await this.authService.login(loginData); + + res.setHeader('Set-Cookie', [cookie]); + res.status(200).json({ data: user, message: 'login' }); + } catch (error) { + next(error); + } + }; + + public logOut = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userReq = req as RequestWithUser; + const user = userReq.user; + await this.authService.logout(user); + + // res.setHeader('Set-Cookie', ['Authorization=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax']); + res.clearCookie('Authorization', { + httpOnly: true, + path: '/', + sameSite: 'lax', + // secure: true, // 프로덕션에서 HTTPS일 때만 + }); + res.status(200).json({ message: 'logout' }); + } catch (error) { + next(error); + } + }; +} diff --git a/templates/default/src/controllers/users.controller.ts b/templates/default/src/controllers/users.controller.ts new file mode 100644 index 0000000..c728db2 --- /dev/null +++ b/templates/default/src/controllers/users.controller.ts @@ -0,0 +1,59 @@ +import { Request, Response, NextFunction } from 'express'; +import { Service } from 'typedi'; +import { User } from '@interfaces/users.interface'; +import { UsersService } from '@services/users.service'; + +@Service() +export class UsersController { + constructor(private readonly userService: UsersService) {} + + getUsers = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const users = await this.userService.getAllUsers(); + res.json({ data: users }); + } catch (e) { + next(e); + } + }; + + getUserById = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userId: string = req.params.id; + const user = await this.userService.getUserById(userId); + res.json({ data: user }); + } catch (e) { + next(e); + } + }; + + createUser = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userData: User = req.body; + const user = await this.userService.createUser(userData); + res.status(201).json({ data: user }); + } catch (e) { + next(e); + } + }; + + updateUser = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userId: string = req.params.id; + const userData: User = req.body; + const user = await this.userService.updateUser(userId, userData); + res.json({ data: user }); + } catch (e) { + next(e); + } + }; + + deleteUser = async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const userId: string = req.params.id; + await this.userService.deleteUser(userId); + res.status(204).send(); + } catch (e) { + next(e); + } + }; +} diff --git a/templates/default/src/dtos/users.dto.ts b/templates/default/src/dtos/users.dto.ts new file mode 100644 index 0000000..a4c57fb --- /dev/null +++ b/templates/default/src/dtos/users.dto.ts @@ -0,0 +1,22 @@ +import { IsEmail, IsString, IsNotEmpty, MinLength, MaxLength, IsOptional } from 'class-validator'; + +export class PasswordDto { + @IsString() + @IsNotEmpty() + @MinLength(9, { message: 'Password must be at least 9 characters long.' }) + @MaxLength(32, { message: 'Password must be at most 32 characters long.' }) + public password!: string; +} + +export class CreateUserDto extends PasswordDto { + @IsEmail({}, { message: 'Invalid email format.' }) + public email!: string; +} + +export class UpdateUserDto { + @IsOptional() + @IsString() + @MinLength(9, { message: 'Password must be at least 9 characters long.' }) + @MaxLength(32, { message: 'Password must be at most 32 characters long.' }) + public password?: string; +} diff --git a/templates/default/src/exceptions/httpException.ts b/templates/default/src/exceptions/httpException.ts new file mode 100644 index 0000000..ac7b567 --- /dev/null +++ b/templates/default/src/exceptions/httpException.ts @@ -0,0 +1,14 @@ +export class HttpException extends Error { + public status: number; + public message: string; + public data?: unknown; + + constructor(status: number, message: string, data?: unknown) { + super(message); + this.status = status; + this.message = message; + this.data = data; + this.name = this.constructor.name; + Error.captureStackTrace?.(this, this.constructor); + } +} diff --git a/lib/default/src/interfaces/auth.interface.ts b/templates/default/src/interfaces/auth.interface.ts similarity index 92% rename from lib/default/src/interfaces/auth.interface.ts rename to templates/default/src/interfaces/auth.interface.ts index 83947b8..76f16bd 100644 --- a/lib/default/src/interfaces/auth.interface.ts +++ b/templates/default/src/interfaces/auth.interface.ts @@ -2,7 +2,7 @@ import { Request } from 'express'; import { User } from '@interfaces/users.interface'; export interface DataStoredInToken { - id: number; + id: number | string; } export interface TokenData { diff --git a/lib/default/src/interfaces/routes.interface.ts b/templates/default/src/interfaces/routes.interface.ts similarity index 100% rename from lib/default/src/interfaces/routes.interface.ts rename to templates/default/src/interfaces/routes.interface.ts diff --git a/templates/default/src/interfaces/users.interface.ts b/templates/default/src/interfaces/users.interface.ts new file mode 100644 index 0000000..82761d9 --- /dev/null +++ b/templates/default/src/interfaces/users.interface.ts @@ -0,0 +1,5 @@ +export interface User { + id?: string | number; // uuid or number-string + email: string; + password: string; +} diff --git a/templates/default/src/middlewares/auth.middleware.ts b/templates/default/src/middlewares/auth.middleware.ts new file mode 100644 index 0000000..1ed5ce5 --- /dev/null +++ b/templates/default/src/middlewares/auth.middleware.ts @@ -0,0 +1,49 @@ +import { Request, Response, NextFunction } from 'express'; +import { verify, TokenExpiredError, JsonWebTokenError } from 'jsonwebtoken'; +import { SECRET_KEY } from '@config/env'; +import { HttpException } from '@exceptions/httpException'; +import { DataStoredInToken, RequestWithUser } from '@interfaces/auth.interface'; +import { Container } from 'typedi'; +import { IUsersRepository } from '@repositories/users.repository'; + +const getAuthorization = (req: RequestWithUser) => { + const cookie = req.cookies['Authorization']; + if (cookie) return cookie; + + const header = req.header('Authorization'); + if (header && header.startsWith('Bearer ')) { + return header.replace('Bearer ', '').trim(); + } + return null; +}; + +export const AuthMiddleware = async (req: Request, res: Response, next: NextFunction) => { + try { + const userReq = req as RequestWithUser; + const token = getAuthorization(userReq); + if (!token) return next(new HttpException(401, 'Authentication token missing')); + + let payload: DataStoredInToken; + try { + payload = verify(token, SECRET_KEY as string) as DataStoredInToken; + } catch (err) { + if (err instanceof TokenExpiredError) { + return next(new HttpException(401, 'Authentication token expired')); + } + if (err instanceof JsonWebTokenError) { + return next(new HttpException(401, 'Invalid authentication token')); + } + return next(new HttpException(401, 'Authentication failed')); + } + + // 타입 일치 유의 (number/string) + const userRepo = Container.get('UsersRepository'); + const findUser = await userRepo.findById(String(payload.id)); + if (!findUser) return next(new HttpException(401, 'User not found with this token')); + + (req as RequestWithUser).user = findUser; + next(); + } catch (error) { + next(new HttpException(500, 'Authentication middleware error')); + } +}; diff --git a/templates/default/src/middlewares/error.middleware.ts b/templates/default/src/middlewares/error.middleware.ts new file mode 100644 index 0000000..46b98fd --- /dev/null +++ b/templates/default/src/middlewares/error.middleware.ts @@ -0,0 +1,19 @@ +import { NextFunction, Request, Response } from 'express'; +import { HttpException } from '@exceptions/httpException'; +import { logger } from '@utils/logger'; + +export const ErrorMiddleware = (error: HttpException, req: Request, res: Response, next: NextFunction) => { + const status = error.status || 500; + const message = error.message || 'Something went wrong'; + logger.error(`[${req.method}] ${req.originalUrl} | Status: ${status} | Message: ${message}`); + + res.status(status).json({ + success: false, + error: { + code: status, + message, + ...(process.env.NODE_ENV === 'development' && error.stack ? { stack: error.stack } : {}), + ...(typeof error.data === 'object' && error.data !== null ? { data: error.data } : {}), + }, + }); +}; diff --git a/templates/default/src/middlewares/validation.middleware.ts b/templates/default/src/middlewares/validation.middleware.ts new file mode 100644 index 0000000..3c0a4de --- /dev/null +++ b/templates/default/src/middlewares/validation.middleware.ts @@ -0,0 +1,20 @@ +import { plainToInstance } from 'class-transformer'; +import { validateOrReject, ValidationError } from 'class-validator'; +import { NextFunction, Request, Response } from 'express'; +import { HttpException } from '@exceptions/httpException'; + +export const ValidationMiddleware = + (type: any, skipMissingProperties = false, whitelist = true, forbidNonWhitelisted = true) => + async (req: Request, res: Response, next: NextFunction) => { + const dto = plainToInstance(type, req.body); + try { + await validateOrReject(dto, { skipMissingProperties, whitelist, forbidNonWhitelisted }); + req.body = dto; + next(); + } catch (errors: ValidationError[] | any) { + const message = Array.isArray(errors) + ? errors.map((error: ValidationError) => Object.values(error.constraints || {}).join(', ')).join(', ') + : String(errors); + next(new HttpException(400, message)); + } + }; diff --git a/templates/default/src/repositories/users.repository.ts b/templates/default/src/repositories/users.repository.ts new file mode 100644 index 0000000..f4dd0ff --- /dev/null +++ b/templates/default/src/repositories/users.repository.ts @@ -0,0 +1,52 @@ +import { Service } from 'typedi'; +import { User } from '@interfaces/users.interface'; + +export interface IUsersRepository { + findAll(): Promise; + findById(id: string): Promise; + findByEmail(email: string): Promise; + save(user: User): Promise; + update(id: string, update: User): Promise; + delete(id: string): Promise; +} + +@Service('UsersRepository') +export class UsersRepository implements IUsersRepository { + private users: User[] = []; + + async findAll(): Promise { + return this.users; + } + + async findById(id: string): Promise { + return this.users.find(u => u.id === id); + } + + async findByEmail(email: string): Promise { + return this.users.find(u => u.email === email); + } + + async save(user: User): Promise { + this.users.push(user); + return user; + } + + async update(id: string, user: User): Promise { + const idx = this.users.findIndex(u => u.id === id); + if (idx === -1) return undefined; + + this.users[idx] = { ...this.users[idx], password: user.password }; + return this.users[idx]; + } + + async delete(id: string): Promise { + const idx = this.users.findIndex(u => u.id === id); + if (idx === -1) return false; + this.users.splice(idx, 1); + return true; + } + + reset() { + this.users = []; + } +} diff --git a/templates/default/src/routes/auth.route.ts b/templates/default/src/routes/auth.route.ts new file mode 100644 index 0000000..26b0846 --- /dev/null +++ b/templates/default/src/routes/auth.route.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { Service, Inject } from 'typedi'; +import { AuthController } from '@controllers/auth.controller'; +import { CreateUserDto } from '@dtos/users.dto'; +import { Routes } from '@interfaces/routes.interface'; +import { AuthMiddleware } from '@middlewares/auth.middleware'; +import { ValidationMiddleware } from '@middlewares/validation.middleware'; + +@Service() +export class AuthRoute implements Routes { + public router: Router = Router(); + public path = '/auth'; + + constructor(@Inject() private authController: AuthController) { + this.initializeRoutes(); + } + + private initializeRoutes() { + this.router.post(`${this.path}/signup`, ValidationMiddleware(CreateUserDto), this.authController.signUp); + this.router.post(`${this.path}/login`, ValidationMiddleware(CreateUserDto), this.authController.logIn); + this.router.post(`${this.path}/logout`, AuthMiddleware, this.authController.logOut); + } +} diff --git a/templates/default/src/routes/users.route.ts b/templates/default/src/routes/users.route.ts new file mode 100644 index 0000000..95cad01 --- /dev/null +++ b/templates/default/src/routes/users.route.ts @@ -0,0 +1,24 @@ +import { Router } from 'express'; +import { Service, Inject } from 'typedi'; +import { UsersController } from '@controllers/users.controller'; +import { CreateUserDto, UpdateUserDto } from '@dtos/users.dto'; +import { Routes } from '@interfaces/routes.interface'; +import { ValidationMiddleware } from '@middlewares/validation.middleware'; + +@Service() +export class UsersRoute implements Routes { + public router: Router = Router(); + public path = '/users'; + + constructor(@Inject() private userController: UsersController) { + this.initializeRoutes(); + } + + private initializeRoutes() { + this.router.get(this.path, this.userController.getUsers); + this.router.get(`${this.path}/:id`, this.userController.getUserById); + this.router.post(this.path, ValidationMiddleware(CreateUserDto), this.userController.createUser); + this.router.put(`${this.path}/:id`, ValidationMiddleware(UpdateUserDto), this.userController.updateUser); + this.router.delete(`${this.path}/:id`, this.userController.deleteUser); + } +} diff --git a/templates/default/src/server.ts b/templates/default/src/server.ts new file mode 100644 index 0000000..8c273d3 --- /dev/null +++ b/templates/default/src/server.ts @@ -0,0 +1,39 @@ +import { Container } from 'typedi'; +import App from '@/app'; +import { validateEnv } from '@config/validateEnv'; +import { UsersRepository } from '@repositories/users.repository'; +import { AuthRoute } from '@routes/auth.route'; +import { UsersRoute } from '@routes/users.route'; + +// 환경변수 유효성 검증 +validateEnv(); + +// DI 등록 +Container.set('UsersRepository', new UsersRepository()); + +// 라우트 모듈을 필요에 따라 동적으로 배열화 가능 +const routes = [Container.get(UsersRoute), Container.get(AuthRoute)]; + +// API prefix는 app.ts에서 기본값 세팅, 필요하면 인자로 전달 +const appInstance = new App(routes); + +// listen()이 서버 객체(http.Server)를 반환하도록 app.ts를 살짝 수정 +const server = appInstance.listen(); + +// Graceful Shutdown: 운영환경에서 필수! +if (server && typeof server.close === 'function') { + // SIGINT: Ctrl+C, SIGTERM: Docker/k8s kill 등 + ['SIGINT', 'SIGTERM'].forEach(signal => { + process.on(signal, () => { + console.log(`Received ${signal}, closing server...`); + server.close(() => { + console.log('HTTP server closed gracefully'); + // 필요하면 DB/Redis 등 외부 자원 해제 코드 추가 + process.exit(0); + }); + }); + }); +} + +// 테스트 코드 등에서 서버 객체 활용하고 싶으면 +export default server; diff --git a/templates/default/src/services/auth.service.ts b/templates/default/src/services/auth.service.ts new file mode 100644 index 0000000..ec6d2f7 --- /dev/null +++ b/templates/default/src/services/auth.service.ts @@ -0,0 +1,62 @@ +import { hash, compare } from 'bcryptjs'; +import { sign } from 'jsonwebtoken'; +import { Service, Inject } from 'typedi'; +import { SECRET_KEY } from '@config/env'; +import { HttpException } from '@exceptions/httpException'; +import { DataStoredInToken, TokenData } from '@interfaces/auth.interface'; +import { User } from '@interfaces/users.interface'; +import { IUsersRepository } from '@repositories/users.repository'; + +@Service() +export class AuthService { + constructor(@Inject('UsersRepository') private usersRepository: IUsersRepository) {} + + private createToken(user: User): TokenData { + if (!SECRET_KEY) throw new Error('SECRET_KEY is not defined'); + + if (user.id === undefined) { + throw new Error('User id is undefined'); + } + + const dataStoredInToken: DataStoredInToken = { id: user.id }; + const expiresIn = 60 * 60; // 1h + const token = sign(dataStoredInToken, SECRET_KEY as string, { expiresIn }); + return { expiresIn, token }; + } + + private createCookie(tokenData: TokenData): string { + return `Authorization=${tokenData.token}; HttpOnly; Max-Age=${tokenData.expiresIn}; Path=/; SameSite=Lax;${process.env.NODE_ENV === 'production' ? ' Secure;' : ''}`; + } + + public async signup(userData: User): Promise { + const findUser = await this.usersRepository.findByEmail(userData.email); + if (findUser) throw new HttpException(409, `Email is already in use`); + + const hashedPassword = await hash(userData.password, 10); + const newUser: User = { id: String(Date.now()), email: userData.email, password: hashedPassword }; + + await this.usersRepository.save(newUser); + return newUser; + } + + public async login(loginData: User): Promise<{ cookie: string; user: User }> { + const findUser = await this.usersRepository.findByEmail(loginData.email); + if (!findUser) throw new HttpException(401, `Invalid email or password.`); + + const isPasswordMatching = await compare(loginData.password, findUser.password); + if (!isPasswordMatching) throw new HttpException(401, 'Password is incorrect'); + + const tokenData = this.createToken(findUser); + const cookie = this.createCookie(tokenData); + + return { cookie, user: findUser }; + } + + public async logout(user: User): Promise { + // 로그아웃은 실제 서비스에서는 서버에서 세션/리프레시토큰을 블랙리스트 처리 등 구현 가능 + // 여기서는 클라이언트의 쿠키를 삭제하면 충분 + console.log(`User with email ${user.email} logged out.`); + + return; + } +} diff --git a/templates/default/src/services/users.service.ts b/templates/default/src/services/users.service.ts new file mode 100644 index 0000000..01503e8 --- /dev/null +++ b/templates/default/src/services/users.service.ts @@ -0,0 +1,46 @@ +import { hash } from 'bcryptjs'; +import { Service, Inject } from 'typedi'; +import { User } from '@interfaces/users.interface'; +import { IUsersRepository } from '@repositories/users.repository'; +import { HttpException } from '@exceptions/httpException'; + +@Service() +export class UsersService { + constructor(@Inject('UsersRepository') private usersRepository: IUsersRepository) {} + + async getAllUsers(): Promise { + return this.usersRepository.findAll(); + } + + async getUserById(id: string): Promise { + const user = await this.usersRepository.findById(id); + if (!user) throw new HttpException(404, 'User not found'); + return user; + } + + async createUser(user: User): Promise { + const exists = await this.usersRepository.findByEmail(user.email); + if (exists) throw new HttpException(409, 'Email already exists'); + + const hashedPassword = await hash(user.password, 10); + const created: User = { id: String(Date.now()), email: user.email, password: hashedPassword }; + return await this.usersRepository.save(created); + } + + async updateUser(id: string, update: User): Promise { + const exists = await this.usersRepository.findById(id); + if (!exists) throw new HttpException(404, 'User not found'); + + const hashedPassword = await hash(update.password, 10); + const user = { ...update, password: hashedPassword }; + + const updated = await this.usersRepository.update(id, user); + if (!updated) throw new HttpException(404, 'User not found'); + return updated; + } + + async deleteUser(id: string): Promise { + const deleted = await this.usersRepository.delete(id); + if (!deleted) throw new HttpException(404, 'User not found'); + } +} diff --git a/templates/default/src/test/e2e/auth.e2e.spec.ts b/templates/default/src/test/e2e/auth.e2e.spec.ts new file mode 100644 index 0000000..552ac2c --- /dev/null +++ b/templates/default/src/test/e2e/auth.e2e.spec.ts @@ -0,0 +1,40 @@ +import request from 'supertest'; +import { createTestApp, resetUserDB } from '@/test/setup'; + +describe('Auth API', () => { + let server: any; + const prefix = '/api/v1'; + + beforeAll(() => { + server = createTestApp(); // 공유 리포지토리 사용 + }); + + beforeEach(() => { + resetUserDB(); // 각 테스트 전에 리포지토리 초기화 + }); + + const user = { email: 'authuser@example.com', password: 'authpassword123' }; + + it('should signup a user', async () => { + const res = await request(server).post(`${prefix}/auth/signup`).send(user); + expect(res.statusCode).toBe(201); + expect(res.body.data.email).toBe(user.email); + }); + + it('should login a user and set cookie', async () => { + await request(server).post(`${prefix}/auth/signup`).send(user); + const res = await request(server).post(`${prefix}/auth/login`).send(user); + expect(res.statusCode).toBe(200); + expect(res.body.data.email).toBe(user.email); + expect(res.header['set-cookie']).toBeDefined(); + }); + + it('should logout a user', async () => { + await request(server).post(`${prefix}/auth/signup`).send(user); + const loginRes = await request(server).post(`${prefix}/auth/login`).send(user); + const cookie = loginRes.headers['set-cookie']; + const logoutRes = await request(server).post(`${prefix}/auth/logout`).set('Cookie', cookie[0]); + expect(logoutRes.statusCode).toBe(200); + expect(logoutRes.body.message).toBe('logout'); + }); +}); diff --git a/templates/default/src/test/e2e/users.e2e.spec.ts b/templates/default/src/test/e2e/users.e2e.spec.ts new file mode 100644 index 0000000..e3e9e80 --- /dev/null +++ b/templates/default/src/test/e2e/users.e2e.spec.ts @@ -0,0 +1,71 @@ +import request from 'supertest'; +import { createTestApp, resetUserDB } from '@/test/setup'; + +describe('Users API', () => { + let server: any; + const prefix = '/api/v1'; + + beforeAll(() => { + server = createTestApp(); + }); + + beforeEach(() => { + resetUserDB(); + }); + + // 공통 유저 데이터 + const user = { email: 'user1@example.com', password: 'password123' }; + let userId: string; + + it('should create a user', async () => { + const res = await request(server).post(`${prefix}/users`).send(user); + expect(res.statusCode).toBe(201); + expect(res.body.data.email).toBe(user.email); + userId = res.body.data.id; + }); + + it('should get all users', async () => { + await request(server).post(`${prefix}/users`).send(user); + const res = await request(server).get(`${prefix}/users`); + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data[0].email).toBe(user.email); + }); + + it('should get a user by id', async () => { + // 먼저 유저 생성 + const createRes = await request(server).post(`${prefix}/users`).send(user); + const id = createRes.body.data.id; + + const res = await request(server).get(`${prefix}/users/${id}`); + expect(res.statusCode).toBe(200); + expect(res.body.data.email).toBe(user.email); + }); + + it('should update a user', async () => { + // 유저 생성 + const createRes = await request(server).post(`${prefix}/users`).send(user); + const id = createRes.body.data.id; + + const newPassword = 'newpassword123'; + const res = await request(server).put(`${prefix}/users/${id}`).send({ password: newPassword }); + expect(res.statusCode).toBe(200); + expect(res.body.data.id).toBe(id); + }); + + it('should delete a user', async () => { + // 유저 생성 + const createRes = await request(server).post(`${prefix}/users`).send(user); + const id = createRes.body.data.id; + + const res = await request(server).delete(`${prefix}/users/${id}`); + expect(res.statusCode).toBe(200); + expect(res.body.message).toBe('deleted'); + }); + + it('should return 404 if user does not exist', async () => { + const res = await request(server).get(`${prefix}/users/invalid-id`); + expect(res.statusCode).toBe(404); + expect(res.body.message).toMatch(/not exist|not found/i); + }); +}); diff --git a/templates/default/src/test/setup.ts b/templates/default/src/test/setup.ts new file mode 100644 index 0000000..b1d22e3 --- /dev/null +++ b/templates/default/src/test/setup.ts @@ -0,0 +1,25 @@ +import Container from 'typedi'; +import App from '@/app'; +import { AuthRoute } from '@routes/auth.route'; +import { UsersRoute } from '@routes/users.route'; +import { UsersRepository, IUsersRepository } from '@repositories/users.repository'; + +let sharedRepo: UsersRepository; + +export function createTestApp({ mockRepo }: { mockRepo?: IUsersRepository } = {}) { + if (!sharedRepo) { + sharedRepo = new UsersRepository(); + Container.set('UserRepository', sharedRepo); + } + if (mockRepo) Container.set('UserRepository', mockRepo); + + const routes = [Container.get(UsersRoute), Container.get(AuthRoute)]; + const appInstance = new App(routes); + return appInstance.getServer(); +} + +export function resetUserDB() { + if (sharedRepo) { + sharedRepo.reset(); + } +} diff --git a/templates/default/src/test/unit/services/auth.service.spec.ts b/templates/default/src/test/unit/services/auth.service.spec.ts new file mode 100644 index 0000000..5c7a313 --- /dev/null +++ b/templates/default/src/test/unit/services/auth.service.spec.ts @@ -0,0 +1,69 @@ +import { compare, hash } from 'bcryptjs'; +import { CreateUserDto } from '@dtos/users.dto'; +import { User } from '@interfaces/users.interface'; +import { UsersRepository } from '@repositories/users.repository'; +import { AuthService } from '@services/auth.service'; + +describe('AuthService (with UserMemoryRepository)', () => { + let authService: AuthService; + let userRepo: UsersRepository; + const testUser: User = { + id: '1', + email: 'authuser@example.com', + password: '', // 실제 해시로 교체 + }; + + beforeEach(async () => { + userRepo = new UsersRepository(); + testUser.password = await hash('plainpw', 10); + // 초기 유저 추가 + await userRepo.save({ ...testUser }); + authService = new AuthService(userRepo); // repo 주입 + }); + + it('signup: 신규 유저를 추가한다', async () => { + const dto: CreateUserDto = { + email: 'newuser@example.com', + password: 'newpassword123', + }; + const created = await authService.signup(dto); + expect(created.email).toBe(dto.email); + + const found = await userRepo.findByEmail(dto.email); + expect(found).toBeDefined(); + expect(await compare(dto.password, found!.password)).toBe(true); + }); + + it('signup: 중복 이메일은 에러 발생', async () => { + const dto: CreateUserDto = { + email: testUser.email, + password: 'anypw', + }; + await expect(authService.signup(dto)).rejects.toThrow(/already in use/); + }); + + it('login: 정상 로그인시 user, cookie 반환', async () => { + // 비밀번호 해시 생성 + const plainPassword = 'mySecret123'; + const email = 'loginuser@example.com'; + const hashed = await hash(plainPassword, 10); + await userRepo.save({ id: '2', email, password: hashed }); + + const result = await authService.login({ email, password: plainPassword }); + expect(result.user.email).toBe(email); + expect(result.cookie).toContain('Authorization='); + }); + + it('login: 이메일 또는 비밀번호가 틀리면 에러', async () => { + // 없는 이메일 + await expect(authService.login({ email: 'nobody@example.com', password: 'xxx' })).rejects.toThrow(/Invalid email or password/i); + + // 잘못된 비밀번호 + const email = testUser.email; + await expect(authService.login({ email, password: 'wrongpw' })).rejects.toThrow(/password/i); + }); + + it('logout: 정상 호출시 오류 없이 끝난다', async () => { + await expect(authService.logout(testUser)).resolves.toBeUndefined(); + }); +}); diff --git a/templates/default/src/test/unit/services/users.service.spec.ts b/templates/default/src/test/unit/services/users.service.spec.ts new file mode 100644 index 0000000..0e3fad7 --- /dev/null +++ b/templates/default/src/test/unit/services/users.service.spec.ts @@ -0,0 +1,89 @@ +import { User } from '@interfaces/users.interface'; +import { UsersRepository } from '@repositories/users.repository'; +import { UsersService } from '@services/users.service'; + +describe('UsersService (with UsersRepository)', () => { + let usersService: UsersService; + let userRepo: UsersRepository; + + // 샘플 유저 데이터 + const user1: User & { id: string } = { id: '1', email: 'one@example.com', password: 'pw1' }; + const user2: User & { id: string } = { id: '2', email: 'two@example.com', password: 'pw2' }; + + beforeEach(async () => { + userRepo = new UsersRepository(); + userRepo.reset(); + // users 직접 저장 (save는 비동기지만 초기화엔 await 생략해도 무방) + await userRepo.save({ ...user1 }); + await userRepo.save({ ...user2 }); + usersService = new UsersService(userRepo); + }); + + it('getAllUsers: 전체 유저 목록 반환', async () => { + const users = await usersService.getAllUsers(); + expect(users.length).toBe(2); + expect(users[0].email).toBe(user1.email); + }); + + it('getUserById: ID로 유저 조회', async () => { + const user = await usersService.getUserById('2'); + expect(user.email).toBe(user2.email); + }); + + it('getUserById: 없는 ID는 예외 발생', async () => { + await expect(usersService.getUserById('999')).rejects.toThrow(/not found/); + }); + + it('createUser: 새 유저 추가', async () => { + const created = await usersService.createUser({ + id: '', // 무시됨 + email: 'new@example.com', + password: 'pw3', + }); + expect(created.email).toBe('new@example.com'); + const all = await usersService.getAllUsers(); + expect(all.length).toBe(3); + }); + + it('createUser: 이미 존재하는 이메일은 예외', async () => { + await expect( + usersService.createUser({ + id: '', + email: user1.email, + password: 'pwX', + }), + ).rejects.toThrow(/exists/); + }); + + it('updateUser: 유저 비밀번호 수정', async () => { + const newPassword = 'newpw'; + const updated = await usersService.updateUser(user2.id as string, { + id: user2.id as string, + email: user2.email, + password: newPassword, + }); + expect(updated).toBeDefined(); + expect(updated!.password).not.toBe(user2.password); // 해시값으로 변경됨 + }); + + it('updateUser: 없는 ID는 예외', async () => { + await expect( + usersService.updateUser('999', { + id: '999', + email: 'no@no.com', + password: 'no', + }), + ).rejects.toThrow(/not found/); + }); + + it('deleteUser: 삭제 성공', async () => { + await usersService.deleteUser(user1.id as string); + const users = await usersService.getAllUsers(); + expect(users.length).toBe(1); + expect(users[0].id).toBe(user2.id); + }); + + it('deleteUser: 없는 ID는 예외', async () => { + await expect(usersService.deleteUser('999')).rejects.toThrow(/not found/); + }); +}); diff --git a/lib/default/src/utils/logger.ts b/templates/default/src/utils/logger.ts similarity index 61% rename from lib/default/src/utils/logger.ts rename to templates/default/src/utils/logger.ts index b8f43a9..a7cba1a 100644 --- a/lib/default/src/utils/logger.ts +++ b/templates/default/src/utils/logger.ts @@ -2,10 +2,12 @@ import { existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import winston from 'winston'; import winstonDaily from 'winston-daily-rotate-file'; -import { LOG_DIR } from '@config'; +import { LOG_DIR } from '@config/env'; + +const logLevel = process.env.LOG_LEVEL || 'info'; // logs dir -const logDir: string = join(__dirname, LOG_DIR); +const logDir: string = join(__dirname, LOG_DIR || '/logs'); if (!existsSync(logDir)) { mkdirSync(logDir); @@ -19,12 +21,8 @@ const logFormat = winston.format.printf(({ timestamp, level, message }) => `${ti * error: 0, warn: 1, info: 2, http: 3, verbose: 4, debug: 5, silly: 6 */ const logger = winston.createLogger({ - format: winston.format.combine( - winston.format.timestamp({ - format: 'YYYY-MM-DD HH:mm:ss', - }), - logFormat, - ), + level: logLevel, + format: winston.format.combine(winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), logFormat), transports: [ // debug log setting new winstonDaily({ @@ -48,13 +46,26 @@ const logger = winston.createLogger({ zippedArchive: true, }), ], + exitOnError: false, // uncaughtException 시 종료 방지 +}); + +if (process.env.NODE_ENV !== 'production') { + logger.add( + new winston.transports.Console({ + format: winston.format.combine(winston.format.splat(), winston.format.colorize()), + }), + ); +} + +process.on('uncaughtException', err => { + logger.error(`Uncaught Exception: ${err.message}`, { stack: err.stack }); + process.exit(1); }); -logger.add( - new winston.transports.Console({ - format: winston.format.combine(winston.format.splat(), winston.format.colorize()), - }), -); +process.on('unhandledRejection', reason => { + logger.error(`Unhandled Rejection: ${JSON.stringify(reason)}`); + process.exit(1); +}); const stream = { write: (message: string) => { diff --git a/templates/default/swagger.yaml b/templates/default/swagger.yaml new file mode 100644 index 0000000..2f6bb18 --- /dev/null +++ b/templates/default/swagger.yaml @@ -0,0 +1,124 @@ +openapi: 3.0.0 +info: + title: Express Typescript API + description: User API Docs + version: "1.0.0" +schemes: + - https + - http +tags: + - name: users + description: users API +paths: + /users: + get: + tags: [users] + summary: Find All Users + responses: + 200: + description: OK + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + 500: + description: Server Error + post: + tags: [users] + summary: Add User + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + 201: + description: Created + 400: + description: Bad Request + 409: + description: Conflict + 500: + description: Server Error + /users/{id}: + get: + tags: [users] + summary: Find User By Id + parameters: + - name: id + in: path + description: User Id + required: true + schema: + type: string + responses: + 200: + description: OK + content: + application/json: + schema: + $ref: '#/components/schemas/User' + 404: + description: Not Found + 500: + description: Server Error + put: + tags: [users] + summary: Update User By Id + parameters: + - name: id + in: path + description: user Id + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/User' + responses: + 200: + description: OK + 400: + description: Bad Request + 409: + description: Conflict + 500: + description: Server Error + delete: + tags: [users] + summary: Delete User By Id + parameters: + - name: id + in: path + description: user Id + required: true + schema: + type: string + responses: + 204: + description: No Content + 404: + description: Not Found + 500: + description: Server Error + +components: + schemas: + User: + type: object + required: + - email + - password + properties: + id: + type: string + email: + type: string + password: + type: string diff --git a/lib/default/tsconfig.json b/templates/default/tsconfig.json similarity index 73% rename from lib/default/tsconfig.json rename to templates/default/tsconfig.json index d18ecef..bbaf778 100644 --- a/lib/default/tsconfig.json +++ b/templates/default/tsconfig.json @@ -1,8 +1,9 @@ { "compileOnSave": false, "compilerOptions": { - "target": "es2017", - "lib": ["es2017", "esnext.asynciterable"], + "strict": true, + "target": "es2020", + "lib": ["es2020", "esnext.asynciterable"], "typeRoots": ["node_modules/@types"], "allowSyntheticDefaultImports": true, "experimentalDecorators": true, @@ -14,7 +15,7 @@ "sourceMap": true, "declaration": true, "outDir": "dist", - "allowJs": true, + "allowJs": false, "noEmit": false, "esModuleInterop": true, "resolveJsonModule": true, @@ -22,18 +23,18 @@ "baseUrl": "src", "paths": { "@/*": ["*"], - "@config": ["config"], + "@config/*": ["config/*"], "@controllers/*": ["controllers/*"], "@dtos/*": ["dtos/*"], "@exceptions/*": ["exceptions/*"], "@interfaces/*": ["interfaces/*"], "@middlewares/*": ["middlewares/*"], - "@models/*": ["models/*"], + "@repositories/*": ["repositories/*"], "@routes/*": ["routes/*"], "@services/*": ["services/*"], "@utils/*": ["utils/*"] } }, - "include": ["src/**/*.ts", "src/**/*.json", ".env"], - "exclude": ["node_modules", "src/http", "src/logs"] + "include": ["src/**/*.ts", "src/**/*.json"], + "exclude": ["node_modules", "dist", "coverage", "logs", "src/http"] } From 8ca356f2919426ff03dbc973715a94973a222964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=84=EA=B5=AC=EB=AA=AC?= <42952358+ljlm0402@users.noreply.github.com> Date: Tue, 15 Jul 2025 03:56:40 +0000 Subject: [PATCH 09/27] fix (test) convert all test descriptions and comments to English --- .../default/src/test/e2e/auth.e2e.spec.ts | 8 +++--- .../default/src/test/e2e/users.e2e.spec.ts | 16 +++++------- .../test/unit/services/auth.service.spec.ts | 22 ++++++++-------- .../test/unit/services/users.service.spec.ts | 26 +++++++++---------- 4 files changed, 34 insertions(+), 38 deletions(-) diff --git a/templates/default/src/test/e2e/auth.e2e.spec.ts b/templates/default/src/test/e2e/auth.e2e.spec.ts index 552ac2c..6e9ffe6 100644 --- a/templates/default/src/test/e2e/auth.e2e.spec.ts +++ b/templates/default/src/test/e2e/auth.e2e.spec.ts @@ -6,22 +6,22 @@ describe('Auth API', () => { const prefix = '/api/v1'; beforeAll(() => { - server = createTestApp(); // 공유 리포지토리 사용 + server = createTestApp(); // Use shared repository for testing }); beforeEach(() => { - resetUserDB(); // 각 테스트 전에 리포지토리 초기화 + resetUserDB(); // Reset repository before each test }); const user = { email: 'authuser@example.com', password: 'authpassword123' }; - it('should signup a user', async () => { + it('should successfully register a new user', async () => { const res = await request(server).post(`${prefix}/auth/signup`).send(user); expect(res.statusCode).toBe(201); expect(res.body.data.email).toBe(user.email); }); - it('should login a user and set cookie', async () => { + it('should login a user and set a cookie', async () => { await request(server).post(`${prefix}/auth/signup`).send(user); const res = await request(server).post(`${prefix}/auth/login`).send(user); expect(res.statusCode).toBe(200); diff --git a/templates/default/src/test/e2e/users.e2e.spec.ts b/templates/default/src/test/e2e/users.e2e.spec.ts index e3e9e80..bee363a 100644 --- a/templates/default/src/test/e2e/users.e2e.spec.ts +++ b/templates/default/src/test/e2e/users.e2e.spec.ts @@ -6,25 +6,24 @@ describe('Users API', () => { const prefix = '/api/v1'; beforeAll(() => { - server = createTestApp(); + server = createTestApp(); // Initialize server with shared repository }); beforeEach(() => { - resetUserDB(); + resetUserDB(); // Reset repository before each test }); - // 공통 유저 데이터 const user = { email: 'user1@example.com', password: 'password123' }; let userId: string; - it('should create a user', async () => { + it('should create a new user', async () => { const res = await request(server).post(`${prefix}/users`).send(user); expect(res.statusCode).toBe(201); expect(res.body.data.email).toBe(user.email); userId = res.body.data.id; }); - it('should get all users', async () => { + it('should retrieve all users', async () => { await request(server).post(`${prefix}/users`).send(user); const res = await request(server).get(`${prefix}/users`); expect(res.statusCode).toBe(200); @@ -32,8 +31,7 @@ describe('Users API', () => { expect(res.body.data[0].email).toBe(user.email); }); - it('should get a user by id', async () => { - // 먼저 유저 생성 + it('should retrieve a user by id', async () => { const createRes = await request(server).post(`${prefix}/users`).send(user); const id = createRes.body.data.id; @@ -42,8 +40,7 @@ describe('Users API', () => { expect(res.body.data.email).toBe(user.email); }); - it('should update a user', async () => { - // 유저 생성 + it('should update user information', async () => { const createRes = await request(server).post(`${prefix}/users`).send(user); const id = createRes.body.data.id; @@ -54,7 +51,6 @@ describe('Users API', () => { }); it('should delete a user', async () => { - // 유저 생성 const createRes = await request(server).post(`${prefix}/users`).send(user); const id = createRes.body.data.id; diff --git a/templates/default/src/test/unit/services/auth.service.spec.ts b/templates/default/src/test/unit/services/auth.service.spec.ts index 5c7a313..805d2db 100644 --- a/templates/default/src/test/unit/services/auth.service.spec.ts +++ b/templates/default/src/test/unit/services/auth.service.spec.ts @@ -10,18 +10,18 @@ describe('AuthService (with UserMemoryRepository)', () => { const testUser: User = { id: '1', email: 'authuser@example.com', - password: '', // 실제 해시로 교체 + password: '', // will be replaced with actual hash }; beforeEach(async () => { userRepo = new UsersRepository(); testUser.password = await hash('plainpw', 10); - // 초기 유저 추가 + // Add initial user await userRepo.save({ ...testUser }); - authService = new AuthService(userRepo); // repo 주입 + authService = new AuthService(userRepo); // inject repo }); - it('signup: 신규 유저를 추가한다', async () => { + it('should sign up a new user', async () => { const dto: CreateUserDto = { email: 'newuser@example.com', password: 'newpassword123', @@ -34,7 +34,7 @@ describe('AuthService (with UserMemoryRepository)', () => { expect(await compare(dto.password, found!.password)).toBe(true); }); - it('signup: 중복 이메일은 에러 발생', async () => { + it('should throw an error if email is already in use', async () => { const dto: CreateUserDto = { email: testUser.email, password: 'anypw', @@ -42,8 +42,8 @@ describe('AuthService (with UserMemoryRepository)', () => { await expect(authService.signup(dto)).rejects.toThrow(/already in use/); }); - it('login: 정상 로그인시 user, cookie 반환', async () => { - // 비밀번호 해시 생성 + it('should return user and cookie on successful login', async () => { + // Create user with hashed password const plainPassword = 'mySecret123'; const email = 'loginuser@example.com'; const hashed = await hash(plainPassword, 10); @@ -54,16 +54,16 @@ describe('AuthService (with UserMemoryRepository)', () => { expect(result.cookie).toContain('Authorization='); }); - it('login: 이메일 또는 비밀번호가 틀리면 에러', async () => { - // 없는 이메일 + it('should throw an error if email or password is incorrect', async () => { + // Non-existing email await expect(authService.login({ email: 'nobody@example.com', password: 'xxx' })).rejects.toThrow(/Invalid email or password/i); - // 잘못된 비밀번호 + // Incorrect password const email = testUser.email; await expect(authService.login({ email, password: 'wrongpw' })).rejects.toThrow(/password/i); }); - it('logout: 정상 호출시 오류 없이 끝난다', async () => { + it('should successfully logout without errors', async () => { await expect(authService.logout(testUser)).resolves.toBeUndefined(); }); }); diff --git a/templates/default/src/test/unit/services/users.service.spec.ts b/templates/default/src/test/unit/services/users.service.spec.ts index 0e3fad7..8d117a8 100644 --- a/templates/default/src/test/unit/services/users.service.spec.ts +++ b/templates/default/src/test/unit/services/users.service.spec.ts @@ -6,37 +6,37 @@ describe('UsersService (with UsersRepository)', () => { let usersService: UsersService; let userRepo: UsersRepository; - // 샘플 유저 데이터 + // Sample user data const user1: User & { id: string } = { id: '1', email: 'one@example.com', password: 'pw1' }; const user2: User & { id: string } = { id: '2', email: 'two@example.com', password: 'pw2' }; beforeEach(async () => { userRepo = new UsersRepository(); userRepo.reset(); - // users 직접 저장 (save는 비동기지만 초기화엔 await 생략해도 무방) + // Directly save users (save is async but await is optional for init) await userRepo.save({ ...user1 }); await userRepo.save({ ...user2 }); usersService = new UsersService(userRepo); }); - it('getAllUsers: 전체 유저 목록 반환', async () => { + it('getAllUsers: should return all users', async () => { const users = await usersService.getAllUsers(); expect(users.length).toBe(2); expect(users[0].email).toBe(user1.email); }); - it('getUserById: ID로 유저 조회', async () => { + it('getUserById: should return user by ID', async () => { const user = await usersService.getUserById('2'); expect(user.email).toBe(user2.email); }); - it('getUserById: 없는 ID는 예외 발생', async () => { + it('getUserById: should throw if ID does not exist', async () => { await expect(usersService.getUserById('999')).rejects.toThrow(/not found/); }); - it('createUser: 새 유저 추가', async () => { + it('createUser: should add a new user', async () => { const created = await usersService.createUser({ - id: '', // 무시됨 + id: '', // ignored email: 'new@example.com', password: 'pw3', }); @@ -45,7 +45,7 @@ describe('UsersService (with UsersRepository)', () => { expect(all.length).toBe(3); }); - it('createUser: 이미 존재하는 이메일은 예외', async () => { + it('createUser: should throw if email already exists', async () => { await expect( usersService.createUser({ id: '', @@ -55,7 +55,7 @@ describe('UsersService (with UsersRepository)', () => { ).rejects.toThrow(/exists/); }); - it('updateUser: 유저 비밀번호 수정', async () => { + it('updateUser: should update user password', async () => { const newPassword = 'newpw'; const updated = await usersService.updateUser(user2.id as string, { id: user2.id as string, @@ -63,10 +63,10 @@ describe('UsersService (with UsersRepository)', () => { password: newPassword, }); expect(updated).toBeDefined(); - expect(updated!.password).not.toBe(user2.password); // 해시값으로 변경됨 + expect(updated!.password).not.toBe(user2.password); // changed to hashed value }); - it('updateUser: 없는 ID는 예외', async () => { + it('updateUser: should throw if ID does not exist', async () => { await expect( usersService.updateUser('999', { id: '999', @@ -76,14 +76,14 @@ describe('UsersService (with UsersRepository)', () => { ).rejects.toThrow(/not found/); }); - it('deleteUser: 삭제 성공', async () => { + it('deleteUser: should delete user successfully', async () => { await usersService.deleteUser(user1.id as string); const users = await usersService.getAllUsers(); expect(users.length).toBe(1); expect(users[0].id).toBe(user2.id); }); - it('deleteUser: 없는 ID는 예외', async () => { + it('deleteUser: should throw if ID does not exist', async () => { await expect(usersService.deleteUser('999')).rejects.toThrow(/not found/); }); }); From 742ddd30d1f3e6f935a4eb948be90dede0b932aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=84=EA=B5=AC=EB=AA=AC?= <42952358+ljlm0402@users.noreply.github.com> Date: Tue, 15 Jul 2025 07:40:25 +0000 Subject: [PATCH 10/27] feat: migrate build system to tsup and refine tsconfig for alias support --- bin/starter.js | 11 +++++----- devtools/swc/.swcrc | 39 --------------------------------- devtools/tsup/tsup.config.ts | 14 ++++++++++++ templates/default/tsconfig.json | 36 +++++++++++++++--------------- 4 files changed, 38 insertions(+), 62 deletions(-) delete mode 100644 devtools/swc/.swcrc create mode 100644 devtools/tsup/tsup.config.ts diff --git a/bin/starter.js b/bin/starter.js index f9f9434..b5b073e 100644 --- a/bin/starter.js +++ b/bin/starter.js @@ -45,13 +45,14 @@ const DEVTOOLS = [ }, }, { - name: 'SWC', - value: 'swc', - files: ['.swcrc'], + name: 'tsup', + value: 'tsup', + files: ['tsup.config.ts'], pkgs: [], - devPkgs: ['@swc/core', '@swc/cli'], + devPkgs: ['tsup'], scripts: { - 'build:swc': 'swc src -d dist --source-maps --copy-files', + 'start:tsup': 'start": "node -r tsconfig-paths/register dist/index.js', + 'build:tsup': 'tsup', }, }, { diff --git a/devtools/swc/.swcrc b/devtools/swc/.swcrc deleted file mode 100644 index fb07f8a..0000000 --- a/devtools/swc/.swcrc +++ /dev/null @@ -1,39 +0,0 @@ -{ - "jsc": { - "parser": { - "syntax": "typescript", - "tsx": false, - "dynamicImport": true, - "decorators": true - }, - "transform": { - "legacyDecorator": true, - "decoratorMetadata": true - }, - "target": "esnext", - "externalHelpers": false, - "keepClassNames": true, - "loose": false, - "minify": { - "compress": false, - "mangle": false - }, - "baseUrl": "src", - "paths": { - "@/*": ["*"], - "@config/*": ["config/*"], - "@controllers/*": ["controllers/*"], - "@dtos/*": ["dtos/*"], - "@exceptions/*": ["exceptions/*"], - "@interfaces/*": ["interfaces/*"], - "@middlewares/*": ["middlewares/*"], - "@repositories/*": ["repositories/*"], - "@routes/*": ["routes/*"], - "@services/*": ["services/*"], - "@utils/*": ["utils/*"] - } - }, - "module": { - "type": "commonjs" - } -} diff --git a/devtools/tsup/tsup.config.ts b/devtools/tsup/tsup.config.ts new file mode 100644 index 0000000..c60f185 --- /dev/null +++ b/devtools/tsup/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], // CLI 등 추가시 여러 entry도 가능 + outDir: 'dist', + format: ['cjs'], // 필요시 'esm'도 ['cjs', 'esm'] + dts: true, + sourcemap: true, + clean: true, + minify: false, + target: 'es2020', + // alias, swc 옵션은 tsconfig.json 기준으로 자동 적용 + // external: [], // node_modules 제외하고 싶을 때 +}); diff --git a/templates/default/tsconfig.json b/templates/default/tsconfig.json index bbaf778..2fd24be 100644 --- a/templates/default/tsconfig.json +++ b/templates/default/tsconfig.json @@ -1,25 +1,14 @@ { - "compileOnSave": false, "compilerOptions": { - "strict": true, "target": "es2020", - "lib": ["es2020", "esnext.asynciterable"], - "typeRoots": ["node_modules/@types"], - "allowSyntheticDefaultImports": true, - "experimentalDecorators": true, - "emitDecoratorMetadata": true, - "forceConsistentCasingInFileNames": true, - "moduleResolution": "node", "module": "commonjs", - "pretty": true, - "sourceMap": true, - "declaration": true, + "moduleResolution": "node", "outDir": "dist", - "allowJs": false, - "noEmit": false, - "esModuleInterop": true, - "resolveJsonModule": true, - "importHelpers": true, + "declaration": true, + "sourceMap": true, + "strict": true, + "lib": ["es2020", "esnext.asynciterable"], + "baseUrl": "src", "paths": { "@/*": ["*"], @@ -33,7 +22,18 @@ "@routes/*": ["routes/*"], "@services/*": ["services/*"], "@utils/*": ["utils/*"] - } + }, + + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "importHelpers": true, + "forceConsistentCasingInFileNames": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "pretty": true, + "allowJs": false, + "noEmit": false }, "include": ["src/**/*.ts", "src/**/*.json"], "exclude": ["node_modules", "dist", "coverage", "logs", "src/http"] From e2892e758d37d9ad6e9ba7d97bba53e2cfa22381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Tue, 15 Jul 2025 23:54:38 +0900 Subject: [PATCH 11/27] feat (Devtools) Del tsup not support. -> Add swc --- bin/starter.js | 15 ++++++------- devtools/docker/Dockerfile.dev | 19 +++++++++++----- devtools/docker/Dockerfile.prod | 35 +++++++++++++++++++---------- devtools/swc/.swcrc | 39 +++++++++++++++++++++++++++++++++ devtools/tsup/tsup.config.ts | 14 ------------ templates/default/package.json | 2 +- 6 files changed, 84 insertions(+), 40 deletions(-) create mode 100644 devtools/swc/.swcrc delete mode 100644 devtools/tsup/tsup.config.ts diff --git a/bin/starter.js b/bin/starter.js index b5b073e..8b7cfdd 100644 --- a/bin/starter.js +++ b/bin/starter.js @@ -17,8 +17,8 @@ import fs from 'fs-extra'; import { execa } from 'execa'; import editJsonFile from 'edit-json-file'; import { fileURLToPath } from 'url'; -import recast from 'recast'; -import * as tsParser from 'recast/parsers/typescript.js'; +// import recast from 'recast'; +// import * as tsParser from 'recast/parsers/typescript.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const templatesDir = path.join(__dirname, '../templates'); @@ -45,14 +45,13 @@ const DEVTOOLS = [ }, }, { - name: 'tsup', - value: 'tsup', - files: ['tsup.config.ts'], + name: 'SWC', + value: 'swc', + files: ['.swcrc'], pkgs: [], - devPkgs: ['tsup'], + devPkgs: ['@swc/cli', '@swc/core'], scripts: { - 'start:tsup': 'start": "node -r tsconfig-paths/register dist/index.js', - 'build:tsup': 'tsup', + 'build:swc': 'swc src -d dist --strip-leading-paths --copy-files --delete-dir-on-start', }, }, { diff --git a/devtools/docker/Dockerfile.dev b/devtools/docker/Dockerfile.dev index c71cc71..00820f3 100644 --- a/devtools/docker/Dockerfile.dev +++ b/devtools/docker/Dockerfile.dev @@ -1,13 +1,20 @@ -FROM node:20-bullseye-slim +# 베이스 이미지 +FROM node:20-alpine +# 작업 디렉토리 생성 WORKDIR /app -COPY package*.json ./ -RUN npm ci +# 패키지 설치(캐시 활용) +COPY package.json pnpm-lock.yaml* yarn.lock* package-lock.json* ./ +RUN if [ -f yarn.lock ]; then yarn install; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install; \ + else npm install; fi -COPY . ./ +# 전체 코드 복사 (단, node_modules는 제외됨) +COPY . . -ENV NODE_ENV=development +# 개발용 포트 (예: 3000) EXPOSE 3000 -CMD ["npm", "run", "dev"] +# 개발용 명령어: nodemon, ts-node-dev 등 +CMD [ "npm", "run", "dev"] diff --git a/devtools/docker/Dockerfile.prod b/devtools/docker/Dockerfile.prod index 927830f..ee82926 100644 --- a/devtools/docker/Dockerfile.prod +++ b/devtools/docker/Dockerfile.prod @@ -1,22 +1,35 @@ -# Build stage -FROM node:20-bullseye-slim AS builder +# 1. Build Stage +FROM node:20-alpine AS builder + WORKDIR /app -COPY package*.json ./ -RUN npm ci +COPY package.json pnpm-lock.yaml* yarn.lock* package-lock.json* ./ +RUN if [ -f yarn.lock ]; then yarn install --production=false; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --prod=false; \ + else npm install --production=false; fi COPY . . -RUN npm run build -# Runtime stage -FROM node:20-bullseye-slim +# TypeScript 빌드 +RUN npx tsc + +# 2. Run Stage (더 가벼운 이미지 사용 가능) +FROM node:20-alpine + WORKDIR /app -ENV NODE_ENV=production +# 의존성(production only) +COPY package.json pnpm-lock.yaml* yarn.lock* package-lock.json* ./ +RUN if [ -f yarn.lock ]; then yarn install --production=true --frozen-lockfile; \ + elif [ -f pnpm-lock.yaml ]; then corepack enable && pnpm install --prod --frozen-lockfile; \ + else npm install --production --frozen-lockfile; fi -COPY --from=builder /app/package*.json ./ +# 빌드된 코드만 복사 (node_modules, dist) COPY --from=builder /app/dist ./dist -COPY --from=builder /app/node_modules ./node_modules + +# 환경설정 등 추가 복사 (필요시) +# COPY .env ./ EXPOSE 3000 -CMD ["npm", "run", "start"] + +CMD [ "npm", "start" ] diff --git a/devtools/swc/.swcrc b/devtools/swc/.swcrc new file mode 100644 index 0000000..d427bc3 --- /dev/null +++ b/devtools/swc/.swcrc @@ -0,0 +1,39 @@ +{ + "jsc": { + "parser": { + "syntax": "typescript", + "tsx": false, + "dynamicImport": true, + "decorators": true + }, + "transform": { + "legacyDecorator": true, + "decoratorMetadata": true + }, + "target": "es2017", + "externalHelpers": false, + "keepClassNames": true, + "loose": false, + "minify": { + "compress": false, + "mangle": false + }, + "baseUrl": "src", + "paths": { + "@/*": ["*"], + "@config/*": ["config/*"], + "@controllers/*": ["controllers/*"], + "@dtos/*": ["dtos/*"], + "@exceptions/*": ["exceptions/*"], + "@interfaces/*": ["interfaces/*"], + "@middlewares/*": ["middlewares/*"], + "@repositories/*": ["repositories/*"], + "@routes/*": ["routes/*"], + "@services/*": ["services/*"], + "@utils/*": ["utils/*"] + } + }, + "module": { + "type": "commonjs" + } +} diff --git a/devtools/tsup/tsup.config.ts b/devtools/tsup/tsup.config.ts deleted file mode 100644 index c60f185..0000000 --- a/devtools/tsup/tsup.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from 'tsup'; - -export default defineConfig({ - entry: ['src/index.ts'], // CLI 등 추가시 여러 entry도 가능 - outDir: 'dist', - format: ['cjs'], // 필요시 'esm'도 ['cjs', 'esm'] - dts: true, - sourcemap: true, - clean: true, - minify: false, - target: 'es2020', - // alias, swc 옵션은 tsconfig.json 기준으로 자동 적용 - // external: [], // node_modules 제외하고 싶을 때 -}); diff --git a/templates/default/package.json b/templates/default/package.json index 89c5d7f..5d70369 100644 --- a/templates/default/package.json +++ b/templates/default/package.json @@ -5,7 +5,7 @@ "author": "", "license": "MIT", "scripts": { - "start": "npm run build && cross-env NODE_ENV=production node dist/server.js", + "start": "cross-env NODE_ENV=production node dist/server.js", "dev": "cross-env NODE_ENV=development nodemon", "build": "tsc && tsc-alias", "test": "jest --forceExit --detectOpenHandles", From a061d042e25c0d12477789bab57a349b5de3b2ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=84=EA=B5=AC=EB=AA=AC?= <42952358+ljlm0402@users.noreply.github.com> Date: Wed, 16 Jul 2025 04:07:23 +0000 Subject: [PATCH 12/27] add devtool tsup --- bin/starter.js | 11 +++++++++++ devtools/tsup/tsup.config.ts | 15 +++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 devtools/tsup/tsup.config.ts diff --git a/bin/starter.js b/bin/starter.js index 8b7cfdd..3f2cff7 100644 --- a/bin/starter.js +++ b/bin/starter.js @@ -44,6 +44,17 @@ const DEVTOOLS = [ format: 'prettier --check .', }, }, + { + name: 'Tsup', + value: 'tsup', + files: ['tsup.config.ts'], + pkgs: [], + devPkgs: ['tsup', '@swc/core'], + scripts: { + 'start:tsup': 'node -r tsconfig-paths/register dist/server.js', + 'build:tsup': 'tsup', + }, + }, { name: 'SWC', value: 'swc', diff --git a/devtools/tsup/tsup.config.ts b/devtools/tsup/tsup.config.ts new file mode 100644 index 0000000..9184f14 --- /dev/null +++ b/devtools/tsup/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/server.ts'], // CLI 등 추가시 여러 entry도 가능 + outDir: 'dist', // 출력 디렉토리 + format: ['cjs'], // 필요시 'esm'도 ['cjs', 'esm'] + dts: true, // 타입 선언 파일(.d.ts) 생성 + sourcemap: true, // 소스맵 생성 + clean: true, // 빌드 전 dist 폴더 정리 + target: 'es2020', // 트랜스파일 대상 + minify: false, // JS 파일을 압축(minify) 해서 더 작게 만듭니다. + treeshake: true, //사용하지 않는 코드를 제거합니다. 기본은 true지만 명시 + // alias, swc 옵션은 tsconfig.json 기준으로 자동 적용 + // external: [], // node_modules 제외하고 싶을 때 +}); From 2dfbfe75a40ee3eab975593b168214786ea43a98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=84=EA=B5=AC=EB=AA=AC?= <42952358+ljlm0402@users.noreply.github.com> Date: Wed, 16 Jul 2025 04:57:59 +0000 Subject: [PATCH 13/27] fix: (tsup) import type, del devPkg @swc/core --- bin/starter.js | 2 +- templates/default/src/services/auth.service.ts | 2 +- templates/default/src/services/users.service.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bin/starter.js b/bin/starter.js index 3f2cff7..b3f257a 100644 --- a/bin/starter.js +++ b/bin/starter.js @@ -49,7 +49,7 @@ const DEVTOOLS = [ value: 'tsup', files: ['tsup.config.ts'], pkgs: [], - devPkgs: ['tsup', '@swc/core'], + devPkgs: ['tsup'], scripts: { 'start:tsup': 'node -r tsconfig-paths/register dist/server.js', 'build:tsup': 'tsup', diff --git a/templates/default/src/services/auth.service.ts b/templates/default/src/services/auth.service.ts index ec6d2f7..2776bee 100644 --- a/templates/default/src/services/auth.service.ts +++ b/templates/default/src/services/auth.service.ts @@ -5,7 +5,7 @@ import { SECRET_KEY } from '@config/env'; import { HttpException } from '@exceptions/httpException'; import { DataStoredInToken, TokenData } from '@interfaces/auth.interface'; import { User } from '@interfaces/users.interface'; -import { IUsersRepository } from '@repositories/users.repository'; +import type { IUsersRepository } from '@repositories/users.repository'; @Service() export class AuthService { diff --git a/templates/default/src/services/users.service.ts b/templates/default/src/services/users.service.ts index 01503e8..021568c 100644 --- a/templates/default/src/services/users.service.ts +++ b/templates/default/src/services/users.service.ts @@ -2,7 +2,7 @@ import { hash } from 'bcryptjs'; import { Service, Inject } from 'typedi'; import { User } from '@interfaces/users.interface'; import { IUsersRepository } from '@repositories/users.repository'; -import { HttpException } from '@exceptions/httpException'; +import type { HttpException } from '@exceptions/httpException'; @Service() export class UsersService { From 1bca3c8ac9f80b5019643aaf0ed5a4d0fc25c10a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Wed, 16 Jul 2025 21:18:43 +0900 Subject: [PATCH 14/27] fix default users.service.ts - import type --- bin/starter.js | 2 +- devtools/docker/nginx.conf | 67 +++++++++++++++++++ .../default/src/services/users.service.ts | 4 +- 3 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 devtools/docker/nginx.conf diff --git a/bin/starter.js b/bin/starter.js index b3f257a..d6b2bbc 100644 --- a/bin/starter.js +++ b/bin/starter.js @@ -68,7 +68,7 @@ const DEVTOOLS = [ { name: 'Docker', value: 'docker', - files: ['.dockerignore', 'docker-compose.yml', 'Dockerfile.dev', 'Dockerfile.prod'], + files: ['.dockerignore', 'docker-compose.yml', 'Dockerfile.dev', 'Dockerfile.prod', 'nginx.conf'], pkgs: [], devPkgs: [], scripts: {}, diff --git a/devtools/docker/nginx.conf b/devtools/docker/nginx.conf new file mode 100644 index 0000000..4cf72dc --- /dev/null +++ b/devtools/docker/nginx.conf @@ -0,0 +1,67 @@ +user nginx; +worker_processes 1; + +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # gzip compression (optional but recommended) + gzip on; + gzip_disable "msie6"; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + gzip_min_length 256; + + upstream api-server { + server server:3000; # 여러 서버면 server api1:3000; server api2:3000; ... + keepalive 100; + } + + server { + listen 80; + server_name localhost; + + # Health check endpoint + location /health { + return 200 'ok'; + add_header Content-Type text/plain; + } + + # Main reverse proxy for API + location / { + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_pass http://api-server; + proxy_read_timeout 300; + proxy_connect_timeout 60; + } + + # (Optional) Static file serving example + # location /static/ { + # alias /app/public/; + # expires 30d; + # access_log off; + # } + } + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + sendfile on; + keepalive_timeout 65; + include /etc/nginx/conf.d/*.conf; +} diff --git a/templates/default/src/services/users.service.ts b/templates/default/src/services/users.service.ts index 021568c..4327113 100644 --- a/templates/default/src/services/users.service.ts +++ b/templates/default/src/services/users.service.ts @@ -1,8 +1,8 @@ import { hash } from 'bcryptjs'; import { Service, Inject } from 'typedi'; +import { HttpException } from '@exceptions/httpException'; import { User } from '@interfaces/users.interface'; -import { IUsersRepository } from '@repositories/users.repository'; -import type { HttpException } from '@exceptions/httpException'; +import type { IUsersRepository } from '@repositories/users.repository'; @Service() export class UsersService { From e51303f62ed4c8b6bc3673fdcf8f0fe7d44b6a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Wed, 16 Jul 2025 22:01:31 +0900 Subject: [PATCH 15/27] feat (starter): Add func initialValue, hint(recommand) required --- bin/starter.js | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/bin/starter.js b/bin/starter.js index d6b2bbc..5cf0f61 100644 --- a/bin/starter.js +++ b/bin/starter.js @@ -43,9 +43,10 @@ const DEVTOOLS = [ 'lint:fix': 'npm run lint -- --fix', format: 'prettier --check .', }, + recommand: true, }, { - name: 'Tsup', + name: 'tsup', value: 'tsup', files: ['tsup.config.ts'], pkgs: [], @@ -54,6 +55,7 @@ const DEVTOOLS = [ 'start:tsup': 'node -r tsconfig-paths/register dist/server.js', 'build:tsup': 'tsup', }, + recommand: true, }, { name: 'SWC', @@ -64,6 +66,7 @@ const DEVTOOLS = [ scripts: { 'build:swc': 'swc src -d dist --strip-leading-paths --copy-files --delete-dir-on-start', }, + recommand: false, }, { name: 'Docker', @@ -72,6 +75,7 @@ const DEVTOOLS = [ pkgs: [], devPkgs: [], scripts: {}, + recommand: false, }, { name: 'Husky & Lint-Staged', @@ -81,6 +85,7 @@ const DEVTOOLS = [ devPkgs: ['husky', 'lint-staged'], scripts: { prepare: 'husky install' }, requires: [], + recommand: false, }, { name: 'PM2', @@ -92,6 +97,7 @@ const DEVTOOLS = [ 'deploy:prod': 'npm run build && pm2 start ecosystem.config.js --only prod', 'deploy:dev': 'pm2 start ecosystem.config.js --only dev', }, + recommand: false, }, { name: 'GitHub Actions', @@ -100,6 +106,7 @@ const DEVTOOLS = [ pkgs: [], devPkgs: [], scripts: {}, + recommand: false, }, ]; @@ -252,7 +259,7 @@ async function main() { { label: 'yarn', value: 'yarn' }, { label: 'pnpm', value: 'pnpm' }, ], - initial: 0, + initialValue: 'npm', }); if (isCancel(pkgManager)) return cancel('Aborted.'); if (await checkPkgManagerInstalled(pkgManager)) break; @@ -269,7 +276,7 @@ async function main() { const template = await select({ message: 'Choose a template:', options: templateDirs.map(t => ({ label: t, value: t })), - initial: 0, + initialValue: 'default', }); if (isCancel(template)) return cancel('Aborted.'); @@ -295,7 +302,9 @@ async function main() { // 6. 개발 도구 옵션 선택(멀티) let devtoolValues = await multiselect({ message: 'Select additional developer tools:', - options: DEVTOOLS.map(({ name, value }) => ({ label: name, value })), + options: DEVTOOLS.map(({ name, value, recommand }) => ({ label: name, value, hint: recommand && 'recommand' })), + initialValues: ['prettier', 'tsup'], + required: false, }); if (isCancel(devtoolValues)) return cancel('Aborted.'); devtoolValues = resolveDependencies(devtoolValues); @@ -347,7 +356,7 @@ async function main() { spinner.succeed('Base dependencies installed!'); // [4] git 첫 커밋 옵션 - // await gitInitAndFirstCommit(destDir); + await gitInitAndFirstCommit(destDir); outro(chalk.greenBright('\n🎉 Project setup complete!\n')); console.log(chalk.cyan(` $ cd ${projectName}`)); From 029c0dac78853d6abcbc43eb01bda16ae103ff6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Thu, 17 Jul 2025 01:08:30 +0900 Subject: [PATCH 16/27] feat (husky - git hook) fixed husky v8+ template --- bin/common.js | 100 ++++++++++++++ bin/starter.js | 122 ++---------------- devtools/husky/.husky/commit-msg | 4 + devtools/husky/.husky/pre-commit | 4 + devtools/husky/.husky/pre-push | 4 + devtools/husky/.huskyrc | 5 - devtools/husky/.lintstagedrc.json | 3 - .../prettier}/.editorconfig | 0 8 files changed, 126 insertions(+), 116 deletions(-) create mode 100644 bin/common.js create mode 100644 devtools/husky/.husky/commit-msg create mode 100644 devtools/husky/.husky/pre-commit create mode 100644 devtools/husky/.husky/pre-push delete mode 100644 devtools/husky/.huskyrc delete mode 100644 devtools/husky/.lintstagedrc.json rename {templates/default => devtools/prettier}/.editorconfig (100%) diff --git a/bin/common.js b/bin/common.js new file mode 100644 index 0000000..4f84296 --- /dev/null +++ b/bin/common.js @@ -0,0 +1,100 @@ +import { fileURLToPath } from 'url'; +import path from 'path'; + +export const packageManager = [ + { label: 'npm', value: 'npm' }, + { label: 'yarn', value: 'yarn' }, + { label: 'pnpm', value: 'pnpm' }, +]; + +export const devTools = [ + { + name: 'Prettier & ESLint', + value: 'prettier', + files: ['.prettierrc', '.eslintrc', '.eslintignore', '.editorconfig'], + pkgs: [], + devPkgs: [ + 'prettier', + 'eslint', + '@typescript-eslint/eslint-plugin', + '@typescript-eslint/parser', + 'eslint-config-prettier', + 'eslint-plugin-prettier', + ], + scripts: { + lint: 'eslint --ignore-path .gitignore --ext .ts src/', + 'lint:fix': 'npm run lint -- --fix', + format: 'prettier --check .', + }, + desc: 'Code Formatter', + }, + { + name: 'tsup', + value: 'tsup', + files: ['tsup.config.ts'], + pkgs: [], + devPkgs: ['tsup'], + scripts: { + 'start:tsup': 'node -r tsconfig-paths/register dist/server.js', + 'build:tsup': 'tsup', + }, + desc: 'Fastest Bundler', + }, + { + name: 'SWC', + value: 'swc', + files: ['.swcrc'], + pkgs: [], + devPkgs: ['@swc/cli', '@swc/core'], + scripts: { + 'build:swc': 'swc src -d dist --strip-leading-paths --copy-files --delete-dir-on-start', + }, + desc: 'Super Fast Compiler', + }, + { + name: 'Docker', + value: 'docker', + files: ['.dockerignore', 'docker-compose.yml', 'Dockerfile.dev', 'Dockerfile.prod', 'nginx.conf'], + pkgs: [], + devPkgs: [], + scripts: {}, + desc: 'Container', + }, + { + name: 'Husky', + value: 'husky', + files: ['.husky'], + pkgs: [], + devPkgs: ['husky'], + scripts: { prepare: 'husky install' }, + requires: [], + desc: 'Git hooks', + }, + { + name: 'PM2', + value: 'pm2', + files: ['ecosystem.config.js'], + pkgs: [], + devPkgs: ['pm2'], + scripts: { + 'deploy:prod': 'pm2 start ecosystem.config.js --only prod', + 'deploy:dev': 'pm2 start ecosystem.config.js --only dev', + }, + desc: 'Process Manager', + }, + { + name: 'GitHub Actions', + value: 'github', + files: ['.github/workflows/ci.yml'], + pkgs: [], + devPkgs: [], + scripts: {}, + desc: 'CI/CD', + }, +]; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export const templatesPkg = path.join(__dirname, '../templates'); + +export const devtoolsPkg = path.join(__dirname, '../devtools'); diff --git a/bin/starter.js b/bin/starter.js index 5cf0f61..97cfbcc 100644 --- a/bin/starter.js +++ b/bin/starter.js @@ -11,109 +11,19 @@ import { select, multiselect, text, isCancel, intro, outro, cancel, note, confirm } from '@clack/prompts'; import chalk from 'chalk'; +import editJsonFile from 'edit-json-file'; +import { execa } from 'execa'; +import fs from 'fs-extra'; import ora from 'ora'; import path from 'path'; -import fs from 'fs-extra'; -import { execa } from 'execa'; -import editJsonFile from 'edit-json-file'; -import { fileURLToPath } from 'url'; +import { packageManager, devTools, templatesPkg, devtoolsPkg } from './common.js'; // import recast from 'recast'; // import * as tsParser from 'recast/parsers/typescript.js'; -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const templatesDir = path.join(__dirname, '../templates'); -const devtoolsDir = path.join(__dirname, '../devtools'); - -const DEVTOOLS = [ - { - name: 'Prettier & ESLint', - value: 'prettier', - files: ['.prettierrc', '.prettierignore', '.eslintrc', '.eslintignore'], - pkgs: [], - devPkgs: [ - 'prettier', - 'eslint', - '@typescript-eslint/eslint-plugin', - '@typescript-eslint/parser', - 'eslint-config-prettier', - 'eslint-plugin-prettier', - ], - scripts: { - lint: 'eslint --ignore-path .gitignore --ext .ts src/', - 'lint:fix': 'npm run lint -- --fix', - format: 'prettier --check .', - }, - recommand: true, - }, - { - name: 'tsup', - value: 'tsup', - files: ['tsup.config.ts'], - pkgs: [], - devPkgs: ['tsup'], - scripts: { - 'start:tsup': 'node -r tsconfig-paths/register dist/server.js', - 'build:tsup': 'tsup', - }, - recommand: true, - }, - { - name: 'SWC', - value: 'swc', - files: ['.swcrc'], - pkgs: [], - devPkgs: ['@swc/cli', '@swc/core'], - scripts: { - 'build:swc': 'swc src -d dist --strip-leading-paths --copy-files --delete-dir-on-start', - }, - recommand: false, - }, - { - name: 'Docker', - value: 'docker', - files: ['.dockerignore', 'docker-compose.yml', 'Dockerfile.dev', 'Dockerfile.prod', 'nginx.conf'], - pkgs: [], - devPkgs: [], - scripts: {}, - recommand: false, - }, - { - name: 'Husky & Lint-Staged', - value: 'husky', - files: ['.huskyrc', 'lint-staged.config.js'], - pkgs: [], - devPkgs: ['husky', 'lint-staged'], - scripts: { prepare: 'husky install' }, - requires: [], - recommand: false, - }, - { - name: 'PM2', - value: 'pm2', - files: ['ecosystem.config.js'], - pkgs: [], - devPkgs: ['pm2'], - scripts: { - 'deploy:prod': 'npm run build && pm2 start ecosystem.config.js --only prod', - 'deploy:dev': 'pm2 start ecosystem.config.js --only dev', - }, - recommand: false, - }, - { - name: 'GitHub Actions', - value: 'github', - files: ['.github/workflows/ci.yml'], - pkgs: [], - devPkgs: [], - scripts: {}, - recommand: false, - }, -]; - // ========== [공통 함수들] ========== -// Node 버전 체크 -function checkNodeVersion(min = 18) { +// Node 버전 체크 (16+) +function checkNodeVersion(min = 16) { const major = parseInt(process.versions.node.split('.')[0], 10); if (major < min) { console.error(chalk.red(`Node.js ${min}+ required. You have ${process.versions.node}.`)); @@ -160,7 +70,7 @@ function resolveDependencies(selected) { let changed = true; while (changed) { changed = false; - for (const tool of DEVTOOLS) { + for (const tool of devTools) { if (all.has(tool.value) && tool.requires) { for (const req of tool.requires) { if (!all.has(req)) { @@ -177,7 +87,7 @@ function resolveDependencies(selected) { // 파일 복사 async function copyDevtoolFiles(devtool, destDir) { for (const file of devtool.files) { - const src = path.join(devtoolsDir, devtool.value, file); + const src = path.join(devtoolsPkg, devtool.value, file); const dst = path.join(destDir, file); if (await fs.pathExists(src)) { await fs.copy(src, dst, { overwrite: true }); @@ -242,7 +152,7 @@ async function gitInitAndFirstCommit(destDir) { // ========== [메인 CLI 실행 흐름] ========== async function main() { // 1. Node 버전 체크 - checkNodeVersion(18); + checkNodeVersion(16); // 2. CLI 최신버전 안내 (자신의 패키지 이름/버전 직접 입력) await checkForUpdate('typescript-express-starter', '10.2.2'); @@ -254,11 +164,7 @@ async function main() { while (true) { pkgManager = await select({ message: 'Which package manager do you want to use?', - options: [ - { label: 'npm', value: 'npm' }, - { label: 'yarn', value: 'yarn' }, - { label: 'pnpm', value: 'pnpm' }, - ], + options: packageManager, initialValue: 'npm', }); if (isCancel(pkgManager)) return cancel('Aborted.'); @@ -268,7 +174,7 @@ async function main() { note(`Using: ${pkgManager}`); // 4. 템플릿 선택 - const templateDirs = (await fs.readdir(templatesDir)).filter(f => fs.statSync(path.join(templatesDir, f)).isDirectory()); + const templateDirs = (await fs.readdir(templatesPkg)).filter(f => fs.statSync(path.join(templatesPkg, f)).isDirectory()); if (templateDirs.length === 0) { printError('No templates found!'); return; @@ -302,7 +208,7 @@ async function main() { // 6. 개발 도구 옵션 선택(멀티) let devtoolValues = await multiselect({ message: 'Select additional developer tools:', - options: DEVTOOLS.map(({ name, value, recommand }) => ({ label: name, value, hint: recommand && 'recommand' })), + options: devTools.map(({ name, value, desc }) => ({ label: name, value, hint: desc })), initialValues: ['prettier', 'tsup'], required: false, }); @@ -314,7 +220,7 @@ async function main() { // [1] 템플릿 복사 const spinner = ora('Copying template...').start(); try { - await fs.copy(path.join(templatesDir, template), destDir, { overwrite: true }); + await fs.copy(path.join(templatesPkg, template), destDir, { overwrite: true }); spinner.succeed('Template copied!'); } catch (e) { spinner.fail('Template copy failed!'); @@ -324,7 +230,7 @@ async function main() { // [2] 개발 도구 파일/패키지/스크립트/코드패치 for (const val of devtoolValues) { - const tool = DEVTOOLS.find(d => d.value === val); + const tool = devTools.find(d => d.value === val); if (!tool) continue; spinner.start(`Copying ${tool.name} files...`); diff --git a/devtools/husky/.husky/commit-msg b/devtools/husky/.husky/commit-msg new file mode 100644 index 0000000..5426a93 --- /dev/null +++ b/devtools/husky/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx commitlint --edit $1 diff --git a/devtools/husky/.husky/pre-commit b/devtools/husky/.husky/pre-commit new file mode 100644 index 0000000..449fcde --- /dev/null +++ b/devtools/husky/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npm test diff --git a/devtools/husky/.husky/pre-push b/devtools/husky/.husky/pre-push new file mode 100644 index 0000000..f22d70c --- /dev/null +++ b/devtools/husky/.husky/pre-push @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npm run build diff --git a/devtools/husky/.huskyrc b/devtools/husky/.huskyrc deleted file mode 100644 index 4d077c8..0000000 --- a/devtools/husky/.huskyrc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "hooks": { - "pre-commit": "lint-staged" - } -} diff --git a/devtools/husky/.lintstagedrc.json b/devtools/husky/.lintstagedrc.json deleted file mode 100644 index 1391e71..0000000 --- a/devtools/husky/.lintstagedrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "*.ts": ["npm run lint"] -} diff --git a/templates/default/.editorconfig b/devtools/prettier/.editorconfig similarity index 100% rename from templates/default/.editorconfig rename to devtools/prettier/.editorconfig From 6e86f55038d5a795d224aa6ec8005676b05198c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Thu, 17 Jul 2025 02:12:00 +0900 Subject: [PATCH 17/27] feat (docker) generate docker-compose by dbType --- bin/common.js | 10 ++-- bin/db-map.js | 96 ++++++++++++++++++++++++++++++ bin/starter.js | 53 ++++++++++++----- devtools/docker/docker-compose.yml | 39 ------------ 4 files changed, 140 insertions(+), 58 deletions(-) create mode 100644 bin/db-map.js delete mode 100644 devtools/docker/docker-compose.yml diff --git a/bin/common.js b/bin/common.js index 4f84296..8ab2ae4 100644 --- a/bin/common.js +++ b/bin/common.js @@ -1,13 +1,13 @@ import { fileURLToPath } from 'url'; import path from 'path'; -export const packageManager = [ +export const PACKAGE_MANAGER = [ { label: 'npm', value: 'npm' }, { label: 'yarn', value: 'yarn' }, { label: 'pnpm', value: 'pnpm' }, ]; -export const devTools = [ +export const DEVTOOLS_VALUES = [ { name: 'Prettier & ESLint', value: 'prettier', @@ -54,7 +54,7 @@ export const devTools = [ { name: 'Docker', value: 'docker', - files: ['.dockerignore', 'docker-compose.yml', 'Dockerfile.dev', 'Dockerfile.prod', 'nginx.conf'], + files: ['.dockerignore', 'Dockerfile.dev', 'Dockerfile.prod', 'nginx.conf'], pkgs: [], devPkgs: [], scripts: {}, @@ -95,6 +95,6 @@ export const devTools = [ const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export const templatesPkg = path.join(__dirname, '../templates'); +export const TEMPLATES = path.join(__dirname, '../templates'); -export const devtoolsPkg = path.join(__dirname, '../devtools'); +export const DEVTOOLS = path.join(__dirname, '../devtools'); diff --git a/bin/db-map.js b/bin/db-map.js new file mode 100644 index 0000000..d5432dd --- /dev/null +++ b/bin/db-map.js @@ -0,0 +1,96 @@ +// db-map.js +export const TEMPLATE_DB = { + default: null, + graphql: 'postgres', + knex: 'mysql', + mikroorm: 'mongo', + mongoose: 'mongo', + 'node-postgres': 'postgres', + prisma: 'mysql', + sequelize: 'mysql', + typegoose: 'mongo', + typeorm: 'postgres', +}; + +export const DB_SERVICES = { + postgres: ` + pg: + container_name: pg + image: postgres:14.5-alpine + ports: + - "5432:5432" + env_file: + - .env.development.local + restart: always + networks: + - backend + `, + mysql: ` + mysql: + container_name: mysql + image: mysql:5.7 + ports: + - "3306:3306" + env_file: + - .env.development.local + networks: + - backend + `, + mongo: ` + mongo: + container_name: mongo + image: mongo + ports: + - "27017:27017" + env_file: + - .env.development.local + networks: + - backend + `, +}; + +export const BASE_COMPOSE = (dbSnippet = '') => ` +version: '3.9' + +services: + proxy: + container_name: proxy + image: nginx:alpine + ports: + - '80:80' + volumes: + - ./nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - server + restart: unless-stopped + networks: + - backend + + server: + container_name: server + build: + context: ./ + dockerfile: Dockerfile.dev + ports: + - '3000:3000' + volumes: + - ./:/app:cached + - /app/node_modules + env_file: + - .env.development.local + depends_on: + - ${dbSnippet ? dbSnippet.match(/^\s*(\w+):/)?.[1] : ''} + restart: unless-stopped + networks: + - backend + +${dbSnippet.trim() ? dbSnippet : ''} + +networks: + backend: + driver: bridge + +volumes: + pgdata: + driver: local +`; diff --git a/bin/starter.js b/bin/starter.js index 97cfbcc..ecaa6ac 100644 --- a/bin/starter.js +++ b/bin/starter.js @@ -16,7 +16,9 @@ import { execa } from 'execa'; import fs from 'fs-extra'; import ora from 'ora'; import path from 'path'; -import { packageManager, devTools, templatesPkg, devtoolsPkg } from './common.js'; +import { PACKAGE_MANAGER, DEVTOOLS_VALUES, TEMPLATES, DEVTOOLS } from './common.js'; +import { TEMPLATE_DB, DB_SERVICES, BASE_COMPOSE } from './db-map.js'; + // import recast from 'recast'; // import * as tsParser from 'recast/parsers/typescript.js'; @@ -70,7 +72,7 @@ function resolveDependencies(selected) { let changed = true; while (changed) { changed = false; - for (const tool of devTools) { + for (const tool of DEVTOOLS_VALUES) { if (all.has(tool.value) && tool.requires) { for (const req of tool.requires) { if (!all.has(req)) { @@ -87,7 +89,7 @@ function resolveDependencies(selected) { // 파일 복사 async function copyDevtoolFiles(devtool, destDir) { for (const file of devtool.files) { - const src = path.join(devtoolsPkg, devtool.value, file); + const src = path.join(DEVTOOLS, devtool.value, file); const dst = path.join(destDir, file); if (await fs.pathExists(src)) { await fs.copy(src, dst, { overwrite: true }); @@ -135,6 +137,22 @@ function printError(message, suggestion = null) { } } +// docker-compose 생성 +async function generateCompose(template, destDir) { + // 템플릿에 맞는 DB 선택 + const dbType = TEMPLATE_DB[template]; + const dbSnippet = dbType ? DB_SERVICES[dbType] : ''; + + // docker-compose.yml 내용 생성 + const composeYml = BASE_COMPOSE(dbSnippet); + + // 파일로 기록 + const filePath = path.join(destDir, 'docker-compose.yml'); + await fs.writeFile(filePath, composeYml, 'utf8'); + + return dbType; +} + // Git init & 첫 커밋 async function gitInitAndFirstCommit(destDir) { const doGit = await confirm({ message: 'Initialize git and make first commit?', initial: true }); @@ -164,7 +182,7 @@ async function main() { while (true) { pkgManager = await select({ message: 'Which package manager do you want to use?', - options: packageManager, + options: PACKAGE_MANAGER, initialValue: 'npm', }); if (isCancel(pkgManager)) return cancel('Aborted.'); @@ -174,7 +192,7 @@ async function main() { note(`Using: ${pkgManager}`); // 4. 템플릿 선택 - const templateDirs = (await fs.readdir(templatesPkg)).filter(f => fs.statSync(path.join(templatesPkg, f)).isDirectory()); + const templateDirs = (await fs.readdir(TEMPLATES)).filter(f => fs.statSync(path.join(TEMPLATES, f)).isDirectory()); if (templateDirs.length === 0) { printError('No templates found!'); return; @@ -208,7 +226,7 @@ async function main() { // 6. 개발 도구 옵션 선택(멀티) let devtoolValues = await multiselect({ message: 'Select additional developer tools:', - options: devTools.map(({ name, value, desc }) => ({ label: name, value, hint: desc })), + options: DEVTOOLS_VALUES.map(({ name, value, desc }) => ({ label: name, value, hint: desc })), initialValues: ['prettier', 'tsup'], required: false, }); @@ -218,9 +236,9 @@ async function main() { // === [진행] === // [1] 템플릿 복사 - const spinner = ora('Copying template...').start(); + const spinner = ora('Copying template...\n').start(); try { - await fs.copy(path.join(templatesPkg, template), destDir, { overwrite: true }); + await fs.copy(path.join(TEMPLATES, template), destDir, { overwrite: true }); spinner.succeed('Template copied!'); } catch (e) { spinner.fail('Template copy failed!'); @@ -230,34 +248,41 @@ async function main() { // [2] 개발 도구 파일/패키지/스크립트/코드패치 for (const val of devtoolValues) { - const tool = devTools.find(d => d.value === val); + const tool = DEVTOOLS_VALUES.find(d => d.value === val); if (!tool) continue; - spinner.start(`Copying ${tool.name} files...`); + spinner.start(`Copying ${tool.name} files...\n`); await copyDevtoolFiles(tool, destDir); spinner.succeed(`${tool.name} files copied!`); if (tool.pkgs?.length > 0) { - spinner.start(`Installing ${tool.name} packages (prod)...`); + spinner.start(`Installing ${tool.name} packages (prod)...\n`); await installPackages(tool.pkgs, pkgManager, false, destDir); spinner.succeed(`${tool.name} packages (prod) installed!`); } if (tool.devPkgs?.length > 0) { - spinner.start(`Installing ${tool.name} packages (dev)...`); + spinner.start(`Installing ${tool.name} packages (dev)...\n`); await installPackages(tool.devPkgs, pkgManager, true, destDir); spinner.succeed(`${tool.name} packages (dev) installed!`); } if (Object.keys(tool.scripts).length) { - spinner.start(`Updating scripts for ${tool.name}...`); + spinner.start(`Updating scripts for ${tool.name}...\n`); await updatePackageJson(tool.scripts, destDir); spinner.succeed(`${tool.name} scripts updated!`); } + + // [2-1] 개발 도구 - Docker 선택 한 경우, docker-compose.yml 생성 + if (tool.value === 'docker') { + spinner.start(`Creating docker-compose ...\n`); + const dbType = await generateCompose(template, destDir); + spinner.succeed(`docker-compose.yml with ${dbType || 'no'} DB created!`); + } } // [3] 템플릿 기본 패키지 설치 - spinner.start(`Installing base dependencies with ${pkgManager}...`); + spinner.start(`Installing base dependencies with ${pkgManager}...\n`); await execa(pkgManager, ['install'], { cwd: destDir, stdio: 'inherit' }); spinner.succeed('Base dependencies installed!'); diff --git a/devtools/docker/docker-compose.yml b/devtools/docker/docker-compose.yml deleted file mode 100644 index 41c567e..0000000 --- a/devtools/docker/docker-compose.yml +++ /dev/null @@ -1,39 +0,0 @@ -version: '3.9' - -services: - proxy: - container_name: proxy - image: nginx:alpine - ports: - - '80:80' - volumes: - - ./nginx.conf:/etc/nginx/nginx.conf:ro - depends_on: - - server - restart: unless-stopped - networks: - - backend - - server: - container_name: server - build: - context: ./ - dockerfile: Dockerfile.dev - ports: - - '3000:3000' - volumes: - - ./:/app:cached - - /app/node_modules - env_file: - - .env - restart: unless-stopped - networks: - - backend - -networks: - backend: - driver: bridge - -volumes: - data: - driver: local From e32be9d50dffb987b3e6b2ceab8728f46827583c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Wed, 23 Jul 2025 21:58:31 +0900 Subject: [PATCH 18/27] feat (default template) refactor package tsyringe, pino, zod, vitest DI: tsyringe Logger: pino Validation: zod Test: vitest --- bin/db-map.js | 1 - templates/default/jest.config.js | 6 +- templates/default/package.json | 22 ++--- templates/default/src/app.ts | 3 +- .../src/controllers/auth.controller.ts | 7 +- .../src/controllers/users.controller.ts | 16 ++-- templates/default/src/dtos/users.dto.ts | 36 ++++--- .../src/middlewares/auth.middleware.ts | 6 +- .../src/middlewares/validation.middleware.ts | 25 ++--- .../src/repositories/users.repository.ts | 4 +- templates/default/src/routes/auth.route.ts | 12 +-- templates/default/src/routes/users.route.ts | 12 +-- templates/default/src/server.ts | 7 +- .../default/src/services/auth.service.ts | 7 +- .../default/src/services/users.service.ts | 10 +- .../default/src/test/e2e/auth.e2e.spec.ts | 1 + .../default/src/test/e2e/users.e2e.spec.ts | 6 +- templates/default/src/test/setup.ts | 14 ++- .../test/unit/services/auth.service.spec.ts | 1 + .../test/unit/services/users.service.spec.ts | 1 + templates/default/src/utils/logger.ts | 95 ++++++++----------- templates/default/vitest.config.ts | 18 ++++ 22 files changed, 157 insertions(+), 153 deletions(-) create mode 100644 templates/default/vitest.config.ts diff --git a/bin/db-map.js b/bin/db-map.js index d5432dd..d5bb051 100644 --- a/bin/db-map.js +++ b/bin/db-map.js @@ -1,4 +1,3 @@ -// db-map.js export const TEMPLATE_DB = { default: null, graphql: 'postgres', diff --git a/templates/default/jest.config.js b/templates/default/jest.config.js index cc9ad30..5f7ae93 100644 --- a/templates/default/jest.config.js +++ b/templates/default/jest.config.js @@ -1,11 +1,15 @@ +const { pathsToModuleNameMapper } = require('ts-jest'); +const { compilerOptions } = require('./tsconfig.json'); + module.exports = { preset: 'ts-jest', testEnvironment: 'node', + setupFiles: ['/src/test/setup.ts'], roots: ['/src'], transform: { '^.+\\.tsx?$': 'ts-jest', }, - moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/src' }), + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { prefix: '/src/' }), coverageDirectory: 'coverage', collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts', '!src/**/index.ts'], testPathIgnorePatterns: ['/node_modules/', '/dist/', '/logs/'], diff --git a/templates/default/package.json b/templates/default/package.json index 5d70369..fe46981 100644 --- a/templates/default/package.json +++ b/templates/default/package.json @@ -8,14 +8,12 @@ "start": "cross-env NODE_ENV=production node dist/server.js", "dev": "cross-env NODE_ENV=development nodemon", "build": "tsc && tsc-alias", - "test": "jest --forceExit --detectOpenHandles", - "test:unit": "jest --testPathPattern=unit", - "test:e2e": "jest --testPathPattern=E2E" + "test": "vitest run", + "test:e2e": "vitest run src/test/e2e", + "test:unit": "vitest run src/test/unit" }, "dependencies": { "bcryptjs": "^3.0.2", - "class-transformer": "^0.5.1", - "class-validator": "^0.14.2", "compression": "^1.8.0", "cookie-parser": "^1.4.7", "cors": "^2.8.5", @@ -27,13 +25,14 @@ "hpp": "^0.2.3", "jsonwebtoken": "^9.0.2", "morgan": "^1.10.0", + "pino": "^9.7.0", + "pino-pretty": "^13.0.0", "reflect-metadata": "^0.2.2", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "tslib": "^2.8.1", - "typedi": "^0.10.0", - "winston": "^3.17.0", - "winston-daily-rotate-file": "^5.0.0" + "tsyringe": "^4.10.0", + "zod": "^4.0.5" }, "devDependencies": { "@types/compression": "^1.8.1", @@ -41,7 +40,6 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.3", "@types/hpp": "^0.2.6", - "@types/jest": "^30.0.0", "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.10", "@types/node": "^20.11.30", @@ -50,16 +48,16 @@ "@types/swagger-ui-express": "^4.1.8", "cross-env": "^7.0.3", "eslint": "^9.30.1", - "jest": "^30.0.4", "node-gyp": "^11.2.0", "nodemon": "^3.1.10", "rimraf": "^6.0.1", "supertest": "^7.1.3", - "ts-jest": "^29.4.0", "ts-node": "^10.9.2", "tsc-alias": "^1.8.16", "tsconfig-paths": "^4.2.0", - "typescript": "^5.5.3" + "typescript": "^5.5.3", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.4" }, "engines": { "node": ">=18.17.0" diff --git a/templates/default/src/app.ts b/templates/default/src/app.ts index d338e9f..3309258 100644 --- a/templates/default/src/app.ts +++ b/templates/default/src/app.ts @@ -1,14 +1,13 @@ -import 'reflect-metadata'; import compression from 'compression'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import express from 'express'; +import rateLimit from 'express-rate-limit'; import helmet from 'helmet'; import hpp from 'hpp'; import morgan from 'morgan'; import swaggerJSDoc from 'swagger-jsdoc'; import swaggerUi from 'swagger-ui-express'; -import rateLimit from 'express-rate-limit'; import { NODE_ENV, PORT, LOG_FORMAT, CREDENTIALS } from '@config/env'; import { Routes } from '@interfaces/routes.interface'; import { ErrorMiddleware } from '@middlewares/error.middleware'; diff --git a/templates/default/src/controllers/auth.controller.ts b/templates/default/src/controllers/auth.controller.ts index 77084cc..4a533cc 100644 --- a/templates/default/src/controllers/auth.controller.ts +++ b/templates/default/src/controllers/auth.controller.ts @@ -1,12 +1,12 @@ import { Request, Response, NextFunction } from 'express'; -import { Service } from 'typedi'; +import { injectable, inject } from 'tsyringe'; import { RequestWithUser } from '@interfaces/auth.interface'; import { User } from '@interfaces/users.interface'; import { AuthService } from '@services/auth.service'; -@Service() +@injectable() export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor(@inject(AuthService) private readonly authService: AuthService) {} public signUp = async (req: Request, res: Response, next: NextFunction): Promise => { try { @@ -36,7 +36,6 @@ export class AuthController { const user = userReq.user; await this.authService.logout(user); - // res.setHeader('Set-Cookie', ['Authorization=; HttpOnly; Path=/; Max-Age=0; SameSite=Lax']); res.clearCookie('Authorization', { httpOnly: true, path: '/', diff --git a/templates/default/src/controllers/users.controller.ts b/templates/default/src/controllers/users.controller.ts index c728db2..929a4d9 100644 --- a/templates/default/src/controllers/users.controller.ts +++ b/templates/default/src/controllers/users.controller.ts @@ -1,16 +1,16 @@ import { Request, Response, NextFunction } from 'express'; -import { Service } from 'typedi'; +import { injectable, inject } from 'tsyringe'; import { User } from '@interfaces/users.interface'; import { UsersService } from '@services/users.service'; -@Service() +@injectable() export class UsersController { - constructor(private readonly userService: UsersService) {} + constructor(@inject(UsersService) private readonly userService: UsersService) {} getUsers = async (req: Request, res: Response, next: NextFunction): Promise => { try { const users = await this.userService.getAllUsers(); - res.json({ data: users }); + res.json({ data: users, message: 'findAll' }); } catch (e) { next(e); } @@ -20,7 +20,7 @@ export class UsersController { try { const userId: string = req.params.id; const user = await this.userService.getUserById(userId); - res.json({ data: user }); + res.json({ data: user, message: 'findById' }); } catch (e) { next(e); } @@ -30,7 +30,7 @@ export class UsersController { try { const userData: User = req.body; const user = await this.userService.createUser(userData); - res.status(201).json({ data: user }); + res.status(201).json({ data: user, message: 'create' }); } catch (e) { next(e); } @@ -41,7 +41,7 @@ export class UsersController { const userId: string = req.params.id; const userData: User = req.body; const user = await this.userService.updateUser(userId, userData); - res.json({ data: user }); + res.json({ data: user, message: 'update' }); } catch (e) { next(e); } @@ -51,7 +51,7 @@ export class UsersController { try { const userId: string = req.params.id; await this.userService.deleteUser(userId); - res.status(204).send(); + res.status(204).json({ message: 'delete' }); } catch (e) { next(e); } diff --git a/templates/default/src/dtos/users.dto.ts b/templates/default/src/dtos/users.dto.ts index a4c57fb..3ad96b5 100644 --- a/templates/default/src/dtos/users.dto.ts +++ b/templates/default/src/dtos/users.dto.ts @@ -1,22 +1,20 @@ -import { IsEmail, IsString, IsNotEmpty, MinLength, MaxLength, IsOptional } from 'class-validator'; +import { z } from 'zod'; -export class PasswordDto { - @IsString() - @IsNotEmpty() - @MinLength(9, { message: 'Password must be at least 9 characters long.' }) - @MaxLength(32, { message: 'Password must be at most 32 characters long.' }) - public password!: string; -} +// 비밀번호 공통 스키마 +export const passwordSchema = z + .string() + .min(9, { message: 'Password must be at least 9 characters long.' }) + .max(32, { message: 'Password must be at most 32 characters long.' }); -export class CreateUserDto extends PasswordDto { - @IsEmail({}, { message: 'Invalid email format.' }) - public email!: string; -} +// 회원가입 DTO (signup, login 공용) +export const createUserSchema = z.object({ + email: z.string().email({ message: 'Invalid email format.' }), + password: passwordSchema, +}); -export class UpdateUserDto { - @IsOptional() - @IsString() - @MinLength(9, { message: 'Password must be at least 9 characters long.' }) - @MaxLength(32, { message: 'Password must be at most 32 characters long.' }) - public password?: string; -} +export type CreateUserDto = z.infer; + +// 수정 DTO (패스워드만 optional) +export const updateUserSchema = createUserSchema.partial(); + +export type UpdateUserDto = z.infer; diff --git a/templates/default/src/middlewares/auth.middleware.ts b/templates/default/src/middlewares/auth.middleware.ts index 1ed5ce5..719ae18 100644 --- a/templates/default/src/middlewares/auth.middleware.ts +++ b/templates/default/src/middlewares/auth.middleware.ts @@ -1,10 +1,10 @@ import { Request, Response, NextFunction } from 'express'; import { verify, TokenExpiredError, JsonWebTokenError } from 'jsonwebtoken'; +import { container } from 'tsyringe'; import { SECRET_KEY } from '@config/env'; import { HttpException } from '@exceptions/httpException'; import { DataStoredInToken, RequestWithUser } from '@interfaces/auth.interface'; -import { Container } from 'typedi'; -import { IUsersRepository } from '@repositories/users.repository'; +import { UsersRepository } from '@repositories/users.repository'; const getAuthorization = (req: RequestWithUser) => { const cookie = req.cookies['Authorization']; @@ -37,7 +37,7 @@ export const AuthMiddleware = async (req: Request, res: Response, next: NextFunc } // 타입 일치 유의 (number/string) - const userRepo = Container.get('UsersRepository'); + const userRepo = container.resolve(UsersRepository); const findUser = await userRepo.findById(String(payload.id)); if (!findUser) return next(new HttpException(401, 'User not found with this token')); diff --git a/templates/default/src/middlewares/validation.middleware.ts b/templates/default/src/middlewares/validation.middleware.ts index 3c0a4de..a508c99 100644 --- a/templates/default/src/middlewares/validation.middleware.ts +++ b/templates/default/src/middlewares/validation.middleware.ts @@ -1,20 +1,15 @@ -import { plainToInstance } from 'class-transformer'; -import { validateOrReject, ValidationError } from 'class-validator'; import { NextFunction, Request, Response } from 'express'; +import type { ZodTypeAny } from 'zod'; import { HttpException } from '@exceptions/httpException'; -export const ValidationMiddleware = - (type: any, skipMissingProperties = false, whitelist = true, forbidNonWhitelisted = true) => - async (req: Request, res: Response, next: NextFunction) => { - const dto = plainToInstance(type, req.body); - try { - await validateOrReject(dto, { skipMissingProperties, whitelist, forbidNonWhitelisted }); - req.body = dto; - next(); - } catch (errors: ValidationError[] | any) { - const message = Array.isArray(errors) - ? errors.map((error: ValidationError) => Object.values(error.constraints || {}).join(', ')).join(', ') - : String(errors); - next(new HttpException(400, message)); +export function ValidationMiddleware(schema: ZodTypeAny) { + return (req: Request, res: Response, next: NextFunction) => { + const result = schema.safeParse(req.body); + if (!result.success) { + const message = result.error.issues.map(e => e.message).join(', '); + return next(new HttpException(400, message)); } + req.body = result.data; + next(); }; +} diff --git a/templates/default/src/repositories/users.repository.ts b/templates/default/src/repositories/users.repository.ts index f4dd0ff..93699d0 100644 --- a/templates/default/src/repositories/users.repository.ts +++ b/templates/default/src/repositories/users.repository.ts @@ -1,4 +1,4 @@ -import { Service } from 'typedi'; +import { singleton } from 'tsyringe'; import { User } from '@interfaces/users.interface'; export interface IUsersRepository { @@ -10,7 +10,7 @@ export interface IUsersRepository { delete(id: string): Promise; } -@Service('UsersRepository') +@singleton() export class UsersRepository implements IUsersRepository { private users: User[] = []; diff --git a/templates/default/src/routes/auth.route.ts b/templates/default/src/routes/auth.route.ts index 26b0846..53d09d6 100644 --- a/templates/default/src/routes/auth.route.ts +++ b/templates/default/src/routes/auth.route.ts @@ -1,23 +1,23 @@ import { Router } from 'express'; -import { Service, Inject } from 'typedi'; +import { injectable, inject } from 'tsyringe'; import { AuthController } from '@controllers/auth.controller'; -import { CreateUserDto } from '@dtos/users.dto'; +import { createUserSchema } from '@dtos/users.dto'; import { Routes } from '@interfaces/routes.interface'; import { AuthMiddleware } from '@middlewares/auth.middleware'; import { ValidationMiddleware } from '@middlewares/validation.middleware'; -@Service() +@injectable() export class AuthRoute implements Routes { public router: Router = Router(); public path = '/auth'; - constructor(@Inject() private authController: AuthController) { + constructor(@inject(AuthController) private authController: AuthController) { this.initializeRoutes(); } private initializeRoutes() { - this.router.post(`${this.path}/signup`, ValidationMiddleware(CreateUserDto), this.authController.signUp); - this.router.post(`${this.path}/login`, ValidationMiddleware(CreateUserDto), this.authController.logIn); + this.router.post(`${this.path}/signup`, ValidationMiddleware(createUserSchema), this.authController.signUp); + this.router.post(`${this.path}/login`, ValidationMiddleware(createUserSchema), this.authController.logIn); this.router.post(`${this.path}/logout`, AuthMiddleware, this.authController.logOut); } } diff --git a/templates/default/src/routes/users.route.ts b/templates/default/src/routes/users.route.ts index 95cad01..594001d 100644 --- a/templates/default/src/routes/users.route.ts +++ b/templates/default/src/routes/users.route.ts @@ -1,24 +1,24 @@ import { Router } from 'express'; -import { Service, Inject } from 'typedi'; +import { injectable, inject } from 'tsyringe'; import { UsersController } from '@controllers/users.controller'; -import { CreateUserDto, UpdateUserDto } from '@dtos/users.dto'; +import { createUserSchema, updateUserSchema } from '@dtos/users.dto'; import { Routes } from '@interfaces/routes.interface'; import { ValidationMiddleware } from '@middlewares/validation.middleware'; -@Service() +@injectable() export class UsersRoute implements Routes { public router: Router = Router(); public path = '/users'; - constructor(@Inject() private userController: UsersController) { + constructor(@inject(UsersController) private userController: UsersController) { this.initializeRoutes(); } private initializeRoutes() { this.router.get(this.path, this.userController.getUsers); this.router.get(`${this.path}/:id`, this.userController.getUserById); - this.router.post(this.path, ValidationMiddleware(CreateUserDto), this.userController.createUser); - this.router.put(`${this.path}/:id`, ValidationMiddleware(UpdateUserDto), this.userController.updateUser); + this.router.post(this.path, ValidationMiddleware(createUserSchema), this.userController.createUser); + this.router.put(`${this.path}/:id`, ValidationMiddleware(updateUserSchema), this.userController.updateUser); this.router.delete(`${this.path}/:id`, this.userController.deleteUser); } } diff --git a/templates/default/src/server.ts b/templates/default/src/server.ts index 8c273d3..d43d88c 100644 --- a/templates/default/src/server.ts +++ b/templates/default/src/server.ts @@ -1,4 +1,5 @@ -import { Container } from 'typedi'; +import 'reflect-metadata'; +import { container } from 'tsyringe'; import App from '@/app'; import { validateEnv } from '@config/validateEnv'; import { UsersRepository } from '@repositories/users.repository'; @@ -9,10 +10,10 @@ import { UsersRoute } from '@routes/users.route'; validateEnv(); // DI 등록 -Container.set('UsersRepository', new UsersRepository()); +container.registerInstance(UsersRepository, new UsersRepository()); // 라우트 모듈을 필요에 따라 동적으로 배열화 가능 -const routes = [Container.get(UsersRoute), Container.get(AuthRoute)]; +const routes = [container.resolve(UsersRoute), container.resolve(AuthRoute)]; // API prefix는 app.ts에서 기본값 세팅, 필요하면 인자로 전달 const appInstance = new App(routes); diff --git a/templates/default/src/services/auth.service.ts b/templates/default/src/services/auth.service.ts index 2776bee..0b62c1c 100644 --- a/templates/default/src/services/auth.service.ts +++ b/templates/default/src/services/auth.service.ts @@ -1,15 +1,16 @@ import { hash, compare } from 'bcryptjs'; import { sign } from 'jsonwebtoken'; -import { Service, Inject } from 'typedi'; +import { injectable, inject } from 'tsyringe'; import { SECRET_KEY } from '@config/env'; import { HttpException } from '@exceptions/httpException'; import { DataStoredInToken, TokenData } from '@interfaces/auth.interface'; import { User } from '@interfaces/users.interface'; +import { UsersRepository } from '@repositories/users.repository'; import type { IUsersRepository } from '@repositories/users.repository'; -@Service() +@injectable() export class AuthService { - constructor(@Inject('UsersRepository') private usersRepository: IUsersRepository) {} + constructor(@inject(UsersRepository) private usersRepository: IUsersRepository) {} private createToken(user: User): TokenData { if (!SECRET_KEY) throw new Error('SECRET_KEY is not defined'); diff --git a/templates/default/src/services/users.service.ts b/templates/default/src/services/users.service.ts index 4327113..2f1af5f 100644 --- a/templates/default/src/services/users.service.ts +++ b/templates/default/src/services/users.service.ts @@ -1,12 +1,13 @@ import { hash } from 'bcryptjs'; -import { Service, Inject } from 'typedi'; +import { injectable, inject } from 'tsyringe'; import { HttpException } from '@exceptions/httpException'; import { User } from '@interfaces/users.interface'; +import { UsersRepository } from '@repositories/users.repository'; import type { IUsersRepository } from '@repositories/users.repository'; -@Service() +@injectable() export class UsersService { - constructor(@Inject('UsersRepository') private usersRepository: IUsersRepository) {} + constructor(@inject(UsersRepository) private usersRepository: IUsersRepository) {} async getAllUsers(): Promise { return this.usersRepository.findAll(); @@ -24,7 +25,8 @@ export class UsersService { const hashedPassword = await hash(user.password, 10); const created: User = { id: String(Date.now()), email: user.email, password: hashedPassword }; - return await this.usersRepository.save(created); + await this.usersRepository.save(created); + return created; } async updateUser(id: string, update: User): Promise { diff --git a/templates/default/src/test/e2e/auth.e2e.spec.ts b/templates/default/src/test/e2e/auth.e2e.spec.ts index 6e9ffe6..f110cb5 100644 --- a/templates/default/src/test/e2e/auth.e2e.spec.ts +++ b/templates/default/src/test/e2e/auth.e2e.spec.ts @@ -1,4 +1,5 @@ import request from 'supertest'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; import { createTestApp, resetUserDB } from '@/test/setup'; describe('Auth API', () => { diff --git a/templates/default/src/test/e2e/users.e2e.spec.ts b/templates/default/src/test/e2e/users.e2e.spec.ts index bee363a..b03b422 100644 --- a/templates/default/src/test/e2e/users.e2e.spec.ts +++ b/templates/default/src/test/e2e/users.e2e.spec.ts @@ -1,4 +1,5 @@ import request from 'supertest'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; import { createTestApp, resetUserDB } from '@/test/setup'; describe('Users API', () => { @@ -55,13 +56,12 @@ describe('Users API', () => { const id = createRes.body.data.id; const res = await request(server).delete(`${prefix}/users/${id}`); - expect(res.statusCode).toBe(200); - expect(res.body.message).toBe('deleted'); + expect(res.statusCode).toBe(204); }); it('should return 404 if user does not exist', async () => { const res = await request(server).get(`${prefix}/users/invalid-id`); expect(res.statusCode).toBe(404); - expect(res.body.message).toMatch(/not exist|not found/i); + expect(res.body.error.message).toMatch(/not exist|not found/i); }); }); diff --git a/templates/default/src/test/setup.ts b/templates/default/src/test/setup.ts index b1d22e3..44e9885 100644 --- a/templates/default/src/test/setup.ts +++ b/templates/default/src/test/setup.ts @@ -1,4 +1,5 @@ -import Container from 'typedi'; +import 'reflect-metadata'; +import { container } from 'tsyringe'; import App from '@/app'; import { AuthRoute } from '@routes/auth.route'; import { UsersRoute } from '@routes/users.route'; @@ -7,13 +8,18 @@ import { UsersRepository, IUsersRepository } from '@repositories/users.repositor let sharedRepo: UsersRepository; export function createTestApp({ mockRepo }: { mockRepo?: IUsersRepository } = {}) { + // 항상 새로운 인스턴스를 주입하고 싶으면 reset logic 추가 필요 if (!sharedRepo) { sharedRepo = new UsersRepository(); - Container.set('UserRepository', sharedRepo); + container.registerInstance(UsersRepository, sharedRepo); + } + // mockRepo가 있으면 주입 + if (mockRepo) { + container.registerInstance(UsersRepository, mockRepo as UsersRepository); } - if (mockRepo) Container.set('UserRepository', mockRepo); - const routes = [Container.get(UsersRoute), Container.get(AuthRoute)]; + // 클래스 타입을 직접 주입 + const routes = [container.resolve(UsersRoute), container.resolve(AuthRoute)]; const appInstance = new App(routes); return appInstance.getServer(); } diff --git a/templates/default/src/test/unit/services/auth.service.spec.ts b/templates/default/src/test/unit/services/auth.service.spec.ts index 805d2db..273ada8 100644 --- a/templates/default/src/test/unit/services/auth.service.spec.ts +++ b/templates/default/src/test/unit/services/auth.service.spec.ts @@ -1,4 +1,5 @@ import { compare, hash } from 'bcryptjs'; +import { describe, it, expect, beforeEach } from 'vitest'; import { CreateUserDto } from '@dtos/users.dto'; import { User } from '@interfaces/users.interface'; import { UsersRepository } from '@repositories/users.repository'; diff --git a/templates/default/src/test/unit/services/users.service.spec.ts b/templates/default/src/test/unit/services/users.service.spec.ts index 8d117a8..b540fa8 100644 --- a/templates/default/src/test/unit/services/users.service.spec.ts +++ b/templates/default/src/test/unit/services/users.service.spec.ts @@ -1,3 +1,4 @@ +import { describe, it, expect, beforeEach } from 'vitest'; import { User } from '@interfaces/users.interface'; import { UsersRepository } from '@repositories/users.repository'; import { UsersService } from '@services/users.service'; diff --git a/templates/default/src/utils/logger.ts b/templates/default/src/utils/logger.ts index a7cba1a..1b44008 100644 --- a/templates/default/src/utils/logger.ts +++ b/templates/default/src/utils/logger.ts @@ -1,76 +1,57 @@ import { existsSync, mkdirSync } from 'fs'; import { join } from 'path'; -import winston from 'winston'; -import winstonDaily from 'winston-daily-rotate-file'; +import pino from 'pino'; import { LOG_DIR } from '@config/env'; -const logLevel = process.env.LOG_LEVEL || 'info'; - -// logs dir const logDir: string = join(__dirname, LOG_DIR || '/logs'); - if (!existsSync(logDir)) { - mkdirSync(logDir); + mkdirSync(logDir, { recursive: true }); } -// Define log format -const logFormat = winston.format.printf(({ timestamp, level, message }) => `${timestamp} ${level}: ${message}`); +// 파일 로깅용 경로 +const debugLogPath = join(logDir, 'debug.log'); +const errorLogPath = join(logDir, 'error.log'); -/* - * Log Level - * error: 0, warn: 1, info: 2, http: 3, verbose: 4, debug: 5, silly: 6 - */ -const logger = winston.createLogger({ - level: logLevel, - format: winston.format.combine(winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), logFormat), - transports: [ - // debug log setting - new winstonDaily({ - level: 'debug', - datePattern: 'YYYY-MM-DD', - dirname: logDir + '/debug', // log file /logs/debug/*.log in save - filename: `%DATE%.log`, - maxFiles: 30, // 30 Days saved - json: false, - zippedArchive: true, - }), - // error log setting - new winstonDaily({ - level: 'error', - datePattern: 'YYYY-MM-DD', - dirname: logDir + '/error', // log file /logs/error/*.log in save - filename: `%DATE%.log`, - maxFiles: 30, // 30 Days saved - handleExceptions: true, - json: false, - zippedArchive: true, - }), - ], - exitOnError: false, // uncaughtException 시 종료 방지 -}); +// 로그 레벨 및 환경 설정 +const isProd = process.env.NODE_ENV === 'production'; +const logLevel = process.env.LOG_LEVEL || 'info'; -if (process.env.NODE_ENV !== 'production') { - logger.add( - new winston.transports.Console({ - format: winston.format.combine(winston.format.splat(), winston.format.colorize()), - }), - ); -} +// Pino 인스턴스 +export const logger = pino( + { + level: logLevel, + formatters: { + level: label => ({ level: label }), + }, + transport: !isProd + ? { + // 개발환경: 예쁜 콘솔 출력 + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'yyyy-mm-dd HH:MM:ss', + ignore: 'pid,hostname', + }, + } + : undefined, + }, + isProd ? pino.destination(debugLogPath) : undefined, +); + +// 파일 로깅은 에러만 별도 핸들러로 예시 +export const errorLogger = isProd ? pino(pino.destination(errorLogPath)) : logger; +// morgan stream 인터페이스 +export const stream = { + write: (msg: string) => logger.info(msg.trim()), +}; + +// 전역 에러 핸들링 (필요하면) process.on('uncaughtException', err => { logger.error(`Uncaught Exception: ${err.message}`, { stack: err.stack }); process.exit(1); }); - process.on('unhandledRejection', reason => { logger.error(`Unhandled Rejection: ${JSON.stringify(reason)}`); process.exit(1); }); - -const stream = { - write: (message: string) => { - logger.info(message.substring(0, message.lastIndexOf('\n'))); - }, -}; - -export { logger, stream }; diff --git a/templates/default/vitest.config.ts b/templates/default/vitest.config.ts new file mode 100644 index 0000000..36a42d6 --- /dev/null +++ b/templates/default/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; +import tsconfigPaths from 'vite-tsconfig-paths'; + +export default defineConfig({ + test: { + environment: 'node', + setupFiles: ['./src/test/setup.ts'], + include: ['src/**/*.test.ts', 'src/**/*.spec.ts'], + exclude: ['node_modules', 'dist', 'coverage', 'logs', 'src/http'], + coverage: { + reporter: ['text', 'lcov'], + reportsDirectory: 'coverage', + include: ['src/**/*.{ts,tsx}'], + exclude: ['src/**/*.d.ts', 'src/**/index.ts'], + }, + }, + plugins: [tsconfigPaths()], +}); From 14089b8b0615edeff65903d0a972706438ee757c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Tue, 29 Jul 2025 22:17:32 +0900 Subject: [PATCH 19/27] move starter --- lib/starter.js | 262 ------------------------------------------------- 1 file changed, 262 deletions(-) delete mode 100644 lib/starter.js diff --git a/lib/starter.js b/lib/starter.js deleted file mode 100644 index 16bec17..0000000 --- a/lib/starter.js +++ /dev/null @@ -1,262 +0,0 @@ -/***************************************************************** - * Create TypeScript Express Starter - * 2019.12.18 ~ 🎮 - * Made By AGUMON 🦖 - * https://github.com/ljlm0402/typescript-express-starter - *****************************************************************/ - -const chalk = require("chalk"); -const { exec } = require("child_process"); -const editJsonFile = require("edit-json-file"); -const { createWriteStream, readdir } = require("fs"); -const { writeFile } = require("gitignore"); -const inquirer = require("inquirer"); -const { ncp } = require("ncp"); -const ora = require("ora"); -const path = require("path"); -const { promisify } = require("util"); - -const readDir = promisify(readdir); -const asyncExec = promisify(exec); -const writeGitignore = promisify(writeFile); - -/** - * @method createProject - * @description Create a project - */ -const createProject = async (projectName) => { - let spinner; - - try { - const template = await chooseTemplates(); - // const isUpdated = await dependenciesUpdates(); - // const isDeduped = await dependenciesDeduped(); - - console.log("[ 1 / 3 ] 🔍 copying project..."); - console.log("[ 2 / 3 ] 🚚 fetching node_modules..."); - - await copyProjectFiles(projectName, template); - await updatePackageJson(projectName); - - console.log("[ 3 / 3 ] 🔗 linking node_modules..."); - console.log( - "\u001b[2m──────────────────────────────────────────\u001b[22m" - ); - - spinner = ora(); - spinner.start(); - - await installNodeModules(projectName, spinner); - // isUpdated && (await updateNodeModules(projectName, spinner)); - // isDeduped && (await dedupeNodeModules(projectName, spinner)); - await postInstallScripts(projectName, template, spinner); - - await createGitignore(projectName, spinner); - await initGit(projectName); - - await succeedConsole(template, spinner); - } catch (error) { - await failConsole(error, spinner); - } -}; - -/** - * @method getDirectories - * @description Get the templates directory. - */ -const getTemplateDir = async () => { - const contents = await readDir(__dirname, { withFileTypes: true }); - const directories = contents - .filter((p) => p.isDirectory()) - .map((p) => p.name); - - return directories; -}; - -/** - * @method chooseTemplates - * @description Choose a template. - */ -const chooseTemplates = async () => { - const directories = await getTemplateDir(); - const { chooseTemplates } = await inquirer.prompt([ - { - type: "list", - name: "chooseTemplates", - message: "Select a template", - choices: [...directories, new inquirer.Separator()], - }, - ]); - - return chooseTemplates; -}; - -/** - * @method dependenciesUpdates - * @description npm dependencies updated. - */ -const dependenciesUpdates = async () => { - const { isUpdated } = await inquirer.prompt([ - { - type: "confirm", - name: "isUpdated", - message: "Update the package dependencies to their latest versions ?", - }, - ]); - - if (isUpdated) { - const { isUpdatedReconfirm } = await inquirer.prompt([ - { - type: "confirm", - name: "isUpdatedReconfirm", - message: - "The updated dependencies may contain breaking changes. Continue to update the dependencies anyway ?", - }, - ]); - - return isUpdatedReconfirm; - } - - return false; -}; - -/** - * @method dependenciesDeduped - * @description npm duplicate dependencies removed. - */ -const dependenciesDeduped = async () => { - const { isDeduped } = await inquirer.prompt([ - { - type: "confirm", - name: "isDeduped", - message: "Deduplicate the package dependency tree (recommended) ?", - }, - ]); - - return isDeduped; -}; - -/** - * @method copyProjectFiles - * @description Duplicate the template. - */ -const copyProjectFiles = async (destination, directory) => { - return new Promise((resolve, reject) => { - const source = path.join(__dirname, `./${directory}`); - const options = { - clobber: true, - stopOnErr: true, - }; - - ncp.limit = 16; - ncp(source, destination, options, function (err) { - if (err) reject(err); - resolve(); - }); - }); -}; - -/** - * @method updatePackageJson - * @description Edit package.json. - */ -const updatePackageJson = async (destination) => { - const file = editJsonFile(`${destination}/package.json`, { autosave: true }); - - file.set("name", path.basename(destination)); -}; - -/** - * @method installNodeModules - * @description Install node modules. - */ -const installNodeModules = async (destination, spinner) => { - spinner.text = "Install node_modules...\n"; - await asyncExec("npm install --legacy-peer-deps", { cwd: destination }); -}; - -/** - * @method updateNodeModules - * @description Update node modules. - */ -const updateNodeModules = async (destination, spinner) => { - spinner.text = "Update node_modules...\n"; - await asyncExec("npm update --legacy-peer-deps", { cwd: destination }); -}; - -/** - * @method dedupeNodeModules - * @description Dedupe node modules. - */ -const dedupeNodeModules = async (destination, spinner) => { - spinner.text = "Dedupe node_modules...\n"; - await asyncExec("npm dedupe --legacy-peer-deps", { cwd: destination }); -}; - -/** - * @method postInstallScripts - * @description After installation, configure the settings according to the template. - */ -const postInstallScripts = async (destination, template, spinner) => { - switch (template) { - case "prisma": - { - spinner.text = "Run prisma generate..."; - await asyncExec("npm run prisma:generate", { cwd: destination }); - } - break; - } -}; - -/** - * @method createGitignore - * @description Create a .gitignore. - */ -const createGitignore = async (destination, spinner) => { - spinner.text = "Create .gitignore..."; - - const file = createWriteStream(path.join(destination, ".gitignore"), { - flags: "a", - }); - - return writeGitignore({ - type: "Node", - file: file, - }); -}; - -/** - * @method initGit - * @description Initialize git settings. - */ -const initGit = async (destination) => { - await asyncExec("git init", { cwd: destination }); -}; - -/** - * @method succeedConsole - * @description When the project is successful, the console is displayed. - */ -const succeedConsole = async (template, spinner) => { - spinner.succeed(chalk`{green Complete setup project}`); - - const msg = - { - prisma: - "⛰ Prisma installed. Check your .env settings and then run `npm run prisma:migrate`", - knex: "⛰ Knex installed. Check your .env settings and then run `npm run migrate`", - }[template] || ""; - - msg && console.log(msg); -}; - -/** - * @method failConsole - * @description When the project is fail, the console is displayed. - */ -const failConsole = async (error, spinner) => { - spinner.fail(chalk`{red Please leave this error as an issue}`); - console.error(error); -}; - -module.exports = createProject; From 506e3274caba9c62a2142500a97e58a6ca22adc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=84=EA=B5=AC=EB=AA=AC?= <42952358+ljlm0402@users.noreply.github.com> Date: Tue, 5 Aug 2025 07:50:19 +0000 Subject: [PATCH 20/27] add biome, docker, jest, vitest & devtools category --- bin/common.js | 87 +++++++++-- bin/starter.js | 145 ++++++++++-------- devtools/biome/.biome.json | 34 ++++ devtools/biome/.biomeignore | 29 ++++ .../default => devtools/docker}/Makefile | 0 .../default => devtools/jest}/jest.config.js | 0 devtools/jest/src/test/e2e/auth.e2e.spec.ts | 40 +++++ devtools/jest/src/test/e2e/users.e2e.spec.ts | 66 ++++++++ .../jest}/src/test/setup.ts | 0 .../test/unit/services/auth.service.spec.ts | 69 +++++++++ .../test/unit/services/users.service.spec.ts | 89 +++++++++++ .../vitest}/src/test/e2e/auth.e2e.spec.ts | 0 .../vitest}/src/test/e2e/users.e2e.spec.ts | 0 devtools/vitest/src/test/setup.ts | 31 ++++ .../test/unit/services/auth.service.spec.ts | 0 .../test/unit/services/users.service.spec.ts | 0 .../vitest}/vitest.config.ts | 0 templates/default/package.json | 22 +-- 18 files changed, 516 insertions(+), 96 deletions(-) create mode 100644 devtools/biome/.biome.json create mode 100644 devtools/biome/.biomeignore rename {templates/default => devtools/docker}/Makefile (100%) rename {templates/default => devtools/jest}/jest.config.js (100%) create mode 100644 devtools/jest/src/test/e2e/auth.e2e.spec.ts create mode 100644 devtools/jest/src/test/e2e/users.e2e.spec.ts rename {templates/default => devtools/jest}/src/test/setup.ts (100%) create mode 100644 devtools/jest/src/test/unit/services/auth.service.spec.ts create mode 100644 devtools/jest/src/test/unit/services/users.service.spec.ts rename {templates/default => devtools/vitest}/src/test/e2e/auth.e2e.spec.ts (100%) rename {templates/default => devtools/vitest}/src/test/e2e/users.e2e.spec.ts (100%) create mode 100644 devtools/vitest/src/test/setup.ts rename {templates/default => devtools/vitest}/src/test/unit/services/auth.service.spec.ts (100%) rename {templates/default => devtools/vitest}/src/test/unit/services/users.service.spec.ts (100%) rename {templates/default => devtools/vitest}/vitest.config.ts (100%) diff --git a/bin/common.js b/bin/common.js index 8ab2ae4..b79edbd 100644 --- a/bin/common.js +++ b/bin/common.js @@ -8,9 +8,27 @@ export const PACKAGE_MANAGER = [ ]; export const DEVTOOLS_VALUES = [ + // == [Formatter] == // + { + name: 'Biome', + value: 'biome', + category: 'Formatter', + files: ['.biome.json', '.biomeignore'], + pkgs: [], + devPkgs: [ + '@biomejs/biome', + ], + scripts: { + "lint": "biome lint .", + "check": "biome check .", + "format": "biome format . --write", + }, + desc: 'All-in-one formatter and linter', + }, { name: 'Prettier & ESLint', value: 'prettier', + category: 'Formatter', files: ['.prettierrc', '.eslintrc', '.eslintignore', '.editorconfig'], pkgs: [], devPkgs: [ @@ -18,61 +36,105 @@ export const DEVTOOLS_VALUES = [ 'eslint', '@typescript-eslint/eslint-plugin', '@typescript-eslint/parser', - 'eslint-config-prettier', - 'eslint-plugin-prettier', + 'eslint-config-prettier' ], scripts: { lint: 'eslint --ignore-path .gitignore --ext .ts src/', 'lint:fix': 'npm run lint -- --fix', format: 'prettier --check .', + 'format:fix': 'prettier --write .' }, - desc: 'Code Formatter', + desc: 'Separate formatter and linter setup', }, + + // == [Compiler] == // { name: 'tsup', value: 'tsup', + category: 'Compiler', files: ['tsup.config.ts'], pkgs: [], devPkgs: ['tsup'], scripts: { 'start:tsup': 'node -r tsconfig-paths/register dist/server.js', - 'build:tsup': 'tsup', + 'build:tsup': 'tsup --config tsup.config.ts', }, - desc: 'Fastest Bundler', + desc: 'Fast bundler for TypeScript', }, { name: 'SWC', value: 'swc', + category: 'Compiler', files: ['.swcrc'], pkgs: [], devPkgs: ['@swc/cli', '@swc/core'], scripts: { + 'start:swc': 'node dist/server.js', 'build:swc': 'swc src -d dist --strip-leading-paths --copy-files --delete-dir-on-start', }, - desc: 'Super Fast Compiler', + desc: 'Rust-based TypeScript compiler', }, + + // == [Testing] == // + { + name: 'Jest', + value: 'jest', + category: 'Testing', + files: ['jest.config.js'], + pkgs: [], + devPkgs: ['@types/supertest', 'supertest', '@types/jest', 'jest', 'ts-jest'], + scripts: { + "test": "jest --forceExit --detectOpenHandles", + "test:e2e": "jest --testPathPattern=e2e", + "test:unit": "jest --testPathPattern=unit" + }, + desc: 'Industry-standard test runner for Node.js', + }, + { + name: 'Vitest', + value: 'vitest', + category: 'Testing', + files: ['vitest.config.ts'], + pkgs: [], + devPkgs: ['@types/supertest', 'supertest', 'vitest', 'vite-tsconfig-paths'], + scripts: { + "test": "vitest run", + "test:e2e": "vitest run src/test/e2e", + "test:unit": "vitest run src/test/unit", + }, + desc: 'Fast Vite-powered unit/e2e test framework', + }, + + // == [Infrastructure] == // { name: 'Docker', value: 'docker', - files: ['.dockerignore', 'Dockerfile.dev', 'Dockerfile.prod', 'nginx.conf'], + category: 'Infrastructure', + files: ['.dockerignore', 'Dockerfile.dev', 'Dockerfile.prod', 'nginx.conf', 'Makefile'], pkgs: [], devPkgs: [], scripts: {}, - desc: 'Container', + desc: 'Containerized dev & prod environment', }, + + // == [Git Tools] == // { name: 'Husky', value: 'husky', + category: 'Git Tools', files: ['.husky'], pkgs: [], devPkgs: ['husky'], scripts: { prepare: 'husky install' }, requires: [], - desc: 'Git hooks', + desc: 'Git hooks for automation', }, + + // == [Deployment] == // { name: 'PM2', value: 'pm2', + category: 'Deployment', files: ['ecosystem.config.js'], pkgs: [], devPkgs: ['pm2'], @@ -80,16 +142,19 @@ export const DEVTOOLS_VALUES = [ 'deploy:prod': 'pm2 start ecosystem.config.js --only prod', 'deploy:dev': 'pm2 start ecosystem.config.js --only dev', }, - desc: 'Process Manager', + desc: 'Process manager for Node.js', }, + + // == [CI/CD] == // { name: 'GitHub Actions', value: 'github', + category: 'CI/CD', files: ['.github/workflows/ci.yml'], pkgs: [], devPkgs: [], scripts: {}, - desc: 'CI/CD', + desc: 'CI/CD workflow automation', }, ]; diff --git a/bin/starter.js b/bin/starter.js index ecaa6ac..cfd2f98 100644 --- a/bin/starter.js +++ b/bin/starter.js @@ -9,7 +9,16 @@ * Made with ❤️ by AGUMON 🦖 *****************************************************************/ -import { select, multiselect, text, isCancel, intro, outro, cancel, note, confirm } from '@clack/prompts'; +import { + select, + text, + isCancel, + intro, + outro, + cancel, + note, + confirm, +} from '@clack/prompts'; import chalk from 'chalk'; import editJsonFile from 'edit-json-file'; import { execa } from 'execa'; @@ -19,9 +28,6 @@ import path from 'path'; import { PACKAGE_MANAGER, DEVTOOLS_VALUES, TEMPLATES, DEVTOOLS } from './common.js'; import { TEMPLATE_DB, DB_SERVICES, BASE_COMPOSE } from './db-map.js'; -// import recast from 'recast'; -// import * as tsParser from 'recast/parsers/typescript.js'; - // ========== [공통 함수들] ========== // Node 버전 체크 (16+) @@ -33,7 +39,7 @@ function checkNodeVersion(min = 16) { } } -// 최신 CLI 버전 체크 (배포용 이름으로 변경 필요!) +// 최신 CLI 버전 체크 async function checkForUpdate(pkgName, localVersion) { try { const { stdout } = await execa('npm', ['view', pkgName, 'version']); @@ -41,9 +47,7 @@ async function checkForUpdate(pkgName, localVersion) { if (latest !== localVersion) { console.log(chalk.yellow(`🔔 New version available: ${latest} (You are on ${localVersion})\n $ npm i -g ${pkgName}`)); } - } catch { - /* 무시 */ - } + } catch { } } // 패키지매니저 글로벌 설치여부 @@ -104,8 +108,7 @@ async function installPackages(pkgs, pkgManager, dev = true, destDir = process.c const pkgsWithLatest = []; for (const pkg of pkgs) { const version = await getLatestVersion(pkg); - if (version) pkgsWithLatest.push(`${pkg}@${version}`); - else pkgsWithLatest.push(pkg); + pkgsWithLatest.push(version ? `${pkg}@${version}` : pkg); } const installCmd = pkgManager === 'npm' @@ -122,14 +125,12 @@ async function updatePackageJson(scripts, destDir) { const pkgPath = path.join(destDir, 'package.json'); const file = editJsonFile(pkgPath, { autosave: true }); Object.entries(scripts).forEach(([k, v]) => file.set(`scripts.${k}`, v)); - // Husky 자동 추가 예시 if (!file.get('scripts.prepare') && fs.existsSync(path.join(destDir, '.huskyrc'))) { file.set('scripts.prepare', 'husky install'); } file.save(); } -// 친절한 에러/경고 안내 function printError(message, suggestion = null) { console.log(chalk.bgRed.white(' ERROR '), chalk.red(message)); if (suggestion) { @@ -139,17 +140,11 @@ function printError(message, suggestion = null) { // docker-compose 생성 async function generateCompose(template, destDir) { - // 템플릿에 맞는 DB 선택 const dbType = TEMPLATE_DB[template]; const dbSnippet = dbType ? DB_SERVICES[dbType] : ''; - - // docker-compose.yml 내용 생성 const composeYml = BASE_COMPOSE(dbSnippet); - - // 파일로 기록 const filePath = path.join(destDir, 'docker-compose.yml'); await fs.writeFile(filePath, composeYml, 'utf8'); - return dbType; } @@ -172,10 +167,12 @@ async function main() { // 1. Node 버전 체크 checkNodeVersion(16); - // 2. CLI 최신버전 안내 (자신의 패키지 이름/버전 직접 입력) + // 2. CLI 최신버전 안내 await checkForUpdate('typescript-express-starter', '10.2.2'); - intro(chalk.cyanBright.bold('✨ TypeScript Express Starter')); + const gradientBanner = + '\x1B[38;2;66;211;146m✨\x1B[39m\x1B[38;2;66;211;146m \x1B[39m\x1B[38;2;66;211;146mT\x1B[39m\x1B[38;2;66;211;146my\x1B[39m\x1B[38;2;66;211;146mp\x1B[39m\x1B[38;2;66;211;146me\x1B[39m\x1B[38;2;67;209;149mS\x1B[39m\x1B[38;2;68;206;152mc\x1B[39m\x1B[38;2;69;204;155mr\x1B[39m\x1B[38;2;70;201;158mi\x1B[39m\x1B[38;2;71;199;162mp\x1B[39m\x1B[38;2;72;196;165mt\x1B[39m\x1B[38;2;73;194;168m \x1B[39m\x1B[38;2;74;192;171mE\x1B[39m\x1B[38;2;75;189;174mx\x1B[39m\x1B[38;2;76;187;177mp\x1B[39m\x1B[38;2;77;184;180mr\x1B[39m\x1B[38;2;78;182;183me\x1B[39m\x1B[38;2;79;179;186ms\x1B[39m\x1B[38;2;80;177;190ms\x1B[39m\x1B[38;2;81;175;193m \x1B[39m\x1B[38;2;82;172;196mS\x1B[39m\x1B[38;2;83;170;199mt\x1B[39m\x1B[38;2;84;167;202ma\x1B[39m\x1B[38;2;85;165;205mr\x1B[39m\x1B[38;2;86;162;208mt\x1B[39m\x1B[38;2;87;160;211me\x1B[39m\x1B[38;2;88;158;215mr\x1B[39m'; + intro(gradientBanner); // 3. 패키지 매니저 선택 + 글로벌 설치 확인 let pkgManager; @@ -185,7 +182,7 @@ async function main() { options: PACKAGE_MANAGER, initialValue: 'npm', }); - if (isCancel(pkgManager)) return cancel('Aborted.'); + if (isCancel(pkgManager)) return cancel('❌ Aborted.'); if (await checkPkgManagerInstalled(pkgManager)) break; printError(`${pkgManager} is not installed globally! Please install it first.`); } @@ -193,50 +190,62 @@ async function main() { // 4. 템플릿 선택 const templateDirs = (await fs.readdir(TEMPLATES)).filter(f => fs.statSync(path.join(TEMPLATES, f)).isDirectory()); - if (templateDirs.length === 0) { - printError('No templates found!'); - return; - } + if (templateDirs.length === 0) return printError('No templates found!'); + const template = await select({ message: 'Choose a template:', options: templateDirs.map(t => ({ label: t, value: t })), initialValue: 'default', }); - if (isCancel(template)) return cancel('Aborted.'); + if (isCancel(template)) return cancel('❌ Aborted.'); - // 5. 프로젝트명 (중복체크/덮어쓰기) - let projectName; - let destDir; + // 5. 프로젝트명 입력 (중복체크/덮어쓰기) + let projectName, destDir; while (true) { projectName = await text({ message: 'Enter your project name:', initial: 'my-app', validate: val => (!val ? 'Project name is required' : undefined), }); - if (isCancel(projectName)) return cancel('Aborted.'); + if (isCancel(projectName)) return cancel('❌ Aborted.'); destDir = path.resolve(process.cwd(), projectName); if (await fs.pathExists(destDir)) { const overwrite = await confirm({ message: `Directory "${projectName}" already exists. Overwrite?` }); if (overwrite) break; - else continue; - } - break; + } else break; } - // 6. 개발 도구 옵션 선택(멀티) - let devtoolValues = await multiselect({ - message: 'Select additional developer tools:', - options: DEVTOOLS_VALUES.map(({ name, value, desc }) => ({ label: name, value, hint: desc })), - initialValues: ['prettier', 'tsup'], - required: false, - }); - if (isCancel(devtoolValues)) return cancel('Aborted.'); + // 6. 개발 도구 옵션 선택 (category 기준으로 그룹화) + const groupedDevtools = DEVTOOLS_VALUES.reduce((acc, tool) => { + const cat = tool.category || 'Others'; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(tool); + return acc; + }, {}); + + // 6-1. 개발 도구 옵션 선택 (category별 하나씩만 선택하는 방식) + let devtoolValues = []; + for (const [category, tools] of Object.entries(groupedDevtools)) { + const picked = await select({ + message: `Select a tool for "${category}":`, + options: [ + { label: 'None', value: null }, + ...tools.map(({ name, value, desc }) => ({ + label: `${name} (${desc})`, + value, + })), + ], + initialValue: null, + }); + if (isCancel(picked)) return cancel('❌ Aborted.'); + if (picked) devtoolValues.push(picked); + } devtoolValues = resolveDependencies(devtoolValues); // === [진행] === // [1] 템플릿 복사 - const spinner = ora('Copying template...\n').start(); + const spinner = ora('Copying template...').start(); try { await fs.copy(path.join(TEMPLATES, template), destDir, { overwrite: true }); spinner.succeed('Template copied!'); @@ -246,45 +255,45 @@ async function main() { return process.exit(1); } + // [1-1] Testing 도구를 선택한 경우에만 /src/test 예제 복사 + const testDevtool = devtoolValues + .map(val => DEVTOOLS_VALUES.find(d => d.value === val)) + .find(tool => tool && tool.category === 'Testing'); + + if (testDevtool) { + const devtoolTestDir = path.join(DEVTOOLS, testDevtool.value, 'src', 'test'); + const projectTestDir = path.join(destDir, 'src', 'test'); + if (await fs.pathExists(devtoolTestDir)) { + await fs.copy(devtoolTestDir, projectTestDir, { overwrite: true }); + console.log(chalk.gray(` ⎯ test files for ${testDevtool.name} copied.`)); + } + } + // [2] 개발 도구 파일/패키지/스크립트/코드패치 for (const val of devtoolValues) { const tool = DEVTOOLS_VALUES.find(d => d.value === val); if (!tool) continue; - spinner.start(`Copying ${tool.name} files...\n`); + spinner.start(`Setting up ${tool.name}...`); await copyDevtoolFiles(tool, destDir); - spinner.succeed(`${tool.name} files copied!`); - if (tool.pkgs?.length > 0) { - spinner.start(`Installing ${tool.name} packages (prod)...\n`); - await installPackages(tool.pkgs, pkgManager, false, destDir); - spinner.succeed(`${tool.name} packages (prod) installed!`); - } + // [2-1] 개발 도구 - 패키지 설치 + if (tool.pkgs?.length > 0) await installPackages(tool.pkgs, pkgManager, false, destDir); + if (tool.devPkgs?.length > 0) await installPackages(tool.devPkgs, pkgManager, true, destDir); - if (tool.devPkgs?.length > 0) { - spinner.start(`Installing ${tool.name} packages (dev)...\n`); - await installPackages(tool.devPkgs, pkgManager, true, destDir); - spinner.succeed(`${tool.name} packages (dev) installed!`); - } + // [2-2] 개발 도구 - 스크립트 추가 등 + if (Object.keys(tool.scripts).length) await updatePackageJson(tool.scripts, destDir); - if (Object.keys(tool.scripts).length) { - spinner.start(`Updating scripts for ${tool.name}...\n`); - await updatePackageJson(tool.scripts, destDir); - spinner.succeed(`${tool.name} scripts updated!`); - } - - // [2-1] 개발 도구 - Docker 선택 한 경우, docker-compose.yml 생성 - if (tool.value === 'docker') { - spinner.start(`Creating docker-compose ...\n`); - const dbType = await generateCompose(template, destDir); - spinner.succeed(`docker-compose.yml with ${dbType || 'no'} DB created!`); - } + // [2-3] 개발 도구 - Docker 선택 한 경우, docker-compose.yml 생성 + if (tool.value === 'docker') await generateCompose(template, destDir); + + spinner.succeed(`${tool.name} setup done.`); } // [3] 템플릿 기본 패키지 설치 - spinner.start(`Installing base dependencies with ${pkgManager}...\n`); + spinner.start(`Installing base dependencies with ${pkgManager}...`); await execa(pkgManager, ['install'], { cwd: destDir, stdio: 'inherit' }); - spinner.succeed('Base dependencies installed!'); + spinner.succeed('📦 Base dependencies installed!'); // [4] git 첫 커밋 옵션 await gitInitAndFirstCommit(destDir); diff --git a/devtools/biome/.biome.json b/devtools/biome/.biome.json new file mode 100644 index 0000000..7bd5cba --- /dev/null +++ b/devtools/biome/.biome.json @@ -0,0 +1,34 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.0.0/schema.json", + "files": { + "ignore": [ + "node_modules/**", + "dist/**", + "build/**" + ] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 150, + "quoteStyle": "single", + "trailingComma": "all", + "semiColon": true, + "arrowParentheses": "avoid" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "style/noExplicitAny": "off", + "style/noNonNullAssertion": "off", + "suspicious/noExplicitAny": "off", + "style/noVar": "off", + "complexity/useArrowFunctions": "off" + } + }, + "organizeImports": { + "enabled": true + } +} diff --git a/devtools/biome/.biomeignore b/devtools/biome/.biomeignore new file mode 100644 index 0000000..b010109 --- /dev/null +++ b/devtools/biome/.biomeignore @@ -0,0 +1,29 @@ +# node_modules and build outputs +node_modules/ +dist/ +build/ +coverage/ +.tmp/ +.temp/ + +# 환경설정 및 로그 +.env +.env.* +*.log + +# TypeScript compiled output +*.js +*.d.ts +*.map + +# OS, IDE/Editor 관련 +.DS_Store +.vscode/ +.idea/ +*.swp + +# 기타 무시할 파일/폴더 +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* diff --git a/templates/default/Makefile b/devtools/docker/Makefile similarity index 100% rename from templates/default/Makefile rename to devtools/docker/Makefile diff --git a/templates/default/jest.config.js b/devtools/jest/jest.config.js similarity index 100% rename from templates/default/jest.config.js rename to devtools/jest/jest.config.js diff --git a/devtools/jest/src/test/e2e/auth.e2e.spec.ts b/devtools/jest/src/test/e2e/auth.e2e.spec.ts new file mode 100644 index 0000000..6e9ffe6 --- /dev/null +++ b/devtools/jest/src/test/e2e/auth.e2e.spec.ts @@ -0,0 +1,40 @@ +import request from 'supertest'; +import { createTestApp, resetUserDB } from '@/test/setup'; + +describe('Auth API', () => { + let server: any; + const prefix = '/api/v1'; + + beforeAll(() => { + server = createTestApp(); // Use shared repository for testing + }); + + beforeEach(() => { + resetUserDB(); // Reset repository before each test + }); + + const user = { email: 'authuser@example.com', password: 'authpassword123' }; + + it('should successfully register a new user', async () => { + const res = await request(server).post(`${prefix}/auth/signup`).send(user); + expect(res.statusCode).toBe(201); + expect(res.body.data.email).toBe(user.email); + }); + + it('should login a user and set a cookie', async () => { + await request(server).post(`${prefix}/auth/signup`).send(user); + const res = await request(server).post(`${prefix}/auth/login`).send(user); + expect(res.statusCode).toBe(200); + expect(res.body.data.email).toBe(user.email); + expect(res.header['set-cookie']).toBeDefined(); + }); + + it('should logout a user', async () => { + await request(server).post(`${prefix}/auth/signup`).send(user); + const loginRes = await request(server).post(`${prefix}/auth/login`).send(user); + const cookie = loginRes.headers['set-cookie']; + const logoutRes = await request(server).post(`${prefix}/auth/logout`).set('Cookie', cookie[0]); + expect(logoutRes.statusCode).toBe(200); + expect(logoutRes.body.message).toBe('logout'); + }); +}); diff --git a/devtools/jest/src/test/e2e/users.e2e.spec.ts b/devtools/jest/src/test/e2e/users.e2e.spec.ts new file mode 100644 index 0000000..c539771 --- /dev/null +++ b/devtools/jest/src/test/e2e/users.e2e.spec.ts @@ -0,0 +1,66 @@ +import request from 'supertest'; +import { createTestApp, resetUserDB } from '@/test/setup'; + +describe('Users API', () => { + let server: any; + const prefix = '/api/v1'; + + beforeAll(() => { + server = createTestApp(); // Initialize server with shared repository + }); + + beforeEach(() => { + resetUserDB(); // Reset repository before each test + }); + + const user = { email: 'user1@example.com', password: 'password123' }; + let userId: string; + + it('should create a new user', async () => { + const res = await request(server).post(`${prefix}/users`).send(user); + expect(res.statusCode).toBe(201); + expect(res.body.data.email).toBe(user.email); + userId = res.body.data.id; + }); + + it('should retrieve all users', async () => { + await request(server).post(`${prefix}/users`).send(user); + const res = await request(server).get(`${prefix}/users`); + expect(res.statusCode).toBe(200); + expect(Array.isArray(res.body.data)).toBe(true); + expect(res.body.data[0].email).toBe(user.email); + }); + + it('should retrieve a user by id', async () => { + const createRes = await request(server).post(`${prefix}/users`).send(user); + const id = createRes.body.data.id; + + const res = await request(server).get(`${prefix}/users/${id}`); + expect(res.statusCode).toBe(200); + expect(res.body.data.email).toBe(user.email); + }); + + it('should update user information', async () => { + const createRes = await request(server).post(`${prefix}/users`).send(user); + const id = createRes.body.data.id; + + const newPassword = 'newpassword123'; + const res = await request(server).put(`${prefix}/users/${id}`).send({ password: newPassword }); + expect(res.statusCode).toBe(200); + expect(res.body.data.id).toBe(id); + }); + + it('should delete a user', async () => { + const createRes = await request(server).post(`${prefix}/users`).send(user); + const id = createRes.body.data.id; + + const res = await request(server).delete(`${prefix}/users/${id}`); + expect(res.statusCode).toBe(204); + }); + + it('should return 404 if user does not exist', async () => { + const res = await request(server).get(`${prefix}/users/invalid-id`); + expect(res.statusCode).toBe(404); + expect(res.body.error.message).toMatch(/not exist|not found/i); + }); +}); diff --git a/templates/default/src/test/setup.ts b/devtools/jest/src/test/setup.ts similarity index 100% rename from templates/default/src/test/setup.ts rename to devtools/jest/src/test/setup.ts diff --git a/devtools/jest/src/test/unit/services/auth.service.spec.ts b/devtools/jest/src/test/unit/services/auth.service.spec.ts new file mode 100644 index 0000000..805d2db --- /dev/null +++ b/devtools/jest/src/test/unit/services/auth.service.spec.ts @@ -0,0 +1,69 @@ +import { compare, hash } from 'bcryptjs'; +import { CreateUserDto } from '@dtos/users.dto'; +import { User } from '@interfaces/users.interface'; +import { UsersRepository } from '@repositories/users.repository'; +import { AuthService } from '@services/auth.service'; + +describe('AuthService (with UserMemoryRepository)', () => { + let authService: AuthService; + let userRepo: UsersRepository; + const testUser: User = { + id: '1', + email: 'authuser@example.com', + password: '', // will be replaced with actual hash + }; + + beforeEach(async () => { + userRepo = new UsersRepository(); + testUser.password = await hash('plainpw', 10); + // Add initial user + await userRepo.save({ ...testUser }); + authService = new AuthService(userRepo); // inject repo + }); + + it('should sign up a new user', async () => { + const dto: CreateUserDto = { + email: 'newuser@example.com', + password: 'newpassword123', + }; + const created = await authService.signup(dto); + expect(created.email).toBe(dto.email); + + const found = await userRepo.findByEmail(dto.email); + expect(found).toBeDefined(); + expect(await compare(dto.password, found!.password)).toBe(true); + }); + + it('should throw an error if email is already in use', async () => { + const dto: CreateUserDto = { + email: testUser.email, + password: 'anypw', + }; + await expect(authService.signup(dto)).rejects.toThrow(/already in use/); + }); + + it('should return user and cookie on successful login', async () => { + // Create user with hashed password + const plainPassword = 'mySecret123'; + const email = 'loginuser@example.com'; + const hashed = await hash(plainPassword, 10); + await userRepo.save({ id: '2', email, password: hashed }); + + const result = await authService.login({ email, password: plainPassword }); + expect(result.user.email).toBe(email); + expect(result.cookie).toContain('Authorization='); + }); + + it('should throw an error if email or password is incorrect', async () => { + // Non-existing email + await expect(authService.login({ email: 'nobody@example.com', password: 'xxx' })).rejects.toThrow(/Invalid email or password/i); + + // Incorrect password + const email = testUser.email; + await expect(authService.login({ email, password: 'wrongpw' })).rejects.toThrow(/password/i); + }); + + it('should successfully logout without errors', async () => { + await expect(authService.logout(testUser)).resolves.toBeUndefined(); + }); +}); diff --git a/devtools/jest/src/test/unit/services/users.service.spec.ts b/devtools/jest/src/test/unit/services/users.service.spec.ts new file mode 100644 index 0000000..8d117a8 --- /dev/null +++ b/devtools/jest/src/test/unit/services/users.service.spec.ts @@ -0,0 +1,89 @@ +import { User } from '@interfaces/users.interface'; +import { UsersRepository } from '@repositories/users.repository'; +import { UsersService } from '@services/users.service'; + +describe('UsersService (with UsersRepository)', () => { + let usersService: UsersService; + let userRepo: UsersRepository; + + // Sample user data + const user1: User & { id: string } = { id: '1', email: 'one@example.com', password: 'pw1' }; + const user2: User & { id: string } = { id: '2', email: 'two@example.com', password: 'pw2' }; + + beforeEach(async () => { + userRepo = new UsersRepository(); + userRepo.reset(); + // Directly save users (save is async but await is optional for init) + await userRepo.save({ ...user1 }); + await userRepo.save({ ...user2 }); + usersService = new UsersService(userRepo); + }); + + it('getAllUsers: should return all users', async () => { + const users = await usersService.getAllUsers(); + expect(users.length).toBe(2); + expect(users[0].email).toBe(user1.email); + }); + + it('getUserById: should return user by ID', async () => { + const user = await usersService.getUserById('2'); + expect(user.email).toBe(user2.email); + }); + + it('getUserById: should throw if ID does not exist', async () => { + await expect(usersService.getUserById('999')).rejects.toThrow(/not found/); + }); + + it('createUser: should add a new user', async () => { + const created = await usersService.createUser({ + id: '', // ignored + email: 'new@example.com', + password: 'pw3', + }); + expect(created.email).toBe('new@example.com'); + const all = await usersService.getAllUsers(); + expect(all.length).toBe(3); + }); + + it('createUser: should throw if email already exists', async () => { + await expect( + usersService.createUser({ + id: '', + email: user1.email, + password: 'pwX', + }), + ).rejects.toThrow(/exists/); + }); + + it('updateUser: should update user password', async () => { + const newPassword = 'newpw'; + const updated = await usersService.updateUser(user2.id as string, { + id: user2.id as string, + email: user2.email, + password: newPassword, + }); + expect(updated).toBeDefined(); + expect(updated!.password).not.toBe(user2.password); // changed to hashed value + }); + + it('updateUser: should throw if ID does not exist', async () => { + await expect( + usersService.updateUser('999', { + id: '999', + email: 'no@no.com', + password: 'no', + }), + ).rejects.toThrow(/not found/); + }); + + it('deleteUser: should delete user successfully', async () => { + await usersService.deleteUser(user1.id as string); + const users = await usersService.getAllUsers(); + expect(users.length).toBe(1); + expect(users[0].id).toBe(user2.id); + }); + + it('deleteUser: should throw if ID does not exist', async () => { + await expect(usersService.deleteUser('999')).rejects.toThrow(/not found/); + }); +}); diff --git a/templates/default/src/test/e2e/auth.e2e.spec.ts b/devtools/vitest/src/test/e2e/auth.e2e.spec.ts similarity index 100% rename from templates/default/src/test/e2e/auth.e2e.spec.ts rename to devtools/vitest/src/test/e2e/auth.e2e.spec.ts diff --git a/templates/default/src/test/e2e/users.e2e.spec.ts b/devtools/vitest/src/test/e2e/users.e2e.spec.ts similarity index 100% rename from templates/default/src/test/e2e/users.e2e.spec.ts rename to devtools/vitest/src/test/e2e/users.e2e.spec.ts diff --git a/devtools/vitest/src/test/setup.ts b/devtools/vitest/src/test/setup.ts new file mode 100644 index 0000000..44e9885 --- /dev/null +++ b/devtools/vitest/src/test/setup.ts @@ -0,0 +1,31 @@ +import 'reflect-metadata'; +import { container } from 'tsyringe'; +import App from '@/app'; +import { AuthRoute } from '@routes/auth.route'; +import { UsersRoute } from '@routes/users.route'; +import { UsersRepository, IUsersRepository } from '@repositories/users.repository'; + +let sharedRepo: UsersRepository; + +export function createTestApp({ mockRepo }: { mockRepo?: IUsersRepository } = {}) { + // 항상 새로운 인스턴스를 주입하고 싶으면 reset logic 추가 필요 + if (!sharedRepo) { + sharedRepo = new UsersRepository(); + container.registerInstance(UsersRepository, sharedRepo); + } + // mockRepo가 있으면 주입 + if (mockRepo) { + container.registerInstance(UsersRepository, mockRepo as UsersRepository); + } + + // 클래스 타입을 직접 주입 + const routes = [container.resolve(UsersRoute), container.resolve(AuthRoute)]; + const appInstance = new App(routes); + return appInstance.getServer(); +} + +export function resetUserDB() { + if (sharedRepo) { + sharedRepo.reset(); + } +} diff --git a/templates/default/src/test/unit/services/auth.service.spec.ts b/devtools/vitest/src/test/unit/services/auth.service.spec.ts similarity index 100% rename from templates/default/src/test/unit/services/auth.service.spec.ts rename to devtools/vitest/src/test/unit/services/auth.service.spec.ts diff --git a/templates/default/src/test/unit/services/users.service.spec.ts b/devtools/vitest/src/test/unit/services/users.service.spec.ts similarity index 100% rename from templates/default/src/test/unit/services/users.service.spec.ts rename to devtools/vitest/src/test/unit/services/users.service.spec.ts diff --git a/templates/default/vitest.config.ts b/devtools/vitest/vitest.config.ts similarity index 100% rename from templates/default/vitest.config.ts rename to devtools/vitest/vitest.config.ts diff --git a/templates/default/package.json b/templates/default/package.json index fe46981..1aafbf0 100644 --- a/templates/default/package.json +++ b/templates/default/package.json @@ -1,16 +1,13 @@ { - "name": "typescript-express-starter", - "version": "0.0.0", + "name": "default-template", + "version": "1.0.0", "description": "", "author": "", - "license": "MIT", + "license": "ISC", "scripts": { "start": "cross-env NODE_ENV=production node dist/server.js", "dev": "cross-env NODE_ENV=development nodemon", - "build": "tsc && tsc-alias", - "test": "vitest run", - "test:e2e": "vitest run src/test/e2e", - "test:unit": "vitest run src/test/unit" + "build": "tsc && tsc-alias" }, "dependencies": { "bcryptjs": "^3.0.2", @@ -43,23 +40,14 @@ "@types/jsonwebtoken": "^9.0.10", "@types/morgan": "^1.9.10", "@types/node": "^20.11.30", - "@types/supertest": "^6.0.3", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "cross-env": "^7.0.3", - "eslint": "^9.30.1", "node-gyp": "^11.2.0", "nodemon": "^3.1.10", - "rimraf": "^6.0.1", - "supertest": "^7.1.3", "ts-node": "^10.9.2", "tsc-alias": "^1.8.16", "tsconfig-paths": "^4.2.0", - "typescript": "^5.5.3", - "vite-tsconfig-paths": "^5.1.4", - "vitest": "^3.2.4" - }, - "engines": { - "node": ">=18.17.0" + "typescript": "^5.5.3" } } From 94943eda390ca0a92096c799cec3acd4d3cea77e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=84=EA=B5=AC=EB=AA=AC?= <42952358+ljlm0402@users.noreply.github.com> Date: Thu, 7 Aug 2025 02:47:56 +0000 Subject: [PATCH 21/27] Fix Readme --- README.md | 324 +++++++++++------------------------------------------- 1 file changed, 67 insertions(+), 257 deletions(-) diff --git a/README.md b/README.md index 40cfd4b..95bbb4a 100644 --- a/README.md +++ b/README.md @@ -52,278 +52,109 @@
-## 😎 Introducing The Project +--- -Express consists of JavaScript, which makes it vulnerable to type definitions. -That's why we avoid supersets with starter packages that introduce TypeScript. +## 📝 Introduction -The package is configured to use TypeScript instead of JavaScript. +**TypeScript Express Starter** provides a robust starting point for building secure, scalable, and maintainable RESTful APIs. -> The project referred to [express-generator-typescript](https://github.com/seanpmaxwell/express-generator-typescript) +It blends the flexibility and simplicity of Express with TypeScript’s type safety, supporting rapid development without compromising code quality or maintainability. -### 🤔 What is Express ? +- Clean architecture and modular structure -Express is a fast, open and concise web framework and is a Node.js based project. +- Built-in security, logging, validation, and developer tooling -## 🚀 Quick Start +- Instantly ready for both prototyping and production -### Install with the npm Global Package +## ⚡️ Quick Start ```bash -$ npm install -g typescript-express-starter -``` - -### Run npx to Install The Package - -npx is a tool in the JavaScript package management module, npm. +# Install globally +npm install -g typescript-express-starter -This is a tool that allows you to run the npm package on a single run without installing the package. +# Scaffold a new project +npx typescript-express-starter my-app +cd my-app -If you do not enter a project name, it defaults to _typescript-express-starter_. - -```bash -$ npx typescript-express-starter "project name" +# Run in development mode +npm run dev ``` +- Access the app: http://localhost:3000/ -### Select a Templates - -Example Cli - -Start your typescript-express-starter app in development mode at `http://localhost:3000/` - -#### Template Type - -| Name | Description | -| :---------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Default | Express Default | -| [Routing Controllers](https://github.com/typestack/routing-controllers) | Create structured, declarative and beautifully organized class-based controllers with heavy decorators usage | -| [Sequelize](https://github.com/sequelize/sequelize) | Easy to use multi SQL dialect ORM for Node.js | -| [Mongoose](https://github.com/Automattic/mongoose) | MongoDB Object Modeling(ODM) designed to work in an asynchronous environment | -| [TypeORM](https://github.com/typeorm/typeorm) | An ORM that can run in Node.js and Others | -| [Prisma](https://github.com/prisma/prisma) | Modern Database Access for TypeScript & Node.js | -| [Knex](https://github.com/knex/knex) | SQL query builder for Postgres, MySQL, MariaDB, SQLite3 and Oracle | -| [GraphQL](https://github.com/graphql/graphql-js) | query language for APIs and a runtime for fulfilling those queries with your existing data | -| [Typegoose](https://github.com/typegoose/typegoose) | Define Mongoose models using TypeScript classes | -| [Mikro ORM](https://github.com/mikro-orm/mikro-orm) | TypeScript ORM for Node.js based on Data Mapper, Unit of Work and Identity Map patterns. Supports MongoDB, MySQL, MariaDB, PostgreSQL and SQLite databases | -| [Node Postgres](https://node-postgres.com/) | node-postgres is a collection of node.js modules for interfacing with your PostgreSQL database | - -#### Template to be developed - -| Name | Description | -| :------------------------------------------------------------------------------ | :------------------------------------------------------------------------- | -| [Sequelize Typescript](https://github.com/RobinBuschmann/sequelize-typescript) | Decorators and some other features for sequelize | -| [TS SQL](https://github.com/codemix/ts-sql) | A SQL database implemented purely in TypeScript type annotations | -| [inversify-express-utils](https://github.com/inversify/inversify-express-utils) | Some utilities for the development of Express application with InversifyJS | -| [postgress typescript]() | | -| [graphql prisma]() | | - -## 🛎 Available Commands for the Server - -- Run the Server in production mode : `npm run start` or `Start typescript-express-starter` in VS Code -- Run the Server in development mode : `npm run dev` or `Dev typescript-express-starter` in VS Code -- Run all unit-tests : `npm test` or `Test typescript-express-starter` in VS Code -- Check for linting errors : `npm run lint` or `Lint typescript-express-starter` in VS Code -- Fix for linting : `npm run lint:fix` or `Lint:Fix typescript-express-starter` in VS Code - -## 💎 The Package Features - -

-    -    -    -

-

-    - -    -    -    -    -    - - -

-

-    -    -    - -

- -### 🐳 Docker :: Container Platform - -[Docker](https://docs.docker.com/) is a platform for developers and sysadmins to build, run, and share applications with containers. - -[Docker](https://docs.docker.com/get-docker/) Install. - -- starts the containers in the background and leaves them running : `docker-compose up -d` -- Stops containers and removes containers, networks, volumes, and images : `docker-compose down` - -Modify `docker-compose.yml` and `Dockerfile` file to your source code. +- Auto-generated API docs: http://localhost:3000/api-docs -### ♻️ NGINX :: Web Server +### Example -[NGINX](https://www.nginx.com/) is a web server that can also be used as a reverse proxy, load balancer, mail proxy and HTTP cache. +## 🔥 Core Features +- Express + TypeScript: Full type safety and modern JavaScript support -Proxying is typically used to distribute the load among several servers, seamlessly show content from different websites, or pass requests for processing to application servers over protocols other than HTTP. +- Modern Logging: Fast, structured logging with Pino -When NGINX proxies a request, it sends the request to a specified proxied server, fetches the response, and sends it back to the client. +- Validation: Schema-based runtime validation with Zod -Modify `nginx.conf` file to your source code. +- Dependency Injection: Lightweight and flexible with tsyringe -### ✨ ESLint, Prettier :: Code Formatter +- Security: Helmet, CORS, HPP, rate limiting included by default -[Prettier](https://prettier.io/) is an opinionated code formatter. +- API Docs: Swagger/OpenAPI out of the box -[ESLint](https://eslint.org/), Find and fix problems in your JavaScript code +- Developer Tools: ESLint, Prettier, Jest, Docker, PM2, NGINX, Makefile -It enforces a consistent style by parsing your code and re-printing it with its own rules that take the maximum line length into account, wrapping code when necessary. +- Modular: Easy to customize and extend -1. Install [VSCode](https://code.visualstudio.com/) Extension [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode), [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) +## 🧩 Template Choices +Choose your preferred stack during setup! +Support for major databases and patterns via CLI: -2. `CMD` + `Shift` + `P` (Mac Os) or `Ctrl` + `Shift` + `P` (Windows) +| Template | Stack / Integration | +| ------------- | ------------------------------ | +| Default | Express + TypeScript (vanilla) | +| Sequelize | Sequelize ORM | +| Mongoose | MongoDB ODM (Mongoose) | +| TypeORM | TypeORM | +| Prisma | Prisma ORM | +| Knex | SQL Query Builder | +| GraphQL | GraphQL support | +| Typegoose | TS-friendly Mongoose | +| Mikro ORM | Data Mapper ORM (multi-DB) | +| Node Postgres | PostgreSQL driver (pg) | +| Drizzle | Drizzle | -3. Format Selection With +More templates are regularly added and updated. -4. Configure Default Formatter... +## 🛠 Developer Tooling & Ecosystem -5. Prettier - Code formatter +- Logging: Pino, Pino-pretty -Formatter Setting +- Validation: Zod -> Palantir, the backers behind TSLint announced in 2019 that they would be deprecating TSLint in favor of supporting typescript-eslint in order to benefit the community. -> So, migration from TSLint to ESLint. +- Dependency Injection: tsyringe -### 📗 Swagger :: API Document +- API Documentation: Swagger (swagger-jsdoc, swagger-ui-express) -[Swagger](https://swagger.io/) is Simplify API development for users, teams, and enterprises with the Swagger open source and professional toolset. +- Code Quality: ESLint, Prettier, EditorConfig -Easily used by Swagger to design and document APIs at scale. +- Testing: Jest, Vitest -Start your app in development mode at `http://localhost:3000/api-docs` +- Build Tools: SWC, TSC, Nodemon, Makefile, Tsup -Modify `swagger.yaml` file to your source code. +- Production Ready: Docker, Docker Compose, PM2, NGINX -### 🌐 REST Client :: HTTP Client Tools +- Environment Management: dotenv, envalid -REST Client allows you to send HTTP request and view the response in Visual Studio Code directly. +## 🤔 Comparison: NestJS Boilerplate -VSCode Extension [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) Install. - -Modify `*.http` file in src/http folder to your source code. - -### 🔮 PM2 :: Advanced, Production process manager for Node.js - -[PM2](https://pm2.keymetrics.io/) is a daemon process manager that will help you manage and keep your application online 24/7. - -- production mode :: `npm run deploy:prod` or `pm2 start ecosystem.config.js --only prod` -- development mode :: `npm run deploy:dev` or `pm2 start ecosystem.config.js --only dev` - -Modify `ecosystem.config.js` file to your source code. - -### 🏎 SWC :: a super-fast JavaScript / TypeScript compiler - -[SWC](https://swc.rs/) is an extensible Rust-based platform for the next generation of fast developer tools. - -`SWC is 20x faster than Babel on a single thread and 70x faster on four cores.` - -- tsc build :: `npm run build` -- swc build :: `npm run build:swc` - -Modify `.swcrc` file to your source code. - -### 💄 Makefile :: This is a setting file of the make program used to make the compilation that occurs repeatedly on Linux - -[Makefile](https://makefiletutorial.com/)s are used to help decide which parts of a large program need to be recompiled. - -- help :: `make help` - -Modify `Makefile` file to your source code. - -## 🗂 Code Structure (default) - -```sh -│ -├──📂 .vscode -│ ├── launch.json -│ └── settings.json -│ -├──📂 src -│ ├──📂 config -│ │ └── index.ts -│ │ -│ ├──📂 controllers -│ │ ├── auth.controller.ts -│ │ └── users.controller.ts -│ │ -│ ├──📂 dtos -│ │ └── users.dto.ts -│ │ -│ ├──📂 exceptions -│ │ └── httpException.ts -│ │ -│ ├──📂 http -│ │ ├── auth.http -│ │ └── users.http -│ │ -│ ├──📂 interfaces -│ │ ├── auth.interface.ts -│ │ ├── routes.interface.ts -│ │ └── users.interface.ts -│ │ -│ ├──📂 middlewares -│ │ ├── auth.middleware.ts -│ │ ├── error.middleware.ts -│ │ └── validation.middleware.ts -│ │ -│ ├──📂 models -│ │ └── users.model.ts -│ │ -│ ├──📂 routes -│ │ ├── auth.route.ts -│ │ └── users.route.ts -│ │ -│ ├──📂 services -│ │ ├── auth.service.ts -│ │ └── users.service.ts -│ │ -│ ├──📂 test -│ │ ├── auth.test.ts -│ │ └── users.test.ts -│ │ -│ ├──📂 utils -│ │ ├── logger.ts -│ │ └── vaildateEnv.ts -│ │ -│ ├── app.ts -│ └── server.ts -│ -├── .dockerignore -├── .editorconfig -├── .env.development.local -├── .env.production.local -├── .env.test.local -├── .eslintignore -├── .eslintrc -├── .gitignore -├── .huskyrc -├── .lintstagedrc.json -├── .prettierrc -├── .swcrc -├── docker-compose.yml -├── Dockerfile.dev -├── Dockerfile.prod -├── ecosystem.config.js -├── jest.config.js -├── Makefile -├── nginx.conf -├── nodemon.json -├── package-lock.json -├── package.json -├── swagger.yaml -└── tsconfig.json -``` +| Criteria | TypeScript Express Starter | NestJS | +| -------------- | ---------------------------------- | ----------------------------- | +| Learning Curve | Low (familiar Express patterns) | Higher (OOP/DI/Decorators) | +| Flexibility | Maximum (customize anything) | Convention-based, opinionated | +| Modularity | Module/middleware oriented | Strong module system | +| Type Safety | Full TS support | Full TS support | +| Testing | Jest, Vitest supported | Jest + E2E built-in | +| Scale | Fast prototyping to mid-size apps | Best for large-scale projects | +| DI Framework | tsyringe (lightweight) | Built-in container | +| Real World Use | Great for microservices, rapid dev | Enterprise-grade applications | ## ⭐️ Stargazers @@ -337,28 +168,7 @@ Modify `Makefile` file to your source code. [![Contributors repo roster for @ljlm0402/typescript-express-starter](https://contributors-img.web.app/image?repo=ljlm0402/typescript-express-starter)](https://github.com/ljlm0402/typescript-express-starter/graphs/contributors) -## 💳 License - -[MIT](LICENSE) - -## 📑 Recommended Commit Message - -| When | Commit Message | -| :--------------- | :----------------- | -| Add Feature | ✨ Add Feature | -| Fix Bug | 🐞 Fix Bug | -| Refactoring Code | 🛠 Refactoring Code | -| Install Package | 📦 Install Package | -| Fix Readme | 📚 Fix Readme | -| Update Version | 🌼 Update Version | -| New Template | 🎉 New Template | - -## 📬 Please request an issue - -Please leave a question or question as an issue. - -I will do my best to answer and reflect. -Thank you for your interest. +## 📄 License +MIT(LICENSE) © AGUMON (ljlm0402) -# ദ്ദി*ˊᗜˋ*) From 29491fe890c79d53682e348007158e35ba4f577e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Sun, 10 Aug 2025 12:54:35 +0900 Subject: [PATCH 22/27] =?UTF-8?q?=EC=B5=9C=EC=8B=A0=20CLI=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=20=EC=B2=B4=ED=81=AC=20&=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=A0=81=20=EC=84=A4=EC=B9=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/starter.js | 40 +++++++++++++++++++++--------------- templates/default/nginx.conf | 17 --------------- 2 files changed, 23 insertions(+), 34 deletions(-) delete mode 100644 templates/default/nginx.conf diff --git a/bin/starter.js b/bin/starter.js index cfd2f98..7ef9b2d 100644 --- a/bin/starter.js +++ b/bin/starter.js @@ -9,16 +9,7 @@ * Made with ❤️ by AGUMON 🦖 *****************************************************************/ -import { - select, - text, - isCancel, - intro, - outro, - cancel, - note, - confirm, -} from '@clack/prompts'; +import { select, text, isCancel, intro, outro, cancel, note, confirm } from '@clack/prompts'; import chalk from 'chalk'; import editJsonFile from 'edit-json-file'; import { execa } from 'execa'; @@ -39,15 +30,32 @@ function checkNodeVersion(min = 16) { } } -// 최신 CLI 버전 체크 +// 최신 CLI 버전 체크 & 선택적 설치 async function checkForUpdate(pkgName, localVersion) { try { const { stdout } = await execa('npm', ['view', pkgName, 'version']); const latest = stdout.trim(); if (latest !== localVersion) { - console.log(chalk.yellow(`🔔 New version available: ${latest} (You are on ${localVersion})\n $ npm i -g ${pkgName}`)); + console.log(chalk.yellow(`🔔 New version available: ${latest} (You are on ${localVersion})`)); + const shouldUpdate = await confirm({ + message: `Do you want to update ${pkgName} to version ${latest}?`, + initial: true, + }); + if (shouldUpdate) { + console.log(chalk.gray(` Updating to latest version...`)); + try { + await execa('npm', ['install', '-g', `${pkgName}@${latest}`], { stdio: 'inherit' }); + console.log(chalk.green(` ✓ Updated ${pkgName} to ${latest}`)); + } catch (err) { + printError(`Failed to update ${pkgName}`, err.message); + } + } else { + console.log(chalk.gray('Skipped updating.')); + } } - } catch { } + } catch (err) { + printError('Failed to check latest version', err.message); + } } // 패키지매니저 글로벌 설치여부 @@ -256,9 +264,7 @@ async function main() { } // [1-1] Testing 도구를 선택한 경우에만 /src/test 예제 복사 - const testDevtool = devtoolValues - .map(val => DEVTOOLS_VALUES.find(d => d.value === val)) - .find(tool => tool && tool.category === 'Testing'); + const testDevtool = devtoolValues.map(val => DEVTOOLS_VALUES.find(d => d.value === val)).find(tool => tool && tool.category === 'Testing'); if (testDevtool) { const devtoolTestDir = path.join(DEVTOOLS, testDevtool.value, 'src', 'test'); @@ -286,7 +292,7 @@ async function main() { // [2-3] 개발 도구 - Docker 선택 한 경우, docker-compose.yml 생성 if (tool.value === 'docker') await generateCompose(template, destDir); - + spinner.succeed(`${tool.name} setup done.`); } diff --git a/templates/default/nginx.conf b/templates/default/nginx.conf deleted file mode 100644 index 20aa3e7..0000000 --- a/templates/default/nginx.conf +++ /dev/null @@ -1,17 +0,0 @@ - server { - listen 80; - server_name localhost; - - location / { - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_http_version 1.1; - proxy_pass http://api-server; - } - - location /health { - return 200 'ok'; - add_header Content-Type text/plain; - } - } From 5e2df6059874ff9ba0ef97d5e9e72e9097456c66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E1=84=8B=E1=85=B5=E1=86=B7=E1=84=80=E1=85=A7=E1=86=BC?= =?UTF-8?q?=E1=84=86=E1=85=B5=E1=86=AB?= Date: Sun, 10 Aug 2025 16:18:31 +0900 Subject: [PATCH 23/27] =?UTF-8?q?feat=20devtools=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=A7=80=EC=A0=95=20=EB=B0=8F=20eslint=20v9=20=EB=8C=80?= =?UTF-8?q?=EC=9D=91,=20default=20=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/common.js | 44 ++++++-------- bin/starter.js | 57 ++++++++++++++++--- devtools/jest/src/test/e2e/users.e2e.spec.ts | 2 - devtools/prettier/.editorconfig | 12 ---- devtools/prettier/.eslintignore | 16 ------ devtools/prettier/.eslintrc | 18 ------ devtools/prettier/eslint.config.js | 25 ++++++++ .../vitest/src/test/e2e/users.e2e.spec.ts | 2 - .../src/middlewares/auth.middleware.ts | 2 +- .../src/middlewares/error.middleware.ts | 4 +- 10 files changed, 95 insertions(+), 87 deletions(-) delete mode 100644 devtools/prettier/.editorconfig delete mode 100644 devtools/prettier/.eslintignore delete mode 100644 devtools/prettier/.eslintrc create mode 100644 devtools/prettier/eslint.config.js diff --git a/bin/common.js b/bin/common.js index b79edbd..cae2fb9 100644 --- a/bin/common.js +++ b/bin/common.js @@ -15,13 +15,11 @@ export const DEVTOOLS_VALUES = [ category: 'Formatter', files: ['.biome.json', '.biomeignore'], pkgs: [], - devPkgs: [ - '@biomejs/biome', - ], + devPkgs: ['@biomejs/biome@2.1.4'], scripts: { - "lint": "biome lint .", - "check": "biome check .", - "format": "biome format . --write", + lint: 'biome lint .', + check: 'biome check .', + format: 'biome format . --write', }, desc: 'All-in-one formatter and linter', }, @@ -29,20 +27,14 @@ export const DEVTOOLS_VALUES = [ name: 'Prettier & ESLint', value: 'prettier', category: 'Formatter', - files: ['.prettierrc', '.eslintrc', '.eslintignore', '.editorconfig'], + files: ['.prettierrc', 'eslint.config.js'], pkgs: [], - devPkgs: [ - 'prettier', - 'eslint', - '@typescript-eslint/eslint-plugin', - '@typescript-eslint/parser', - 'eslint-config-prettier' - ], + devPkgs: ['eslint@^9.33.0', 'eslint-config-prettier@^10.1.1', 'globals@^15.10.0', 'prettier@3.6.2', 'typescript-eslint@^8.39.0'], scripts: { - lint: 'eslint --ignore-path .gitignore --ext .ts src/', + lint: 'eslint --ext .ts src/', 'lint:fix': 'npm run lint -- --fix', format: 'prettier --check .', - 'format:fix': 'prettier --write .' + 'format:fix': 'prettier --write .', }, desc: 'Separate formatter and linter setup', }, @@ -54,7 +46,7 @@ export const DEVTOOLS_VALUES = [ category: 'Compiler', files: ['tsup.config.ts'], pkgs: [], - devPkgs: ['tsup'], + devPkgs: ['tsup@8.5.0'], scripts: { 'start:tsup': 'node -r tsconfig-paths/register dist/server.js', 'build:tsup': 'tsup --config tsup.config.ts', @@ -67,7 +59,7 @@ export const DEVTOOLS_VALUES = [ category: 'Compiler', files: ['.swcrc'], pkgs: [], - devPkgs: ['@swc/cli', '@swc/core'], + devPkgs: ['@swc/cli@0.7.8', '@swc/core@1.13.3'], scripts: { 'start:swc': 'node dist/server.js', 'build:swc': 'swc src -d dist --strip-leading-paths --copy-files --delete-dir-on-start', @@ -82,11 +74,11 @@ export const DEVTOOLS_VALUES = [ category: 'Testing', files: ['jest.config.js'], pkgs: [], - devPkgs: ['@types/supertest', 'supertest', '@types/jest', 'jest', 'ts-jest'], + devPkgs: ['@types/supertest@6.0.3', 'supertest@7.1.4', '@types/jest@30.0.0', 'jest@30.0.5', 'ts-jest@29.4.1'], scripts: { - "test": "jest --forceExit --detectOpenHandles", - "test:e2e": "jest --testPathPattern=e2e", - "test:unit": "jest --testPathPattern=unit" + test: 'jest --forceExit --detectOpenHandles', + 'test:e2e': 'jest --testPathPattern=e2e', + 'test:unit': 'jest --testPathPattern=unit', }, desc: 'Industry-standard test runner for Node.js', }, @@ -96,11 +88,11 @@ export const DEVTOOLS_VALUES = [ category: 'Testing', files: ['vitest.config.ts'], pkgs: [], - devPkgs: ['@types/supertest', 'supertest', 'vitest', 'vite-tsconfig-paths'], + devPkgs: ['@types/supertest@6.0.3', 'supertest@7.1.4', 'vite-tsconfig-paths@5.1.4', 'vitest@3.2.4'], scripts: { - "test": "vitest run", - "test:e2e": "vitest run src/test/e2e", - "test:unit": "vitest run src/test/unit", + test: 'vitest run', + 'test:e2e': 'vitest run src/test/e2e', + 'test:unit': 'vitest run src/test/unit', }, desc: 'Fast Vite-powered unit/e2e test framework', }, diff --git a/bin/starter.js b/bin/starter.js index 7ef9b2d..694a2dc 100644 --- a/bin/starter.js +++ b/bin/starter.js @@ -110,20 +110,61 @@ async function copyDevtoolFiles(devtool, destDir) { } } -// 패키지 설치 (최신버전) +function isExplicitSpecifier(spec) { + return ( + spec.startsWith('http://') || + spec.startsWith('https://') || + spec.startsWith('git+') || + spec.startsWith('file:') || + spec.startsWith('link:') || + spec.startsWith('workspace:') || + spec.startsWith('npm:') + ); +} + +// 'pkg' / '@scope/pkg' vs 'pkg@^1.2.3' / '@scope/pkg@1.2.3' 구분 +function splitNameAndVersion(spec) { + if (spec.startsWith('@')) { + const idx = spec.indexOf('@', 1); // 스코프 다음 '@'가 버전 구분자 + if (idx === -1) return { name: spec, version: null }; + return { name: spec.slice(0, idx), version: spec.slice(idx + 1) }; + } else { + const idx = spec.indexOf('@'); + if (idx === -1) return { name: spec, version: null }; + return { name: spec.slice(0, idx), version: spec.slice(idx + 1) }; + } +} + +// 패키지 설치 (버전/범위 지정 시 그대로, 없으면 latest 조회해 고정) async function installPackages(pkgs, pkgManager, dev = true, destDir = process.cwd()) { if (!pkgs || pkgs.length === 0) return; - const pkgsWithLatest = []; - for (const pkg of pkgs) { - const version = await getLatestVersion(pkg); - pkgsWithLatest.push(version ? `${pkg}@${version}` : pkg); + + const resolved = []; + for (const spec of pkgs) { + // URL/파일/워크스페이스/별칭은 그대로 통과 + if (isExplicitSpecifier(spec)) { + resolved.push(spec); + continue; + } + + const { name, version } = splitNameAndVersion(spec); + // 이미 버전/범위가 명시된 경우 그대로 사용 (예: ^9.33.0, ~10.1.8, 9.33.0) + if (version && version.length > 0) { + resolved.push(`${name}@${version}`); + continue; + } + + // 버전 미지정 → npm view로 latest 조회 후 고정 + const latest = await getLatestVersion(name); + resolved.push(latest ? `${name}@${latest}` : name); } + const installCmd = pkgManager === 'npm' - ? ['install', dev ? '--save-dev' : '', ...pkgsWithLatest].filter(Boolean) + ? ['install', dev ? '--save-dev' : '', ...resolved].filter(Boolean) : pkgManager === 'yarn' - ? ['add', dev ? '--dev' : '', ...pkgsWithLatest].filter(Boolean) - : ['add', dev ? '-D' : '', ...pkgsWithLatest].filter(Boolean); + ? ['add', dev ? '--dev' : '', ...resolved].filter(Boolean) + : ['add', dev ? '-D' : '', ...resolved].filter(Boolean); await execa(pkgManager, installCmd, { cwd: destDir, stdio: 'inherit' }); } diff --git a/devtools/jest/src/test/e2e/users.e2e.spec.ts b/devtools/jest/src/test/e2e/users.e2e.spec.ts index c539771..072621a 100644 --- a/devtools/jest/src/test/e2e/users.e2e.spec.ts +++ b/devtools/jest/src/test/e2e/users.e2e.spec.ts @@ -14,13 +14,11 @@ describe('Users API', () => { }); const user = { email: 'user1@example.com', password: 'password123' }; - let userId: string; it('should create a new user', async () => { const res = await request(server).post(`${prefix}/users`).send(user); expect(res.statusCode).toBe(201); expect(res.body.data.email).toBe(user.email); - userId = res.body.data.id; }); it('should retrieve all users', async () => { diff --git a/devtools/prettier/.editorconfig b/devtools/prettier/.editorconfig deleted file mode 100644 index 4a7ea30..0000000 --- a/devtools/prettier/.editorconfig +++ /dev/null @@ -1,12 +0,0 @@ -root = true - -[*] -indent_style = space -indent_size = 2 -end_of_line = lf -charset = utf-8 -trim_trailing_whitespace = true -insert_final_newline = true - -[*.md] -trim_trailing_whitespace = false diff --git a/devtools/prettier/.eslintignore b/devtools/prettier/.eslintignore deleted file mode 100644 index cce063c..0000000 --- a/devtools/prettier/.eslintignore +++ /dev/null @@ -1,16 +0,0 @@ -# 빌드 산출물 -dist/ -coverage/ -logs/ -node_modules/ - -# 설정 파일 -*.config.js -*.config.ts - -# 환경파일 등 -.env -.env.* - -# 기타 -!.eslintrc.js diff --git a/devtools/prettier/.eslintrc b/devtools/prettier/.eslintrc deleted file mode 100644 index ef87e0d..0000000 --- a/devtools/prettier/.eslintrc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "extends": ["plugin:@typescript-eslint/recommended", "prettier"], - "parserOptions": { - "ecmaVersion": 2022, - "sourceType": "module", - }, - "env": { - "node": true, - "es2022": true, - }, - "rules": { - "@typescript-eslint/no-explicit-any": "off", - "@typescript-eslint/explicit-module-boundary-types": "off", - "@typescript-eslint/ban-types": "off", - "@typescript-eslint/no-var-requires": "off", - }, -} diff --git a/devtools/prettier/eslint.config.js b/devtools/prettier/eslint.config.js new file mode 100644 index 0000000..38cef6f --- /dev/null +++ b/devtools/prettier/eslint.config.js @@ -0,0 +1,25 @@ +import tseslint from 'typescript-eslint'; +import globals from 'globals'; +import eslintConfigPrettier from 'eslint-config-prettier'; + +export default [ + { + ignores: ['dist/', 'coverage/', 'logs/', 'node_modules/', '*.config.js', '*.config.ts', '.env', '.env.*', '!.eslintrc.js'], + }, + ...tseslint.configs.recommended, + eslintConfigPrettier, + { + files: ['**/*.ts'], + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { ...globals.node, ...globals.es2022 }, + }, + rules: { + '@typescript-eslint/no-explicit-any': 'off', + '@typescript-eslint/explicit-module-boundary-types': 'off', + '@typescript-eslint/ban-types': 'off', + '@typescript-eslint/no-var-requires': 'off', + }, + }, +]; diff --git a/devtools/vitest/src/test/e2e/users.e2e.spec.ts b/devtools/vitest/src/test/e2e/users.e2e.spec.ts index b03b422..75699cf 100644 --- a/devtools/vitest/src/test/e2e/users.e2e.spec.ts +++ b/devtools/vitest/src/test/e2e/users.e2e.spec.ts @@ -15,13 +15,11 @@ describe('Users API', () => { }); const user = { email: 'user1@example.com', password: 'password123' }; - let userId: string; it('should create a new user', async () => { const res = await request(server).post(`${prefix}/users`).send(user); expect(res.statusCode).toBe(201); expect(res.body.data.email).toBe(user.email); - userId = res.body.data.id; }); it('should retrieve all users', async () => { diff --git a/templates/default/src/middlewares/auth.middleware.ts b/templates/default/src/middlewares/auth.middleware.ts index 719ae18..46789d2 100644 --- a/templates/default/src/middlewares/auth.middleware.ts +++ b/templates/default/src/middlewares/auth.middleware.ts @@ -43,7 +43,7 @@ export const AuthMiddleware = async (req: Request, res: Response, next: NextFunc (req as RequestWithUser).user = findUser; next(); - } catch (error) { + } catch { next(new HttpException(500, 'Authentication middleware error')); } }; diff --git a/templates/default/src/middlewares/error.middleware.ts b/templates/default/src/middlewares/error.middleware.ts index 46b98fd..2d0eb70 100644 --- a/templates/default/src/middlewares/error.middleware.ts +++ b/templates/default/src/middlewares/error.middleware.ts @@ -1,8 +1,8 @@ -import { NextFunction, Request, Response } from 'express'; +import { Request, Response } from 'express'; import { HttpException } from '@exceptions/httpException'; import { logger } from '@utils/logger'; -export const ErrorMiddleware = (error: HttpException, req: Request, res: Response, next: NextFunction) => { +export const ErrorMiddleware = (error: HttpException, req: Request, res: Response) => { const status = error.status || 500; const message = error.message || 'Something went wrong'; logger.error(`[${req.method}] ${req.originalUrl} | Status: ${status} | Message: ${message}`); From ebf94122407bb4502c3a0fd328e971b8546be08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=84=EA=B5=AC=EB=AA=AC?= <42952358+ljlm0402@users.noreply.github.com> Date: Mon, 11 Aug 2025 05:36:08 +0000 Subject: [PATCH 24/27] fix env, del envalid package --- templates/default/.env | 21 +++++ templates/default/.env.development.local | 17 ---- templates/default/.env.production.local | 17 ---- templates/default/.env.test.local | 17 ---- templates/default/package.json | 5 +- templates/default/src/app.ts | 25 +++--- templates/default/src/config/env.ts | 77 ++++++++++++++++++- templates/default/src/config/validateEnv.ts | 16 ---- .../src/middlewares/error.middleware.ts | 3 +- templates/default/src/server.ts | 9 +-- .../default/src/services/auth.service.ts | 4 +- templates/default/src/utils/logger.ts | 6 +- 12 files changed, 121 insertions(+), 96 deletions(-) create mode 100644 templates/default/.env delete mode 100644 templates/default/src/config/validateEnv.ts diff --git a/templates/default/.env b/templates/default/.env new file mode 100644 index 0000000..c3a0adf --- /dev/null +++ b/templates/default/.env @@ -0,0 +1,21 @@ +# APP +PORT=3000 + +# JWT / Token +SECRET_KEY=yourSuperSecretKey + +# LOGGING +LOG_DIR=../logs +LOG_LEVER=info + +# CORS 기본값 +ORIGIN=http://localhost:3000 +CREDENTIALS=true +CORS_ORIGINS=http://localhost:3000,https://yourdomain.com + +# SERVER URL +API_SERVER_URL=https://api.yourdomain.com + +# 선택적 환경변수 +SENTRY_DSN= +REDIS_URL=redis://localhost:6379 \ No newline at end of file diff --git a/templates/default/.env.development.local b/templates/default/.env.development.local index f7ef67f..505fd79 100644 --- a/templates/default/.env.development.local +++ b/templates/default/.env.development.local @@ -1,22 +1,5 @@ # APP -PORT=3000 NODE_ENV=development -# JWT/토큰 -SECRET_KEY=yourSuperSecretKey - # LOGGING LOG_FORMAT=dev -LOG_DIR=../logs - -# CORS -ORIGIN=http://localhost:3000 -CREDENTIALS=true -CORS_ORIGINS=http://localhost:3000,https://yourdomain.com - -# SERVER URL -API_SERVER_URL=https://api.yourdomain.com - -# 추가(실전 활용시) -# SENTRY_DSN= -# REDIS_URL= \ No newline at end of file diff --git a/templates/default/.env.production.local b/templates/default/.env.production.local index 80191f8..11ad836 100644 --- a/templates/default/.env.production.local +++ b/templates/default/.env.production.local @@ -1,22 +1,5 @@ # APP -PORT=3000 NODE_ENV=production -# JWT/토큰 -SECRET_KEY=yourSuperSecretKey - # LOGGING LOG_FORMAT=combined -LOG_DIR=../logs - -# CORS -ORIGIN=http://localhost:3000 -CREDENTIALS=true -CORS_ORIGINS=http://localhost:3000,https://yourdomain.com - -# SERVER URL -API_SERVER_URL=https://api.yourdomain.com - -# 추가(실전 활용시) -# SENTRY_DSN= -# REDIS_URL= \ No newline at end of file diff --git a/templates/default/.env.test.local b/templates/default/.env.test.local index e29efdc..2a51176 100644 --- a/templates/default/.env.test.local +++ b/templates/default/.env.test.local @@ -1,22 +1,5 @@ # APP -PORT=3000 NODE_ENV=test -# JWT/토큰 -SECRET_KEY=yourSuperSecretKey - # LOGGING LOG_FORMAT=dev -LOG_DIR=../logs - -# CORS -ORIGIN=http://localhost:3000 -CREDENTIALS=true -CORS_ORIGINS=http://localhost:3000,https://yourdomain.com - -# SERVER URL -API_SERVER_URL=https://api.yourdomain.com - -# 추가(실전 활용시) -# SENTRY_DSN= -# REDIS_URL= diff --git a/templates/default/package.json b/templates/default/package.json index 1aafbf0..27177d9 100644 --- a/templates/default/package.json +++ b/templates/default/package.json @@ -1,7 +1,7 @@ { - "name": "default-template", + "name": "default", "version": "1.0.0", - "description": "", + "description": "default template", "author": "", "license": "ISC", "scripts": { @@ -15,7 +15,6 @@ "cookie-parser": "^1.4.7", "cors": "^2.8.5", "dotenv": "^17.1.0", - "envalid": "^8.0.0", "express": "^5.1.0", "express-rate-limit": "^7.5.1", "helmet": "^8.1.0", diff --git a/templates/default/src/app.ts b/templates/default/src/app.ts index 3309258..761d72b 100644 --- a/templates/default/src/app.ts +++ b/templates/default/src/app.ts @@ -8,7 +8,10 @@ import hpp from 'hpp'; import morgan from 'morgan'; import swaggerJSDoc from 'swagger-jsdoc'; import swaggerUi from 'swagger-ui-express'; -import { NODE_ENV, PORT, LOG_FORMAT, CREDENTIALS } from '@config/env'; +import { + NODE_ENV, PORT, LOG_FORMAT, CREDENTIALS, + CORS_ORIGIN_LIST, API_SERVER_URL, +} from '@config/env'; import { Routes } from '@interfaces/routes.interface'; import { ErrorMiddleware } from '@middlewares/error.middleware'; import { logger, stream } from '@utils/logger'; @@ -66,7 +69,9 @@ class App { this.app.use(morgan(LOG_FORMAT || 'dev', { stream })); // CORS 화이트리스트를 환경변수에서 관리 - const allowedOrigins = process.env.CORS_ORIGINS?.split(',').map(origin => origin.trim()) || ['http://localhost:3000']; + const allowedOrigins = CORS_ORIGIN_LIST.length > 0 + ? CORS_ORIGIN_LIST + : ['http://localhost:3000']; this.app.use( cors({ @@ -87,13 +92,13 @@ class App { contentSecurityPolicy: this.env === 'production' ? { - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'"], - objectSrc: ["'none'"], - upgradeInsecureRequests: [], - }, - } + directives: { + defaultSrc: ["'self'"], + scriptSrc: ["'self'", "'unsafe-inline'"], + objectSrc: ["'none'"], + upgradeInsecureRequests: [], + }, + } : false, // 개발 환경에서는 CSP 비활성화 (hot reload 등 편의) referrerPolicy: { policy: 'no-referrer' }, }), @@ -121,7 +126,7 @@ class App { }, servers: [ { - url: process.env.API_SERVER_URL || `http://localhost:${this.port}${apiPrefix}`, + url: API_SERVER_URL || `http://localhost:${this.port}${apiPrefix}`, description: this.env === 'production' ? 'Production server' : 'Local server', }, ], diff --git a/templates/default/src/config/env.ts b/templates/default/src/config/env.ts index ef17df5..bb8b718 100644 --- a/templates/default/src/config/env.ts +++ b/templates/default/src/config/env.ts @@ -1,5 +1,76 @@ import { config } from 'dotenv'; -config({ path: `.env.${process.env.NODE_ENV || 'development'}.local` }); +import { existsSync } from 'fs'; +import { resolve } from 'path'; +import { z } from 'zod'; -export const CREDENTIALS = process.env.CREDENTIALS === 'true'; -export const { NODE_ENV, PORT, SECRET_KEY, LOG_FORMAT, LOG_DIR, ORIGIN } = process.env; +/** + * 1) dotenv 로드 순서 + * - .env (공통) + * - .env.{NODE_ENV}.local (환경별 override, 있으면 덮어씀) + */ +config(); // .env +const nodeEnv = process.env.NODE_ENV || 'development'; +const layerPath = resolve(process.cwd(), `.env.${nodeEnv}.local`); +if (existsSync(layerPath)) { + config({ path: layerPath }); +} + +/** + * 2) Zod 스키마 정의 + * - 필수/선택/기본값 정책은 필요에 맞게 수정 가능 + */ +const EnvSchema = z + .object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('development'), + PORT: z.coerce.number().int().positive().optional(), // 기본값은 app.ts에서 3000 처리 + + SECRET_KEY: z.string().min(1), + + LOG_FORMAT: z.string().min(1).optional(), // 기본값은 app.ts에서 'dev' + LOG_DIR: z.string().min(1), + LOG_LEVEL: z.string().min(1), + + ORIGIN: z.string().min(1), // 필요시 배열화 가능 + CREDENTIALS: z.coerce.boolean(), // 'true'/'false' 문자열 → boolean + CORS_ORIGINS: z.string().optional(), // "http://a.com,http://b.com" + + API_SERVER_URL: z.string().url().optional(), + + SENTRY_DSN: z.string().default(''), + REDIS_URL: z.string().url().default('redis://localhost:6379'), + }) + .strip(); + +/** + * 3) 검증(모듈 import 시점에 실행) + */ +const parsed = EnvSchema.safeParse(process.env); +if (!parsed.success) { + console.error('\n❌ Invalid environment variables:\n'); + console.error(parsed.error.format()); + process.exit(1); +} +const env = parsed.data; + +/** + * 4) 타입 안전한 상수 export + * - 다른 파일에서는 process.env 직접 쓰지 말고 여기서만 가져가세요. + */ +export const NODE_ENV = env.NODE_ENV; +export const PORT = env.PORT; // app.ts에서 PORT || 3000 +export const SECRET_KEY = env.SECRET_KEY; + +export const LOG_FORMAT = env.LOG_FORMAT; // app.ts에서 LOG_FORMAT || 'dev' +export const LOG_DIR = env.LOG_DIR; +export const LOG_LEVEL= env.LOG_LEVEL; + +export const ORIGIN = env.ORIGIN; +export const CREDENTIALS = env.CREDENTIALS; + +export const SENTRY_DSN = env.SENTRY_DSN; +export const REDIS_URL = env.REDIS_URL; +export const API_SERVER_URL = env.API_SERVER_URL; + +// CORS Origins를 배열로도 제공 (없으면 []) +export const CORS_ORIGIN_LIST = + (env.CORS_ORIGINS?.split(',').map(s => s.trim()).filter(Boolean)) ?? []; diff --git a/templates/default/src/config/validateEnv.ts b/templates/default/src/config/validateEnv.ts deleted file mode 100644 index 907e97a..0000000 --- a/templates/default/src/config/validateEnv.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { cleanEnv, port, str, bool, url } from 'envalid'; - -export const validateEnv = () => { - cleanEnv(process.env, { - NODE_ENV: str({ choices: ['development', 'production', 'test'] }), - PORT: port(), - SECRET_KEY: str(), - LOG_FORMAT: str(), - LOG_DIR: str(), - ORIGIN: str(), - CREDENTIALS: bool(), - // 옵션 환경변수 예시 - SENTRY_DSN: str({ default: '' }), - REDIS_URL: url({ default: 'redis://localhost:6379' }), - }); -}; diff --git a/templates/default/src/middlewares/error.middleware.ts b/templates/default/src/middlewares/error.middleware.ts index 2d0eb70..06307bc 100644 --- a/templates/default/src/middlewares/error.middleware.ts +++ b/templates/default/src/middlewares/error.middleware.ts @@ -1,4 +1,5 @@ import { Request, Response } from 'express'; +import { NODE_ENV } from '@config/env'; import { HttpException } from '@exceptions/httpException'; import { logger } from '@utils/logger'; @@ -12,7 +13,7 @@ export const ErrorMiddleware = (error: HttpException, req: Request, res: Respons error: { code: status, message, - ...(process.env.NODE_ENV === 'development' && error.stack ? { stack: error.stack } : {}), + ...(NODE_ENV === 'development' && error.stack ? { stack: error.stack } : {}), ...(typeof error.data === 'object' && error.data !== null ? { data: error.data } : {}), }, }); diff --git a/templates/default/src/server.ts b/templates/default/src/server.ts index d43d88c..05add38 100644 --- a/templates/default/src/server.ts +++ b/templates/default/src/server.ts @@ -1,14 +1,11 @@ import 'reflect-metadata'; +import '@config/env'; import { container } from 'tsyringe'; import App from '@/app'; -import { validateEnv } from '@config/validateEnv'; import { UsersRepository } from '@repositories/users.repository'; import { AuthRoute } from '@routes/auth.route'; import { UsersRoute } from '@routes/users.route'; -// 환경변수 유효성 검증 -validateEnv(); - // DI 등록 container.registerInstance(UsersRepository, new UsersRepository()); @@ -19,11 +16,10 @@ const routes = [container.resolve(UsersRoute), container.resolve(AuthRoute)]; const appInstance = new App(routes); // listen()이 서버 객체(http.Server)를 반환하도록 app.ts를 살짝 수정 -const server = appInstance.listen(); +const server = appInstance.listen(); // PORT를 쓰려면 이렇게 전달도 가능 // Graceful Shutdown: 운영환경에서 필수! if (server && typeof server.close === 'function') { - // SIGINT: Ctrl+C, SIGTERM: Docker/k8s kill 등 ['SIGINT', 'SIGTERM'].forEach(signal => { process.on(signal, () => { console.log(`Received ${signal}, closing server...`); @@ -36,5 +32,4 @@ if (server && typeof server.close === 'function') { }); } -// 테스트 코드 등에서 서버 객체 활용하고 싶으면 export default server; diff --git a/templates/default/src/services/auth.service.ts b/templates/default/src/services/auth.service.ts index 0b62c1c..8199c9a 100644 --- a/templates/default/src/services/auth.service.ts +++ b/templates/default/src/services/auth.service.ts @@ -1,7 +1,7 @@ import { hash, compare } from 'bcryptjs'; import { sign } from 'jsonwebtoken'; import { injectable, inject } from 'tsyringe'; -import { SECRET_KEY } from '@config/env'; +import { NODE_ENV, SECRET_KEY } from '@config/env'; import { HttpException } from '@exceptions/httpException'; import { DataStoredInToken, TokenData } from '@interfaces/auth.interface'; import { User } from '@interfaces/users.interface'; @@ -26,7 +26,7 @@ export class AuthService { } private createCookie(tokenData: TokenData): string { - return `Authorization=${tokenData.token}; HttpOnly; Max-Age=${tokenData.expiresIn}; Path=/; SameSite=Lax;${process.env.NODE_ENV === 'production' ? ' Secure;' : ''}`; + return `Authorization=${tokenData.token}; HttpOnly; Max-Age=${tokenData.expiresIn}; Path=/; SameSite=Lax;${NODE_ENV === 'production' ? ' Secure;' : ''}`; } public async signup(userData: User): Promise { diff --git a/templates/default/src/utils/logger.ts b/templates/default/src/utils/logger.ts index 1b44008..808b825 100644 --- a/templates/default/src/utils/logger.ts +++ b/templates/default/src/utils/logger.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync } from 'fs'; import { join } from 'path'; import pino from 'pino'; -import { LOG_DIR } from '@config/env'; +import { LOG_DIR, LOG_LEVEL, NODE_ENV } from '@config/env'; const logDir: string = join(__dirname, LOG_DIR || '/logs'); if (!existsSync(logDir)) { @@ -13,8 +13,8 @@ const debugLogPath = join(logDir, 'debug.log'); const errorLogPath = join(logDir, 'error.log'); // 로그 레벨 및 환경 설정 -const isProd = process.env.NODE_ENV === 'production'; -const logLevel = process.env.LOG_LEVEL || 'info'; +const isProd = NODE_ENV === 'production'; +const logLevel = LOG_LEVEL || 'info'; // Pino 인스턴스 export const logger = pino( From 6e2099e0126584ef87c0ccb22dba21e722ec8e72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=84=EA=B5=AC=EB=AA=AC?= <42952358+ljlm0402@users.noreply.github.com> Date: Mon, 11 Aug 2025 06:34:43 +0000 Subject: [PATCH 25/27] fix readme.md --- README.kr.md | 401 +++++++++++++++------------------------------------ README.md | 130 +++++++++++------ 2 files changed, 197 insertions(+), 334 deletions(-) diff --git a/README.kr.md b/README.kr.md index ee31b6c..4eda14d 100644 --- a/README.kr.md +++ b/README.kr.md @@ -3,11 +3,11 @@ 프로젝트 로고

- 타입스크립트 익스프레스 스타터 + TypeScript Express Starter
-

🚀 타입스크립트 기반의 익스프레스 보일러 플레이트 스타터 패키지

+

🚀 TypeScript 기반 Express RESTful API 보일러플레이트

@@ -20,31 +20,16 @@ npm 버전 - npm 릴리즈 버전 + GitHub 릴리즈 버전 npm 다운로드 수 - npm 패키지 라이선스 + 라이선스

-

- - github 스타 - - - github 포크 - - - github 컨트리뷰터 - - - github 이슈 - -

-
- [🇰🇷 한국어](https://github.com/ljlm0402/typescript-express-starter/blob/master/README.kr.md) @@ -52,272 +37,142 @@
-## 😎 프로젝트를 소개합니다. - -Express는 유형 정의에 취약한 JavaScript로 구성 되어있습니다. - -이것이 바로 TypeScript를 도입하는 스타터 패키지로 수퍼 세트를 피하는 이유입니다. - -패키지는 JavaScript 대신 TypeScript를 사용하도록 구성되어 있습니다. - -> 참고 : [express-generator-typescript](https://github.com/seanpmaxwell/express-generator-typescript) - -### 🤔 Express는 무엇인가요 ? - -Node.js를 위한 빠르고 개방적인 간결한 웹 프레임워크입니다. - -## 🚀 시작하기 - -### npm 전역 설치 - -```bash -$ npm install -g typescript-express-starter -``` - -### npx를 통해 프로젝트를 설치 - -프로젝트 이름을 입력하지 않으면, 기본값으로 _typescript-express-starter_ 폴더로 설치됩니다. - -```bash -$ npx typescript-express-starter "project name" -``` - -### 원하시는 템플릿을 선택 - -예시 - -설치가 완료되면 Script 명령어를 통해 프로젝트를 실행합니다. - -#### 템플릿 종류 +## 📝 소개 -| 이름 | 설명 | -| :---------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Default | Express 기본 | -| [routing controllers](https://github.com/typestack/routing-controllers) | 데코레이터 사용량이 많은 구조화되고 선언적이며 아름답게 구성된 클래스 기반 컨트롤러 생성 | -| [Sequelize](https://github.com/sequelize/sequelize) | PostgreSQL, MySQL, MariaDB, SQLite, Microsoft SQL Server를 지원하는 Promise 패턴 기반의 Node.js ORM | -| [Mongoose](https://github.com/Automattic/mongoose) | Node.js와 MongoDB를 위한 ODM(Object Data Mapping) 라이브러리 | -| [TypeORM](https://github.com/typeorm/typeorm) | 자바스크립트, 타입스크립트과 함께 사용되어 Node.js, React Native, Expo에서 실행될 수 있는 ORM | -| [Prisma](https://github.com/prisma/prisma) | 데이터베이스에 데이터를 프로그래밍 언어의 객체와 매핑하여 기존에 SQL로 작성하던 데이터를 수정, 테이블 구조 변경등의 작업을 객체를 통해 프로그래밍적으로 할 수 있도록 해주는 ORM | -| [Knex](https://github.com/knex/knex) | 쿼리 빌더를 위한 라이브러리 | -| [GraphQL](https://github.com/graphql/graphql-js) | API 용 쿼리 언어이며 기존 데이터로 이러한 쿼리를 수행하기위한 런타임 | -| [Typegoose](https://github.com/typegoose/typegoose) | 타입스크립트 클래스를 사용하여 몽구스 모델 정의 | -| [Mikro ORM](https://github.com/mikro-orm/mikro-orm) | 데이터 매퍼, 작업 단위 및 아이덴티티 맵 패턴을 기반으로 하는 Node.js용 TypeScript ORM. MongoDB, MySQL, MariaDB, PostgreSQL 및 SQLite 데이터베이스를 지원 | -| [Node Postgres](https://node-postgres.com/) | PostgreSQL 데이터베이스와 인터페이스하기 위한 node.js 모듈 | - -#### 추후 개발 할 템플릿 - -| 이름 | 설명 | -| :------------------------------------------------------------------------------ | :------------------------------------------------------------------ | -| [Sequelize Typescript](https://github.com/RobinBuschmann/sequelize-typescript) | 데코레이터 및 Sequelize를 위한 몇 가지 기능 | -| [TS SQL](https://github.com/codemix/ts-sql) | SQL 데이터베이스는 TypeScript 유형 주석으로 순전히 구현 | -| [inversify-express-utils](https://github.com/inversify/inversify-express-utils) | InversifyJS를 사용한 Express 애플리케이션 개발을 위한 일부 유틸리티 | -| [postgress Typescript]() | | -| [graphql-prisma]() | | - -## 🛎 Script 명령어 - -- 프로덕션 모드 실행 : `npm run start` 아니면 `Start typescript-express-starter` VS Code 로 -- 개발 모드 실행 : `npm run dev` 아니면 `Dev typescript-express-starter` VS Code 로 -- 단위 테스트 : `npm test` 아니면 `Test typescript-express-starter` VS Code 로 -- 코드 포맷터 검사 : `npm run lint` 아니면 `Lint typescript-express-starter` VS Code 로 -- 코드 포맷터 적용 : `npm run lint:fix` 아니면 `Lint:Fix typescript-express-starter` VS Code 로 - -## 💎 프로젝트 기능 - -

-    -    -    -

-

-    - -    -    -    -    -    - - -

-

-    -    -    - -

+**TypeScript Express Starter**는 안정적이고 확장 가능한 RESTful API 서버를 빠르게 구축할 수 있는 보일러플레이트입니다. +Express의 유연함과 간결함에 TypeScript의 타입 안정성을 결합하여, 프로토타입 단계부터 프로덕션까지 품질과 유지보수성을 보장합니다. -### 🐳 Docker :: 컨테이너 플랫폼 +- 깔끔한 아키텍처와 모듈 구조 -[Docker](https://docs.docker.com/)란, 컨테이너 기반의 오픈소스 가상화 플랫폼이다. +- 기본 내장 보안, 로깅, 유효성 검사, 개발 도구 -[설치 홈페이지](https://docs.docker.com/get-docker/)에 접속해서 설치를 해줍니다. +- 빠른 개발과 안정적인 배포 환경 지원 -- 백그라운드에서 컨테이너를 시작하고 실행 : `docker-compose up -d` -- 컨테이너를 중지하고 컨테이너, 네트워크, 볼륨 및 이미지를 제거 : `docker-compose down` +## 💎 주요 기능 -수정을 원하시면 `docker-compose.yml`과 `Dockerfile`를 수정해주시면 됩니다. +- ⚡ **TypeScript + Express** — 최신 JavaScript와 완전한 타입 안정성 제공 -### ♻️ Nginx :: 웹 서버 +- 📜 **API 문서** — Swagger/OpenAPI를 기본 제공 -[Nginx](https://www.nginx.com/) 역방향 프록시,로드 밸런서, 메일 프록시 및 HTTP 캐시로도 사용할 수있는 웹 서버입니다. +- 🛡 **보안** — Helmet, CORS, HPP, 요청 속도 제한(rate limiting) 기본 포함 -프록시는 일반적으로 여러 서버에로드를 분산하거나, 다른 웹 사이트의 콘텐츠를 원활하게 표시하거나, HTTP 이외의 프로토콜을 통해 처리 요청을 애플리케이션 서버에 전달하는 데 사용됩니다. +- 🧩 **유효성 검사** — Zod 기반의 스키마 런타임 유효성 검사 -Nginx 요청을 프록시하면 지정된 프록시 서버로 요청을 보내고 응답을 가져 와서 클라이언트로 다시 보냅니다. +- 🔗 **의존성 주입** — tsyringe를 활용한 경량 DI 지원 -수정을 원하시면 `nginx.conf` 파일을 수정해주시면 됩니다. +- 🗄 **데이터베이스 연동** — Sequelize, Prisma, Mongoose, TypeORM, Knex, Drizzle 등 지원 -### ✨ ESLint, Prettier :: 정적 코드 분석 및 코드 스타일 변환 +- 🛠 **개발 도구** — ESLint, Prettier, Jest, Docker, PM2, NGINX, Makefile 포함 -[ESLint](https://eslint.org/)는 JavaScript 코드에서 발견 된 문제 패턴을 식별하기위한 정적 코드 분석 도구입니다. +- 🧱 **모듈형 아키텍처** — 손쉽게 확장 및 유지보수 가능 -[Prettier](https://prettier.io/)는 개발자가 작성한 코드를 정해진 코딩 스타일을 따르도록 변환해주는 도구입니다. +- 🚀 **프로덕션 준비 완료** — Docker, PM2, NGINX 지원 -코드를 구문 분석하고 최대 줄 길이를 고려하여 필요한 경우 코드를 래핑하는 자체 규칙으로 다시 인쇄하여 일관된 스타일을 적용합니다. +## ⚡️ 빠른 시작 -1. [VSCode](https://code.visualstudio.com/) Extension에서 [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode), [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) 설치합니다. - -2. 설치가 완료되면, 단축키 `CMD` + `Shift` + `P` (Mac Os) 또는 `Ctrl` + `Shift` + `P` (Windows) 입력합니다. - -3. Format Selection With 선택합니다. - -4. Configure Default Formatter... 선택합니다. - -5. Prettier - Code formatter 적용합니다. - -Formatter 설정 - -> 2019년, TSLint 지원이 종료 되어 ESLint를 적용하였습니다. - -### 📗 Swagger :: API 문서화 - -[Swagger](https://swagger.io/)는 개발자가 REST 웹 서비스를 설계, 빌드, 문서화, 소비하는 일을 도와주는 대형 도구 생태계의 지원을 받는 오픈 소스 소프트웨어 프레임워크이다. - -API를 대규모로 설계하고 문서화하는 데 용이하게 사용합니다. - -Swagger URL은 `http://localhost:3000/api-docs` 으로 작성했습니다. - -수정을 원하시면 `swagger.yaml` 파일을 수정해주시면 됩니다. - -### 🌐 REST Client :: HTTP Client 도구 - -REST 클라이언트를 사용하면 HTTP 요청을 보내고 Visual Studio Code에서 직접 응답을 볼 수 있습니다. - -VSCode Extension에서 [REST Client](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) 설치합니다. - -수정을 원하시면 src/http 폴더 안에 `*.http` 파일을 수정해주시면 됩니다. - -### 🔮 PM2 :: 웹 애플리케이션을 운영 및 프로세스 관리자 - -[PM2](https://pm2.keymetrics.io/)란, 서버에서 웹 애플리케이션을 운영할 때 보통 데몬으로 서버를 띄워야 하고 Node.js의 경우 서버가 크래시나면 재시작을 하기 위해서 워치독(watchdog) 류의 프로세스 관리자이다. - -- 프로덕션 모드 :: `npm run deploy:prod` 또는 `pm2 start ecosystem.config.js --only prod` -- 개발 모드 :: `npm run deploy:dev` 또는 `pm2 start ecosystem.config.js --only dev` - -수정을 원하시면 `ecosystem.config.js` 파일을 수정해주시면 됩니다. - -### 🏎 SWC :: 강하고 빠른 자바스크립트 / 타입스크립트 컴파일러 - -[SWC](https://swc.rs/)는 차세대 고속 개발자 도구를 위한 확장 가능한 Rust 기반 플랫폼입니다. - -`SWC는 단일 스레드에서 Babel보다 20배, 4개 코어에서 70배 빠릅니다.` - -- tsc 빌드 :: `npm run build` -- swc 빌드 :: `npm run build:swc` +```bash +# 전역 설치 +npm install -g typescript-express-starter -수정을 원하시면 `.swcrc` 파일을 수정해주시면 됩니다. +# 새 프로젝트 생성 +typescript-express-starter +cd my-app -### 💄 Makefile :: Linux에서 반복 적으로 발생하는 컴파일을 쉽게하기위해서 사용하는 make 프로그램의 설정 파일 +# 개발 모드 실행 +npm run dev +``` +- 앱 접속: http://localhost:3000/ -- 도움말 :: `make help` +- 자동 생성된 API 문서: http://localhost:3000/api-docs -수정을 원하시면 `Makefile` 파일을 수정해주시면 됩니다. +### 샘플 영상 -## 🗂 코드 구조 (default) +## 📂 프로젝트 구조 ```bash -│ -├──📂 .vscode -│ ├── launch.json -│ └── settings.json -│ -├──📂 src -│ ├──📂 config -│ │ └── index.ts -│ │ -│ ├──📂 controllers -│ │ ├── auth.controller.ts -│ │ └── users.controller.ts -│ │ -│ ├──📂 dtos -│ │ └── users.dto.ts -│ │ -│ ├──📂 exceptions -│ │ └── httpException.ts -│ │ -│ ├──📂 http -│ │ ├── auth.http -│ │ └── users.http -│ │ -│ ├──📂 interfaces -│ │ ├── auth.interface.ts -│ │ ├── routes.interface.ts -│ │ └── users.interface.ts -│ │ -│ ├──📂 middlewares -│ │ ├── auth.middleware.ts -│ │ ├── error.middleware.ts -│ │ └── validation.middleware.ts -│ │ -│ ├──📂 models -│ │ └── users.model.ts -│ │ -│ ├──📂 routes -│ │ ├── auth.route.ts -│ │ └── users.route.ts -│ │ -│ ├──📂 services -│ │ ├── auth.service.ts -│ │ └── users.service.ts -│ │ -│ ├──📂 test -│ │ ├── auth.test.ts -│ │ └── users.test.ts -│ │ -│ ├──📂 utils -│ │ ├── logger.ts -│ │ └── vaildateEnv.ts -│ │ -│ ├── app.ts -│ └── server.ts -│ -├── .dockerignore -├── .editorconfig -├── .env.development.local -├── .env.production.local -├── .env.test.local -├── .eslintignore -├── .eslintrc -├── .gitignore -├── .huskyrc -├── .lintstagedrc.json -├── .prettierrc -├── .swcrc -├── docker-compose.yml -├── Dockerfile.dev -├── Dockerfile.prod -├── ecosystem.config.js -├── jest.config.js -├── Makefile -├── nginx.conf -├── nodemon.json -├── package-lock.json -├── package.json -├── swagger.yaml -└── tsconfig.json +src/ + ├── config/ # 환경 변수, 설정 파일 + ├── controllers/ # 요청 처리 및 응답 반환 + ├── dtos/ # 요청/응답 데이터 구조 정의 + ├── exceptions/ # 커스텀 예외 클래스 + ├── interfaces/ # 타입/인터페이스 정의 + ├── middlewares/ # 미들웨어 (로그, 인증, 에러 처리 등) + ├── repositories/ # 데이터베이스 접근 로직 + ├── routes/ # 라우팅 정의 + ├── services/ # 비즈니스 로직 + ├── utils/ # 유틸리티 함수 + ├── app.ts # Express 앱 초기화 + └── server.ts # 서버 실행 엔트리 포인트 + +.env # 기본 환경 변수 +.env.development.local # 개발 환경 변수 +.env.production.local # 운영 환경 변수 +.env.test.local # 테스트 환경 변수 +nodemon.json # Nodemon 환경 변수 +swagger.yaml # Swagger API 문서 정의 +tsconfig.jsnon # TypeScript 환경 변수 ``` +## 🛠 개발 도구(Devtools) 유형 + +| 구분 | 도구 / 설정 파일 | 설명 | +| --------------- | ------------------- | -------------------------------- | +| **코드 포맷터 / 린터** | `biome`, `prettier, eslint` | 코드 포맷팅 및 린팅 규칙 설정 | +| **빌드 / 번들러** | `swc`, `tsup` | 빌드 및 번들링 설정 | +| **테스트** | `jest`, `vitest` | 단위/통합 테스트 프레임워크 | +| **프로세스 매니저** | `pm2` | Node.js 프로세스 관리 및 모니터링 | +| **CI/CD** | `github` | GitHub Actions 워크플로우 설정 | +| **Git 훅** | `husky` | 커밋/푸시 전 린트 및 테스트 실행 | +| **컨테이너화** | `docker` | Docker 및 docker-compose 배포 환경 설정 | + +> 이 표를 통해 각 도구의 용도와 역할을 한눈에 파악할 수 있습니다. + +## 🧩 템플릿 선택 + +CLI를 통해 원하는 스택을 선택하여 프로젝트를 생성할 수 있습니다. + +| 템플릿 | 스택 / 통합 기능 | +| ------------- | ---------------------------- | +| Default | Express + TypeScript | +| Sequelize | Sequelize ORM | +| Mongoose | MongoDB ODM (Mongoose) | +| TypeORM | TypeORM | +| Prisma | Prisma ORM | +| Knex | SQL Query Builder | +| GraphQL | GraphQL 지원 | +| Typegoose | TS 친화적인 Mongoose | +| Mikro ORM | 멀티 DB 지원 Data Mapper ORM | +| Node Postgres | PostgreSQL 드라이버 (pg) | +| Drizzle | Drizzle | + +## 🤔 포지셔닝: 각 프레임워크 사용에 적합한 상황 + +| 기준 | TypeScript Express Starter | NestJS | +| -------- | ----------------------------------- | ------------------------- | +| 학습 곡선 | ✅ 낮음 — Express에 익숙하다면 바로 사용 가능 | 높음 — OOP/DI/데코레이터 학습 필요 | +| 유연성 | ✅ 매우 높음 — 스택의 모든 부분을 자유롭게 커스터마이징 가능 | 컨벤션 기반, 구조가 정해져 있음 | +| 모듈성 | 미들웨어 & 모듈 패턴 | 🌟 강력한 내장 모듈 시스템 | +| 타입 안정성 | 완전한 TypeScript 지원 | 완전한 TypeScript 지원 | +| 테스트 | ✅ Jest & Vitest 지원 — 원하는 방식 선택 가능 | Jest E2E 테스트 환경 내장 | +| 확장성 | ✅ 빠른 프로토타이핑 → 중규모 애플리케이션에 적합 | 🌟 대규모 엔터프라이즈 애플리케이션에 최적화 | +| DI 프레임워크 | 경량 tsyringe — 최소한의 오버헤드 | 🌟 기능이 풍부한 내장 DI 컨테이너 | +| 최적 사용 사례 | ✅ 마이크로서비스, MVP, 빠른 개발 속도 | 🌟 복잡하고 대규모의 엔터프라이즈 환경 | + +## 📑 권장 커밋 메시지 + +| 상황 | 커밋 메시지 | +| --------- | ------------ | +| 기능 추가 | ✨ 기능 추가 | +| 버그 수정 | 🐞 버그 수정 | +| 코드 리팩토링 | 🛠 코드 리팩토링 | +| 패키지 설치 | 📦 패키지 설치 | +| 문서 수정 | 📚 문서 수정 | +| 버전 업데이트 | 🌼 버전 업데이트 | +| 신규 템플릿 추가 | 🎉 신규 템플릿 추가 | + +## 📄 라이선스 +MIT(LICENSE) © AGUMON (ljlm0402) + ## ⭐️ 응원해주신 분들 [![Stargazers repo roster for @ljlm0402/typescript-express-starter](https://reporoster.com/stars/ljlm0402/typescript-express-starter)](https://github.com/ljlm0402/typescript-express-starter/stargazers) @@ -329,29 +184,3 @@ VSCode Extension에서 [REST Client](https://marketplace.visualstudio.com/items? ## 🤝 도움주신 분들 [![Contributors repo roster for @ljlm0402/typescript-express-starter](https://contributors-img.web.app/image?repo=ljlm0402/typescript-express-starter)](https://github.com/ljlm0402/typescript-express-starter/graphs/contributors) - -## 💳 라이선스 - -[MIT](LICENSE) - -## 📑 커밋 메시지 정의 - -| 언제 | 메시지 | -| :----------------- | :-------------------- | -| 기능 추가 | ✨ 기능 추가 | -| 버그 수정 | 🐞 버그 수정 | -| 코드 개선 | 🛠 코드 개선 | -| 패키지 설치 | 📦 패키지 설치 | -| 문서 수정 | 📚 문서 수정 | -| 버전 업데이트 | 🌼 버전 업데이트 | -| 새로운 템플릿 추가 | 🎉 새로운 템플릿 추가 | - -## 📬 이슈를 남겨주세요 - -건의 사항이나 질문 등을 이슈로 남겨주세요. - -최선을 다해 답변하고 반영하겠습니다. - -관심을 가져주셔서 감사합니다. - -# ദ്ദി*ˊᗜˋ*) diff --git a/README.md b/README.md index 95bbb4a..f106b60 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,6 @@ --- - ## 📝 Introduction **TypeScript Express Starter** provides a robust starting point for building secure, scalable, and maintainable RESTful APIs. @@ -67,6 +66,26 @@ It blends the flexibility and simplicity of Express with TypeScript’s type saf - Instantly ready for both prototyping and production +## 💎 Features + +- ⚡ **TypeScript + Express** — Modern JS with full type safety + +- 📜 **API Docs** — Swagger/OpenAPI ready out-of-the-box + +- 🛡 **Security** — Helmet, CORS, HPP, rate limiting + +- 🧩 **Validation** — Zod schema-based runtime validation + +- 🔗 **Dependency Injection** — Lightweight DI with tsyringe + +- 🗄 **Database Integrations** — Sequelize, Prisma, Mongoose, TypeORM, Knex, Drizzle, etc. + +- 🛠 **Developer Tools** — ESLint, Prettier, Jest, Docker, PM2, NGINX, Makefile + +- 🧱 **Modular Architecture** — Easily extendable and maintainable + +- 🚀 **Production Ready** — Docker, PM2, NGINX support + ## ⚡️ Quick Start ```bash @@ -74,7 +93,7 @@ It blends the flexibility and simplicity of Express with TypeScript’s type saf npm install -g typescript-express-starter # Scaffold a new project -npx typescript-express-starter my-app +typescript-express-starter cd my-app # Run in development mode @@ -86,30 +105,54 @@ npm run dev ### Example -## 🔥 Core Features -- Express + TypeScript: Full type safety and modern JavaScript support - -- Modern Logging: Fast, structured logging with Pino +## 📂 Project Structure -- Validation: Schema-based runtime validation with Zod - -- Dependency Injection: Lightweight and flexible with tsyringe - -- Security: Helmet, CORS, HPP, rate limiting included by default +```bash +src/ + ├── config/ # Configuration files, environment settings + ├── controllers/ # Request handling & response logic + ├── dtos/ # Data Transfer Objects for request/response + ├── exceptions/ # Custom exception classes + ├── interfaces/ # TypeScript interfaces and type definitions + ├── middlewares/ # Middlewares (logging, auth, error handling, etc.) + ├── repositories/ # Database access logic + ├── routes/ # API route definitions + ├── services/ # Business logic + ├── utils/ # Utility/helper functions + ├── app.ts # Express app initialization + └── server.ts # Server entry point + +.env # Default environment variables +.env.development.local # Development-specific variables +.env.production.local # Production-specific variables +.env.test.local # Test-specific variables +nodemon.json # Nodemon variables +swagger.yaml # Swagger API documentation +tsconfig.jsnon # TypeScript variables +``` -- API Docs: Swagger/OpenAPI out of the box +## 🛠 Devtools Types -- Developer Tools: ESLint, Prettier, Jest, Docker, PM2, NGINX, Makefile +| Category | Tools / Configs | Description | +| --------------------------- | --------------------------- | -------------------------------------------- | +| **Code Formatter / Linter** | `biome`, `prettier, eslint` | Code formatting & linting rules | +| **Build / Bundler** | `swc`, `tsup` | Build & bundling configuration | +| **Testing** | `jest`, `vitest` | Unit & integration testing frameworks | +| **Process Manager** | `pm2` | Manage and monitor Node.js processes | +| **CI/CD** | `github` | GitHub Actions workflow settings | +| **Git Hooks** | `husky` | Pre-commit / pre-push hooks for lint/test | +| **Containerization** | `docker` | Docker & docker-compose setup for deployment | -- Modular: Easy to customize and extend +> This categorization helps developers quickly understand what each tool is used for without checking every folder. ## 🧩 Template Choices + Choose your preferred stack during setup! Support for major databases and patterns via CLI: | Template | Stack / Integration | | ------------- | ------------------------------ | -| Default | Express + TypeScript (vanilla) | +| Default | Express + TypeScript | | Sequelize | Sequelize ORM | | Mongoose | MongoDB ODM (Mongoose) | | TypeORM | TypeORM | @@ -121,40 +164,36 @@ Support for major databases and patterns via CLI: | Node Postgres | PostgreSQL driver (pg) | | Drizzle | Drizzle | -More templates are regularly added and updated. - -## 🛠 Developer Tooling & Ecosystem - -- Logging: Pino, Pino-pretty - -- Validation: Zod - -- Dependency Injection: tsyringe - -- API Documentation: Swagger (swagger-jsdoc, swagger-ui-express) - -- Code Quality: ESLint, Prettier, EditorConfig +> More templates are regularly added and updated. -- Testing: Jest, Vitest +## 🤔 Positioning: When to Use Each -- Build Tools: SWC, TSC, Nodemon, Makefile, Tsup +| Criteria | TypeScript Express Starter | NestJS | +| ---------------- | --------------------------------------------------- | ------------------------------------------ | +| Learning Curve | ✅ Low — easy for anyone familiar with Express | Higher — requires OOP/DI/Decorators | +| Flexibility | ✅ Maximum — customize any part of the stack | Convention-based, opinionated structure | +| Modularity | Middleware & modular pattern | 🌟 Strong built-in module system | +| Type Safety | Full TypeScript support | Full TypeScript support | +| Testing | ✅ Supports Jest & Vitest — flexible choice | Built-in Jest E2E setup | +| Scale | ✅ Fast prototyping → mid-size apps | 🌟 Large-scale enterprise apps | +| DI Framework | Lightweight tsyringe — minimal overhead | 🌟 Full-featured DI container | +| Best Fit | ✅ Microservices, quick MVPs, developer agility | 🌟 Complex, enterprise-grade applications | -- Production Ready: Docker, Docker Compose, PM2, NGINX -- Environment Management: dotenv, envalid +## 📑 Recommended Commit Message -## 🤔 Comparison: NestJS Boilerplate +| When | Commit Message | +| --------------- | ------------------ | +| Add Feature | ✨ Add Feature | +| Fix Bug | 🐞 Fix Bug | +| Refactor Code | 🛠 Refactor Code | +| Install Package | 📦 Install Package | +| Fix Readme | 📚 Fix Readme | +| Update Version | 🌼 Update Version | +| New Template | 🎉 New Template | -| Criteria | TypeScript Express Starter | NestJS | -| -------------- | ---------------------------------- | ----------------------------- | -| Learning Curve | Low (familiar Express patterns) | Higher (OOP/DI/Decorators) | -| Flexibility | Maximum (customize anything) | Convention-based, opinionated | -| Modularity | Module/middleware oriented | Strong module system | -| Type Safety | Full TS support | Full TS support | -| Testing | Jest, Vitest supported | Jest + E2E built-in | -| Scale | Fast prototyping to mid-size apps | Best for large-scale projects | -| DI Framework | tsyringe (lightweight) | Built-in container | -| Real World Use | Great for microservices, rapid dev | Enterprise-grade applications | +## 📄 License +MIT(LICENSE) © AGUMON (ljlm0402) ## ⭐️ Stargazers @@ -167,8 +206,3 @@ More templates are regularly added and updated. ## 🤝 Contributors [![Contributors repo roster for @ljlm0402/typescript-express-starter](https://contributors-img.web.app/image?repo=ljlm0402/typescript-express-starter)](https://github.com/ljlm0402/typescript-express-starter/graphs/contributors) - - -## 📄 License -MIT(LICENSE) © AGUMON (ljlm0402) - From b8e32058c67d829498cd67306418de5a0638510c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=84=EA=B5=AC=EB=AA=AC?= <42952358+ljlm0402@users.noreply.github.com> Date: Tue, 12 Aug 2025 05:56:43 +0000 Subject: [PATCH 26/27] Add TEMPLATES_VALUES --- bin/common.js | 113 +++++++++++++++++++++++++++++++++++++++++++++++++ bin/starter.js | 12 +++++- 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/bin/common.js b/bin/common.js index cae2fb9..8b08c9b 100644 --- a/bin/common.js +++ b/bin/common.js @@ -7,6 +7,119 @@ export const PACKAGE_MANAGER = [ { label: 'pnpm', value: 'pnpm' }, ]; +export const TEMPLATES_VALUES = [ + { + name: 'Default', + value: 'default', + desc: 'Basic Express + TypeScript project', + active: true, + tags: ['express', 'typescript', 'starter'], + version: 'v1.0.0', + maintainer: 'core', + lastUpdated: '2025-08-12', + }, + { + name: 'GraphQL', + value: 'graphql', + desc: 'GraphQL API template based on Apollo Server', + active: false, + tags: ['graphql', 'apollo', 'api'], + version: 'v1.2.0', + maintainer: 'core', + lastUpdated: '2025-08-10', + }, + { + name: 'Knex', + value: 'knex', + desc: 'SQL query builder template using Knex.js', + active: false, + tags: ['sql', 'knex', 'database'], + version: 'v0.95.x', + maintainer: 'db-team', + lastUpdated: '2025-07-30', + }, + { + name: 'Mikro-ORM', + value: 'mikro-orm', + desc: 'TypeScript ORM template based on Mikro-ORM', + active: false, + tags: ['orm', 'typescript', 'database'], + version: 'v6.x', + maintainer: 'orm-team', + lastUpdated: '2025-07-25', + }, + { + name: 'Mongoose', + value: 'mongoose', + desc: 'MongoDB ODM template using Mongoose', + active: false, + tags: ['mongodb', 'odm', 'database'], + version: 'v7.x', + maintainer: 'nosql-team', + lastUpdated: '2025-08-01', + }, + { + name: 'Node-Postgres', + value: 'node-postgres', + desc: 'PostgreSQL template using pg library', + active: false, + tags: ['postgres', 'sql', 'database'], + version: 'v8.x', + maintainer: 'db-team', + lastUpdated: '2025-07-20', + }, + { + name: 'Prisma', + value: 'prisma', + desc: 'SQL/NoSQL ORM template based on Prisma', + active: false, + tags: ['orm', 'prisma', 'database'], + version: 'v5.x', + maintainer: 'orm-team', + lastUpdated: '2025-08-05', + }, + { + name: 'Routing-Controllers', + value: 'routing-controllers', + desc: 'Express controller structure with TypeScript decorators', + active: false, + tags: ['decorator', 'controller', 'express'], + version: 'v0.10.x', + maintainer: 'core', + lastUpdated: '2025-07-15', + }, + { + name: 'Sequelize', + value: 'sequelize', + desc: 'SQL ORM template based on Sequelize', + active: false, + tags: ['orm', 'sequelize', 'database'], + version: 'v6.x', + maintainer: 'orm-team', + lastUpdated: '2025-07-28', + }, + { + name: 'Typegoose', + value: 'typegoose', + desc: 'TypeScript-friendly MongoDB ODM using Typegoose', + active: false, + tags: ['mongodb', 'typegoose', 'odm'], + version: 'v11.x', + maintainer: 'nosql-team', + lastUpdated: '2025-08-08', + }, + { + name: 'TypeORM', + value: 'typeorm', + desc: 'SQL/NoSQL ORM template based on TypeORM', + active: false, + tags: ['orm', 'typeorm', 'database'], + version: 'v0.3.x', + maintainer: 'orm-team', + lastUpdated: '2025-07-18', + }, +]; + export const DEVTOOLS_VALUES = [ // == [Formatter] == // { diff --git a/bin/starter.js b/bin/starter.js index 694a2dc..4de5998 100644 --- a/bin/starter.js +++ b/bin/starter.js @@ -16,7 +16,7 @@ import { execa } from 'execa'; import fs from 'fs-extra'; import ora from 'ora'; import path from 'path'; -import { PACKAGE_MANAGER, DEVTOOLS_VALUES, TEMPLATES, DEVTOOLS } from './common.js'; +import { PACKAGE_MANAGER, TEMPLATES_VALUES, DEVTOOLS_VALUES, TEMPLATES, DEVTOOLS } from './common.js'; import { TEMPLATE_DB, DB_SERVICES, BASE_COMPOSE } from './db-map.js'; // ========== [공통 함수들] ========== @@ -241,9 +241,17 @@ async function main() { const templateDirs = (await fs.readdir(TEMPLATES)).filter(f => fs.statSync(path.join(TEMPLATES, f)).isDirectory()); if (templateDirs.length === 0) return printError('No templates found!'); + const options = TEMPLATES_VALUES + .filter(t => t.active && templateDirs.includes(t.value)) + .map(t => ({ + label: t.name, // UI에 표시될 이름 + value: t.value, // 선택 값 + hint: t.desc, // 오른쪽에 표시될 설명 + })); + const template = await select({ message: 'Choose a template:', - options: templateDirs.map(t => ({ label: t, value: t })), + options: options, initialValue: 'default', }); if (isCancel(template)) return cancel('❌ Aborted.'); From b39a518c5dea393644526166775dd853616b0ca2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=84=EA=B5=AC=EB=AA=AC?= <42952358+ljlm0402@users.noreply.github.com> Date: Tue, 12 Aug 2025 05:57:29 +0000 Subject: [PATCH 27/27] Fix README.md Project Logo --- README.kr.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.kr.md b/README.kr.md index 4eda14d..aa49648 100644 --- a/README.kr.md +++ b/README.kr.md @@ -1,6 +1,6 @@


- 프로젝트 로고 + 프로젝트 로고

TypeScript Express Starter diff --git a/README.md b/README.md index f106b60..07eecf5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@


- Project Logo + Project Logo

TypeScript Express Starter