create-saas-starter-workspace 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/LICENSE +17 -0
  3. package/README.md +19 -0
  4. package/bin/index.mjs +201 -0
  5. package/package.json +25 -0
  6. package/src/scaffold.mjs +109 -0
  7. package/src/validate.mjs +22 -0
  8. package/templates/workspace/.claude/settings.json +44 -0
  9. package/templates/workspace/.claude/skills/bridge-guide/SKILL.md +71 -0
  10. package/templates/workspace/.claude/skills/eas-deploy-guide/SKILL.md +107 -0
  11. package/templates/workspace/.claude/skills/go-live/SKILL.md +66 -0
  12. package/templates/workspace/.claude/skills/kickoff/SKILL.md +72 -0
  13. package/templates/workspace/.claude/skills/launch/SKILL.md +69 -0
  14. package/templates/workspace/.claude/skills/native-app-guide/SKILL.md +102 -0
  15. package/templates/workspace/.claude/skills/preview/SKILL.md +43 -0
  16. package/templates/workspace/.claude/skills/probe/SKILL.md +17 -0
  17. package/templates/workspace/.claude/skills/release/SKILL.md +51 -0
  18. package/templates/workspace/.claude/skills/sketch/SKILL.md +19 -0
  19. package/templates/workspace/.claude/skills/store-release-guide/SKILL.md +239 -0
  20. package/templates/workspace/.claude/skills/vercel-cron/SKILL.md +17 -0
  21. package/templates/workspace/.claude/skills/warmup/SKILL.md +33 -0
  22. package/templates/workspace/AGENTS.md +39 -0
  23. package/templates/workspace/CLAUDE.md +2 -0
  24. package/templates/workspace/LICENSE +17 -0
  25. package/templates/workspace/README.md +20 -0
  26. package/templates/workspace/START-HERE.md +52 -0
  27. package/templates/workspace/gitignore +18 -0
  28. package/templates/workspace/mobile/.env.example +25 -0
  29. package/templates/workspace/mobile/AGENTS.md +69 -0
  30. package/templates/workspace/mobile/App.tsx +47 -0
  31. package/templates/workspace/mobile/CLAUDE.md +2 -0
  32. package/templates/workspace/mobile/LICENSE +17 -0
  33. package/templates/workspace/mobile/README.md +73 -0
  34. package/templates/workspace/mobile/app.config.ts +153 -0
  35. package/templates/workspace/mobile/assets/android-icon-background.png +0 -0
  36. package/templates/workspace/mobile/assets/android-icon-foreground.png +0 -0
  37. package/templates/workspace/mobile/assets/android-icon-monochrome.png +0 -0
  38. package/templates/workspace/mobile/assets/favicon.png +0 -0
  39. package/templates/workspace/mobile/assets/icon.png +0 -0
  40. package/templates/workspace/mobile/assets/splash-icon.png +0 -0
  41. package/templates/workspace/mobile/docs/web-adapter/README.md +130 -0
  42. package/templates/workspace/mobile/docs/web-adapter/route-app-bridge.ts +235 -0
  43. package/templates/workspace/mobile/eas.json +31 -0
  44. package/templates/workspace/mobile/gitignore +45 -0
  45. package/templates/workspace/mobile/index.ts +12 -0
  46. package/templates/workspace/mobile/package.json +38 -0
  47. package/templates/workspace/mobile/pnpm-lock.yaml +5201 -0
  48. package/templates/workspace/mobile/src/auth/LoginScreen.tsx +192 -0
  49. package/templates/workspace/mobile/src/bridge/capabilities.test.ts +44 -0
  50. package/templates/workspace/mobile/src/bridge/capabilities.ts +42 -0
  51. package/templates/workspace/mobile/src/bridge/contract.test.ts +49 -0
  52. package/templates/workspace/mobile/src/bridge/contract.ts +146 -0
  53. package/templates/workspace/mobile/src/bridge/messaging.test.ts +49 -0
  54. package/templates/workspace/mobile/src/bridge/messaging.ts +33 -0
  55. package/templates/workspace/mobile/src/bridge/reader.test.ts +52 -0
  56. package/templates/workspace/mobile/src/bridge/reader.ts +31 -0
  57. package/templates/workspace/mobile/src/bridge/router.test.ts +124 -0
  58. package/templates/workspace/mobile/src/bridge/router.ts +89 -0
  59. package/templates/workspace/mobile/src/config/env.ts +51 -0
  60. package/templates/workspace/mobile/src/i18n.ts +71 -0
  61. package/templates/workspace/mobile/src/session/secureSession.ts +63 -0
  62. package/templates/workspace/mobile/src/session/sessionHandoff.ts +151 -0
  63. package/templates/workspace/mobile/src/ui/ErrorView.tsx +75 -0
  64. package/templates/workspace/mobile/src/ui/LoadingView.tsx +38 -0
  65. package/templates/workspace/mobile/src/ui/OfflineView.tsx +73 -0
  66. package/templates/workspace/mobile/src/webview/Host.tsx +353 -0
  67. package/templates/workspace/mobile/src/webview/linkBoundary.test.ts +57 -0
  68. package/templates/workspace/mobile/src/webview/linkBoundary.ts +58 -0
  69. package/templates/workspace/mobile/tsconfig.json +8 -0
  70. package/templates/workspace/mobile/vitest.config.ts +14 -0
  71. package/templates/workspace/package.json +9 -0
  72. package/templates/workspace/scripts/doctor.mjs +291 -0
  73. package/templates/workspace/ssb/README.md +10 -0
  74. package/templates/workspace/ssb/contract.ts +146 -0
  75. package/templates/workspace/ssb/reader.ts +31 -0
  76. package/templates/workspace/web/.env.example +39 -0
  77. package/templates/workspace/web/.gitattributes +1 -0
  78. package/templates/workspace/web/.github/workflows/ci.yml +61 -0
  79. package/templates/workspace/web/.vscode/settings.json +8 -0
  80. package/templates/workspace/web/AGENTS.md +103 -0
  81. package/templates/workspace/web/CLAUDE.md +2 -0
  82. package/templates/workspace/web/DESIGN.md +18 -0
  83. package/templates/workspace/web/LICENSE +17 -0
  84. package/templates/workspace/web/README.md +48 -0
  85. package/templates/workspace/web/app/error.tsx +28 -0
  86. package/templates/workspace/web/app/favicon.ico +0 -0
  87. package/templates/workspace/web/app/global-error.tsx +19 -0
  88. package/templates/workspace/web/app/globals.css +130 -0
  89. package/templates/workspace/web/app/layout.tsx +33 -0
  90. package/templates/workspace/web/app/not-found.tsx +12 -0
  91. package/templates/workspace/web/app/page.tsx +11 -0
  92. package/templates/workspace/web/components/ui/button.tsx +58 -0
  93. package/templates/workspace/web/components.json +25 -0
  94. package/templates/workspace/web/docs/ENVIRONMENTS.md +102 -0
  95. package/templates/workspace/web/docs/LIMITS.md +54 -0
  96. package/templates/workspace/web/eslint.config.mjs +46 -0
  97. package/templates/workspace/web/features/.gitkeep +0 -0
  98. package/templates/workspace/web/gitignore +51 -0
  99. package/templates/workspace/web/instrumentation-client.ts +16 -0
  100. package/templates/workspace/web/instrumentation.ts +12 -0
  101. package/templates/workspace/web/lib/app-env.ts +12 -0
  102. package/templates/workspace/web/lib/bridge/contract.ts +146 -0
  103. package/templates/workspace/web/lib/bridge/reader.ts +31 -0
  104. package/templates/workspace/web/lib/env.server.ts +33 -0
  105. package/templates/workspace/web/lib/env.ts +21 -0
  106. package/templates/workspace/web/lib/logger.ts +32 -0
  107. package/templates/workspace/web/lib/supabase/admin.ts +14 -0
  108. package/templates/workspace/web/lib/supabase/client.ts +9 -0
  109. package/templates/workspace/web/lib/supabase/server.ts +24 -0
  110. package/templates/workspace/web/lib/utils.ts +6 -0
  111. package/templates/workspace/web/next.config.ts +16 -0
  112. package/templates/workspace/web/npmrc +14 -0
  113. package/templates/workspace/web/package.json +60 -0
  114. package/templates/workspace/web/pnpm-lock.yaml +9155 -0
  115. package/templates/workspace/web/postcss.config.mjs +7 -0
  116. package/templates/workspace/web/sentry.edge.config.ts +9 -0
  117. package/templates/workspace/web/sentry.server.config.ts +9 -0
  118. package/templates/workspace/web/supabase/migrations/.gitkeep +0 -0
  119. package/templates/workspace/web/tests/setup.ts +1 -0
  120. package/templates/workspace/web/tests/utils.test.ts +12 -0
  121. package/templates/workspace/web/tsconfig.json +35 -0
  122. package/templates/workspace/web/vercel.json +6 -0
  123. package/templates/workspace/web/vitest.config.ts +15 -0
