@gaonjs/cli 0.34.0 → 0.35.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.
@@ -20,6 +20,19 @@ export interface ScaffoldResult {
20
20
  /** 손 수리가 필요한 지점 — 수리 안내 문장(§7.5.3)을 그대로 담는다. */
21
21
  readonly warnings: string[];
22
22
  }
23
+ /**
24
+ * 결정 155·142: `g auth --app <앱>` 이 앱별 세션 secret 환경변수를 .env·.env.example
25
+ * 에도 시드한다. web 은 SESSION_SECRET 이 프로젝트 스캐폴드(.env.example.tpl)에 이미
26
+ * 있어 no-op 이지만, 비-web 앱(admin 등)은 이 키가 어디에도 없어 app.config 의
27
+ * `process.env.ADMIN_SESSION_SECRET ?? '공개 고정 dev secret'` 이 조용히 폴백으로
28
+ * 돌아 세션 위조 표면이 된다. env 파일에 키를 심어 사용자가 설정해야 함을 드러낸다
29
+ * (앱별 세션 완전 분리를 env 층까지 확장).
30
+ *
31
+ * 이미 그 키가 있으면 건드리지 않는다(멱등). 파일이 없으면 만들지 않는다 — 스캐폴드가
32
+ * .env 를 새로 만드는 관례가 없어(사용자가 cp .env.example .env), 존재하는 파일만 패치한다.
33
+ * 반환: 실제로 키를 추가한 상대 경로들.
34
+ */
35
+ export declare function patchEnvFiles(root: string, app: string): string[];
23
36
  /** 인증 스캐폴드가 생성하는 파일 목록(라우트 제외). 템플릿을 읽어 렌더링한다.
24
37
  * 결정 155: 공개 가입 여부(includePublic)로 registration·Signup·dashboard 변형을 가른다. */
25
38
  export declare function authScaffoldFiles(opts?: AuthScaffoldOptions): ScaffoldFile[];
