@gaonjs/cli 0.42.3 → 0.47.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 (42) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/db.js +29 -7
  3. package/dist/commands/new.d.ts +2 -0
  4. package/dist/commands/new.js +8 -3
  5. package/dist/db/reset.js +20 -10
  6. package/dist/db/resolve.d.ts +15 -0
  7. package/dist/db/resolve.js +29 -0
  8. package/dist/doctor/pageprops-destructure.d.ts +2 -2
  9. package/dist/doctor/pageprops-destructure.js +29 -23
  10. package/dist/doctor/schema-relations.js +6 -1
  11. package/dist/doctor.js +1 -1
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.js +18 -2
  14. package/dist/mcp/tools.js +15 -1
  15. package/dist/scaffold/job.js +3 -1
  16. package/dist/templates/project/.dockerignore.tpl +3 -0
  17. package/dist/templates/project/.env.example.tpl +1 -1
  18. package/dist/templates/project/AGENTS.md.tpl +3 -2
  19. package/dist/templates/project/CLAUDE.md.tpl +4 -3
  20. package/dist/templates/project/Dockerfile.tpl +6 -1
  21. package/dist/templates/project/agents/async.md.tpl +44 -6
  22. package/dist/templates/project/agents/data.md.tpl +206 -15
  23. package/dist/templates/project/agents/frontend.md.tpl +36 -8
  24. package/dist/templates/project/agents/mail.md.tpl +2 -1
  25. package/dist/templates/project/agents/realtime.md.tpl +26 -2
  26. package/dist/templates/project/agents/seal.md.tpl +9 -1
  27. package/dist/templates/project/agents/security.md.tpl +18 -0
  28. package/dist/templates/project/agents/storage.md.tpl +15 -8
  29. package/dist/templates/project/agents/web.md.tpl +38 -2
  30. package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +4 -3
  31. package/dist/templates/project/apps/web/controllers/home.ts.tpl +1 -1
  32. package/dist/templates/project/apps/web/main.ts.tpl +1 -1
  33. package/dist/templates/project/apps/web/routes.ts.tpl +1 -1
  34. package/dist/templates/project/docker-compose.yaml.tpl +1 -1
  35. package/dist/templates/project/pnpm-workspace.yaml.tpl +1 -1
  36. package/dist/templates/project/vite.config.ts.tpl +1 -1
  37. package/dist/work.d.ts +21 -0
  38. package/dist/work.js +45 -1
  39. package/package.json +11 -6
  40. package/dist/doctor/shared-composable-purity.d.ts +0 -8
  41. package/dist/doctor/shared-composable-purity.js +0 -164
  42. package/dist/templates/index.ts +0 -109
@@ -23,8 +23,11 @@
23
23
  | 디스크 선택 | `Storage.disk('s3').put(...)` | 기본 디스크 외 다른 디스크로 |
24
24
 
25
25
  - **URL 은 `Storage.url()` 한 곳**이지만 **드라이버로 갈린다**:
26
- - **로컬 디스크**: 항상 `${baseUrl}/${key}` **공개 경로**를 만든다(서명 없음 · `expiresIn` 무시).
27
- `baseUrl` 기본값은 `/storage`(웹이 경로를 정적 서빙·라우트로 노출) presigned 개념이 없다.
26
+ - **로컬 디스크**: 항상 `${publicUrl}/${key}` **공개 경로**를 만든다(서명 없음 · `expiresIn` 무시 ·
27
+ `publicUrl` 생략 `/storage`) presigned 개념이 없다. **주의: 프레임웍이 경로를 자동 서빙하지
28
+ 않는다** — 브라우저에 보여 주려면 앱이 그 경로를 직접 노출해야 한다(예: 컨트롤러 라우트에서
29
+ `Storage.get(key)` 로 읽어 응답). 자동 서빙 없이 `url()` 만 렌더하면 조용한 404 다. 개발·표시가
30
+ 목적이면 **s3 디스크(dev = compose MinIO · zero-config)가 정본 경로**다.
28
31
  - **s3 디스크**: `publicUrl`(공개 버킷·CDN·R2 public)이 있으면 `${publicUrl}/${key}`,
29
32
  없으면 만료 있는 **presigned URL**(`getSignedUrl` · `expiresIn` 초 · 기본 3600)을 만든다.
30
33
  존재하지 않는 `Attachment.urlFor`·`Storage.signedUrl` 같은 헬퍼를 만들지 말 것 —
