@gaonjs/cli 0.1.3 → 0.1.5

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.
@@ -0,0 +1,30 @@
1
+ export interface AuthScaffoldOptions {
2
+ /** 대상 앱(apps/<app>). 기본 'web'. */
3
+ readonly app?: string;
4
+ }
5
+ /** 생성할 파일 하나 — 경로는 프로젝트 루트 기준. */
6
+ export interface ScaffoldFile {
7
+ readonly path: string;
8
+ readonly contents: string;
9
+ }
10
+ export interface ScaffoldResult {
11
+ readonly created: string[];
12
+ readonly skipped: string[];
13
+ readonly patched: string[];
14
+ }
15
+ /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다. */
16
+ export declare function authScaffoldFiles(opts?: AuthScaffoldOptions): ScaffoldFile[];
17
+ /**
18
+ * 기존 routes.ts 에 세션·회원가입 리소스를 끼워 넣는다. 이미 있으면 null.
19
+ * `routes((r) => {` 콜백 여는 지점 뒤에 두 줄을 삽입한다.
20
+ */
21
+ export declare function patchRoutes(existing: string): string | null;
22
+ /** 스캐폴드를 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip 한다. routes.ts 는 패치. */
23
+ export declare function writeAuthScaffold(cwd: string, opts?: AuthScaffoldOptions): ScaffoldResult;
24
+ export interface GenerateAuthOptions {
25
+ readonly cwd?: string;
26
+ readonly app?: string;
27
+ readonly json?: boolean;
28
+ }
29
+ /** `gaon g auth` 진입점. 스캐폴드를 쓰고 결과를 사람/JSON 으로 출력한다. */
30
+ export declare function runGenerateAuthCommand(opts?: GenerateAuthOptions): number;
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @gaonjs/cli · `gaon g auth` — 인증 스캐폴드 제너레이터 (M5)
3
+ *
4
+ * 회원가입/로그인/세션/보호 라우트의 스키마·모델·컨트롤러·페이지·배선을
5
+ * 한 번에 생성한다(§7). 생성 직후 회원가입→로그인→보호 라우트가 동작한다.
6
+ *
7
+ * 템플릿은 `templates/auth/*.tpl` 에 별도 파일로 둔다(Rails 관례) — 제너레이터
8
+ * 로직과 템플릿 디자인을 분리해 .vue 마크업을 파일 단위로 수정할 수 있게 한다.
9
+ * 이 파일은 템플릿을 읽어 토큰({{APP_NAME}})을 치환하고 목표 경로에 쓴다.
10
+ *
11
+ * 생성물은 파사드 서브패스(gaonjs/data·gaonjs/web·gaonjs/vue)만 import 한다
12
+ * — 개발자는 gaonjs 하나만 설치한다(§2.5.2). 세션은 Redis, passwordDigest 는
13
+ * hidden 컬럼이라 페이지로 새지 않는다(§4.2).
14
+ */
15
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ // ── 템플릿 로드·치환 ───────────────────────────────────────────
19
+ // 템플릿은 이 모듈과 같은 위치의 templates/auth/ 에 있다(빌드가 dist 로 복사).
20
+ const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'auth');
21
+ /** 템플릿 파일을 읽어 토큰을 치환한다. {{APP_NAME}} 는 Vue 의 {{ }} 보간과
22
+ * 겹치지 않는 고정 리터럴이라 단순 replaceAll 로 안전하다. */
23
+ function renderTemplate(name, app) {
24
+ const raw = readFileSync(join(TEMPLATE_DIR, name), 'utf8');
25
+ return raw.replaceAll('{{APP_NAME}}', app);
26
+ }
27
+ /** 템플릿 파일 → 생성 경로 매핑(라우트 제외 — 라우트는 패치로 처리). */
28
+ const TEMPLATES = [
29
+ { tpl: 'user.schema.ts.tpl', out: () => 'domain/schema/user.ts' },
30
+ { tpl: 'user.model.ts.tpl', out: () => 'domain/models/user.ts' },
31
+ { tpl: 'auth.wiring.ts.tpl', out: (a) => `apps/${a}/auth.ts` },
32
+ { tpl: 'session.controller.ts.tpl', out: (a) => `apps/${a}/controllers/session.ts` },
33
+ { tpl: 'registration.controller.ts.tpl', out: (a) => `apps/${a}/controllers/registration.ts` },
34
+ { tpl: 'dashboard.controller.ts.tpl', out: (a) => `apps/${a}/controllers/dashboard.ts` },
35
+ { tpl: 'Login.vue.tpl', out: (a) => `apps/${a}/pages/auth/Login.vue` },
36
+ { tpl: 'Signup.vue.tpl', out: (a) => `apps/${a}/pages/auth/Signup.vue` },
37
+ { tpl: 'Dashboard.vue.tpl', out: (a) => `apps/${a}/pages/Dashboard.vue` },
38
+ { tpl: 'app.ts.tpl', out: (a) => `apps/${a}/app.ts` },
39
+ { tpl: 'server.ts.tpl', out: () => 'server.ts' },
40
+ ];
41
+ /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다. */
42
+ export function authScaffoldFiles(opts = {}) {
43
+ const app = opts.app ?? 'web';
44
+ return TEMPLATES.map(({ tpl, out }) => ({ path: out(app), contents: renderTemplate(tpl, app) }));
45
+ }
46
+ /**
47
+ * 기존 routes.ts 에 세션·회원가입 리소스를 끼워 넣는다. 이미 있으면 null.
48
+ * `routes((r) => {` 콜백 여는 지점 뒤에 두 줄을 삽입한다.
49
+ */
50
+ export function patchRoutes(existing) {
51
+ if (existing.includes("resource('session')"))
52
+ return null;
53
+ const m = existing.match(/routes\(\s*\(\s*\w+\s*\)\s*=>\s*\{/);
54
+ if (!m || m.index === undefined)
55
+ return null;
56
+ const insertAt = m.index + m[0].length;
57
+ const inject = "\n r.resource('session') // 로그인/로그아웃 (gaon g auth)" +
58
+ "\n r.resource('registration') // 회원가입 (gaon g auth)";
59
+ return existing.slice(0, insertAt) + inject + existing.slice(insertAt);
60
+ }
61
+ /** 스캐폴드를 디스크에 쓴다. 기존 파일은 덮어쓰지 않고 skip 한다. routes.ts 는 패치. */
62
+ export function writeAuthScaffold(cwd, opts = {}) {
63
+ const root = resolve(cwd);
64
+ const app = opts.app ?? 'web';
65
+ const created = [];
66
+ const skipped = [];
67
+ const patched = [];
68
+ for (const file of authScaffoldFiles(opts)) {
69
+ const abs = join(root, file.path);
70
+ if (existsSync(abs)) {
71
+ skipped.push(file.path);
72
+ continue;
73
+ }
74
+ mkdirSync(dirname(abs), { recursive: true });
75
+ writeFileSync(abs, file.contents, 'utf8');
76
+ created.push(file.path);
77
+ }
78
+ // routes.ts — 있으면 패치, 없으면 템플릿에서 생성.
79
+ const routesPath = join(root, 'apps', app, 'routes.ts');
80
+ const routesRel = `apps/${app}/routes.ts`;
81
+ if (existsSync(routesPath)) {
82
+ const patchedContent = patchRoutes(readFileSync(routesPath, 'utf8'));
83
+ if (patchedContent) {
84
+ writeFileSync(routesPath, patchedContent, 'utf8');
85
+ patched.push(routesRel);
86
+ }
87
+ else {
88
+ skipped.push(routesRel);
89
+ }
90
+ }
91
+ else {
92
+ mkdirSync(dirname(routesPath), { recursive: true });
93
+ writeFileSync(routesPath, renderTemplate('routes.ts.tpl', app), 'utf8');
94
+ created.push(routesRel);
95
+ }
96
+ return { created: created.sort(), skipped: skipped.sort(), patched: patched.sort() };
97
+ }
98
+ /** `gaon g auth` 진입점. 스캐폴드를 쓰고 결과를 사람/JSON 으로 출력한다. */
99
+ export function runGenerateAuthCommand(opts = {}) {
100
+ const cwd = opts.cwd ?? process.cwd();
101
+ const app = opts.app ?? 'web';
102
+ const result = writeAuthScaffold(cwd, { app });
103
+ if (opts.json) {
104
+ process.stdout.write(JSON.stringify({ command: 'g auth', app, ...result }, null, 2) + '\n');
105
+ return 0;
106
+ }
107
+ const lines = [''];
108
+ lines.push(` gaon g auth — 인증 스캐폴드 (${app} 앱)`);
109
+ lines.push('');
110
+ for (const f of result.created)
111
+ lines.push(` + ${f}`);
112
+ for (const f of result.patched)
113
+ lines.push(` ~ ${f} (라우트 추가)`);
114
+ for (const f of result.skipped)
115
+ lines.push(` · ${f} (이미 있음 — 건너뜀)`);
116
+ lines.push('');
117
+ lines.push(' 다음 단계:');
118
+ lines.push(' 1) gaon dev 로 .gaon 타입 브리지를 생성한다 (tables·routes).');
119
+ lines.push(' 2) gaon db diff && gaon db migrate 로 users 테이블을 만든다.');
120
+ lines.push(' 3) REDIS_URL·SESSION_SECRET·COOKIE_SECRET 을 설정하고 server.ts 를 실행한다.');
121
+ lines.push(' → /registration/new 회원가입 · /session/new 로그인 · 보호 라우트는 this.requireAuth().');
122
+ lines.push('');
123
+ process.stdout.write(lines.join('\n') + '\n');
124
+ return 0;
125
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export { runDevCommand, startDev, resolveDevLayout, type DevDeps, type DevLayout, type DevApp, type DevEvent, type DevHandle, type DevCommandOptions, } from "./dev.js";
2
2
  export { runCheck, runCheckCommand, type CheckDeps, type CheckResult, type TypecheckResult, type CheckCommandOptions, } from "./check.js";
3
+ export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, type AuthScaffoldOptions, type ScaffoldFile, type ScaffoldResult, type GenerateAuthOptions, } from "./generate.js";
3
4
  export interface RoadmapReport {
4
5
  readonly name: "gaon";
5
6
  readonly version: string;
package/dist/index.js CHANGED
@@ -12,8 +12,10 @@
12
12
  import { MILESTONES, VERSION, HOMEPAGE } from "@gaonjs/core";
13
13
  import { runDevCommand } from "./dev.js";
14
14
  import { runCheckCommand } from "./check.js";
15
+ import { runGenerateAuthCommand } from "./generate.js";
15
16
  export { runDevCommand, startDev, resolveDevLayout, } from "./dev.js";
16
17
  export { runCheck, runCheckCommand, } from "./check.js";
18
+ export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, } from "./generate.js";
17
19
  /** `--json` 출력용 구조화 리포트. */
18
20
  export function roadmapReport(version = VERSION) {
19
21
  return {
@@ -60,6 +62,7 @@ function renderHelp(version = VERSION) {
60
62
  " gaon dev .gaon 타입 브리지를 감시·재생성 (스키마·라우트)",
61
63
  " gaon dev --json 재생성 이벤트를 JSON 으로 출력",
62
64
  " gaon check .gaon 재생성 후 타입 검사 (CI·AI 정합)",
65
+ " gaon g auth 인증 스캐폴드 생성 (회원가입·로그인·세션·보호 라우트)",
63
66
  " gaon --json 같은 정보를 JSON 으로 출력",
64
67
  " gaon --version 버전 출력",
65
68
  " gaon --help 이 도움말",
@@ -94,6 +97,20 @@ export function runCli(argv, opts = {}) {
94
97
  });
95
98
  return;
96
99
  }
100
+ // `gaon g auth` — 인증 스캐폴드 제너레이터(§7 M5). 다른 제너레이터는 후속.
101
+ if (argv[0] === "g" || argv[0] === "generate") {
102
+ if (argv[1] === "auth") {
103
+ const appIdx = argv.indexOf("--app");
104
+ const app = appIdx >= 0 ? argv[appIdx + 1] : undefined;
105
+ const code = runGenerateAuthCommand({ app, json: argv.includes("--json") });
106
+ process.exitCode = code;
107
+ return;
108
+ }
109
+ process.stderr.write(` ✗ 알 수 없는 제너레이터: ${argv[1] ?? "(없음)"}\n` +
110
+ ` → 현재 지원: gaon g auth [--app <이름>]\n`);
111
+ process.exitCode = 1;
112
+ return;
113
+ }
97
114
  if (argv.includes("--help") || argv.includes("-h")) {
98
115
  process.stdout.write(renderHelp(version) + "\n");
99
116
  return;
@@ -0,0 +1,16 @@
1
+ <script setup lang="ts">
2
+ import { pageProps } from 'gaonjs/vue'
3
+
4
+ // dashboard#show 의 render props — user 는 직렬화되며 passwordDigest 는 없다(§4.2).
5
+ const { user } = pageProps<'dashboard#show'>()
6
+ </script>
7
+
8
+ <template>
9
+ <main>
10
+ <h1>환영합니다, {{ user.name }}님</h1>
11
+ <p>{{ user.email }}</p>
12
+ <form method="post" action="/session?_method=DELETE">
13
+ <button type="submit">로그아웃</button>
14
+ </form>
15
+ </main>
16
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import { pageProps } from 'gaonjs/vue'
3
+
4
+ // 컨트롤러 session#new 의 render props 타입이 그대로 흐른다(§6.2).
5
+ const { error, csrf } = pageProps<'session#new'>()
6
+ </script>
7
+
8
+ <template>
9
+ <form method="post" action="/session">
10
+ <input type="hidden" name="_csrf" :value="csrf" />
11
+ <h1>로그인</h1>
12
+ <p v-if="error" class="error">{{ error }}</p>
13
+ <label>이메일 <input name="email" type="email" required /></label>
14
+ <label>비밀번호 <input name="password" type="password" required /></label>
15
+ <button type="submit">로그인</button>
16
+ <a href="/registration/new">회원가입</a>
17
+ </form>
18
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup lang="ts">
2
+ import { pageProps } from 'gaonjs/vue'
3
+
4
+ const { error, csrf } = pageProps<'registration#new'>()
5
+ </script>
6
+
7
+ <template>
8
+ <form method="post" action="/registration">
9
+ <input type="hidden" name="_csrf" :value="csrf" />
10
+ <h1>회원가입</h1>
11
+ <p v-if="error" class="error">{{ error }}</p>
12
+ <label>이름 <input name="name" required /></label>
13
+ <label>이메일 <input name="email" type="email" required /></label>
14
+ <label>비밀번호 <input name="password" type="password" required /></label>
15
+ <button type="submit">회원가입</button>
16
+ <a href="/session/new">로그인</a>
17
+ </form>
18
+ </template>
@@ -0,0 +1,29 @@
1
+ // 웹 앱 팩토리 — gaon g auth 스캐폴드. createWebApp 으로 세션·인증을 배선한다.
2
+ import { createApp, type AppSessionOptions } from 'gaonjs/web'
3
+ import appRoutes from './routes.js'
4
+ import session from './controllers/session.js'
5
+ import registration from './controllers/registration.js'
6
+ import dashboard from './controllers/dashboard.js'
7
+ import { loadUser } from './auth.js'
8
+
9
+ export interface WebAppDeps {
10
+ /** 세션 설정 — { redisUrl, secret } (또는 redis 인스턴스). */
11
+ readonly session: AppSessionOptions
12
+ /** 서명 쿠키/CSRF 용 비밀. */
13
+ readonly cookieSecret?: string
14
+ }
15
+
16
+ export function createWebApp(deps: WebAppDeps) {
17
+ return createApp({
18
+ apps: [
19
+ {
20
+ name: '{{APP_NAME}}',
21
+ routes: appRoutes,
22
+ controllers: { session, registration, dashboard },
23
+ session: deps.session,
24
+ auth: { loadUser, loginRedirect: '/session/new' },
25
+ },
26
+ ],
27
+ cookieSecret: deps.cookieSecret,
28
+ })
29
+ }
@@ -0,0 +1,16 @@
1
+ // 인증 배선 — gaon g auth 스캐폴드.
2
+ import type { AuthOptions } from 'gaonjs/web'
3
+ import { User } from '../../domain/models/user.js'
4
+
5
+ // 세션에 심긴 userId 로 사용자를 로드한다(§7). web 은 도메인을 loadUser 로 받는다.
6
+ export const loadUser: AuthOptions['loadUser'] = async (id) =>
7
+ await User.query().where('id', '=', BigInt(String(id))).first()
8
+
9
+ // this.currentUser 에 User 필드 타입을 얹는다(GaonRouteMap 과 동일 관례).
10
+ declare module 'gaonjs/web' {
11
+ interface GaonCurrentUser {
12
+ id: bigint
13
+ name: string
14
+ email: string
15
+ }
16
+ }
@@ -0,0 +1,10 @@
1
+ // 보호 라우트 예시 — gaon g auth 스캐폴드. requireAuth 로 지킨다(§7).
2
+ import { controller } from 'gaonjs/web'
3
+
4
+ export default controller({
5
+ // GET / — 로그인해야 볼 수 있는 홈. 비로그인은 로그인 페이지로 보내진다.
6
+ async show() {
7
+ const user = this.requireAuth()
8
+ return this.render('Dashboard', { user })
9
+ },
10
+ })
@@ -0,0 +1,20 @@
1
+ // 회원가입 컨트롤러 — gaon g auth 스캐폴드.
2
+ import { controller, hashPassword } from 'gaonjs/web'
3
+ import { User } from '../../../domain/models/user.js'
4
+
5
+ export default controller({
6
+ // GET /registration/new — 회원가입 폼
7
+ async new() {
8
+ return this.render('auth/Signup', { error: null as string | null, csrf: this.csrfToken() })
9
+ },
10
+ // POST /registration — 회원가입
11
+ async create() {
12
+ const { name, email, password } = this.params({
13
+ _row: {} as { name: string; email: string; password: string },
14
+ })
15
+ const passwordDigest = await hashPassword(password)
16
+ const user = await User.create({ name, email, passwordDigest })
17
+ this.auth.login(user)
18
+ return this.redirect('/')
19
+ },
20
+ })
@@ -0,0 +1,7 @@
1
+ import { routes } from 'gaonjs/web'
2
+
3
+ export default routes((r) => {
4
+ r.get('/', 'dashboard#show') // 보호 홈 (gaon g auth)
5
+ r.resource('session') // 로그인/로그아웃 (gaon g auth)
6
+ r.resource('registration') // 회원가입 (gaon g auth)
7
+ })
@@ -0,0 +1,14 @@
1
+ // 서버 진입점 — gaon g auth 스캐폴드. `node dist/server.js` 로 실행.
2
+ import { createWebApp } from './apps/{{APP_NAME}}/app.js'
3
+
4
+ const app = await createWebApp({
5
+ session: {
6
+ redisUrl: process.env.REDIS_URL ?? 'redis://127.0.0.1:6379',
7
+ secret: process.env.SESSION_SECRET ?? 'change-me-to-a-32+char-random-secret!!',
8
+ },
9
+ cookieSecret: process.env.COOKIE_SECRET,
10
+ })
11
+
12
+ const port = Number(process.env.PORT ?? 3000)
13
+ await app.listen({ port, host: '0.0.0.0' })
14
+ console.log(`web 앱이 http://localhost:${port} 에서 실행 중입니다.`)
@@ -0,0 +1,29 @@
1
+ // 세션 컨트롤러(로그인/로그아웃) — gaon g auth 스캐폴드.
2
+ import { controller, verifyPassword } from 'gaonjs/web'
3
+ import { User } from '../../../domain/models/user.js'
4
+
5
+ export default controller({
6
+ // GET /session/new — 로그인 폼
7
+ async new() {
8
+ return this.render('auth/Login', { error: null as string | null, csrf: this.csrfToken() })
9
+ },
10
+ // POST /session — 로그인
11
+ async create() {
12
+ const { email, password } = this.params({ _row: {} as { email: string; password: string } })
13
+ const user = await User.query().where('email', '=', email).first()
14
+ // passwordDigest 는 hidden 이지만 서버 코드에서는 투명하게 읽힌다(§4.2).
15
+ if (user && (await verifyPassword(password, user.passwordDigest))) {
16
+ this.auth.login(user)
17
+ return this.redirect('/')
18
+ }
19
+ return this.render('auth/Login', {
20
+ error: '이메일 또는 비밀번호가 올바르지 않습니다.' as string | null,
21
+ csrf: this.csrfToken(),
22
+ })
23
+ },
24
+ // DELETE /session — 로그아웃
25
+ async destroy() {
26
+ this.auth.logout()
27
+ return this.redirect('/session/new')
28
+ },
29
+ })
@@ -0,0 +1,5 @@
1
+ // 사용자 모델 — gaon g auth 스캐폴드.
2
+ import { model } from 'gaonjs/data'
3
+ import { users } from '../schema/user.js'
4
+
5
+ export const User = model(users, {})
@@ -0,0 +1,11 @@
1
+ // 사용자 스키마 — gaon g auth 스캐폴드.
2
+ // 등록 보일러플레이트는 없다 — .gaon/tables.d.ts 가 자동 생성한다(§6.3).
3
+ import { table, t } from 'gaonjs/data'
4
+
5
+ export const users = table('users', {
6
+ id: t.id(),
7
+ name: t.string().max(100),
8
+ email: t.string().max(255),
9
+ passwordDigest: t.string().hidden(), // hidden — 페이지로 새지 않는다(§4.2)
10
+ ...t.timestamps(),
11
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -24,11 +24,11 @@
24
24
  "README.md"
25
25
  ],
26
26
  "dependencies": {
27
- "@gaonjs/core": "0.1.2",
28
- "@gaonjs/data": "0.2.0",
29
- "@gaonjs/web": "0.1.3"
27
+ "@gaonjs/core": "0.1.3",
28
+ "@gaonjs/data": "0.2.2",
29
+ "@gaonjs/web": "0.2.0"
30
30
  },
31
31
  "scripts": {
32
- "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json"
32
+ "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
33
33
  }
34
34
  }