@@ -0,0 +1,103 @@
1
+ # my-space
2
+
3
+ 무료 티어 기반 웹 SaaS 스타터. Owner는 결정하고 confirm하며, 실행은 AI가 한다.
4
+
5
+ <!-- BEGIN:nextjs-agent-rules -->
6
+ ## Next.js 주의: 학습 데이터와 다름
7
+
8
+ 이 버전은 breaking change가 있다. API·관례·파일 구조가 학습 데이터와 다를 수 있으니, 코드를 쓰기 전 `node_modules/next/dist/docs/`의 관련 문서를 읽는다. deprecation 경고를 따른다.
9
+ <!-- END:nextjs-agent-rules -->
10
+
11
+ ## 배포 시나리오 (v1)
12
+
13
+ 1. `/warmup` — 외부 CLI 로그인 검증, 의존성 install·audit
14
+ 2. `/go-live` — 클라우드 리소스(GitHub·Vercel·Supabase dev/prod·Sentry) 생성, 첫 배포, 보안 진단
15
+ 3. `/probe <아이디어>` — 적응형 인터뷰로 요구사항 도출
16
+ 4. `/sketch` — shadcn과 프로젝트 디자인 컨벤션 기반 시안
17
+ 5. TDD 개발 — Vitest 테스트 작성, 통과, 커밋을 반복
18
+ 6. `git push` — CI 통과 시 Vercel 자동 배포
19
+
20
+ `node_modules/`가 없으면 `/warmup`을, `.vercel/`이 없으면 `/go-live`를 먼저 실행한다.
21
+
22
+ ## AI 도구 (CLI)
23
+
24
+ AI는 아래 CLI로 클라우드를 직접 조작한다. 가역성 등급에 따라 자동 실행하거나 Owner confirm을 받는다.
25
+
26
+ | CLI | 주요 작업 | 가역성 |
27
+ | --- | --- | --- |
28
+ | `gh` | repo·visibility·secrets·secret scanning·PR·이슈 | 대부분 자동, repo 삭제·public 전환은 확인 |
29
+ | `vercel` | env vars·project link·deploy·logs·domains | 자동, prod env 삭제·도메인 변경은 확인 |
30
+ | `pnpm exec supabase` (web/에서) | projects·migration·db push·gen types·link | dev 자동, prod `db push`·`db reset`은 확인 |
31
+ | `sentry-cli` | organization·project 조회/생성, sourcemap·release | 자동 |
32
+ | `pnpm` | install·add·remove·audit·dlx | 자동, major 업그레이드는 확인 |
33
+ | `curl` + Supabase Management API | Auth redirect URL 등 CLI 미커버 항목 | 자동 (PAT = `supabase login`이 저장한 토큰, `/warmup`이 로그인을 점검) |
34
+ | `git` | 표준 | 자동, force push·hard reset은 확인 |
35
+
36
+ 비가역·고위험은 반드시 Owner confirm: prod DB 마이그레이션, prod env 삭제, repo public 전환, prod `db reset`, main force push.
37
+
38
+ Owner가 직접(CLI 미지원, AI는 안내만): 카드 결제, 도메인 DNS 변경, Supabase Personal Access Token 발급, Sentry Auth Token 발급(대시보드 Settings → Auth Tokens).
39
+
40
+ ## 핵심 코드 규칙
41
+
42
+ - TDD 우선: 새 기능은 Vitest 테스트를 작성하고 통과시킨 뒤 커밋한다. 테스트는 `tests/`에 누적한다.
43
+ - 로그(서버): `lib/logger.ts`를 쓴다. 에러는 Sentry로, 감사 로그는 Supabase `logs` 테이블로 보낸다.
44
+ - 로그(클라이언트): 에러는 `Sentry.captureException()`(browser SDK), 영속 이벤트는 server action을 거쳐 `lib/logger.ts`, 일시 디버그는 `console.log`. `lib/logger.ts`는 server-only라 클라이언트에서 직접 import하지 않는다.
45
+ - env: 클라이언트·일반 코드는 `import { env } from "@/lib/env"`(NEXT_PUBLIC_ 만). 서버 전용 키(SUPABASE_SECRET_KEY 등)는 `import { env } from "@/lib/env.server"`(server-only 가드). `process.env.X` 직접 접근은 빌드 시점 설정 파일(`next.config.*`, `sentry.{server,edge}.config.ts`, `instrumentation.ts`, `instrumentation-client.ts`, `vercel.json`)에서만 허용한다.
46
+ - 보안 경계 (RLS 기본 + 서버측 처리):
47
+ - 노출되는 모든 테이블은 RLS를 켠다. 새 Supabase 테이블은 RLS 정책을 같은 마이그레이션 파일에 동봉한다(동봉하지 않으면 거부).
48
+ - 쓰기와 민감한 읽기는 서버측에서 한다(Server Component·Action의 쿠키 기반 user-scoped 클라이언트). queries·actions는 `repository.ts`에만 의존한다.
49
+ - realtime 구독과 storage signed-upload는 client-direct 경로를 유지한다.
50
+ - `service_role`은 server-only 관리 경로 전용이다. 여기서 '서버측'은 user-scoped 클라이언트를 뜻하며 `service_role`과 다르다.
51
+ - secret key: `lib/supabase/admin.ts`에서만 쓴다(server-only 가드). `NEXT_PUBLIC_*` 접두사를 붙이지 않는다. auth 흐름에서 `admin.ts`를 import하지 않는다(RLS·세션 우회 위험).
52
+ - Supabase Auth: `@supabase/ssr`을 쓴다. `@supabase/auth-helpers-nextjs`(deprecated)는 쓰지 않는다.
53
+ - 환경 분기: 서버 코드는 `import { env } from "@/lib/env.server"` 후 `env.APP_ENV`를 비교한다(값: `production` | `preview` | `development`). `APP_ENV`는 벤더 중립 신호다. Vercel은 `VERCEL_ENV`를 여기 매핑하고, 그 외 호스트는 `APP_ENV`를 직접 설정한다(`lib/app-env.ts`).
54
+ - 의존성 정책: caret range, `pnpm-lock.yaml` 커밋, CI `--frozen-lockfile`. `.npmrc`의 `minimum-release-age=14d`로 갓 나온 npm 패키지는 cooldown 후에만 설치한다. 의존성 변경은 lockfile과 같은 커밋에 포함한다.
55
+
56
+ ## 폴더 구조 (FSD-light)
57
+
58
+ ```
59
+ app/ # Next.js 라우트만 (page·layout·route handler)
60
+ ├── sketch/<feature>/ # /sketch 임시 시안 — 채택안만 남기고 정리
61
+ ├── api/cron/<job>/ # cron route handler
62
+ └── <route>/_components/ # 라우트 전용 UI (underscore = private)
63
+ features/<feature>/ # 도메인 모듈 (자기완결)
64
+ ├── components/ # 이 기능 전용 UI
65
+ ├── repository.ts # 이 기능의 모든 Supabase 호출 소유 (데이터 접근 seam)
66
+ ├── actions.ts # server actions — repository.ts에만 의존
67
+ ├── queries.ts # 데이터 쿼리 — repository.ts에만 의존
68
+ ├── schema.ts # zod (form·경계 검증) — DB schema와 정합 유지
69
+ └── index.ts # public API — 외부 import는 여기만
70
+ supabase/migrations/ # DB schema SSOT (.sql + RLS)
71
+ components/ui/ # shadcn primitives (글로벌 재사용)
72
+ lib/ # cross-cutting 인프라
73
+ ├── supabase/{server,client,admin}.ts
74
+ ├── supabase/types.ts # DB→TS 자동 생성 (gen types, read-only)
75
+ ├── env.ts # 클라이언트·일반 코드용 (NEXT_PUBLIC_ 만)
76
+ ├── env.server.ts # server-only (SUPABASE_SECRET_KEY 등)
77
+ ├── app-env.ts # 벤더 중립 배포 환경(APP_ENV) 해석 (순수 함수)
78
+ ├── logger.ts utils.ts
79
+ sentry.{server,edge}.config.ts # server/edge SDK init (instrumentation.ts가 register)
80
+ instrumentation.ts # server/edge Sentry register + onRequestError
81
+ instrumentation-client.ts # browser SDK init (Next가 자동 인식)
82
+ tests/<feature>/ # Vitest 회귀 (feature 미러)
83
+ docs/{ENVIRONMENTS,LIMITS}.md # 레퍼런스
84
+ ```
85
+
86
+ ## 폴더 규칙
87
+
88
+ - `app/`은 얇다: 비즈니스 로직을 두지 않고 `features/`를 조합만 한다.
89
+ - 의존은 단방향이다: `app → features → lib`. feature끼리는 다른 feature의 `index.ts`(public API)만 import한다(내부 직접 import 금지 = 캡슐화). `lib`은 `features`를 import하지 않는다(ESLint로 강제).
90
+ - 두 feature가 서로를 import하게 되면, 공통 책임을 상위 feature로 추출한다(예: cart + payment → `features/checkout/`).
91
+ - 여러 페이지가 공유하는 복합 UI(Header, Shell)는 `features/layout/components/`에 둔다.
92
+ - 여러 feature가 공유하는 schema(Money, Address)는 우선 `lib/schemas/`에 둔다. 반복 패턴이 분명해지면 `entities/<entity>/`를 정식 도입한다.
93
+ - 큰 복합 UI가 쌓이면 `widgets/<widget>/`를 정식 도입한다.
94
+ - schema 3층: DB(`supabase/migrations/*.sql`, SSOT) → TS 타입(`lib/supabase/types.ts`, 자동 생성) → zod(`features/<feature>/schema.ts`, form·경계 검증). DB가 SSOT이고, 변경 시 나머지 두 층을 동기화한다.
95
+
96
+ ## 환경 분리
97
+
98
+ dev/prod를 서로 다른 Supabase 프로젝트로 격리한다. 매트릭스는 [`docs/ENVIRONMENTS.md`](docs/ENVIRONMENTS.md), 무료 티어 한도는 [`docs/LIMITS.md`](docs/LIMITS.md).
99
+
100
+ - dev: `<repo>-dev` Supabase + Sentry `env=development`(송출 off) + Vercel preview·localhost
101
+ - prod: `<repo>-prod` Supabase + Sentry `env=production` + Vercel production
102
+
103
+ 코드 분기는 서버에서 `env.APP_ENV`(`@/lib/env.server`)로 한다. 빌드 시점 파일(`sentry.{server,edge}.config.ts`·`instrumentation*.ts`·`next.config.ts`)은 env 모듈보다 먼저 실행되므로 `process.env`를 직접 읽고 `lib/app-env.ts`로 APP_ENV를 해석한다.
@@ -0,0 +1,2 @@
1
+ <!-- AGENTS.md를 그대로 불러옵니다. 이 repo의 에이전트 규칙은 AGENTS.md에서 단일 관리합니다. -->
2
+ @AGENTS.md
@@ -0,0 +1,18 @@
1
+ # DESIGN
2
+
3
+ 프로젝트의 디자인 언어(컬러·타이포그래피·간격·컴포넌트 규칙)를 여기 적는다. `/sketch`가 이 파일을 디자인 컨벤션으로 읽는다. 비어 있어도 동작하며, 채울수록 시안이 프로젝트 톤에 맞는다.
4
+
5
+ ## Colors
6
+ <!-- 예: --primary, --bg, --text -->
7
+
8
+ ## Typography
9
+ <!-- 폰트, 크기 스케일, 굵기 -->
10
+
11
+ ## Spacing & Layout
12
+ <!-- 간격 단위, 그리드, 브레이크포인트 -->
13
+
14
+ ## Components
15
+ <!-- 버튼, 카드, 입력창 규칙 -->
16
+
17
+ ## 참고
18
+ - 마크다운으로 디자인 언어를 적는 포맷 예시: [getdesign.md](https://getdesign.md/), [awesome-design-md](https://github.com/VoltAgent/awesome-design-md)
@@ -0,0 +1,17 @@
1
+ Copyright (c) 2026 시스템설계자
2
+
3
+ This template is distributed to students of the author's course
4
+ (brand: 시스템설계자).
5
+
6
+ You may use it as the starting point for any of your own projects —
7
+ personal or commercial. You may modify it freely, ship products built
8
+ with it, and you keep all rights to the work you create on top.
9
+
10
+ You may not redistribute the template itself, repackage it as a course
11
+ or book, or include it in any dataset used to train machine learning
12
+ models. Access is part of the course.
13
+
14
+ THE TEMPLATE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
15
+ OR IMPLIED. THE AUTHOR SHALL NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR
16
+ OTHER LIABILITY ARISING FROM OR IN CONNECTION WITH THE TEMPLATE OR ITS
17
+ USE.
@@ -0,0 +1,48 @@
1
+ # my-space
2
+
3
+ 무료 티어로 실제 운영되는 웹 SaaS를 만드는 스타터 템플릿입니다. Owner는 결정하고, AI가 CLI·코드 작업을 합니다.
4
+
5
+ ## 스택
6
+
7
+ Next.js (App Router) · TypeScript · Tailwind v4 · shadcn/ui · Supabase · Vercel · Sentry · Vitest. 모두 무료 티어 안에서 동작합니다.
8
+
9
+ ## 빠른 시작
10
+
11
+ 이 폴더는 워크스페이스의 `web/` 서브프로젝트입니다. 명령(스킬)은 워크스페이스 루트의 `.claude/`에 있으므로, AI 에이전트는 루트에서 엽니다. `web/`에는 도구가 없습니다(순수 코드). 아래 표는 그 흐름의 참고용입니다.
12
+
13
+ | 단계 | 명령 | AI가 하는 일 | Owner가 하는 일 |
14
+ | --- | --- | --- | --- |
15
+ | 1. 환경 점검 | `/warmup` | 외부 CLI·로그인 검증, 의존성 install·audit | 부족한 로그인 안내 따르기 |
16
+ | 2. 첫 배포 | `/go-live` | GitHub·Vercel·Supabase dev/prod·Sentry 프로비저닝, env 동기화, 첫 배포, 보안 진단 | 비가역 작업 confirm |
17
+ | 3. 기획 | `/probe <아이디어>` | 인터뷰로 요구사항 도출 | 질문에 답변 |
18
+ | 4. 디자인 | `/sketch` | shadcn과 프로젝트 디자인 컨벤션 기반 UI | 원하면 DESIGN.md 채우기 |
19
+ | 5. 개발 | "X 기능 만들어줘" | TDD로 개발, Vitest 통과까지 반복 | 요구사항 명시·검토 |
20
+ | 6. 푸시 | `git push` | CI 통과 시 Vercel 자동 배포 | URL 확인 |
21
+
22
+ 코드 규칙·폴더 구조·환경 분기 규약은 [`AGENTS.md`](AGENTS.md)에 있습니다.
23
+
24
+ ## 사전 준비물
25
+
26
+ `/warmup`이 안내하지만, 미리 가입·설치하면 빠릅니다.
27
+
28
+ - [Node.js](https://nodejs.org/) 현재 LTS
29
+ - [pnpm](https://pnpm.io/) 최신
30
+ - [GitHub](https://github.com) 계정 + `gh auth login`
31
+ - [Vercel](https://vercel.com) 계정 + `vercel login` (GitHub OAuth 권장)
32
+ - [Supabase](https://supabase.com) 계정 + `pnpm exec supabase login`(이 폴더에서) + [Personal Access Token](https://supabase.com/dashboard/account/tokens)
33
+ - [Sentry](https://sentry.io) 계정 + `sentry-cli login`
34
+
35
+ ## 의존성 정책
36
+
37
+ - 버전: caret range(`^x.y.z`). 보안 패치는 자동으로, major는 고정.
38
+ - 결정성: `pnpm-lock.yaml` 커밋 + CI `--frozen-lockfile`.
39
+ - 공급망: `.npmrc`의 `minimum-release-age=14d`로 갓 나온 패키지는 cooldown 후 설치.
40
+ - CVE: CI에서 `pnpm audit --prod --audit-level=high`.
41
+
42
+ ## 환경 분리
43
+
44
+ dev/prod를 서로 다른 Supabase 프로젝트로 격리합니다(무료 슬롯 2개 사용). 로컬 `pnpm dev`와 Vercel preview는 dev DB에, Vercel production(`main`)은 prod DB에 붙습니다. 매트릭스는 [`docs/ENVIRONMENTS.md`](docs/ENVIRONMENTS.md), 한도는 [`docs/LIMITS.md`](docs/LIMITS.md).
45
+
46
+ ## 라이선스
47
+
48
+ 강의 수강생용 템플릿입니다. [LICENSE](LICENSE).
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import * as Sentry from "@sentry/nextjs";
4
+ import { useEffect } from "react";
5
+
6
+ export default function Error({
7
+ error,
8
+ reset,
9
+ }: {
10
+ error: Error & { digest?: string };
11
+ reset: () => void;
12
+ }) {
13
+ useEffect(() => {
14
+ Sentry.captureException(error);
15
+ }, [error]);
16
+
17
+ return (
18
+ <main className="mx-auto flex max-w-2xl flex-1 flex-col items-start justify-center gap-4 px-6">
19
+ <h2 className="text-xl font-semibold">문제가 발생했어요</h2>
20
+ <button
21
+ onClick={reset}
22
+ className="rounded bg-primary px-3 py-1.5 text-sm text-primary-foreground"
23
+ >
24
+ 다시 시도
25
+ </button>
26
+ </main>
27
+ );
28
+ }
@@ -0,0 +1,19 @@
1
+ "use client";
2
+
3
+ import * as Sentry from "@sentry/nextjs";
4
+ import NextError from "next/error";
5
+ import { useEffect } from "react";
6
+
7
+ export default function GlobalError({ error }: { error: Error & { digest?: string } }) {
8
+ useEffect(() => {
9
+ Sentry.captureException(error);
10
+ }, [error]);
11
+
12
+ return (
13
+ <html lang="ko">
14
+ <body>
15
+ <NextError statusCode={0} />
16
+ </body>
17
+ </html>
18
+ );
19
+ }
@@ -0,0 +1,130 @@
1
+ @import "tailwindcss";
2
+ @import "tw-animate-css";
3
+ @import "shadcn/tailwind.css";
4
+
5
+ @custom-variant dark (&:is(.dark *));
6
+
7
+ @theme inline {
8
+ --color-background: var(--background);
9
+ --color-foreground: var(--foreground);
10
+ --font-sans: var(--font-sans);
11
+ --font-mono: var(--font-geist-mono);
12
+ --font-heading: var(--font-sans);
13
+ --color-sidebar-ring: var(--sidebar-ring);
14
+ --color-sidebar-border: var(--sidebar-border);
15
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
16
+ --color-sidebar-accent: var(--sidebar-accent);
17
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
18
+ --color-sidebar-primary: var(--sidebar-primary);
19
+ --color-sidebar-foreground: var(--sidebar-foreground);
20
+ --color-sidebar: var(--sidebar);
21
+ --color-chart-5: var(--chart-5);
22
+ --color-chart-4: var(--chart-4);
23
+ --color-chart-3: var(--chart-3);
24
+ --color-chart-2: var(--chart-2);
25
+ --color-chart-1: var(--chart-1);
26
+ --color-ring: var(--ring);
27
+ --color-input: var(--input);
28
+ --color-border: var(--border);
29
+ --color-destructive: var(--destructive);
30
+ --color-accent-foreground: var(--accent-foreground);
31
+ --color-accent: var(--accent);
32
+ --color-muted-foreground: var(--muted-foreground);
33
+ --color-muted: var(--muted);
34
+ --color-secondary-foreground: var(--secondary-foreground);
35
+ --color-secondary: var(--secondary);
36
+ --color-primary-foreground: var(--primary-foreground);
37
+ --color-primary: var(--primary);
38
+ --color-popover-foreground: var(--popover-foreground);
39
+ --color-popover: var(--popover);
40
+ --color-card-foreground: var(--card-foreground);
41
+ --color-card: var(--card);
42
+ --radius-sm: calc(var(--radius) * 0.6);
43
+ --radius-md: calc(var(--radius) * 0.8);
44
+ --radius-lg: var(--radius);
45
+ --radius-xl: calc(var(--radius) * 1.4);
46
+ --radius-2xl: calc(var(--radius) * 1.8);
47
+ --radius-3xl: calc(var(--radius) * 2.2);
48
+ --radius-4xl: calc(var(--radius) * 2.6);
49
+ }
50
+
51
+ :root {
52
+ --background: oklch(1 0 0);
53
+ --foreground: oklch(0.145 0 0);
54
+ --card: oklch(1 0 0);
55
+ --card-foreground: oklch(0.145 0 0);
56
+ --popover: oklch(1 0 0);
57
+ --popover-foreground: oklch(0.145 0 0);
58
+ --primary: oklch(0.205 0 0);
59
+ --primary-foreground: oklch(0.985 0 0);
60
+ --secondary: oklch(0.97 0 0);
61
+ --secondary-foreground: oklch(0.205 0 0);
62
+ --muted: oklch(0.97 0 0);
63
+ --muted-foreground: oklch(0.556 0 0);
64
+ --accent: oklch(0.97 0 0);
65
+ --accent-foreground: oklch(0.205 0 0);
66
+ --destructive: oklch(0.577 0.245 27.325);
67
+ --border: oklch(0.922 0 0);
68
+ --input: oklch(0.922 0 0);
69
+ --ring: oklch(0.708 0 0);
70
+ --chart-1: oklch(0.87 0 0);
71
+ --chart-2: oklch(0.556 0 0);
72
+ --chart-3: oklch(0.439 0 0);
73
+ --chart-4: oklch(0.371 0 0);
74
+ --chart-5: oklch(0.269 0 0);
75
+ --radius: 0.625rem;
76
+ --sidebar: oklch(0.985 0 0);
77
+ --sidebar-foreground: oklch(0.145 0 0);
78
+ --sidebar-primary: oklch(0.205 0 0);
79
+ --sidebar-primary-foreground: oklch(0.985 0 0);
80
+ --sidebar-accent: oklch(0.97 0 0);
81
+ --sidebar-accent-foreground: oklch(0.205 0 0);
82
+ --sidebar-border: oklch(0.922 0 0);
83
+ --sidebar-ring: oklch(0.708 0 0);
84
+ }
85
+
86
+ .dark {
87
+ --background: oklch(0.145 0 0);
88
+ --foreground: oklch(0.985 0 0);
89
+ --card: oklch(0.205 0 0);
90
+ --card-foreground: oklch(0.985 0 0);
91
+ --popover: oklch(0.205 0 0);
92
+ --popover-foreground: oklch(0.985 0 0);
93
+ --primary: oklch(0.922 0 0);
94
+ --primary-foreground: oklch(0.205 0 0);
95
+ --secondary: oklch(0.269 0 0);
96
+ --secondary-foreground: oklch(0.985 0 0);
97
+ --muted: oklch(0.269 0 0);
98
+ --muted-foreground: oklch(0.708 0 0);
99
+ --accent: oklch(0.269 0 0);
100
+ --accent-foreground: oklch(0.985 0 0);
101
+ --destructive: oklch(0.704 0.191 22.216);
102
+ --border: oklch(1 0 0 / 10%);
103
+ --input: oklch(1 0 0 / 15%);
104
+ --ring: oklch(0.556 0 0);
105
+ --chart-1: oklch(0.87 0 0);
106
+ --chart-2: oklch(0.556 0 0);
107
+ --chart-3: oklch(0.439 0 0);
108
+ --chart-4: oklch(0.371 0 0);
109
+ --chart-5: oklch(0.269 0 0);
110
+ --sidebar: oklch(0.205 0 0);
111
+ --sidebar-foreground: oklch(0.985 0 0);
112
+ --sidebar-primary: oklch(0.488 0.243 264.376);
113
+ --sidebar-primary-foreground: oklch(0.985 0 0);
114
+ --sidebar-accent: oklch(0.269 0 0);
115
+ --sidebar-accent-foreground: oklch(0.985 0 0);
116
+ --sidebar-border: oklch(1 0 0 / 10%);
117
+ --sidebar-ring: oklch(0.556 0 0);
118
+ }
119
+
120
+ @layer base {
121
+ * {
122
+ @apply border-border outline-ring/50;
123
+ }
124
+ body {
125
+ @apply bg-background text-foreground;
126
+ }
127
+ html {
128
+ @apply font-sans;
129
+ }
130
+ }
@@ -0,0 +1,33 @@
1
+ import type { Metadata } from "next";
2
+ import { Geist, Geist_Mono } from "next/font/google";
3
+ import "./globals.css";
4
+
5
+ const geistSans = Geist({
6
+ variable: "--font-geist-sans",
7
+ subsets: ["latin"],
8
+ });
9
+
10
+ const geistMono = Geist_Mono({
11
+ variable: "--font-geist-mono",
12
+ subsets: ["latin"],
13
+ });
14
+
15
+ export const metadata: Metadata = {
16
+ title: "my-space",
17
+ description: "무료티어 기반 웹 SaaS 스타터",
18
+ };
19
+
20
+ export default function RootLayout({
21
+ children,
22
+ }: Readonly<{
23
+ children: React.ReactNode;
24
+ }>) {
25
+ return (
26
+ <html
27
+ lang="ko"
28
+ className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
29
+ >
30
+ <body className="min-h-full flex flex-col">{children}</body>
31
+ </html>
32
+ );
33
+ }
@@ -0,0 +1,12 @@
1
+ import Link from "next/link";
2
+
3
+ export default function NotFound() {
4
+ return (
5
+ <main className="mx-auto flex max-w-2xl flex-1 flex-col items-start justify-center gap-4 px-6">
6
+ <h2 className="text-xl font-semibold">페이지를 찾을 수 없어요</h2>
7
+ <Link href="/" className="text-sm text-muted-foreground underline-offset-4 hover:underline">
8
+ 홈으로
9
+ </Link>
10
+ </main>
11
+ );
12
+ }
@@ -0,0 +1,11 @@
1
+ export default function Home() {
2
+ return (
3
+ <main className="mx-auto flex max-w-2xl flex-1 flex-col items-start justify-center gap-4 px-6">
4
+ <h1 className="text-3xl font-semibold tracking-tight">my-space</h1>
5
+ <p className="text-muted-foreground">
6
+ 템플릿 부트스트랩 완료. <code className="rounded bg-muted px-1.5 py-0.5 text-sm">/probe</code>로
7
+ 아이디어를 요구사항으로 정제한 뒤 개발을 시작하세요.
8
+ </p>
9
+ </main>
10
+ );
11
+ }
@@ -0,0 +1,58 @@
1
+ import { Button as ButtonPrimitive } from "@base-ui/react/button"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+
4
+ import { cn } from "@/lib/utils"
5
+
6
+ const buttonVariants = cva(
7
+ "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
+ {
9
+ variants: {
10
+ variant: {
11
+ default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
12
+ outline:
13
+ "border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
14
+ secondary:
15
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
16
+ ghost:
17
+ "hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
18
+ destructive:
19
+ "bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
20
+ link: "text-primary underline-offset-4 hover:underline",
21
+ },
22
+ size: {
23
+ default:
24
+ "h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
25
+ xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
26
+ sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
27
+ lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
28
+ icon: "size-8",
29
+ "icon-xs":
30
+ "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
31
+ "icon-sm":
32
+ "size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
33
+ "icon-lg": "size-9",
34
+ },
35
+ },
36
+ defaultVariants: {
37
+ variant: "default",
38
+ size: "default",
39
+ },
40
+ }
41
+ )
42
+
43
+ function Button({
44
+ className,
45
+ variant = "default",
46
+ size = "default",
47
+ ...props
48
+ }: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
49
+ return (
50
+ <ButtonPrimitive
51
+ data-slot="button"
52
+ className={cn(buttonVariants({ variant, size, className }))}
53
+ {...props}
54
+ />
55
+ )
56
+ }
57
+
58
+ export { Button, buttonVariants }
@@ -0,0 +1,25 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema.json",
3
+ "style": "base-nova",
4
+ "rsc": true,
5
+ "tsx": true,
6
+ "tailwind": {
7
+ "config": "",
8
+ "css": "app/globals.css",
9
+ "baseColor": "neutral",
10
+ "cssVariables": true,
11
+ "prefix": ""
12
+ },
13
+ "iconLibrary": "lucide",
14
+ "rtl": false,
15
+ "aliases": {
16
+ "components": "@/components",
17
+ "utils": "@/lib/utils",
18
+ "ui": "@/components/ui",
19
+ "lib": "@/lib",
20
+ "hooks": "@/hooks"
21
+ },
22
+ "menuColor": "default",
23
+ "menuAccent": "subtle",
24
+ "registries": {}
25
+ }
@@ -0,0 +1,102 @@
1
+ # 환경 분리 매트릭스 (dev / prod)
2
+
3
+ dev와 prod를 서로 다른 Supabase 프로젝트로 완전히 격리한다. Owner의 로컬 작업이 실 서비스에 닿지 않게 하기 위해서다.
4
+
5
+ ## 매트릭스
6
+
7
+ | 자원 | dev | prod | 분리 방식 |
8
+ |---|---|---|---|
9
+ | Next.js 실행 | `pnpm dev`(localhost:3000) + Vercel preview(feature 브랜치) | Vercel production(main 푸시) | 같은 코드, 런타임에 `APP_ENV`로 분기 |
10
+ | Supabase DB | `<repo>-dev` 프로젝트 | `<repo>-prod` 프로젝트 | 별개의 URL·key |
11
+ | Supabase Auth | dev project, redirect URL `localhost:3000`·`*.vercel.app` | prod project, prod 도메인 redirect | 사용자 계정도 별개 |
12
+ | Sentry | 1 프로젝트, `environment=development` 태그 | 같은 프로젝트, `environment=production` 태그 | env 태그 + 송출 가드 |
13
+ | Sentry 송출 | off (`APP_ENV !== "production"`) | on | 5k/월 한도 보호. preview에서도 송출하지 않음 |
14
+ | Vercel Cron | preview·dev에서 실행 안 함(`APP_ENV === "production"` 가드) | 정상 실행 | 코드 분기 |
15
+ | env vars 소스 | `vercel env pull --environment=development` → `.env.local` | Vercel "Production" 슬롯(자동 주입) | gitignored |
16
+ | GitHub Actions CI | PR·main 모두 같은 GH Secrets로 컴파일·테스트(앱 런타임 실행은 없음) | 동일 | CI는 dev/prod를 구분하지 않는다. 런타임 분기는 Vercel이 담당 |
17
+ | Supabase 마이그레이션 | AI 자동: `--project-ref <dev>` | AI가 Owner confirm 후: `--project-ref <prod>` | Owner 결정점 |
18
+ | 배포 환경 신호 | `APP_ENV=development`(로컬)·`preview`(Vercel preview) | `APP_ENV=production` | Vercel은 `VERCEL_ENV`를 `APP_ENV`로 매핑 |
19
+
20
+ ## 분기 값: APP_ENV
21
+
22
+ 코드 분기의 기준 값은 `APP_ENV`다(`production` | `preview` | `development`). Vercel은 빌드 시 `VERCEL_ENV`를 주입하고, `lib/app-env.ts`가 이를 `APP_ENV`로 해석한다. 그 외 호스트는 `APP_ENV`를 직접 설정한다. 서버 코드는 언제나 `APP_ENV`를 보고, `VERCEL_ENV`를 직접 보지 않는다.
23
+
24
+ ## 일상 흐름
25
+
26
+ ```
27
+ [로컬 dev]
28
+ $ pnpm dev → localhost:3000
29
+ .env.local(dev 슬롯 값) 사용
30
+ → Supabase DEV
31
+ → Sentry 송출 off
32
+
33
+ [feature 브랜치]
34
+ $ git push origin feature-x
35
+ → GHA CI (typecheck·lint·test·build)
36
+ → 통과 시 Vercel preview URL 생성
37
+ → Supabase DEV
38
+ → Sentry env=development
39
+
40
+ [production]
41
+ $ git push origin main
42
+ → GHA CI
43
+ → Vercel Deployment Checks 통과
44
+ → Vercel production 자동 promote
45
+ → Supabase PROD (실 사용자)
46
+ → Sentry env=production (이벤트 송출)
47
+ ```
48
+
49
+ ## 코드에서 환경 분기
50
+
51
+ ```ts
52
+ // 런타임 서버 코드 — env.server (server-only 가드)
53
+ import { env } from "@/lib/env.server";
54
+
55
+ if (env.APP_ENV === "production") {
56
+ // 결제 처리, 외부 알림 등
57
+ }
58
+
59
+ // Vercel Cron route handler 가드
60
+ export async function GET(req: Request) {
61
+ if (env.APP_ENV !== "production") {
62
+ return Response.json({ skipped: "non-prod" });
63
+ }
64
+ // 실제 cron 로직
65
+ }
66
+
67
+ // 클라이언트 코드 — NEXT_PUBLIC_ 만
68
+ import { env } from "@/lib/env";
69
+ const url = env.NEXT_PUBLIC_SUPABASE_URL;
70
+ ```
71
+
72
+ ```ts
73
+ // 예외: 빌드 시점 설정 파일(sentry.{server,edge}.config.ts·instrumentation*.ts·next.config.ts)은
74
+ // env 모듈보다 먼저 실행되므로 process.env를 직접 읽는다.
75
+ Sentry.init({
76
+ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
77
+ enabled: process.env.VERCEL_ENV === "production",
78
+ environment: process.env.VERCEL_ENV ?? "development",
79
+ tracesSampleRate: 0,
80
+ });
81
+ ```
82
+
83
+ ## 마이그레이션 워크플로 (AI 주도)
84
+
85
+ 1. `npx supabase migration new <name>`로 파일 생성
86
+ 2. SQL 작성 (RLS 정책 동봉 필수)
87
+ 3. `npx supabase db push --project-ref $SUPABASE_DEV_REF`로 dev 적용 (자동)
88
+ 4. dev에서 검증한 뒤 Owner에게 보고: "dev 적용·검증 완료. 변경 요약: ___. prod에도 적용할까요?"
89
+ 5. Owner confirm
90
+ 6. `npx supabase db push --project-ref $SUPABASE_PROD_REF`로 prod 적용
91
+
92
+ `$SUPABASE_DEV_REF`·`$SUPABASE_PROD_REF`는 `/go-live`가 `.env.local`과 Vercel env에 저장한다.
93
+
94
+ ## 비용·한도
95
+
96
+ 무료 티어 숫자는 [`LIMITS.md`](LIMITS.md)가 SSOT다. 핵심만: dev/prod 분리에 Supabase 무료 슬롯 2개를 모두 쓰고, Sentry는 prod만 송출해 한도를 보호한다. Vercel 로그 보존은 1시간이라, 그 너머는 Sentry와 Supabase `logs` 테이블에서 조회한다.
97
+
98
+ ## 다루지 않는 것
99
+
100
+ - stage 환경: dev/prod 2환경으로 충분하다. 더 필요하면 Vercel preview를 stage로 쓴다.
101
+ - Supabase 로컬 Docker: 입문자 진입장벽을 피한다.
102
+ - CD 자동 prod 마이그레이션: prod 변경은 항상 Owner confirm을 거치고, CI에서 자동 실행하지 않는다.