@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.
- package/README.md +1 -1
- package/dist/commands/db.js +29 -7
- package/dist/commands/new.d.ts +2 -0
- package/dist/commands/new.js +8 -3
- package/dist/db/reset.js +20 -10
- package/dist/db/resolve.d.ts +15 -0
- package/dist/db/resolve.js +29 -0
- package/dist/doctor/pageprops-destructure.d.ts +2 -2
- package/dist/doctor/pageprops-destructure.js +29 -23
- package/dist/doctor/schema-relations.js +6 -1
- package/dist/doctor.js +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +18 -2
- package/dist/mcp/tools.js +15 -1
- package/dist/scaffold/job.js +3 -1
- package/dist/templates/project/.dockerignore.tpl +3 -0
- package/dist/templates/project/.env.example.tpl +1 -1
- package/dist/templates/project/AGENTS.md.tpl +3 -2
- package/dist/templates/project/CLAUDE.md.tpl +4 -3
- package/dist/templates/project/Dockerfile.tpl +6 -1
- package/dist/templates/project/agents/async.md.tpl +44 -6
- package/dist/templates/project/agents/data.md.tpl +206 -15
- package/dist/templates/project/agents/frontend.md.tpl +36 -8
- package/dist/templates/project/agents/mail.md.tpl +2 -1
- package/dist/templates/project/agents/realtime.md.tpl +26 -2
- package/dist/templates/project/agents/seal.md.tpl +9 -1
- package/dist/templates/project/agents/security.md.tpl +18 -0
- package/dist/templates/project/agents/storage.md.tpl +15 -8
- package/dist/templates/project/agents/web.md.tpl +38 -2
- package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +4 -3
- package/dist/templates/project/apps/web/controllers/home.ts.tpl +1 -1
- package/dist/templates/project/apps/web/main.ts.tpl +1 -1
- package/dist/templates/project/apps/web/routes.ts.tpl +1 -1
- package/dist/templates/project/docker-compose.yaml.tpl +1 -1
- package/dist/templates/project/pnpm-workspace.yaml.tpl +1 -1
- package/dist/templates/project/vite.config.ts.tpl +1 -1
- package/dist/work.d.ts +21 -0
- package/dist/work.js +45 -1
- package/package.json +11 -6
- package/dist/doctor/shared-composable-purity.d.ts +0 -8
- package/dist/doctor/shared-composable-purity.js +0 -164
- 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
|
-
- **로컬 디스크**: 항상 `${
|
|
27
|
-
`
|
|
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). `
|
|
61
|
-
|
|
62
|
-
|
|
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`(
|
|
94
|
-
`
|
|
95
|
-
|
|
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
|
-
//
|
|
369
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
|
|
15
|
-
const body =
|
|
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
|
+
// 배포 후 헬스체크·60초 실측(v0.17 §13.5 M9 완료 기준)에 쓰인다.
|
|
16
16
|
async health() {
|
|
17
17
|
return { ok: true, service: '{{PROJECT_NAME}}' }
|
|
18
18
|
},
|
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
|
-
|
|
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.
|
|
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/
|
|
31
|
-
"@gaonjs/config": "0.18.1",
|
|
35
|
+
"@gaonjs/config": "0.20.0",
|
|
32
36
|
"@gaonjs/core": "0.2.4",
|
|
33
|
-
"@gaonjs/
|
|
37
|
+
"@gaonjs/async": "0.16.0",
|
|
34
38
|
"@gaonjs/i18n": "0.2.4",
|
|
35
39
|
"@gaonjs/mail": "0.3.3",
|
|
36
|
-
"@gaonjs/
|
|
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
|
-
}
|
package/dist/templates/index.ts
DELETED
|
@@ -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
|
-
|