package/dist/generate.js CHANGED
@@ -47,6 +47,41 @@ function sessionSecretEnvFor(app) {
47
47
  function devSessionSecretFor(app) {
48
48
  return `dev-only-session-secret-${app}-change-me-now!!`;
49
49
  }
50
+ /** .env(.example)에 시드할 앱별 세션 secret 플레이스홀더 값(32자 이상 · 운영은 교체). */
51
+ function envSecretPlaceholderFor(app) {
52
+ return `change-me-to-a-32-char-${app}-session-secret!!`;
53
+ }
54
+ /**
55
+ * 결정 155·142: `g auth --app <앱>` 이 앱별 세션 secret 환경변수를 .env·.env.example
56
+ * 에도 시드한다. web 은 SESSION_SECRET 이 프로젝트 스캐폴드(.env.example.tpl)에 이미
57
+ * 있어 no-op 이지만, 비-web 앱(admin 등)은 이 키가 어디에도 없어 app.config 의
58
+ * `process.env.ADMIN_SESSION_SECRET ?? '공개 고정 dev secret'` 이 조용히 폴백으로
59
+ * 돌아 세션 위조 표면이 된다. env 파일에 키를 심어 사용자가 설정해야 함을 드러낸다
60
+ * (앱별 세션 완전 분리를 env 층까지 확장).
61
+ *
62
+ * 이미 그 키가 있으면 건드리지 않는다(멱등). 파일이 없으면 만들지 않는다 — 스캐폴드가
63
+ * .env 를 새로 만드는 관례가 없어(사용자가 cp .env.example .env), 존재하는 파일만 패치한다.
64
+ * 반환: 실제로 키를 추가한 상대 경로들.
65
+ */
66
+ export function patchEnvFiles(root, app) {
67
+ const secretEnv = sessionSecretEnvFor(app);
68
+ const patched = [];
69
+ for (const rel of ['.env', '.env.example']) {
70
+ const abs = join(root, rel);
71
+ if (!existsSync(abs))
72
+ continue;
73
+ const existing = readFileSync(abs, 'utf8');
74
+ // 라인 시작에서 `KEY=` 를 찾는다(주석·부분 일치 회피).
75
+ if (new RegExp(`^${secretEnv}=`, 'm').test(existing))
76
+ continue;
77
+ const block = `\n# ${app} 앱 세션 secret (32자 이상 · 앱별 세션 완전 분리 · 운영은 반드시 교체).\n` +
78
+ `${secretEnv}=${envSecretPlaceholderFor(app)}\n`;
79
+ const sep = existing.endsWith('\n') ? '' : '\n';
80
+ writeFileSync(abs, existing + sep + block, 'utf8');
81
+ patched.push(rel);
82
+ }
83
+ return patched;
84
+ }
50
85
  /** 결정 155: Login 페이지의 회원가입 링크 — 공개 가입 앱에만 넣는다(시큐어 앱은 뺀다).
51
86
  * 템플릿의 `{{SIGNUP_LINK}}` 자리(8칸 들여쓰기)에 in-place 치환되므로 첫 줄은 들여쓰기 없이. */
52
87
  function signupLinkMarkup(app) {
@@ -228,6 +263,9 @@ export function writeAuthScaffold(cwd, opts = {}) {
228
263
  writeFileSync(routesPath, renderTemplate('routes.ts.tpl', app, includePublic), 'utf8');
229
264
  created.push(routesRel);
230
265
  }
266
+ // 결정 155·142(W3): 앱별 세션 secret 을 .env·.env.example 에 시드한다. web 은 이미
267
+ // 있어 멱등, 비-web 앱은 여기서 키가 심어져 "공개 고정 dev secret 폴백" 표면을 막는다.
268
+ patched.push(...patchEnvFiles(root, app));
231
269
  // 결정 155: 시큐어 앱(공개 가입 미생성)은 역할 게이트 대시보드를 깔았다 — role 컬럼을
232
270
  // 두라고 안내한다(§7.5.3 · authorize 예시가 실제 역할 규칙이 되도록).
233
271
  if (!includePublic) {
@@ -248,6 +286,7 @@ export function runGenerateAuthCommand(opts = {}) {
248
286
  process.stdout.write(JSON.stringify({ command: 'g auth', app, public: opts.public ?? app === 'web', ...result }, null, 2) + '\n');
249
287
  return 0;
250
288
  }
289
+ const includePublic = includesPublicRegistration({ app, public: opts.public });
251
290
  const lines = [''];
252
291
  lines.push(` gaon g auth — 인증 스캐폴드 (${app} 앱)`);
253
292
  lines.push('');
@@ -266,7 +305,17 @@ export function runGenerateAuthCommand(opts = {}) {
266
305
  lines.push(` 1) .env 에 REDIS_URL·${secretEnv}(32자 이상)·COOKIE_SECRET 을 설정한다.`);
267
306
  lines.push(' 2) gaon db diff && gaon db migrate 로 users 테이블을 만든다.');
268
307
  lines.push(' 3) gaon dev 로 실행한다 (.gaon 타입 브리지 생성 + 세션·인증 자동 배선).');
269
- lines.push(` → ${prefix}/registration/new 회원가입 · ${prefix}/session/new 로그인 · ${prefix}/dashboard 보호 페이지 (this.requireAuth()).`);
308
+ // 결정 155(W4): 안내문을 실제 산출물과 정합시킨다. 시큐어 스캐폴드(비-web · 비-public)
309
+ // 공개 가입을 안 깔았으므로 없는 /registration/new 경로를 광고하지 않는다 — 대신 역할
310
+ // 부여 경로(seed/DB)와 필요 시 --public 을 안내한다(생성물 ≠ 안내문 자기모순 제거).
311
+ if (includePublic) {
312
+ lines.push(` → ${prefix}/registration/new 회원가입 · ${prefix}/session/new 로그인 · ${prefix}/dashboard 보호 페이지 (this.requireAuth()).`);
313
+ }
314
+ else {
315
+ lines.push(` → ${prefix}/session/new 로그인 · ${prefix}/dashboard 보호 페이지 (this.requireAuth()).`);
316
+ lines.push(` → 공개 회원가입 없음(시큐어 스캐폴드). 관리자 계정은 seed/DB 로 만들거나 승격하세요.`);
317
+ lines.push(` 공개 가입이 필요하면: gaon g auth --app ${app} --public`);
318
+ }
270
319
  lines.push('');
271
320
  process.stdout.write(lines.join('\n') + '\n');
272
321
  return 0;
@@ -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,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} 에서 실행 중입니다.`)
@@ -664,6 +664,29 @@ diff/migrate/status/seed 는 `--db` 를 생략하면 **등록된 전 커넥션
664
664
  미리 보여주고, migrate 는 건너뛴 테이블을 크게 알린다(조용한 무시 방지).
665
665
  - 마이그레이션은 커넥션별로 돈다(§7 · `--db <키>`).
666
666
 
667
+ ### 10.1 시드 — `domain/seed.ts` · `seed()` (§7)
668
+
669
+ 개발·데모용 초기 데이터를 `domain/seed.ts` 한 파일에 선언하고 `gaon db seed` 로
670
+ 실행한다. 파일이 존재하면 등록이고, **default export** 가 시드 정의여야 한다.
671
+
672
+ - **시그니처**: `seed(fn: () => Promise<void> | void): SeedDef` — `gaonjs/data`
673
+ 에서 import 한다. 본문(`fn`)은 **모델을 그대로** 쓴다 — 모델이 커넥션을 자동
674
+ 바인딩하므로(§4.5·§7) 시드는 커넥션을 몰라도 된다.
675
+ - **멱등하게 짠다** — 시드는 재적재에 자주 쓰이므로 여러 번 돌려도 안전해야
676
+ 한다(예: `upsert`/존재 확인 후 생성).
677
+ - `gaon db seed` 는 모델 레이어를 거치므로 **프로젝트 로컬로 실행**하는 것이
678
+ 정본이다(`npx gaon db seed` · 결정 156 · 위 dual package hazard 노트).
679
+
680
+ ```ts
681
+ // domain/seed.ts
682
+ import { seed } from 'gaonjs/data'
683
+ import { User } from './models/User'
684
+
685
+ export default seed(async () => {
686
+ await User.create({ email: 'admin@example.com', name: 'Admin' })
687
+ })
688
+ ```
689
+
667
690
  ## 정본 예시
668
691
 
669
692
  ```ts
@@ -58,15 +58,32 @@
58
58
  · 결정 145)를 예시로 깔고, `domain/schema/users.ts` 에 `role` 컬럼을 두라고 안내한다.
59
59
  관리자는 직접 만들거나 승격한다(공개 가입 라우트 없음). web 앱은 현행대로 공개 가입 O.
60
60
  공개 비-web 앱이 필요하면 `--public` 로 공개 가입을 opt-in 한다.
61
- - CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF
62
- 강제. `api()` 클라이언트는 `<meta name="csrf-token">` 자동으로
63
- 읽어 `X-CSRF-Token` 헤더에 붙인다 (`packages/vue/src/api.ts:184`).
61
+ - CSRF: 세션 앱은 상태 변경 메서드(POST/PUT/PATCH/DELETE)에 CSRF 강제.
62
+ **토큰은 `<meta>` 태그가 아니라 data-page 공유 prop 으로 온다 (결정 116).**
63
+ 프레임웍 Inertia 셸은 `<meta name="csrf-token">` **넣지 않는다** — csrf 는
64
+ 모든 렌더에 자동 주입되는 공유 prop 이라 페이지에서 `useShared().csrf` 로 읽는다
65
+ (`packages/vue/src/shared.ts`). 폼은 그 값을 실어 보낸다 — `useForm({ ..., _csrf:
66
+ shared.csrf })` 또는 HTML 폼이 못 보내는 메서드는 헤더로: `router.delete(url, {
67
+ headers: { 'x-csrf-token': shared.csrf } })` (스캐폴드 Login/Signup/Dashboard 가
68
+ 이 관례를 그대로 깐다).
69
+ - `api()` 클라이언트도 **같은 data-page csrf 를 자동으로 붙인다 (결정 166).** 세션
70
+ 앱에서 상태 변경 JSON 액션(`api('web:posts#tagAdd', ...)` 등)을 불러도 손수 토큰을
71
+ 넘길 필요가 없다 — `api()` 가 data-page 의 `props.csrf`(`useShared().csrf` 와 같은
72
+ 단일 출처)를 읽어 `X-CSRF-Token` 에 실어 준다(`packages/vue/src/api.ts`). data-page
73
+ 가 없거나(비-Inertia) 봉인(seal)이면 레거시 `<meta name="csrf-token">` 로 폴백한다.
74
+ JWT/API 앱은 토큰 인증이라 CSRF 대상이 아니다.
64
75
  - **CSRF 는 세션 위에 얹힌다 — 세션이 없으면 CSRF 도 없다 (결정 93).**
65
76
  세션이 있어야 토큰을 저장·검증할 곳이 생긴다. `gaon new` 기본 web 앱은
66
77
  `app.config.ts` 에 세션을 **기본 배선**해 규칙 8(기본 켬)이 실태가 되게
67
78
  한다 — 폼(POST)을 추가하는 순간 CSRF 가 이미 켜져 있다. 앱에 비-GET
68
79
  라우트가 있는데 `app.config.ts` 에 session 이 없으면 `gaon doctor` 의
69
80
  `csrf-wiring` 이 경고한다(JWT/API 앱은 토큰 인증이라 CSRF 대상 제외).
81
+ - **CSRF/세션 실패는 코어가 Inertia-네이티브로 마감한다 (결정 165).** 세션 만료·
82
+ secret 로테이션·장시간 탭으로 CSRF 가 실패하면, Inertia 요청은 raw JSON 403 이 아니라
83
+ **409 + `X-Inertia-Location` 풀 리로드**(새 세션 쿠키+새 토큰) + `flash.error` 안내로
84
+ 마감된다 — 앱이 안 깨지고 사용자는 재시도로 성공한다. 415(지원 안 되는 Content-Type)도
85
+ 같은 핸들러가 Inertia-네이티브 에러+수리 안내로 마감한다. 비-Inertia(API/JWT)는 종전
86
+ JSON 유지(회귀 없음). 상세는 `agents/web.md` §4.1. 앱은 아무것도 안 한다.
70
87
  - JWT 는 API 앱 전용 옵션. 세션 쿠키가 기본 (v0.15 §7 · v0.11 확정).
71
88
  - **인증·인가는 3층이다** (결정 145 · 149):
72
89
  - **① 인증 `this.requireAuth()`** = **로그인 여부** — 비로그인이면 401(세션 앱은
@@ -87,6 +87,43 @@ describe('SendWelcomeMail (실 NATS JetStream)', () => {
87
87
  - `configureJobs` 는 헬퍼가 대신 해 준다 — 테스트가 부팅 코드를 흉내낼
88
88
  필요가 없다.
89
89
 
90
+ ### 4.1 이벤트/리스너 헬퍼 — `expectEventProcessed`
91
+
92
+ 이벤트 "emit → 리스너 실 처리" 는 잡의 대응 헬퍼 `expectEventProcessed`
93
+ 한 호출로 확증한다 — 임시로 등록 리스너를 실 스트림에 붙여 이 이벤트가
94
+ **리스너 핸들러까지 실행**되는 것을 기다리고 정리한다(`expectJobProcessed`
95
+ 대칭). ⚠️ 검증 대상 리스너(`domain/listeners/*.ts`)가 import 되어 레지스트리에
96
+ 등록돼 있어야 한다(파일=등록).
97
+
98
+ ```ts
99
+ // test/integration/postPublished.integration.test.ts
100
+ import { describe, it } from 'vitest'
101
+ import { connectNats, expectEventProcessed } from 'gaonjs/testing'
102
+ import { PostPublished } from '../../domain/events/postPublished.js'
103
+ import '../../domain/listeners/notifyFollowers.js' // 파일=등록 — import 로 리스너 등록
104
+
105
+ describe('PostPublished (실 NATS JetStream)', () => {
106
+ it('emit 하면 리스너가 처리한다', async () => {
107
+ const nats = await connectNats()
108
+ try {
109
+ await expectEventProcessed(PostPublished, () => PostPublished.emit({ postId: 1n }), { nats })
110
+ } finally {
111
+ await nats.close()
112
+ }
113
+ })
114
+ })
115
+ ```
116
+
117
+ - **시그니처** — `expectEventProcessed(event, trigger, { nats, timeoutMs?, listenerId?, maxDeliver? })`.
118
+ `trigger` 는 emit 을 일으키는 임의 함수 — 이벤트 직접 `emit` 도, 컨트롤러·서비스
119
+ 경유도 된다. 여러 리스너가 붙었을 때 `listenerId`(= `domain/listeners/` 파일명 ·
120
+ `on(..., { id })`)로 특정 리스너만 좁힌다.
121
+ - 리스너가 재전달을 소진하고 드롭되거나 `timeoutMs`(기본 10초) 안에 처리되지
122
+ 않으면 수리 안내(§7.5.3)와 함께 실패한다. 실패 경로를 백오프 계단 없이 빨리
123
+ 확증하려면 `maxDeliver: 1`(첫 실패에서 즉시 드롭).
124
+ - `configureEvents` 는 헬퍼가 대신 해 주고, 끝나면 하네스 배선을 복원한다
125
+ (`expectJobProcessed` 와 동형 · 자기 nats 를 `close()` 해도 다음 테스트 무영향).
126
+
90
127
  ### 5. DB 테스트 격리 — `gaon test` + `test/setup.ts` (결정 111)
91
128
 
92
129
  DB 테스트는 손으로 커넥션을 배선하지 않는다 — `gaon test` 와 스캐폴드
@@ -39,6 +39,12 @@ export default controller({
39
39
  `../../../domain/models/<Pascal>.js` 로 참조한다. `@gaonjs/*` (스코프
40
40
  이름) 은 내부 패키지 이름 — 앱 코드에서 직접 import 하지 않는다.
41
41
  `@inertiajs/vue3` 는 어댑터 내부 의존 — 앱에서 직접 안 쓴다.
42
+ - **직렬화는 자동이다** — JSON 액션 반환값·`this.json(...)`·`this.render` props 는
43
+ 응답 경계에서 `serializeProps` 를 통과한다: `bigint`/`Date` → string, **hidden
44
+ 컬럼 제외**(§4.2 · 결정 122), 함수 값 제거. 그래서 액션은 모델 Row 를 그대로
45
+ 반환해도 안전하다(손 직렬화 불필요). 드물게 응답 경계 **밖**(예: 채널 broadcast
46
+ 페이로드, 커스텀 문자열 응답)에서 같은 규칙으로 직렬화하고 싶으면 직접 부른다:
47
+ `import { serializeProps } from 'gaonjs/web'`.
42
48
  - **`this.render` 인자** = `<PageFolder>/<Page>` (PascalCase 폴더 · Vue
43
49
  파일명과 정합) — 예: `'Posts/Index'` → `apps/<app>/pages/Posts/Index.vue`.
44
50
  - **리소스 부재 = `this.notFound()`** — 조회 결과가 없으면 404 를 손으로 만들지
@@ -233,6 +239,22 @@ async create() {
233
239
 
234
240
  순수 JSON/API 앱(X-Inertia 아님·세션 없음)은 기존대로 **422 JSON** 을 받는다.
235
241
 
242
+ #### 세션/CSRF 실패·415 도 코어가 Inertia-네이티브로 마감한다 (결정 165)
243
+
244
+ 폼 검증(결정 109)과 **대칭**으로, 컨트롤러 액션 밖(디스패처 try-catch 밖)에서 나는
245
+ 인프라 실패도 앱이 손댈 필요 없이 코어가 마감한다 — 세션 만료·secret 로테이션·장시간
246
+ 탭 등 운영 routine 에서 나던 raw JSON 403(앱이 깨지던 지점)을 없앤다:
247
+
248
+ - **세션/CSRF 실패**(세션 만료·CSRF 토큰 불일치) → **409 + `X-Inertia-Location` 풀
249
+ 리로드**. Inertia 클라가 새 문서(=새 세션 쿠키 + 새 CSRF 토큰)를 받고, `flash.error`
250
+ 로 "세션이 만료돼 다시 시도해 주세요." 안내가 뜬다(레이아웃이 `useShared().flash` 를
251
+ 표시하면 자동 · 스캐폴드 기본 레이아웃에 배선됨). 사용자는 재시도로 성공한다.
252
+ - **415(지원 안 되는 Content-Type)** → **Inertia-네이티브 에러 + 수리 안내**. 지원 타입은
253
+ **`application/json` · `multipart/form-data`** 뿐이다 — 폼은 `useForm().post(...)`·
254
+ `router.<메서드>(...)`(gaonjs/vue)로 보내면 Content-Type 이 자동으로 맞는다. 수동
255
+ `fetch` 로 `application/x-www-form-urlencoded` 를 보내면 415 가 난다.
256
+ - **비-Inertia(API/JWT) 요청은 종전 JSON** 마감을 유지한다(회귀 없음).
257
+
236
258
  ### 4.2 공유 prop 자동 주입 — currentUser·csrf·flash (결정 116·117)
237
259
 
238
260
  디스패처가 **모든** Inertia 렌더에 공유 prop 3종을 자동 주입한다 — 컨트롤러가
@@ -12,10 +12,17 @@
12
12
  //
13
13
  // 결정 96: 앱 내부 이동은 `Link`(선언적) — `<a href="/">` 는 전체 문서
14
14
  // 리로드라 SPA 가 깨진다. 외부 URL 만 `<a>`(문서·GitHub).
15
- import { Link } from 'gaonjs/vue'
15
+ import { computed } from 'vue'
16
+ import { Link, useShared } from 'gaonjs/vue'
16
17
 
17
18
  // package.json 의 gaonjs 의존 범위(예: ^0.9.2)에서 캐럿·틸드를 벗겨 표기.
18
19
  const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
20
+
21
+ // 공유 prop flash(결정 116) 를 앱 전역에서 한 번 표시한다 — 세션 만료 안내(결정 165 ·
22
+ // 코어가 심는 flash.error)나 this.flash('success', ...) 가 어느 페이지에서든 뜬다.
23
+ const shared = useShared()
24
+ const flashError = computed(() => shared.flash.error as string | undefined)
25
+ const flashSuccess = computed(() => shared.flash.success as string | undefined)
19
26
  </script>
20
27
 
21
28
  <template>
@@ -37,6 +44,18 @@ const version = '{{GAONJS_VERSION}}'.replace(/^[\^~]/, '')
37
44
  </header>
38
45
 
39
46
  <main class="w-full flex-1">
47
+ <div v-if="flashError || flashSuccess" class="px-6 pt-4">
48
+ <div
49
+ v-if="flashError"
50
+ role="alert"
51
+ class="mx-auto max-w-3xl rounded-md border border-destructive/40 bg-destructive/10 px-4 py-2.5 text-sm text-destructive"
52
+ >{{ flashError }}</div>
53
+ <div
54
+ v-else-if="flashSuccess"
55
+ role="status"
56
+ class="mx-auto max-w-3xl rounded-md border border-primary/30 bg-primary/10 px-4 py-2.5 text-sm text-foreground"
57
+ >{{ flashSuccess }}</div>
58
+ </div>
40
59
  <slot />
41
60
  </main>
42
61
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,13 +27,13 @@
27
27
  "@modelcontextprotocol/sdk": "^1.29.0",
28
28
  "typescript": "^5.9.0",
29
29
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.10.0",
31
- "@gaonjs/core": "0.2.1",
32
- "@gaonjs/config": "0.14.0",
33
- "@gaonjs/i18n": "0.2.0",
30
+ "@gaonjs/config": "0.14.1",
34
31
  "@gaonjs/data": "0.16.0",
32
+ "@gaonjs/async": "0.11.0",
33
+ "@gaonjs/i18n": "0.2.0",
35
34
  "@gaonjs/mail": "0.2.0",
36
- "@gaonjs/web": "0.16.0"
35
+ "@gaonjs/core": "0.2.1",
36
+ "@gaonjs/web": "0.17.0"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""