@@ -57,9 +60,12 @@ storage: process.env.STORAGE_ENDPOINT
57
60
  ```
58
61
 
59
62
  - dev 는 compose 의 `createbuckets` 가 버킷을 만들어 **첫 업로드부터 동작**한다
60
- (결정 132 · zero-config). `cp .env.example .env && gaon dev` 우회 0.
61
- - 로컬 디스크: `{ driver: 'local', root: 'storage', baseUrl?: '/storage' }` — `url(key)`
62
- = `baseUrl + '/' + key`(공개 경로 · `baseUrl` 생략 시 `/storage`).
63
+ (결정 132 · zero-config). `.env` `gaon new` 자동 생성하므로(결정 198)
64
+ `gaon dev` 만으로 우회 0.
65
+ - 로컬 디스크: `{ driver: 'local', root: 'storage', publicUrl?: '/storage' }` `url(key)`
66
+ = `publicUrl + '/' + key`(공개 경로 · `publicUrl` 생략 시 `/storage` · 서빙은 앱 몫 — §2 주의).
67
+ config 필드명은 `publicUrl` 이다(저수준 `localDisk()` 의 `baseUrl` 과 다름 — `baseUrl` 을 config 에
68
+ 쓰면 컴파일 에러).
63
69
  - 운영(R2/S3)은 인프라에서 버킷을 사전 생성한다(앱 밖 관심사) — endpoint·creds
64
70
  만 env 로 바꾼다.
65
71
 
@@ -90,9 +96,10 @@ export default controller({
90
96
  ### 4. 스토리지 오리진 CSP 자동 배선 (결정 131)
91
97
 
92
98
  `storage` 에 s3 디스크가 있으면 프레임웍이 그 오리진을 **CSP 에 자동 배선**한다 —
93
- `endpoint` 는 `connect-src`(브라우저 직접 presigned PUT/GET)와 `img-src` 에,
94
- `publicUrl` `img-src` 에 붙는다. 이미지 표시·직접 업로드가 CSP 로 막히지
95
- 않으므로 **CSP 손으로 넓히지 것**. 오리진은 scheme+host+port 잡는다.
99
+ `endpoint` 는 `connect-src`(브라우저의 presigned **GET** 조회 — presign 표면은 GET 전용이고
100
+ 브라우저 직접 PUT 업로드 API 는 없다 · 업로드는 멀티파트 `this.file` 경로가 정본)와 `img-src` 에,
101
+ `publicUrl` `img-src` 붙는다. 이미지 표시가 CSP 막히지 않으므로
102
+ **CSP 를 손으로 넓히지 말 것**. 오리진은 scheme+host+port 만 잡는다.
96
103
 
97
104
  ### 5. 스토리지를 쓰는 잡·테스트
98
105
 
@@ -103,6 +103,11 @@ export default controller({
103
103
  - `?tag=a&tag=b` 처럼 같은 출처에서 키가 중복되면: 스키마의 해당
104
104
  필드가 배열 타입이면 배열로 수집, 아니면 **마지막 값**을 쓴다
105
105
  (E-3 §5.2 원문).
106
+ - **애드혹 폼(`{ _row }`)은 last-wins 가 적용되지 않는다(결정 294)** —
107
+ 스키마(defs)가 없어 배열/스칼라 의도를 구분할 수 없으므로 중복 쿼리 키는
108
+ **배열 그대로** 통과한다(`?q=a&q=b` → `['a','b']`). 애드혹 폼 타입을
109
+ `string` 으로만 선언하면 배열이 들어와 런타임이 어긋난다 — 배열 가능성이
110
+ 있는 키는 `string | string[]` 로 선언하거나 스키마 파생 폼(①)으로 간다.
106
111
 
107
112
  **출처 명시 탈출구 — `this.body()` / `this.query()`:**
108
113
 
@@ -365,8 +370,10 @@ shared.locale // 로그인/로그아웃·플래시로
365
370
  // ❌ 컨트롤러가 검색 교집합·태그 필터를 인라인 조립
366
371
  // ✅ const rows = await Post.searchPublished(term).latest().offset(o).limit(n).all()
367
372
  // ✅ 페이지네이션은 스코프 체인 종단 paginate — 컨트롤러는 여전히 한 줄(결정 119)
368
- // const page = await Post.searchPublished(term).latest().paginate(this.query('page') ?? 1, 20)
369
- // return this.render('Posts/Index', { page }) // page 통째로 안전(rows Serialized · 나머지 number)
373
+ // (결정 294: this.query('page') 같은 단일 키 접근 표면은 없다 — 애드혹 폼으로 받는다)
374
+ // const { page } = this.query({ _row: {} as { page?: string } })
375
+ // const result = await Post.searchPublished(term).latest().paginate(Number(page ?? 1), 20)
376
+ // return this.render('Posts/Index', { page: result }) // 통째로 안전(rows Serialized · 나머지 number)
370
377
  ```
371
378
 
372
379
  ### 4.4 클라이언트 IP · 헤더는 `this.request` (FastifyRequest 탈출구 · 결정 120)
@@ -422,6 +429,12 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
422
429
  서명 secret · Redis 키 prefix 가 앱 단위로 갇힌다. (쿠키 path 는 `/` 고정 —
423
430
  프리픽스·서브도메인 양쪽 접근에 쿠키가 실리려면 정적 path 가 `/` 여야 한다 ·
424
431
  결정 142. 분리는 위 세 축으로 완성된다.)
432
+ - **쿠키 secure·sameSite 는 app.config 의 session 에서 조정한다(결정 295).**
433
+ `secure` 생략 시 운영(NODE_ENV=production)이면 켬 — 운영인데 비-TLS(사내
434
+ 내부망 http)로 서빙하면 Secure 쿠키가 안 실려 로그인이 조용히 실패하므로 그
435
+ 경우에만 `secure: false` 를 명시한다. `sameSite` 는 기본 `'lax'` —
436
+ `'none'` 은 스펙상 Secure 필수라 `secure: true` 없이 쓰면 부팅 에러다
437
+ (브라우저의 조용한 쿠키 거부를 fail-loud 로 전환).
425
438
  - 스캐폴드는 `gaon g auth` — 로그인/회원가입 컨트롤러·페이지·라우트 일습.
426
439
  - **로그인 필요 액션의 정답 = `this.requireAuth()`** (결정 57). notFound 처럼
427
440
  예외로 마감하지만, **`if` 가드 자체가 없다**는 게 핵심:
@@ -447,6 +460,11 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
447
460
  | `await this.auth.login(user)` | 세션 ID 를 **재생성**하고 사용자 id 를 심어 로그인 상태로 만든다(fixation 방어 · 결정 254) |
448
461
  | `await this.auth.logout()` | 세션을 **파기**한다(잔존 세션 재사용 차단 · 결정 254) |
449
462
 
