@gaonjs/cli 0.40.4 → 0.41.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.
@@ -40,7 +40,13 @@ function validateProjectName(name) {
40
40
  ` → 예: gaon new my-app · gaon new demo`);
41
41
  }
42
42
  }
43
- /** 파사드(gaonjs) 패키지의 실 버전을 찾는다 — package.json 을 위로 훑는다. */
43
+ /**
44
+ * 파사드(gaonjs) 패키지의 실 버전을 찾는다 — package.json 을 위로 훑는다.
45
+ * 정상 설치(node_modules/gaonjs)·개발 트리(packages/gaonjs) 모두에서 인접
46
+ * package.json 을 상향 탐색하면 반드시 잡힌다. 못 찾으면 **fail-loud**(결정 242):
47
+ * 옛 폴백 `^0.5.0` 은 2년 전 스텁 라인을 조용히 핀해 첫 화면부터 API 불일치로
48
+ * 깨지는 스캐폴드를 낳았다 — 조용한 스테일 핀보다 명확한 실패가 낫다.
49
+ */
44
50
  function detectGaonjsVersion() {
45
51
  // 가장 안전한 방법: 이 파일 위치를 기준으로 packages/gaonjs/package.json 을
46
52
  // 상향 탐색. 개발 트리(src)와 배포 트리(dist) 모두에서 동작한다.
@@ -63,8 +69,10 @@ function detectGaonjsVersion() {
63
69
  break;
64
70
  dir = parent;
65
71
  }
66
- // 발견 하면 최근 스텁 배포 라인의 안전 하한.
67
- return '^0.5.0';
72
+ // 결정 242: 스테일 폴백 제거 감지 실패는 즉시 알린다(§7.5.3 · 조용한 실패 금지).
73
+ throw new Error('gaonjs 파사드 버전을 자동 감지하지 못했습니다.\n' +
74
+ ' → gaon 을 gaonjs 설치본(node_modules/gaonjs)을 통해 실행했는지 확인하세요.\n' +
75
+ ' → 정상 설치라면 이 오류는 나지 않습니다(스캐폴드가 인접 package.json 에서 버전을 읽습니다).');
68
76
  }
69
77
  /** 대상 폴더가 비어 있는지(생성 대상으로 안전한지) 확인. */