463
+ 세션이 없는 앱(세션 미구성·JWT/API)에서 `login`/`logout` 을 부르면 **즉시
464
+ throw(500 + 수리 안내)** 한다(결정 296) — 이전엔 조용한 no-op 이라 부팅
465
+ green·로그인만 영구 실패였다. 세션 앱이면 app.config 에 session 을 배선하고,
466
+ JWT 앱이면 `this.jwt.issue`/`this.jwt.refresh` 를 쓴다.
467
+
450
468
  `login`/`logout` 은 세션 ID 재생성·파기(비동기)를 하므로 `await` 를 붙인다. 생략해도
451
469
  디스패처가 응답 직전에 정착시켜 동작하지만(기존 코드 호환), 정본은 `await` 다.
452
470
 
@@ -542,6 +560,18 @@ export default controller({
542
560
  - **render/JSON/redirect 를 한 액션에서 조건 혼용하면 doctor
543
561
  response-mixing 위반** — 액션을 나눈다. 예외는 폼 액션의
544
562
  "실패 render + 성공 redirect" 조합 하나뿐(결정 57 보완).
563
+ - **JSON 액션 반환 객체의 예약 키(결정 294)** — 디스패처는 반환값을 모양으로
564
+ 분기하므로 `redirect` 키를 가진 객체는 리다이렉트로, `json` 키는 this.json
565
+ 결과로, `page`+`props` 조합은 렌더로 **오인**된다. JSON 응답 데이터의 최상위
566
+ 키로 `redirect`·`json` 을 쓰거나 `page`·`props` 를 동시에 쓰지 말 것 —
567
+ 필요하면 한 겹 감싼다(`return { data: { redirect: url } }`).
568
+ - **액션이 아무것도 반환하지 않으면 204 No Content 다** — `this.render(...)`
569
+ 를 호출만 하고 `return` 을 빼먹으면 컴파일은 통과하고 페이지가 조용히
570
+ 빈 204 로 나간다. 렌더·리다이렉트·JSON 은 항상 `return` 과 함께 쓴다.
571
+ - **라우트 타깃 형식 불량은 부팅 에러다(결정 293)** — `r.get('/x', 'posts')`
572
+ 처럼 `#액션` 을 빠뜨리면 이전엔 조용히 라우트가 사라져 무신호 404 였다.
573
+ 이제 `routes()` 가 부팅에서 throw 한다(`'<컨트롤러>#<액션>'` 형식 필수).
574
+ 라우트 표에 있는데 **구현 안 된 액션**은 종전대로 조용히 스킵된다(정상 경로).
545
575
  - **`fetch()` 로 로그인 폼 구현 금지** — 세션 앱 폼은 `gaonjs/vue` 의
546
576
  `useForm(...).post(...)`(결정 64). REST + fetch 는 API 앱(JWT) 전용.
547
577
  - **컨트롤러에 비즈니스 로직 인라인 금지** (§5.3 One Way) — 여러 모델·
@@ -584,6 +614,12 @@ export default controller({
584
614
  | 결정 183 | 검증 사유 로케일화 — 안정 코드 + 예약 namespace `validation.<code>` 로 요청 로케일 번역(미제공 시 내장 fallback · §4.1) |
585
615
  | 결정 253 | hidden 마커 = 열거 가능한 심볼 → `render(page, { ...user })` spread 우회로도 hidden 값이 안 샌다(§4.2) |
586
616
  | 결정 254 | 로그인 시 세션 ID 재생성(fixation 방어) · 로그아웃 시 세션 파기(§4.4 auth) |
617
+ | 결정 293 | 라우트 타깃 형식 불량(`#` 누락·빈 컨트롤러/액션) = 부팅 throw — 조용한 라우트 증발 금지(미구현 액션 스킵은 정상 경로 유지) |
618
+ | 결정 294 | 단일 문자열 키 접근(`this.params('id')`) 표면 없음 — 애드혹 폼으로 받는다 · 애드혹 폼 중복 키는 배열 통과(last-wins 는 스키마 폼만) · JSON 액션 예약 키(redirect/json/page+props) 문서화(§3·§4.3·함정) |
619
+ | 결정 295 | 세션 쿠키 `secure`/`sameSite` 를 app.config session 에서 조정 · `sameSite:'none'`+secure 미충족 = 부팅 에러(§6) |
620
+ | 결정 296 | 세션 없는 앱의 `this.auth.login`/`logout` = fail-loud throw(조용한 no-op 금지 · §6) |
621
+ | 결정 297 | 초기 HTML 문서에도 `Vary: X-Inertia` + `Cache-Control: private, no-cache` — CDN/공유 캐시가 사용자별 data-page(csrf·currentUser)를 캐시하지 못하게 |
622
+ | 결정 298 | render props 순환 참조 = 스택 오버플로 대신 수리 안내 에러(같은 객체의 형제 중복(DAG)은 정상) |
587
623
  | E-1 | 파사드 = `gaonjs` · CLI = `gaon` |
588
624
 
589
625
  ## `@gaonjs/seal` 켠 앱
@@ -3,16 +3,17 @@
3
3
  // 앱 전용 컴포저블은 api()/pageProps() 를 자유롭게 쓸 수 있다.
4
4
  // shared/composables 는 반대 — 인자로만 받는 순수 로직(E-5 §2.2).
5
5
  import { ref } from 'vue'
6
+ import { api } from 'gaonjs/vue'
6
7
 
7
- /** GET /health 를 두드려 서버가 살아 있는지 확인하는 예시 컴포저블. */
8
+ /** JSON 액션 home#health 를 api() 로 두드려 서버가 살아 있는지 확인하는 예시 컴포저블. */
8
9
  export function useApiPing() {
9
10
  const ok = ref<boolean | null>(null)
10
11
  const error = ref<string | null>(null)
11
12
 
12
13
  async function ping(): Promise<void> {
13
14
  try {
14
- const res = await fetch('/health', { headers: { Accept: 'application/json' } })
15
- const body = (await res.json()) as { ok: boolean }
15
+ // 타입드 api() 클라이언트(errata E-3) raw fetch() API 앱(JWT) 전용이다.
16
+ const body = await api('web:home#health')
16
17
  ok.value = body.ok === true
17
18
  error.value = null
18
19
  } catch (err) {
@@ -12,7 +12,7 @@ export default controller({
12
12
  },
13
13
 
14
14
  // GET /health — JSON 액션(errata E-3). 반환값이 곧 응답.
15
- // 배포 후 헬스체크·60초 실측(v0.15 §13.5 M9 완료 기준)에 쓰인다.
15
+ // 배포 후 헬스체크·60초 실측(v0.17 §13.5 M9 완료 기준)에 쓰인다.
16
16
  async health() {
17
17
  return { ok: true, service: '{{PROJECT_NAME}}' }
18
18
  },
@@ -1,4 +1,4 @@
1
- // apps/web/main.ts — 프론트엔드 진입 (v0.16 §6.4 · M3 최소 웹 레이어).
1
+ // apps/web/main.ts — 프론트엔드 진입 (v0.17 §6.4).
2
2
  //
3
3
  // 브라우저 부팅 흐름:
4
4
  // 1) index.html 이 이 파일을 <script type="module"> 로 로드한다.
@@ -1,5 +1,5 @@
1
1
  // web 앱 라우트 — apps/web/routes.ts. 앱 폴더명(web)이 URL 프리픽스가 되지만
2
- // web 앱은 관례상 프리픽스 '/' 를 쓴다(v0.15 §6.1).
2
+ // web 앱은 관례상 프리픽스 '/' 를 쓴다(v0.17 §6.1).
3
3
  import { routes } from 'gaonjs/web'
4
4
 
5
5
  export default routes((r) => {
@@ -1,4 +1,4 @@
1
- # {{PROJECT_NAME}} 개발 스택 — gaon dev 가 자동 기동한다(CLAUDE.md §2 · v0.15 §9).
1
+ # {{PROJECT_NAME}} 개발 스택 — gaon dev 가 자동 기동한다(CLAUDE.md §2 · v0.17 §9).
2
2
  # 목업·인메모리 대체는 금지 — 개발·테스트·운영 모두 실 인프라를 쓴다.
3
3
  #
4
4
  # 기동: docker compose up -d (gaon dev 가 자동 실행)
@@ -1,5 +1,5 @@
1
1
  # pnpm workspace — 앱은 apps/*, 도메인은 domain/, 공용은 shared/ 아래에 둔다.
2
- # 이 관례는 CLAUDE.md §2 · v0.15 §3.2 를 따른다(앱과 domain 은 별도 폴더).
2
+ # 이 관례는 CLAUDE.md §2 · v0.17 §3.2 를 따른다(앱과 domain 은 별도 폴더).
3
3
  packages:
4
4
  - "packages/*"
5
5
 
@@ -1,4 +1,4 @@
1
- // vite.config.ts — 프론트엔드 빌드/개발 서버 설정 (v0.16 §6.4).
1
+ // vite.config.ts — 프론트엔드 빌드/개발 서버 설정 (v0.17 §6.4).
2
2
  //
3
3
  // The One Way: gaon dev 가 Vite 를 middlewareMode 로 붙여 Fastify 한 포트로
4
4
  // 서빙한다(§CLAUDE.md 6 · 이 파일의 server 옵션은 그때 재정의된다).
package/dist/work.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type WorkEvent } from '@gaonjs/async';
1
2
  export interface WorkCommandOptions {
2
3
  readonly json?: boolean;
3
4
  /** NATS 접속지. 생략 시 NATS_URL(그다음 하위호환 GAON_NATS_URL), 기본(4222). */
@@ -12,12 +13,32 @@ export interface WorkCommandOptions {
12
13
  readonly ackWaitMs?: number;
13
14
  /** graceful drain 상한(ms). 생략 시 GAON_WORKER_DRAIN_MS. */
14
15
  readonly drainTimeoutMs?: number;
16
+ /** 발행 완료 아웃박스 행 보존(ms · 0=purge 비활성). 생략 시 GAON_OUTBOX_RETENTION_MS(결정 312). */
17
+ readonly outboxRetentionMs?: number;
18
+ /** 아웃박스 purge 최소 간격(ms). 생략 시 GAON_OUTBOX_PURGE_INTERVAL_MS(결정 312). */
19
+ readonly outboxPurgeIntervalMs?: number;
20
+ /** 아웃박스 릴레이 폴링 주기(ms). 생략 시 GAON_OUTBOX_RELAY_POLL_MS(결정 312). */
21
+ readonly relayPollMs?: number;
15
22
  /** 시그널 등록·해제(테스트 주입). 기본 process. */
16
23
  readonly signals?: {
17
24
  on(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
18
25
  off(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
19
26
  };
20
27
  }
28
+ /**
29
+ * 아웃박스 릴레이 튜닝을 env 에서 읽는다(결정 312 · GAON_WORKER_* 와 대칭).
30
+ * 이전엔 runWork() 프로그래매틱 옵션으로만 존재해 정본 운영 경로(`gaon work`)에서
31
+ * 보존 기간·purge 간격·폴링 주기를 조정할 방법이 없었다. env 미설정·비정상 값은
32
+ * undefined → runWork 기본(보존 7일 · purge 1시간 · 폴 1초 · 결정 78)이 그대로다.
33
+ */
34
+ export declare function outboxTuningFromEnv(env?: Record<string, string | undefined>): {
35
+ outboxRetentionMs?: number;
36
+ outboxPurgeIntervalMs?: number;
37
+ relayPollMs?: number;
38
+ };
39
+ /** WorkEvent → 사람용 한 줄(없으면 undefined). 결정 306·308: 조용한 실패(릴레이
40
+ * 오류·리스너 폐기·워커 인프라 오류)가 기본(human) 모드에서 0 신호이던 갭을 닫는다. */
41
+ export declare function emitHuman(e: WorkEvent): string | undefined;
21
42
  /**
22
43
  * 워커를 띄우고 시그널까지 살려 둔다. 반환 프라미스는 graceful 종료 시 resolve.
23
44
  */
package/dist/work.js CHANGED
@@ -29,7 +29,29 @@ function envInt(name) {
29
29
  const n = Number(raw);
30
30
  return Number.isFinite(n) ? n : undefined;
31
31
  }
32
- function emitHuman(e) {
32
+ /**
33
+ * 아웃박스 릴레이 튜닝을 env 에서 읽는다(결정 312 · GAON_WORKER_* 와 대칭).
34
+ * 이전엔 runWork() 프로그래매틱 옵션으로만 존재해 정본 운영 경로(`gaon work`)에서
35
+ * 보존 기간·purge 간격·폴링 주기를 조정할 방법이 없었다. env 미설정·비정상 값은
36
+ * undefined → runWork 기본(보존 7일 · purge 1시간 · 폴 1초 · 결정 78)이 그대로다.
37
+ */
38
+ export function outboxTuningFromEnv(env = process.env) {
39
+ const readInt = (name) => {
40
+ const raw = env[name];
41
+ if (raw == null || raw === '')
42
+ return undefined;
43
+ const n = Number(raw);
44
+ return Number.isFinite(n) ? n : undefined;
45
+ };
46
+ return {
47
+ outboxRetentionMs: readInt('GAON_OUTBOX_RETENTION_MS'),
48
+ outboxPurgeIntervalMs: readInt('GAON_OUTBOX_PURGE_INTERVAL_MS'),
49
+ relayPollMs: readInt('GAON_OUTBOX_RELAY_POLL_MS'),
50
+ };
51
+ }
52
+ /** WorkEvent → 사람용 한 줄(없으면 undefined). 결정 306·308: 조용한 실패(릴레이
53
+ * 오류·리스너 폐기·워커 인프라 오류)가 기본(human) 모드에서 0 신호이던 갭을 닫는다. */
54
+ export function emitHuman(e) {
33
55
  switch (e.kind) {
34
56
  case 'ready':
35
57
  return ` gaon work · 준비 — 잡 ${e.jobs} · 리스너 ${e.listeners} · 스케줄 ${e.scheduled}`;
@@ -38,13 +60,30 @@ function emitHuman(e) {
38
60
  return e.event.leader ? ' ▶ 스케줄러 리더 — 틱 발행 시작' : ' · 스케줄러 대기(standby)';
39
61
  if (e.event.kind === 'fired')
40
62
  return ` ⏰ 스케줄 발행 — ${e.event.label}`;
63
+ if (e.event.kind === 'error')
64
+ return ` ⚠ 스케줄러 오류 — ${e.event.error}`;
41
65
  return undefined;
42
66
  case 'worker':
43
67
  if (e.event.kind === 'dead')
44
68
  return ` ✗ 잡 DLQ — ${e.event.job} (${e.event.error})`;
69
+ // 결정 308: 재시도·인프라 오류(재적재/DLQ 발행 실패 nak · 결정 258)도 신호한다.
70
+ if (e.event.kind === 'retrying')
71
+ return ` ↻ 잡 재시도 — ${e.event.job} (시도 ${e.event.attempt} · ${e.event.delayMs}ms 뒤)`;
72
+ if (e.event.kind === 'error')
73
+ return ` ⚠ 워커 오류 — ${e.event.error}`;
74
+ return undefined;
75
+ case 'listener':
76
+ // 결정 308: maxDeliver 소진 = 이벤트 영구 폐기(DLQ 없음 · agents/async.md §3) — 반드시 신호.
77
+ if (e.event.kind === 'dropped')
78
+ return ` ✗ 이벤트 폐기 — ${e.event.listener} ← ${e.event.event} (재전달 소진: ${e.event.error})`;
79
+ if (e.event.kind === 'error')
80
+ return ` ⚠ 리스너 오류 — ${e.event.error}`;
45
81
  return undefined;
46
82
  case 'relay':
47
83
  return ` ↪ 아웃박스 릴레이 — ${e.count}건 발행`;
84
+ case 'relay-error':
85
+ // 결정 306: 아웃박스 발행 실패(행 격리·재시도 유지)를 조용히 삼키지 않는다.
86
+ return ` ⚠ 아웃박스 릴레이 오류 — ${e.error}`;
48
87
  case 'purge':
49
88
  return ` 🧹 아웃박스 정리 — ${e.count}건 삭제(보존 기간 초과)`;
50
89
  default:
@@ -99,6 +138,7 @@ export async function runWorkCommand(opts = {}) {
99
138
  registerConnection('main', createDb(cfg), cfg.adapter);
100
139
  }
101
140
  const db = hasConnection('main') ? getConnection('main') : undefined;
141
+ const outboxEnv = outboxTuningFromEnv();
102
142
  const domain = await loadDomain(root);
103
143
  // 로드된 도메인 자산 요약(파일=등록 관측용). 잡·리스너·메일 수를 노출한다.
104
144
  if (json) {
@@ -120,6 +160,10 @@ export async function runWorkCommand(opts = {}) {
120
160
  concurrency: opts.concurrency ?? envInt('GAON_WORKER_CONCURRENCY'),
121
161
  ackWaitMs: opts.ackWaitMs ?? envInt('GAON_WORKER_ACK_WAIT_MS'),
122
162
  drainTimeoutMs: opts.drainTimeoutMs ?? envInt('GAON_WORKER_DRAIN_MS'),
163
+ // 아웃박스 릴레이 튜닝: 옵션 > GAON_OUTBOX_* env > runWork 기본(결정 78·312).
164
+ outboxRetentionMs: opts.outboxRetentionMs ?? outboxEnv.outboxRetentionMs,
165
+ outboxPurgeIntervalMs: opts.outboxPurgeIntervalMs ?? outboxEnv.outboxPurgeIntervalMs,
166
+ relayPollMs: opts.relayPollMs ?? outboxEnv.relayPollMs,
123
167
  onEvent: emit,
124
168
  });
125
169
  }
package/package.json CHANGED
@@ -1,10 +1,15 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.42.3",
3
+ "version": "0.47.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "homepage": "https://gaonjs.dev",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://git.nyx-zone.com/gaon/framework.git",
11
+ "directory": "packages/cli"
12
+ },
8
13
  "engines": {
9
14
  "node": ">=22"
10
15
  },
@@ -27,15 +32,15 @@
27
32
  "@modelcontextprotocol/sdk": "^1.29.0",
28
33
  "typescript": "^5.9.0",
29
34
  "vite": "^7.0.0",
30
- "@gaonjs/async": "0.15.3",
31
- "@gaonjs/config": "0.18.1",
35
+ "@gaonjs/config": "0.20.0",
32
36
  "@gaonjs/core": "0.2.4",
33
- "@gaonjs/data": "0.17.3",
37
+ "@gaonjs/async": "0.16.0",
34
38
  "@gaonjs/i18n": "0.2.4",
35
39
  "@gaonjs/mail": "0.3.3",
36
- "@gaonjs/web": "0.20.5"
40
+ "@gaonjs/data": "0.22.0",
41
+ "@gaonjs/web": "0.24.1"
37
42
  },
38
43
  "scripts": {
39
- "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""
44
+ "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"const fs=require('fs');fs.cpSync('src/templates','dist/templates',{recursive:true,filter:(s)=>!s.endsWith('.ts')});fs.rmSync('dist/templates/index.ts',{force:true})\""
40
45
  }
41
46
  }
@@ -1,8 +0,0 @@
1
- import type { DoctorCheck, RuleReport } from './types.js';
2
- /**
3
- * 단일 shared 컴포저블 소스를 검사해 순수성 위반 목록을 낸다
4
- * (단위 테스트 진입점).
5
- */
6
- export declare function inspectSharedComposable(file: string, source: string, cwd: string): DoctorCheck[];
7
- /** shared/composables/ 를 훑어 순수성 위반을 모두 낸다. */
8
- export declare function checkSharedComposablePurity(cwd: string): Promise<RuleReport>;
@@ -1,164 +0,0 @@
1
- // @gaonjs/cli · doctor · shared 컴포저블 순수성 검사 (M9-E 확장 · errata E-5 §2.2)
2
- //
3
- // shared/composables/ 는 shared 컴포넌트의 "props 로만" 규칙과 정확히
4
- // 같은 구도를 따른다. 라우트를 몰라야 하고(api·pageProps 금지) · domain
5
- // 은 타입으로만 참조해야 한다. 필요한 데이터·호출 함수는 인자로 받는다.
6
- // 정본 근거: errata E-5 §2.2 (컴포저블·레이아웃 관례 · 결정 25).
7
- //
8
- // 검사 대상:
9
- // 1) 프레임웍 모듈(gaonjs · gaonjs/vue · @gaonjs/vue · @gaonjs/web) 에서
10
- // 'api' · 'pageProps' 를 value import 하면 error.
11
- // - type-only 는 무해(구조 참조뿐 · 라우트 지식 필요 없음).
12
- // 2) domain 을 value import 하면 error.
13
- // - type-only 는 허용(모델 Row 타입 등 · 순수 타입 참조).
14
- //
15
- // 방법: shared/composables/*.ts 를 TS AST 로 파싱해 import 선언만 훑는다.
16
- // 상대 import 는 실 파일까지 해석하지 않고 경로 접두사(domain/) 로 판정 —
17
- // 이 검사는 순수성 게이트라 정확도보다 재현성이 우선(false positive 는
18
- // 오히려 안전).
19
- import { readdir, readFile } from 'node:fs/promises';
20
- import { join, relative, resolve, dirname } from 'node:path';
21
- import ts from 'typescript';
22
- /** 라우트 지식을 담은 심볼 — shared 는 참조할 수 없다. */
23
- const FORBIDDEN_FRAMEWORK_NAMES = new Set(['api', 'pageProps']);
24
- /**
25
- * 프레임웍 모듈 패턴. 문서 표기(gaon/vue)는 실 패키지 이름의 짧은
26
- * 별칭이며, 실 배포본은 gaonjs 파사드 subpath 와 @gaonjs 스코프를 쓴다.
27
- * 하나만 잡으면 우회가 쉬우므로 알려진 표기 3종을 모두 매치한다.
28
- */
29
- const FRAMEWORK_MODULE_PATTERNS = [
30
- /^gaonjs$/,
31
- /^gaonjs\/vue$/,
32
- /^@gaonjs\/vue$/,
33
- /^@gaonjs\/web$/,
34
- ];
35
- /** import 선언에서 이름과 type-only 플래그를 추출한다. */
36
- function collectImportedNames(node) {
37
- const out = [];
38
- const clause = node.importClause;
39
- if (!clause)
40
- return out;
41
- const clauseTypeOnly = clause.isTypeOnly;
42
- if (clause.name)
43
- out.push({ name: clause.name.text, typeOnly: clauseTypeOnly });
44
- const bindings = clause.namedBindings;
45
- if (bindings) {
46
- if (ts.isNamespaceImport(bindings)) {
47
- out.push({ name: bindings.name.text, typeOnly: clauseTypeOnly });
48
- }
49
- else if (ts.isNamedImports(bindings)) {
50
- for (const el of bindings.elements) {
51
- out.push({ name: el.name.text, typeOnly: clauseTypeOnly || el.isTypeOnly });
52
- }
53
- }
54
- }
55
- return out;
56
- }
57
- function isRelativeSpecifier(s) {
58
- return s.startsWith('./') || s.startsWith('../');
59
- }
60
- function matchesFrameworkModule(spec) {
61
- return FRAMEWORK_MODULE_PATTERNS.some((re) => re.test(spec));
62
- }
63
- /**
64
- * 단일 shared 컴포저블 소스를 검사해 순수성 위반 목록을 낸다
65
- * (단위 테스트 진입점).
66
- */
67
- export function inspectSharedComposable(file, source, cwd) {
68
- const issues = [];
69
- const rel = relative(cwd, file);
70
- const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
71
- const baseDir = dirname(file);
72
- const visit = (node) => {
73
- if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
74
- const spec = node.moduleSpecifier.text;
75
- const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf));
76
- const names = collectImportedNames(node);
77
- // 1) 프레임웍의 api/pageProps 를 value import → error
78
- if (matchesFrameworkModule(spec)) {
79
- for (const n of names) {
80
- if (n.typeOnly)
81
- continue; // type-only 는 라우트 실행 지식이 아님
82
- if (!FORBIDDEN_FRAMEWORK_NAMES.has(n.name))
83
- continue;
84
- issues.push({
85
- rule: 'shared-composable-purity',
86
- level: 'error',
87
- file: rel,
88
- line: line + 1,
89
- message: `${rel} (line ${line + 1})\n` +
90
- ` '${spec}' 의 '${n.name}' import 발견 · shared 는 라우트를 몰라야 함\n` +
91
- `→ 옵션: apps/<앱>/composables/ 로 옮기거나 · ${n.name} 호출을 인자로 받도록 바꾸라`,
92
- detail: {
93
- kind: 'framework-api',
94
- module: spec,
95
- name: n.name,
96
- },
97
- });
98
- }
99
- }
100
- // 2) domain value import → error (type-only 는 허용)
101
- if (isRelativeSpecifier(spec)) {
102
- const abs = resolve(baseDir, spec);
103
- const parts = relative(cwd, abs).split(/[\\/]/).filter(Boolean);
104
- if (parts[0] === 'domain') {
105
- const valueNames = names.filter((n) => !n.typeOnly);
106
- if (valueNames.length > 0) {
107
- issues.push({
108
- rule: 'shared-composable-purity',
109
- level: 'error',
110
- file: rel,
111
- line: line + 1,
112
- message: `${rel} (line ${line + 1})\n` +
113
- ` domain 값 import 발견 · shared 컴포저블은 domain 을 타입으로만 참조해야 함\n` +
114
- `→ 'import type { ... } from ...' 형태로 바꾸거나 · 필요한 값은 인자로 받도록 바꾸라`,
115
- detail: {
116
- kind: 'domain-value',
117
- module: spec,
118
- names: valueNames.map((n) => n.name),
119
- },
120
- });
121
- }
122
- }
123
- }
124
- }
125
- ts.forEachChild(node, visit);
126
- };
127
- visit(sf);
128
- return issues;
129
- }
130
- /** shared/composables/ 를 훑어 순수성 위반을 모두 낸다. */
131
- export async function checkSharedComposablePurity(cwd) {
132
- const composablesDir = join(cwd, 'shared', 'composables');
133
- const files = [];
134
- await collectTsFiles(composablesDir, files);
135
- const issues = [];
136
- for (const file of files) {
137
- const src = await readFile(file, 'utf8');
138
- issues.push(...inspectSharedComposable(file, src, cwd));
139
- }
140
- return { rule: 'shared-composable-purity', issues };
141
- }
142
- async function collectTsFiles(root, out) {
143
- let entries;
144
- try {
145
- entries = (await readdir(root, { withFileTypes: true }));
146
- }
147
- catch {
148
- return;
149
- }
150
- for (const e of entries) {
151
- const name = e.name;
152
- if (name === 'node_modules' || name === 'dist' || name === '.gaon')
153
- continue;
154
- const full = join(root, name);
155
- if (e.isDirectory())
156
- await collectTsFiles(full, out);
157
- else if (e.isFile() &&
158
- name.endsWith('.ts') &&
159
- !name.endsWith('.d.ts') &&
160
- !name.endsWith('.test.ts')) {
161
- out.push(full);
162
- }
163
- }
164
- }
@@ -1,109 +0,0 @@
1
- // @gaonjs/cli · templates/index.ts — 프로젝트 스캐폴드 템플릿 로더 (M9-F).
2
- //
3
- // `gaon new <name>` 이 소비하는 파일 트리. 소스 트리(src/templates/project/*)
4
- // 를 재귀 스캔해 각 `.tpl` 파일의 최종 경로와 렌더된 내용을 반환한다.
5
- // 렌더는 `{{TOKEN}}` 리터럴 replaceAll — Vue 의 `{{ }}` 보간과 겹치지 않는
6
- // 고정 리터럴이라 정규식 없이 안전하다(generate.ts 와 동일 관례).
7
- //
8
- // 폴더는 관례상 유지되어야 하지만 git 이 빈 폴더를 추적하지 않으므로
9
- // `.gitkeep.tpl` 을 두어 실 파일 `.gitkeep` 으로 렌더한다. 최종 경로는
10
- // `.tpl` 접미사를 벗긴 값이다.
11
-
12
- import { readFileSync, readdirSync } from 'node:fs'
13
- import { dirname, join, posix, relative, sep } from 'node:path'
14
- import { fileURLToPath } from 'node:url'
15
-
16
- /** 생성할 파일 하나 — path 는 프로젝트 루트 기준 상대 경로(POSIX). */
17
- export interface ProjectFile {
18
- readonly path: string
19
- readonly contents: string
20
- }
21
-
22
- /** 템플릿 렌더 시 치환되는 토큰 값. */
23
- export interface ProjectTemplateTokens {
24
- readonly projectName: string
25
- readonly gaonjsVersion: string
26
- /**
27
- * package.json 의 `packageManager` 필드(corepack 핀 · 예 `pnpm@10.27.0`).
28
- * 미지정 시 blessed 기본 pnpm. 선택한 pm 에 맞춰 채운다(결정 169).
29
- */
30
- readonly packageManager?: string
31
- }
32
-
33
- const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'project')
34
-
35
- /**
36
- * pm 별 `packageManager` 핀(corepack 형식 `<pm>@<x.y.z>`). 선택한 pm 을
37
- * 그대로 적어 corepack enforcement 가 install 을 막지 않게 한다(결정 169 ·
38
- * 12차 실사용 yarn 파손). 정본 버전 근거:
39
- * · pnpm@10.27.0 — blessed 툴체인(Dockerfile corepack·CI)과 정합.
40
- * · yarn@1.22.22 — yarn **classic** 최종 안정판. berry(2·4.x)는 `.yarnrc.yml`
41
- * 없이 기본 PnP 라 node_modules 를 읽는 vite·gaon serve 를 깨므로 배제.
42
- * · npm@10.9.9 — engines.node≥22(Node 22 LTS) 동봉 npm 라인의 tip.
43
- */
44
- export const PACKAGE_MANAGER_PINS: Readonly<Record<'pnpm' | 'npm' | 'yarn', string>> = {
45
- pnpm: 'pnpm@10.27.0',
46
- npm: 'npm@10.9.9',
47
- yarn: 'yarn@1.22.22',
48
- }
49
-
50
- /** blessed 기본(pm 미선택 시). */
51
- export const DEFAULT_PACKAGE_MANAGER = PACKAGE_MANAGER_PINS.pnpm
52
-
53
- /** 템플릿 문자열의 {{TOKEN}} 을 치환한다. 알 수 없는 토큰은 그대로 둔다. */
54
- export function renderTemplate(raw: string, tokens: ProjectTemplateTokens): string {
55
- return raw
56
- .replaceAll('{{PROJECT_NAME}}', tokens.projectName)
57
- .replaceAll('{{GAONJS_VERSION}}', tokens.gaonjsVersion)
58
- .replaceAll('{{PACKAGE_MANAGER}}', tokens.packageManager ?? DEFAULT_PACKAGE_MANAGER)
59
- }
60
-
61
- /** 템플릿 폴더를 재귀 스캔해 파일 목록을 만든다(POSIX 경로 · 정렬). */
62
- export function listTemplateFiles(root: string = TEMPLATE_DIR): string[] {
63
- const out: string[] = []
64
- const walk = (dir: string): void => {
65
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
66
- const abs = join(dir, entry.name)
67
- if (entry.isDirectory()) {
68
- walk(abs)
69
- continue
70
- }
71
- if (!entry.isFile()) continue
72
- out.push(abs)
73
- }
74
- }
75
- walk(root)
76
- return out.sort()
77
- }
78
-
79
- /**
80
- * 스캐폴드 파일 전체를 렌더링해 반환한다.
81
- * `.tpl` 접미사는 벗기고, `{{PROJECT_NAME}}`·`{{GAONJS_VERSION}}` 을 치환한다.
82
- */
83
- export function renderProjectFiles(tokens: ProjectTemplateTokens): ProjectFile[] {
84
- const paths = listTemplateFiles()
85
- return paths.map((abs) => {
86
- const rel = relative(TEMPLATE_DIR, abs).split(sep).join(posix.sep)
87
- const outPath = rel.endsWith('.tpl') ? rel.slice(0, -'.tpl'.length) : rel
88
- const raw = readFileSync(abs, 'utf8')
89
- return { path: outPath, contents: renderTemplate(raw, tokens) }
90
- })
91
- }
92
-
93
- /** 테스트·검증용 — 템플릿 폴더의 절대 경로. */
94
- export function templateDir(): string {
95
- return TEMPLATE_DIR
96
- }
97
-
98
- /** 렌더링된 내용에 미치환 토큰이 남아 있는지 검사(회귀 방지). */
99
- export function findUnresolvedTokens(contents: string): string[] {
100
- const re = /\{\{([A-Z_][A-Z0-9_]*)\}\}/g
101
- const found = new Set<string>()
102
- for (const m of contents.matchAll(re)) {
103
- // Vue 템플릿의 {{ prop }} (소문자·공백 시작) 은 제외 — 위 정규식이 대문자만
104
- // 잡으므로 자연스럽게 걸러진다. 남으면 진짜 미치환.
105
- found.add(m[1]!)
106
- }
107
- return [...found].sort()
108
- }
109
-