70
78
  function isEmptyOrMissing(dir) {
@@ -171,7 +179,14 @@ export async function runNewCommand(name, opts = {}) {
171
179
  }
172
180
  // 파일 생성 — 실패 시 부분 생성물이 남지 않도록 폴더를 정리하지는 않는다
173
181
  // (사용자가 원인 파악 후 rm -rf 로 지우도록). 정상 흐름에서는 문제 없음.
174
- const gaonjsVersion = opts.gaonjsVersion ?? detectGaonjsVersion();
182
+ // 결정 242: 버전 감지 실패는 fail-loud — --json 계약을 지키려 emitError 로 라우팅한다.
183
+ let gaonjsVersion;
184
+ try {
185
+ gaonjsVersion = opts.gaonjsVersion ?? detectGaonjsVersion();
186
+ }
187
+ catch (err) {
188
+ return emitError(err instanceof Error ? err.message : String(err), Date.now() - t0);
189
+ }
175
190
  // 결정 169: packageManager 필드 = 선택한 pm 의 corepack 핀. 항상 pnpm 을
176
191
  // 적던 옛 관례는 --pm yarn 시 yarn 1.22 corepack 이 install 을 거부시켰다.
177
192
  const files = renderProjectFiles({
@@ -298,7 +313,8 @@ export async function runNewCommand(name, opts = {}) {
298
313
  lines.push('');
299
314
  lines.push(' 다음 단계:');
300
315
  lines.push(` 1) cd ${name}`);
301
- lines.push(' 2) cp .env.example .env # 환경 변수 편집');
316
+ // 결정 198: .env 스캐폴드가 이미 생성한다(.env.example 사본) cp 재안내는 모순.
317
+ lines.push(' 2) .env 편집 # 이미 생성됨(.env.example 사본) · 필요한 값만 수정');
302
318
  lines.push(' 3) gaon dev # Docker + 타입 브리지 + serve 통합');
303
319
  lines.push(' 4) http://localhost:3000 # 홈 페이지 확인 (60초 실측 · v0.15 §13.5 M9)');
304
320
  lines.push('');
@@ -4,7 +4,7 @@ interface ImportUse {
4
4
  readonly typeOnly: boolean;
5
5
  readonly resolvedAbs: string;
6
6
  }
7
- /** 소스 하나에서 상대 import 를 추출한다(단위 테스트 진입점). */
7
+ /** 소스 하나에서 상대 import 를 추출한다(단위 테스트 진입점 · .ts·.vue 공통). */
8
8
  export declare function extractRelativeImports(file: string, source: string): ImportUse[];
9
9
  /** 프로젝트 전체 의존 방향 검사. */
10
10
  export declare function checkDependencyDirection(cwd: string): Promise<RuleReport>;
@@ -13,9 +13,37 @@
13
13
  import { readdir, readFile, stat } from 'node:fs/promises';
14
14
  import { join, relative, resolve, dirname } from 'node:path';
15
15
  import ts from 'typescript';
16
- /** 소스 하나에서 상대 import 를 추출한다(단위 테스트 진입점). */
16
+ /**
17
+ * .vue SFC 를 파싱용 TS 소스로 바꾼다. `<script>`·`<script setup>` 블록 안
18
+ * 텍스트만 남기고, 나머지(`<template>`·`<style>`·태그 자체)는 공백/개행으로
19
+ * 치환해 **원본과 동일한 줄·칸 위치를 보존**한다 — 위반 line 번호가 .vue 파일
20
+ * 기준으로 정확히 찍히게(§7.5.3). script 밖에는 import 가 없으므로 파싱에서
21
+ * 자연히 사라진다. 두 블록(일반+setup)이 다 있어도 각자 제자리에 남는다.
22
+ */
23
+ function vueToTsPreservingLines(source) {
24
+ const chars = source.split('');
25
+ const keep = new Uint8Array(source.length);
26
+ const re = /<script\b[^>]*>([\s\S]*?)<\/script>/g;
27
+ let m;
28
+ while ((m = re.exec(source)) !== null) {
29
+ const content = m[1] ?? '';
30
+ // 여는 태그 길이 = 전체 매치 − content − 닫는 태그. content 가 비거나 반복돼도 정확.
31
+ const start = m.index + (m[0].length - content.length - '</script>'.length);
32
+ for (let i = start; i < start + content.length; i++)
33
+ keep[i] = 1;
34
+ }
35
+ for (let i = 0; i < chars.length; i++) {
36
+ if (!keep[i] && chars[i] !== '\n' && chars[i] !== '\r')
37
+ chars[i] = ' ';
38
+ }
39
+ return chars.join('');
40
+ }
41
+ /** 소스 하나에서 상대 import 를 추출한다(단위 테스트 진입점 · .ts·.vue 공통). */
17
42
  export function extractRelativeImports(file, source) {
18
- const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
43
+ const isVue = file.endsWith('.vue');
44
+ const text = isVue ? vueToTsPreservingLines(source) : source;
45
+ // .vue 는 TS 가 확장자로 스크립트 종류를 못 잡으므로 TS 로 명시한다.
46
+ const sf = ts.createSourceFile(file, text, ts.ScriptTarget.ES2022, true, isVue ? ts.ScriptKind.TS : undefined);
19
47
  const uses = [];
20
48
  const baseDir = dirname(file);
21
49
  const record = (specifierText, node, typeOnly) => {
@@ -180,7 +208,10 @@ async function collectSourceFiles(root, out) {
180
208
  else if (e.isFile()) {
181
209
  if (name.endsWith('.ts') && !name.endsWith('.d.ts') && !name.endsWith('.test.ts'))
182
210
  out.push(full);
183
- // .vue SFC 이번 M9-E .ts (Vue 컴파일러 도입은 후속).
211
+ // .vue SFC 검사한다(결정 226) <script> 블록 import apps/shared 의
212
+ // app→app·shared→app 값 참조를 잡는다. 헤더 주석(.ts/.vue)과 정합.
213
+ else if (name.endsWith('.vue'))
214
+ out.push(full);
184
215
  }
185
216
  }
186
217
  }
@@ -13,5 +13,11 @@ export declare const FIXERS: Partial<Record<DoctorRule, Fixer>>;
13
13
  /**
14
14
  * 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
15
15
  * 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
16
+ *
17
+ * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 27종을 **빠짐없이** 담는다 —
18
+ * fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
19
+ * 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
20
+ * 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
21
+ * (`fixers/capabilities.test.ts` · ALL_RULES ↔ 카탈로그 대칭).
16
22
  */
17
23
  export declare const FIXER_CAPABILITIES: readonly FixerCapability[];
@@ -27,6 +27,12 @@ export const FIXERS = {
27
27
  /**
28
28
  * 규칙별 fix 지원 여부 카탈로그. 리포트가 사용자에게 무엇이 자동 · 무엇이
29
29
  * 수동 · 이유는 무엇인지 표시하는 데 쓴다(진단 = 수리 안내서 · §7.5.3).
30
+ *
31
+ * **정직성 규약(결정 241)**: 이 배열은 `ALL_RULES` 27종을 **빠짐없이** 담는다 —
32
+ * fixer 가 없는 규칙도 `hasFixer:false` + 구체적 수동 안내로 명시한다. 항목이
33
+ * 빠지면 --fix 리포트가 그 규칙 위반에 대해 일반 문구("수동 수정 필요")만 내
34
+ * 사용자가 왜 자동이 안 되는지 알 수 없다. 전수성은 테스트가 고정한다
35
+ * (`fixers/capabilities.test.ts` · ALL_RULES ↔ 카탈로그 대칭).
30
36
  */
31
37
  export const FIXER_CAPABILITIES = [
32
38
  {
@@ -89,9 +95,79 @@ export const FIXER_CAPABILITIES = [
89
95
  hasFixer: false,
90
96
  note: "수동 · 페이지는 this.render('...') 문자열·Inertia glob 로 해석돼 import 참조 갱신만으론 부족합니다(결정 32·46 · PascalCase 로 rename 후 render 키 확인).",
91
97
  },
98
+ {
99
+ rule: 'auth-wiring',
100
+ hasFixer: false,
101
+ note: '수동 · requireAuth 를 쓰는데 app.config.ts 에 auth 미배선 — auth 블록·세션 설정은 앱 정책이라 자동 주입이 안전하지 않습니다(결정 59 · `gaon g auth` 로 스캐폴드).',
102
+ },
103
+ {
104
+ rule: 'ui-kit-wiring',
105
+ hasFixer: false,
106
+ note: '수동 · UI 킷 import 가 있는데 style.css 에 Tailwind 미배선 — `gaon g ui-kit` 로 킷과 style.css 를 함께 심으세요(결정 76).',
107
+ },
108
+ {
109
+ rule: 'route-registration',
110
+ hasFixer: false,
111
+ note: '수동 · 고아 컨트롤러의 URL·HTTP 메서드·액션은 추론할 수 없습니다 — routes.ts 에 라우트를 추가하거나 쓰지 않는 파일을 지우세요(결정 79).',
112
+ },
113
+ {
114
+ rule: 'static-collision',
115
+ hasFixer: false,
116
+ note: '수동 · 정적 파일이 라우트/에셋에 가려집니다 — 파일명을 바꾸거나 충돌 라우트를 조정하세요(결정 85).',
117
+ },
118
+ {
119
+ rule: 'method-override',
120
+ hasFixer: false,
121
+ note: '수동 · _method HTTP 스푸핑은 설계 기피 대상 — router.delete()/put() 등 실 HTTP 메서드로 전환하세요(결정 89).',
122
+ },
123
+ {
124
+ rule: 'csrf-wiring',
125
+ hasFixer: false,
126
+ note: '수동 · 비-GET 라우트 + session 미배선 — 세션·CSRF 배선은 앱 보안 정책이라 자동 주입이 안전하지 않습니다(결정 93).',
127
+ },
128
+ {
129
+ rule: 'internal-anchor',
130
+ hasFixer: false,
131
+ note: '수동 · 앱 내부 이동 <a> → <Link> 전환은 템플릿·import 편집이 필요합니다(결정 96 · 풀 리로드 방지).',
132
+ },
133
+ {
134
+ rule: 'pageprops-destructure',
135
+ hasFixer: false,
136
+ note: '수동 · pageProps() 구조분해는 반응성을 끊습니다 — pageProps().x 접근이나 computed 로 재작성하세요(결정 99).',
137
+ },
138
+ {
139
+ rule: 'async-offload',
140
+ hasFixer: false,
141
+ note: '수동 · 컨트롤러 인라인 메일·이미지·외부 HTTP 는 응답을 지연시킵니다 — 잡으로 오프로드하세요(결정 102·103 · 재설계).',
142
+ },
143
+ {
144
+ rule: 'page-layout-breakpoint',
145
+ hasFixer: false,
146
+ note: '수동 · 페이지가 레이아웃 브레이크포인트를 직접 다룹니다 — 레이아웃(Default.vue)으로 옮기세요(결정 107).',
147
+ },
148
+ {
149
+ rule: 'link-button-nesting',
150
+ hasFixer: false,
151
+ note: '수동 · <Link><Button> 중첩은 <a><button> 을 낳습니다 — Link(as) 또는 Button 하나로 재구성하세요(결정 113).',
152
+ },
92
153
  {
93
154
  rule: 'seal-security',
94
155
  hasFixer: true,
95
156
  note: 'seal:true 앱의 main.ts 에 @gaonjs/seal/client 정적 import + createGaonApp sealClient 전달을 자동 배선(결정 124). 방어 역전 warning 은 설계 결정이라 수동.',
96
157
  },
158
+ {
159
+ rule: 'schema-relations',
160
+ hasFixer: false,
161
+ note: '수동 · 커넥션을 가로지르는 belongsTo·관계는 도메인 재설계가 필요합니다(§4.5 · 결정 134).',
162
+ },
163
+ {
164
+ rule: 'no-import-meta-env',
165
+ hasFixer: false,
166
+ note: '수동 · .vue 의 import.meta.env(TS1470)는 env 접근자로 전환하세요 — 접근자 도입은 코드 편집이 필요합니다(결정 198).',
167
+ },
168
+ {
169
+ rule: 'locale-parity',
170
+ hasFixer: false,
171
+ note: '수동 · 로케일 간 누락 키는 각 카탈로그(locales/*.json)에 채우세요 — 번역문은 사람이 작성합니다(결정 216).',
172
+ },
97
173
  ];
@@ -1,5 +1,13 @@
1
1
  import type { RuleReport } from './types.js';
2
- /** routes.ts 소스에서 참조된 컨트롤러 이름 집합을 뽑는다(단위 테스트 진입점). */
2
+ /**
3
+ * routes.ts 소스에서 참조된 컨트롤러 이름 집합을 뽑는다(단위 테스트 진입점).
4
+ *
5
+ * 정규식 스캔이 아니라 TS AST 를 파싱한다(doctor 의 지배 관례 · 결정 243). 이유:
6
+ * 주석 안의 `'ctrl#action'` 을 참조로 오인해 진짜 고아를 놓치던(false-negative)
7
+ * 정규식 취약점을 제거하고, 실행 레이어 `routes()`(@gaonjs/web)의 `parseTarget`
8
+ * 의미(‘#’ 앞 부분·trim)를 그대로 따른다. 소스를 **실행하지 않고** 파싱만 하므로
9
+ * (createSourceFile) doctor 의 "routes.ts 를 실행하지 않는다" 계약은 유지된다.
10
+ */
3
11
  export declare function referencedControllers(routesSource: string): Set<string>;
4
12
  /** apps/ 를 훑어 라우트에 등록되지 않은(고아) 컨트롤러를 경고로 낸다. */
5
13
  export declare function checkRouteRegistration(cwd: string): Promise<RuleReport>;
@@ -7,19 +7,53 @@
7
7
  // 컨트롤러 파일은 배선을 깜빡한 흔한 실수 · §7.5.3 수리 안내).
8
8
  //
9
9
  // routes.ts 가 참조하는데 파일이 없는 반대 경우는 이미 generator 가 하드 에러로
10
- // 잡으므로(gaon check 중 throw) 여기서는 고아 컨트롤러만 본다. 판정은 소스 텍스트
11
- // 기반(가벼운 정적 검사) — routes.ts 를 실행하지 않는다.
10
+ // 잡으므로(gaon check 중 throw) 여기서는 고아 컨트롤러만 본다. 판정은 TS AST 파싱
11
+ // (가벼운 정적 검사 · 결정 243) — routes.ts 를 실행하지 않는다(파싱만).
12
12
  import { readdir, readFile } from 'node:fs/promises';
13
13
  import { join } from 'node:path';
14
- /** routes.ts 소스에서 참조된 컨트롤러 이름 집합을 뽑는다(단위 테스트 진입점). */
14
+ import ts from 'typescript';
15
+ /** DSL 메서드 대상: r.get/post/put/patch/delete(path, 'ctrl#action'). */
16
+ const METHOD_CALLS = new Set(['get', 'post', 'put', 'patch', 'delete']);
17
+ /** 문자열 리터럴(따옴표·백틱 무치환)이면 그 텍스트를, 아니면 undefined. */
18
+ function literalText(node) {
19
+ if (node && (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)))
20
+ return node.text;
21
+ return undefined;
22
+ }
23
+ /**
24
+ * routes.ts 소스에서 참조된 컨트롤러 이름 집합을 뽑는다(단위 테스트 진입점).
25
+ *
26
+ * 정규식 스캔이 아니라 TS AST 를 파싱한다(doctor 의 지배 관례 · 결정 243). 이유:
27
+ * 주석 안의 `'ctrl#action'` 을 참조로 오인해 진짜 고아를 놓치던(false-negative)
28
+ * 정규식 취약점을 제거하고, 실행 레이어 `routes()`(@gaonjs/web)의 `parseTarget`
29
+ * 의미(‘#’ 앞 부분·trim)를 그대로 따른다. 소스를 **실행하지 않고** 파싱만 하므로
30
+ * (createSourceFile) doctor 의 "routes.ts 를 실행하지 않는다" 계약은 유지된다.
31
+ */
15
32
  export function referencedControllers(routesSource) {
16
33
  const names = new Set();
17
- // 메서드 대상: r.get('/', 'posts#index') 'posts'
18
- for (const m of routesSource.matchAll(/['"]([A-Za-z_]\w*)#\w+['"]/g))
19
- names.add(m[1]);
20
- // 리소스: r.resource('posts') · r.resources('posts')
21
- for (const m of routesSource.matchAll(/\bresources?\s*\(\s*['"]([A-Za-z_]\w*)['"]/g))
22
- names.add(m[1]);
34
+ const sf = ts.createSourceFile('routes.ts', routesSource, ts.ScriptTarget.Latest, true);
35
+ const visit = (node) => {
36
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
37
+ const method = node.expression.name.text;
38
+ if (method === 'resources' || method === 'resource') {
39
+ // r.resource('posts') · r.resources('posts') → 컨트롤러 = 첫 인자.
40
+ const name = literalText(node.arguments[0])?.trim();
41
+ if (name)
42
+ names.add(name);
43
+ }
44
+ else if (METHOD_CALLS.has(method)) {
45
+ // r.get(path, 'ctrl#action') → 컨트롤러 = 둘째 인자의 '#' 앞부분(trim).
46
+ const target = literalText(node.arguments[1]);
47
+ if (target !== undefined) {
48
+ const controller = (target.split('#')[0] ?? '').trim();
49
+ if (controller)
50
+ names.add(controller);
51
+ }
52
+ }
53
+ }
54
+ ts.forEachChild(node, visit);
55
+ };
56
+ visit(sf);
23
57
  return names;
24
58
  }
25
59
  /** apps/ 를 훑어 라우트에 등록되지 않은(고아) 컨트롤러를 경고로 낸다. */
@@ -1,6 +1,6 @@
1
1
  // @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix · M9-E 확장 · E-5)
2
2
  //
3
- // 25 검사(전체 목록은 AGENTS §2.2 · doctor.ts `ALL_RULES`)가 모두 이 DoctorCheck
3
+ // 모든 doctor 검사(정본 목록·개수는 doctor.ts `ALL_RULES` · AGENTS §2.2)가 이 DoctorCheck
4
4
  // 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
5
5
  // warnings/errors 로 갈라 담는다. 자동화(CI)는 JSON 을 파싱해
6
6
  // errors.length > 0 이면 fail 로 판단한다.
package/dist/index.d.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { type DevCommandOptions } from "./commands/dev.js";
2
+ import { type ServeCommandOptions } from "./serve.js";
1
3
  import { type DoctorRule } from "./doctor.js";
2
4
  export { startDev, resolveDevLayout, regenerateGaonOnce, type DevDeps, type DevLayout, type DevApp, type DevEvent, type DevHandle, type RegenDeps, type RegenResult, } from "./dev.js";
3
5
  export { runDevCommand, type DevCommandOptions } from "./commands/dev.js";
@@ -70,5 +72,16 @@ export interface ParsedNewArgs {
70
72
  * npm 이 프로젝트 이름으로 오인되지 않도록(결정 167 · O-1 근본 fix).
71
73
  */
72
74
  export declare function parseNewArgs(rest: readonly string[]): ParsedNewArgs;
75
+ /**
76
+ * `--port <값>` 플래그를 읽어 검증한다(serve·dev 공용). 플래그가 없으면
77
+ * undefined(기본 포트 폴백). 플래그는 있는데 값이 없거나(마지막 토큰) 다른
78
+ * 플래그면 fail-loud, 값이 있으면 parsePort 로 1~65535 정수를 강제한다 —
79
+ * `Number('abc')=NaN` 이 listen 까지 조용히 흐르던 것을 파싱 경계에서 막는다(결정 240).
80
+ */
81
+ export declare function readPortFlag(argv: readonly string[]): number | undefined;
82
+ /** `gaon serve` argv → ServeCommandOptions. 포트 검증은 fail-loud(결정 240). */
83
+ export declare function parseServeArgs(argv: readonly string[]): ServeCommandOptions;
84
+ /** `gaon dev` argv → DevCommandOptions. 포트 검증은 fail-loud(결정 240). */
85
+ export declare function parseDevArgs(argv: readonly string[]): DevCommandOptions;
73
86
  /** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
74
87
  export declare function runCli(argv: readonly string[], opts?: RunOptions): void;
package/dist/index.js CHANGED
@@ -12,6 +12,8 @@
12
12
  */
13
13
  import { MILESTONES, VERSION, HOMEPAGE, loadDotEnv } from "@gaonjs/core";
14
14
  import { runDevCommand } from "./commands/dev.js";
15
+ import { runServeCommand } from "./serve.js";
16
+ import { parsePort } from "./port.js";
15
17
  import { runCheckCommand } from "./commands/check.js";
16
18
  import { runGenCommand } from "./commands/gen.js";
17
19
  import { runBuildCommand } from "./commands/build.js";
@@ -22,7 +24,6 @@ import { runGenerateAuthCommand } from "./generate.js";
22
24
  import { runGenerateUiKitCommand } from "./uikit.js";
23
25
  import { runGenerateCommand } from "./commands/g.js";
24
26
  import { runHubCommand } from "./hub.js";
25
- import { runServeCommand } from "./serve.js";
26
27
  import { runWorkCommand } from "./work.js";
27
28
  import { runJobsCommand } from "./jobs.js";
28
29
  import { runDbCommand } from "./commands/db.js";
@@ -98,7 +99,7 @@ function renderHelp(version = VERSION) {
98
99
  " gaon new <name> 새 프로젝트 스캐폴드 (파일 → 설치 → git · --skip-install · --skip-git · --package-manager <pnpm|npm|yarn>)",
99
100
  " gaon dev 개발 스택 통합 (Docker · .gaon · serve · work · hub · tsc/vue-tsc · 재시작 워처)",
100
101
  " gaon dev --stop-docker Ctrl+C 시 Docker Compose 도 down",
101
- " gaon dev --no-watch|--no-tsc|--no-vue-tsc|--no-docker|--no-work|--no-hub 개별 debug 옵션",
102
+ " gaon dev --no-watch|--no-tsc|--no-vue-tsc|--no-docker|--no-vite|--no-work|--no-hub 개별 debug 옵션",
102
103
  " gaon dev --port <n> --host <h> serve 리슨 지정",
103
104
  " gaon dev --json 통합 콘솔을 JSON 라인으로 출력(자동화)",
104
105
  " gaon serve 웹 서버 부팅 (gaon.config.ts 자동 배선 · Fastify listen)",
@@ -109,7 +110,7 @@ function renderHelp(version = VERSION) {
109
110
  " gaon build 멀티 앱 프론트 프로덕션 빌드 (gaon gen + apps/* 순회 · 앱별 dist/<앱>·base=/<앱>/ · --json)",
110
111
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
111
112
  " gaon test 테스트 러너 (테스트 DB <db>_test 자동 생성·마이그레이션 후 vitest · --scope unit|integration|all · -- vitest 인자)",
112
- " gaon doctor 정적 검사 (27 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계·import.meta.env·로케일 커버리지)",
113
+ ` gaon doctor 정적 검사 (${ALL_RULES.length} 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선·라우트 등록·정적 충돌·_method·CSRF 배선·내부 앵커·pageProps 구조분해·비동기 오프로드·페이지 레이아웃 브레이크포인트·Link>Button 중첩·seal 클라 배선·보안 역전·§4.5 관계·import.meta.env·로케일 커버리지)`,
113
114
  " gaon doctor --json 자동화용 JSON 출력",
114
115
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
115
116
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -196,6 +197,61 @@ export function parseNewArgs(rest) {
196
197
  }
197
198
  return { name, unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw };
198
199
  }
200
+ /**
201
+ * `--port <값>` 플래그를 읽어 검증한다(serve·dev 공용). 플래그가 없으면
202
+ * undefined(기본 포트 폴백). 플래그는 있는데 값이 없거나(마지막 토큰) 다른
203
+ * 플래그면 fail-loud, 값이 있으면 parsePort 로 1~65535 정수를 강제한다 —
204
+ * `Number('abc')=NaN` 이 listen 까지 조용히 흐르던 것을 파싱 경계에서 막는다(결정 240).
205
+ */
206
+ export function readPortFlag(argv) {
207
+ const idx = argv.indexOf("--port");
208
+ if (idx < 0)
209
+ return undefined;
210
+ const raw = argv[idx + 1];
211
+ if (raw === undefined || raw.startsWith("-")) {
212
+ throw new Error("--port 값이 없습니다.\n" +
213
+ " → 예: gaon serve --port 3000 (1~65535 정수). 지정하지 않으면 기본 포트를 씁니다.");
214
+ }
215
+ return parsePort(raw, "--port");
216
+ }
217
+ /** `gaon serve` argv → ServeCommandOptions. 포트 검증은 fail-loud(결정 240). */
218
+ export function parseServeArgs(argv) {
219
+ const hostIdx = argv.indexOf("--host");
220
+ const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined;
221
+ const workersIdx = argv.indexOf("--workers");
222
+ // --workers <n|auto>: node:cluster 다중화(결정 84). env WEB_CONCURRENCY 도 가능.
223
+ const workersRaw = workersIdx >= 0 ? argv[workersIdx + 1] : undefined;
224
+ const workers = workersRaw === "auto" ? "auto" : workersRaw !== undefined ? Number(workersRaw) : undefined;
225
+ return {
226
+ json: argv.includes("--json"),
227
+ port: readPortFlag(argv),
228
+ host,
229
+ workers,
230
+ // --dev: dev 전용 진단 라우트(/_gaon/health) 등록. gaon dev 가 자식 serve 에 넘긴다(결정 69).
231
+ dev: argv.includes("--dev"),
232
+ };
233
+ }
234
+ /** `gaon dev` argv → DevCommandOptions. 포트 검증은 fail-loud(결정 240). */
235
+ export function parseDevArgs(argv) {
236
+ const hostIdx = argv.indexOf("--host");
237
+ const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined;
238
+ return {
239
+ json: argv.includes("--json"),
240
+ stopDocker: argv.includes("--stop-docker"),
241
+ noWatch: argv.includes("--no-watch"),
242
+ noTsc: argv.includes("--no-tsc"),
243
+ noVueTsc: argv.includes("--no-vue-tsc"),
244
+ noDocker: argv.includes("--no-docker"),
245
+ // --no-vite: 프론트 watch 빌드(vite build --watch) 끄기(결정 239). vite.ts 안내가
246
+ // 이 플래그를 광고했으나 파싱이 없어 조용히 무시되던 드리프트를 닫는다.
247
+ noVite: argv.includes("--no-vite"),
248
+ noWork: argv.includes("--no-work"),
249
+ noHub: argv.includes("--no-hub"),
250
+ timestamp: argv.includes("--timestamp"),
251
+ port: readPortFlag(argv),
252
+ host,
253
+ };
254
+ }
199
255
  /** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
200
256
  export function runCli(argv, opts = {}) {
201
257
  const version = opts.version ?? VERSION;
@@ -212,23 +268,17 @@ export function runCli(argv, opts = {}) {
212
268
  // (serve·work·hub → tsc → 워처 → Docker[--stop-docker 시]).
213
269
  // 개별 debug 옵션은 --no-watch / --no-tsc / --no-vue-tsc / --no-docker / --no-work / --no-hub.
214
270
  if (argv[0] === "dev") {
215
- const portIdx = argv.indexOf("--port");
216
- const hostIdx = argv.indexOf("--host");
217
- const port = portIdx >= 0 ? Number(argv[portIdx + 1]) : undefined;
218
- const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined;
219
- void runDevCommand({
220
- json: argv.includes("--json"),
221
- stopDocker: argv.includes("--stop-docker"),
222
- noWatch: argv.includes("--no-watch"),
223
- noTsc: argv.includes("--no-tsc"),
224
- noVueTsc: argv.includes("--no-vue-tsc"),
225
- noDocker: argv.includes("--no-docker"),
226
- noWork: argv.includes("--no-work"),
227
- noHub: argv.includes("--no-hub"),
228
- timestamp: argv.includes("--timestamp"),
229
- port,
230
- host,
231
- }).catch((err) => {
271
+ let devOpts;
272
+ try {
273
+ devOpts = parseDevArgs(argv);
274
+ }
275
+ catch (err) {
276
+ // 포트 등 인자 파싱 오류는 부팅 전에 즉시 알린다(결정 240 · fail-loud).
277
+ process.stderr.write(` ✗ gaon dev: ${err instanceof Error ? err.message : String(err)}\n`);
278
+ process.exitCode = 1;
279
+ return;
280
+ }
281
+ void runDevCommand(devOpts).catch((err) => {
232
282
  const msg = err instanceof Error ? err.message : String(err);
233
283
  process.stderr.write(` ✗ gaon dev 실패: ${msg}\n`);
234
284
  process.exitCode = 1;
@@ -237,17 +287,17 @@ export function runCli(argv, opts = {}) {
237
287
  }
238
288
  // `gaon serve` — 웹 서버 부팅(§7 · M9-A). gaon.config.ts 자동 배선 후 listen.
239
289
  if (argv[0] === "serve") {
240
- const portIdx = argv.indexOf("--port");
241
- const hostIdx = argv.indexOf("--host");
242
- const workersIdx = argv.indexOf("--workers");
243
- const port = portIdx >= 0 ? Number(argv[portIdx + 1]) : undefined;
244
- const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined;
245
- // --workers <n|auto>: node:cluster 다중화(결정 84). env WEB_CONCURRENCY 도 가능.
246
- const workersRaw = workersIdx >= 0 ? argv[workersIdx + 1] : undefined;
247
- const workers = workersRaw === "auto" ? "auto" : workersRaw !== undefined ? Number(workersRaw) : undefined;
248
- // --dev: dev 전용 진단 라우트(/_gaon/health) 등록. gaon dev 가 자식
249
- // serve 에 넘긴다(결정 69 · dev-only by construction).
250
- void runServeCommand({ json: argv.includes("--json"), port, host, workers, dev: argv.includes("--dev") }).catch((err) => {
290
+ let serveOpts;
291
+ try {
292
+ serveOpts = parseServeArgs(argv);
293
+ }
294
+ catch (err) {
295
+ // 포트 인자 파싱 오류는 부팅 전에 즉시 알린다(결정 240 · fail-loud).
296
+ process.stderr.write(` ✗ gaon serve: ${err instanceof Error ? err.message : String(err)}\n`);
297
+ process.exitCode = 1;
298
+ return;
299
+ }
300
+ void runServeCommand(serveOpts).catch((err) => {
251
301
  const msg = err instanceof Error ? err.message : String(err);
252
302
  process.stderr.write(` ✗ gaon serve 실패: ${msg}\n`);
253
303
  process.exitCode = 1;
package/dist/port.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * 포트 문자열을 1~65535 정수로 검증한다. 유효하지 않으면 throw.
3
+ * @param raw 검증할 값(플래그/ env 에서 온 문자열). 반드시 존재하는 값이어야 한다.
4
+ * @param source 에러 문맥 라벨(예 `--port` · `env PORT`).
5
+ */
6
+ export declare function parsePort(raw: string, source: string): number;
package/dist/port.js ADDED
@@ -0,0 +1,21 @@
1
+ // @gaonjs/cli · 포트 파싱/검증 (감사 P2 D3 · 결정 240)
2
+ //
3
+ // `--port <n>` (serve·dev)·env PORT 를 1~65535 정수로만 받는다. 비숫자·
4
+ // 범위 밖은 조용한 NaN 전파(Fastify listen 이 알 수 없는 포트로 뜨거나
5
+ // 실패) 대신 즉시 throw 한다 — fail-loud(§7.5.3 · 에러 = 수리 안내서).
6
+ //
7
+ // 미지정(플래그·env 부재)은 검증 대상이 아니다 — 호출부가 기본 포트로
8
+ // 폴백한다. 검증은 "값이 주어졌는데 유효하지 않을 때" 만 개입한다.
9
+ /**
10
+ * 포트 문자열을 1~65535 정수로 검증한다. 유효하지 않으면 throw.
11
+ * @param raw 검증할 값(플래그/ env 에서 온 문자열). 반드시 존재하는 값이어야 한다.
12
+ * @param source 에러 문맥 라벨(예 `--port` · `env PORT`).
13
+ */
14
+ export function parsePort(raw, source) {
15
+ const n = Number(raw);
16
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
17
+ throw new Error(`유효하지 않은 포트(${source}): '${raw}'.\n` +
18
+ ` → 포트는 1~65535 사이의 정수여야 합니다(예: 3000). 지정하지 않으면 기본 포트를 씁니다.`);
19
+ }
20
+ return n;
21
+ }
package/dist/serve.js CHANGED
@@ -18,6 +18,7 @@ import { availableParallelism } from 'node:os';
18
18
  import { loadDotEnv } from '@gaonjs/core';
19
19
  import { loadGaonConfig, wireGaon, findConfigPath } from '@gaonjs/config';
20
20
  import { registerTsResolve } from './tsResolve.js';
21
+ import { parsePort } from './port.js';
21
22
  import { computeHealth, DEV_HEALTH_PATH } from './dev/health.js';
22
23
  /**
23
24
  * 워커 수를 결정한다: 옵션 > env `WEB_CONCURRENCY` > 1. `'auto'` = 코어 수
@@ -135,9 +136,10 @@ export async function runServeCommand(opts = {}) {
135
136
  const wired = await wireGaon(config, cwd);
136
137
  emit({ kind: 'starting', configPath, apps: wired.apps.map((a) => a.name) });
137
138
  const host = opts.host ?? config.web?.host ?? '0.0.0.0';
138
- const port = opts.port ??
139
- config.web?.port ??
140
- (process.env.PORT ? Number(process.env.PORT) : 3000);
139
+ // opts.port(프로그램적)는 검증하지 않는다 — port:0(임의 포트) 같은 정당한 값이 있다.
140
+ // env PORT 는 사용자 입력이라 비숫자면 NaN 대신 fail-loud(결정 240 · §7.5.3).
141
+ const envPort = process.env.PORT != null && process.env.PORT !== '' ? parsePort(process.env.PORT, 'env PORT') : undefined;
142
+ const port = opts.port ?? config.web?.port ?? envPort ?? 3000;
141
143
  // dev 전용 진단 라우트(결정 69). listen 전에 등록한다 — 운영 serve 는
142
144
  // opts.dev 가 없어 등록되지 않으므로 /_gaon/health 는 production 에 없다.
143
145
  // wired.app 은 여기서 FastifyInstance 로 해상되므로(cli 는 fastify 타입을
@@ -1,4 +1,4 @@
1
- # {{PROJECT_NAME}} 환경 변수 — cp .env.example .env 편집.
1
+ # {{PROJECT_NAME}} 환경 변수 — gaon new 가 .env 로 자동 복제(결정 198). 값만 채워 쓰세요.
2
2
  # gaon.config.ts 의 env('KEY') 로 참조된다. gaon dev · gaon serve 가 자동 로드.
3
3
 
4
4
  # DB — docker-compose.yaml 의 postgres 서비스와 정합.
@@ -77,7 +77,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
77
77
  │ └─ composables/ shared 컴포저블 (인자로만 · E-5)
78
78
  ├─ gaon.config.ts 루트 설정 (DB · Redis · NATS · ...)
79
79
  ├─ docker-compose.yaml 개발 인프라 (gaon dev 자동 기동)
80
- ├─ .env.example env 템플릿 (cp .env.example .env)
80
+ ├─ .env.example env 템플릿 (gaon new 가 .env 로 자동 복제 · 결정 198)
81
81
  └─ package.json 개발자는 gaonjs 하나만 설치
82
82
  ```
83
83
 
@@ -85,7 +85,7 @@ Gaon 프레임웍 문서: https://gaonjs.dev
85
85
 
86
86
  ```bash
87
87
  gaon check # .gaon 재생성 → 타입검사+build+doctor (CI 한 번에 · --no-doctor 로 doctor 뺌)
88
- gaon doctor # 정적 검사 25종 (상세 AGENTS §2.2)
88
+ gaon doctor # 정적 검사 27종 (상세 AGENTS §2.2)
89
89
  npm test # Vitest · DB 테스트는 실 Docker 필수 (§9)
90
90
  ```
91
91
 
@@ -256,6 +256,11 @@ wall-clock 으로 매치한다(`new Date()` 로컬 시·분·요일). v1 은 **
256
256
  타임존으로 띄운다(예: `TZ=Asia/Seoul gaon work`). 여러 인스턴스는 같은 TZ 로
257
257
  맞춘다(리더가 어느 인스턴스든 같은 wall-clock 을 봐야 한다).
258
258
 
259
+ `gaon.config.ts` 의 `timezone` 을 두면 `gaon work`(부팅 시 wireDomain)가 그
260
+ 값을 `process.env.TZ` 로 세팅한다 — 이 경우 **config.timezone 이 런치 `TZ`
261
+ 환경변수보다 우선**한다(결정 230). 앱 전역에 한 타임존을 못박는 One Way 로,
262
+ serve·work 가 같은 wall-clock 을 보게 하려면 런치별 `TZ` 대신 config 를 쓴다.
263
+
259
264
  #### 중첩 방지 (결정 202)
260
265
 
261
266
  스케줄러는 매 주기 **발행만** 한다(§7 리더). 한 주기보다 오래 걸리는 잡이
@@ -472,11 +472,14 @@ export const Post = model(posts, {
472
472
  })
473
473
  ```
474
474
 
475
- - **여러 모델을 조합하는 읽기 질의도 이름을 붙인다 (§5.3 읽기 규칙 · 결정 114).**
475
+ - **여러 모델을 조합하는 읽기 질의도 이름을 붙인다 (§5.3 읽기 규칙 · 결정 114·228).**
476
476
  검색·태그 필터처럼 여러 모델/서브쿼리를 엮는 읽기는 컨트롤러에 인라인 조립하지
477
- 않는다 모델이 분명하면 **모델 정적 메서드(스코프)**, 대등한 조합이면
478
- `domain/services/` 이름을 붙인다. 컨트롤러에 허용되는 쿼리는 **스코프 체인
479
- 한 줄**까지다(`await Post.published().latest().limit(20).all()`).
477
+ 않는다. **다중 컬럼 검색(whereAny)만이면 스코프**로 이름 붙인다(아래 ). 그러나
478
+ **`join()` 끼는 조합(관계·태그를 조인으로 좁히는 읽기)은 스코프에 못 담는다** —
479
+ `join()` 은 `JoinChain` 을 반환하는데 스코프 함수(ScopeFn)`Chain` 반환을 요구해
480
+ 타입이 안 맞는다(`Chain` 에 `JoinChain` 대입 = TS2322). 이때 정본은 **`domain/services/`
481
+ 에 이름 붙인 서비스**다(결정 228). 컨트롤러에 허용되는 쿼리는 **스코프/서비스 호출
482
+ 한 줄**까지다(`await Post.searchPublished(term).latest().all()` · `await searchPostsByTag(..)`).
480
483
  ```ts
481
484
  // ❌ 컨트롤러 인라인 조립 (검색 교집합을 컨트롤러가 조립)
482
485
  // const ids = await Post.where('title','ilike',p).orWhere('body','ilike',p).pluck('id')
@@ -491,6 +494,20 @@ export const Post = model(posts, {
491
494
  }
492
495
  // 컨트롤러: const rows = await Post.searchPublished(term).latest().all()
493
496
  ```
497
+ **조인이 끼는 조합(태그 필터 + 검색)은 서비스로** — 스코프는 타입 불가(결정 228):
498
+ ```ts
499
+ // domain/services/searchPostsByTag.ts — join 은 스코프에 못 담아 서비스가 정본.
500
+ export async function searchPostsByTag(input: { q?: string; tagId: string; page?: number }) {
501
+ const term = String(input.q ?? '').trim()
502
+ return await Post
503
+ .join('posts_tags', 'posts_tags.postId', 'posts.id') // 태그 필터
504
+ .where('posts_tags.tagId', '=', BigInt(input.tagId))
505
+ .where('published', '=', true)
506
+ .whereAny(['title', 'body'], 'ilike', `%${term}%`) // 검색(괄호로 묶임)
507
+ .paginate(input.page ?? 1, 20)
508
+ }
509
+ // 컨트롤러 index 는 위임만: const result = await searchPostsByTag({ q, tagId, page })
510
+ ```
494
511
 
495
512
  ### 8.1 스키마 파생 폼 — `Model.form` · `Model.form.pick()` (결정 104)
496
513
 
@@ -28,7 +28,7 @@ export const WelcomeMail = mail<{ name: string; email: string }>((u) => ({
28
28
  |---|---|---|
29
29
  | 정의 | `mail<T>((data) => MailMessage)` | 파일명 = 이름 |
30
30
  | 발송 | `def.deliver(data, { locale?, to? })` | 설정된 SMTP 로 보냄 |
31
- | 미리보기 | `def.render(data, { locale? })` | 발송 없이 메시지만(테스트·미리보기) |
31
+ | 미리보기 | `def.render(data, { locale?, to? })` | 발송 없이 메시지만(테스트·미리보기) · `to` 는 `deliver` 와 대칭 |
32
32
 
33
33
  ### 2. 로케일 메일 — `deliver(data, { locale })` (결정 160)
34
34
 
@@ -57,6 +57,8 @@ await WelcomeMail.deliver(data, { locale: 'ja', to: 'ops@example.com' })
57
57
  ·`.env.example` 에 mail 블록이 이미 있어(결정 160) `cp .env.example .env` 후 바로 돈다.
58
58
  운영은 `SMTP_HOST`·자격증명·`SMTP_SECURE=true` 로 교체(같은 코드).
59
59
 
60
+ - **미리보기 정본 = MailPit** 이다 — 조용히 메모리로 삼키는 `captureTransport`(in-memory 싱크)는 **프레임웍 내부 테스트 전용**이라 파사드(`gaonjs/mail`)에 노출하지 않는다(결정 238 · 프로덕션 오배선 시 무신호 유실 방지).
61
+
60
62
  ### 5. `gaon.config.ts` 의 `mail` 블록 (env-gated)
61
63
 
62
64
  메일러는 루트 `gaon.config.ts` 의 `mail` 블록으로 배선된다 — `db`·`redis`·`nats`
@@ -111,6 +111,39 @@ export default controller({
111
111
  - **realtime(NATS) 미설정 앱**에서 호출하면 수리 안내와 함께 throw · **구독자 없음**이면 조용히
112
112
  아무 데도 안 간다(fire-and-forget · 예외 아님).
113
113
 
114
+ ### 2.6 특정/다중 유저 타겟 발송 (결정 227)
115
+
116
+ 채널 전체가 아니라 **특정 유저(들)에게만** 밀 때는 `gaonjs/async` 의
117
+ `sendToUsers(name, userIds, data)` 를 쓴다 — `broadcast(name, data)` 와 **대칭**이되
118
+ 대상 userId 를 지정한다(`broadcast` = 전체 · `sendToUsers` = 지정 유저). `userIds` 는 한
119
+ 명(문자열)이나 여러 명(배열)이고, 대상은 세션 로그인 유저(멤버 `user:<id>`)다.
120
+
121
+ ```ts
122
+ // apps/web/controllers/notifications.ts — service·job·listener 어디서든 동일
123
+ import { controller } from 'gaonjs/web'
124
+ import { sendToUsers } from 'gaonjs/async'
125
+
126
+ export default controller({
127
+ async ping() {
128
+ // 한 명: 'notifications' 채널의 유저 42 에게만.
129
+ const reached = await sendToUsers('notifications', String(42), { type: 'ping' })
130
+ // 여러 명:
131
+ // await sendToUsers('notifications', ['1', '2', '3'], { type: 'announce' })
132
+ return { reached } // reached = 그 채널에 접속한 대상 유저 수
133
+ },
134
+ })
135
+ ```
136
+
137
+ - **전달 대상** — 대상 유저의 **살아있는 연결에만** 간다. 멀티탭이면 그 유저의 **모든 연결**이
138
+ 받고(연결별 전달), 멀티서버여도 대상이 어느 서버에 붙어 있든 받는다(각 서버가 자기 로컬
139
+ 연결을 필터). 비대상 유저는 안 받는다.
140
+ - **반환 = 도달한 대상 유저 수**(`Promise<number>`). 그 채널에 접속(present)한 대상 수를
141
+ 프레즌스 권위(허브 KV)에서 센다. **대상이 전원 오프라인이면 `0` 을 정상 반환**한다 — throw
142
+ 가 아니라 반환값으로 미도달을 알린다(조용히 삼키지 않음). 멀티탭 유저는 연결이 여럿이어도
143
+ present **유저** 기준이라 `1` 로 센다.
144
+ - **아키텍처(errata E-2)** — `broadcast` 와 같은 NATS 채널 subject 를 타므로 새 연결·프로토콜이
145
+ 없다. seal 재봉인·authorize 규칙(§2.5)도 그대로다(authorize 는 구독 시점 게이트 · 재실행 없음).
146
+
114
147
  ### 3. 프레즌스
115
148
 
116
149
  `ctx.presence()` 는 **전 서버의** 현재 접속자를 돌려준다. 목록의 권위는
@@ -125,6 +158,11 @@ const members = await ctx.presence()
125
158
  - **read**(`presence()`)는 KV(권위)를 직접 조회한다.
126
159
  - leave 는 best-effort 이고, 서버가 죽으면 허브가 그 서버의 멤버 전원을
127
160
  **즉시** 정리한다 (TCP `close`).
161
+ - **멤버는 연결 단위로 refcount 된다(결정 225).** 같은 유저(`user:<id>`)가
162
+ 멀티탭·멀티서버로 여러 연결을 열면, **살아 있는 연결이 하나라도 있는 동안
163
+ 로스터에 유지**된다 — 탭 하나를 닫아도 이탈이 아니고, 마지막 연결이 끊길
164
+ 때만 leave 델타가 나간다. 한 서버가 죽어도(cleanupServer) 같은 유저가 다른
165
+ 서버에 붙어 있으면 그 유저는 남는다.
128
166
 
129
167
  ### 4. 클라이언트 (`useChannel` · 결정 87)
130
168
 
@@ -205,7 +243,8 @@ export function useRoom(roomId: number) {
205
243
  | 발화 주체 | 표면 | 쓰는 곳 |
206
244
  | --- | --- | --- |
207
245
  | 클라 메시지에 응답 | `ctx.broadcast(data)` | 채널 훅 `onMessage`(클라 메시지 필요) |
208
- | 서버가 단독으로 밀기 | `broadcast(name, data)` (`gaonjs/async`) | 컨트롤러·서비스·잡·리스너 (클라 메시지 없이 · 결정 126) |
246
+ | 서버가 채널 전체로 밀기 | `broadcast(name, data)` (`gaonjs/async`) | 컨트롤러·서비스·잡·리스너 (클라 메시지 없이 · 결정 126) |
247
+ | 서버가 특정 유저(들)에게 밀기 | `sendToUsers(name, userIds, data)` (`gaonjs/async`) | 컨트롤러·서비스·잡·리스너 (지정 유저만 · 도달 수 반환 · 결정 227) |
209
248
 
210
249
  ```ts
211
250
  // apps/web/channels/chatMessages.ts — 파일명 camelCase · 클라 메시지 응답형
@@ -251,6 +290,11 @@ export default channel({
251
290
  (E-2). NATS 는 broadcast 팬아웃 전용.
252
291
  - **서버 푸시 데이터를 `api()` 폴링으로 대체 금지** — 데이터 경로
253
292
  판단표 위반.
293
+ - **특정 유저 발송을 `broadcast` + 클라 필터로 흉내내지 말 것** — 전체
294
+ broadcast 로 밀고 클라가 `if (내 id)` 로 거르면 **민감 페이로드가 전원에게
295
+ 샌다**(클라가 버려도 이미 도달). 지정 유저는 `sendToUsers`(서버가 대상
296
+ 연결에만 전달 · 결정 227). `sendToUsers` 는 그 **채널에 접속한** 유저만
297
+ 대상이다 — 접속 안 한(오프라인) 유저는 `0` 도달로 반환된다.
254
298
  - **자기 자신의 join 델타** — 클라이언트는 자기 `presence:join` 델타도
255
299
  스냅샷과 별개로 받는다(멱등이라 무해). 필요하면 자기 `id` 로 필터.
256
300
  - **테스트에서 NATS·허브 목업 금지** (§9) — 실 인프라
@@ -265,6 +309,8 @@ export default channel({
265
309
  | 결정 126 | 서버 개시 `broadcast(name, data)`(`gaonjs/async`) — 컨트롤러·서비스·잡에서 클라 메시지 없이 채널 발화 · authorize 재실행 없음 · seal 재봉인 자동 |
266
310
  | 결정 154 | `useChannel` 앱 프리픽스 자동 주입 — `import.meta.env.BASE_URL`(vite base·에셋 base 단일 소스) 로 `<프리픽스>/gaon/ws/<name>` · `opts.path` 는 탈출구 · 프리픽스 앱 실시간 무한 재연결 제거(§4) |
267
311
  | 결정 207 | 허브 fail-fast(§5) — 리스는 얻고 TCP 포트 bind 실패 시 좀비 리더 대신 리스 사임 + `process.exit(1)`(F-13 fix · `onFatal` 훅으로 주입 가능) |
312
+ | 결정 225 | 프레즌스 연결 축 refcount(§3) — 같은 멤버의 멀티탭·멀티서버 연결을 refcount 해 마지막 연결에서만 이탈 · cleanupServer 는 그 서버 연결만 회수(타서버 불간섭) |
313
+ | 결정 227 | 특정/다중 유저 타겟 발송(§2.6) — `sendToUsers(name, userIds, data)` · `broadcast` 와 대칭 · 대상 연결에만 전달(멀티서버·멀티탭) · 도달 유저 수 반환(오프라인=0) · 수정 1 연결 추적 위에 얹음 |
268
314
 
269
315
  ## `@gaonjs/seal` 켠 앱의 채널
270
316
 
@@ -177,7 +177,7 @@ seal 앱 응답에만 `script-src` 에 `'wasm-unsafe-eval'` 을 **자동 주입*
177
177
  5. **반쪽 봉인 금지** — "wire 전체 봉인" 기대. HTTP 만 봉인하고 WS 를 빼먹지 말 것(seal 앱 namespace 는 requireDecrypt).
178
178
  6. **비-seal 앱 번들에 wasm 유입 금지** — `@gaonjs/vue` 가 seal 을 직접 참조하면 회귀. 게이트가 무-wasm 번들을 단언한다.
179
179
 
180
- ## 관련 결정
180
+ ## 관련 결정 번호
181
181
 
182
182
  - **결정 121** — `@gaonjs/seal` 신설(GSP 이식 · 인터셉터 설계 · 앱 토글 · WS requireDecrypt · 기각 대안 4건).
183
183
  - **결정 124** — §3.1 개정: **app-side 정적 주입**(변수 동적 import 폐기) · **wasm 표면 은닉**(불투명 함수 · domain/ua/path 를 wasm 이 확보 · 미끼 시크릿 내장) · **WS 클라 봉인**(`setWsFrameCodec`) · **seal 앱 한정 CSP** · doctor `seal-security` main.ts 배선 검사 + `seal-client-wiring` fixer · 실 브라우저 e2e 게이트 · 부수 정정(`.wasm` MIME · `session.csrf` forwarding).
@@ -27,6 +27,11 @@
27
27
  `expiresIn`(초)로 만료를 조절한다. 존재하지 않는 `Attachment.urlFor` 같은
28
28
  헬퍼를 만들지 말 것 — 표면은 `Storage.url()` 뿐이다.
29
29
  - **키는 경로**다(`avatars/${user.id}.png`). 앞 슬래시는 정규화된다.
30
+ - **`contentType` 은 어댑터별로 다르게 반영된다**(결정 236): `s3` 는 객체
31
+ 메타데이터로 저장해 다운로드·presign 시 그대로 나가고, **로컬**은 저장하지
32
+ 않고 **서빙 시 key 확장자로 Content-Type 이 결정**된다(로컬엔 메타데이터
33
+ 채널이 없음). 로컬에서 타입을 보장하려면 **key 에 올바른 확장자**를 쓰거나
34
+ s3 를 쓴다 — 조용한 무시가 아니라 명시된 관례다.
30
35
 
31
36
  ### 2. 설정 (`gaon.config.ts`)
32
37
 
@@ -19,7 +19,6 @@
19
19
  },
20
20
  "dependencies": {
21
21
  "gaonjs": "{{GAONJS_VERSION}}",
22
- "@inertiajs/vue3": "^3.6.0",
23
22
  "vue": "^3.5.0"
24
23
  },
25
24
  "devDependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.40.4",
3
+ "version": "0.41.0",
4
4
  "description": "Gaon CLI — 스캐폴딩·제너레이터·마이그레이션·dev/serve/work/hub·doctor·check (bin: gaon)",
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/config": "0.16.1",
31
- "@gaonjs/core": "0.2.2",
32
- "@gaonjs/i18n": "0.2.1",
33
- "@gaonjs/data": "0.16.3",
34
- "@gaonjs/async": "0.13.0",
35
- "@gaonjs/web": "0.19.1",
36
- "@gaonjs/mail": "0.2.1"
30
+ "@gaonjs/async": "0.15.0",
31
+ "@gaonjs/config": "0.17.0",
32
+ "@gaonjs/core": "0.2.3",
33
+ "@gaonjs/data": "0.17.0",
34
+ "@gaonjs/mail": "0.3.0",
35
+ "@gaonjs/i18n": "0.2.2",
36
+ "@gaonjs/web": "0.19.2"
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})\""