@gaonjs/cli 0.36.0 → 0.38.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.
@@ -0,0 +1,98 @@
1
+ // @gaonjs/cli · doctor · `.vue` 의 import.meta.env 직접 사용 검출 (결정 198 · F-9 옵션 ② · error)
2
+ //
3
+ // `.vue`(SFC)에서 `import.meta.env.*` 를 직접 쓰면 vue-tsc 가 SFC 가상 모듈을 nodenext
4
+ // CommonJS 출력으로 분류해 **TS1470**(`import.meta` 는 CommonJS 출력 파일에서 불가)로
5
+ // 거부한다 — `gaon check` 가 red 지만 에러 문구가 원인·수리를 안 알려준다(F-9). 이 검사가
6
+ // 그 지점을 먼저 잡아 수리 안내(`env` 접근자)를 준다. 값은 소스 텍스트 기반(주석 제외).
7
+ //
8
+ // 정답 경로(결정 198 · A/B): 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로
9
+ // 읽는다 — `VITE_API_URL` → `env.API_URL`(접두 제거·타입드) · 내장은 `env.dev/prod/mode/
10
+ // baseUrl`. `.env` 의 VITE_* 키가 `.gaon/env.d.ts` 로 물성화돼 없는 키는 컴파일 에러.
11
+ // `.ts`(main.ts 의 import.meta.glob 등)는 ESM 출력이라 문제없어 검사 대상이 아니다 — .vue 만.
12
+ import { readdir, readFile } from 'node:fs/promises';
13
+ import { join, relative } from 'node:path';
14
+ const blankKeepLines = (m) => m.replace(/[^\n]/g, ' ');
15
+ // import.meta 는 `<script>` 에서만 유효하다(템플릿 보간·프로즈에는 못 쓴다). SFC 의 `<template>`
16
+ // 텍스트가 "import.meta.env 미사용" 같은 프로즈로 언급하면 오탐이 되므로, 스캔 전 `<script>`
17
+ // 블록 외 영역을 공백으로 지운다(줄바꿈 보존 → 라인 번호 유지). .ts 는 전체가 스크립트다.
18
+ function scriptOnlyKeepLines(source, isVue) {
19
+ if (!isVue)
20
+ return source;
21
+ const kept = blankKeepLines(source); // 전체를 공백으로 시작 → script 블록만 복원.
22
+ const chars = kept.split('');
23
+ for (const m of source.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)) {
24
+ const inner = m[1];
25
+ const start = (m.index ?? 0) + m[0].indexOf(inner);
26
+ for (let i = 0; i < inner.length; i++)
27
+ chars[start + i] = inner[i];
28
+ }
29
+ return chars.join('');
30
+ }
31
+ // 주석을 공백으로 치환하되 줄바꿈은 보존한다(라인 번호 유지) — "쓰지 말라" 설명 주석 오탐 방지.
32
+ function stripCommentsKeepLines(source) {
33
+ return source
34
+ .replace(/\/\*[\s\S]*?\*\//g, blankKeepLines)
35
+ .replace(/<!--[\s\S]*?-->/g, blankKeepLines)
36
+ .replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
37
+ }
38
+ // import.meta.env 사용(공백 허용 · .env·[·글자 접근 모두). 문자열/식별자 오탐 최소화 위해
39
+ // `import.meta` 뒤 `.env` 또는 `['env']`/`["env"]` 만 잡는다.
40
+ const IMPORT_META_ENV = /import\s*\.\s*meta\s*(?:\.\s*env\b|\[\s*['"]env['"]\s*\])/;
41
+ /** 소스(.vue)에 import.meta.env 코드 사용이 있는지(단위 테스트 진입점 · 스크립트만·주석 제외). */
42
+ export function usesImportMetaEnv(source) {
43
+ return IMPORT_META_ENV.test(stripCommentsKeepLines(scriptOnlyKeepLines(source, true)));
44
+ }
45
+ /** apps/·shared/ 의 .vue 를 훑어 import.meta.env 직접 사용을 error 로 낸다(결정 198). */
46
+ export async function checkNoImportMetaEnv(cwd) {
47
+ const issues = [];
48
+ for (const base of ['apps', 'shared']) {
49
+ for (const abs of await walkVue(join(cwd, base))) {
50
+ const source = await readFile(abs, 'utf8').catch(() => '');
51
+ const stripped = stripCommentsKeepLines(scriptOnlyKeepLines(source, true));
52
+ const rel = relative(cwd, abs);
53
+ for (const line of stripped.split('\n').map((l, i) => ({ l, i }))) {
54
+ if (!IMPORT_META_ENV.test(line.l))
55
+ continue;
56
+ issues.push({
57
+ rule: 'no-import-meta-env',
58
+ level: 'error',
59
+ file: rel,
60
+ line: line.i + 1,
61
+ message: `\`.vue\` 에서 import.meta.env 직접 사용: ${rel}:${line.i + 1}. SFC 는 nodenext 아래 ` +
62
+ `CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부합니다(gaon check red · F-9).\n` +
63
+ `→ 클라 공개 환경변수는 \`env\` 접근자로 읽으세요: \`import { env } from 'gaonjs/vue'\` 후 ` +
64
+ `\`env.API_URL\`(= .env 의 VITE_API_URL · 접두 제거) · 내장은 \`env.dev/prod/mode/baseUrl\`. ` +
65
+ `없는 키는 \`.gaon/env.d.ts\`(.env 스캔 생성)로 컴파일 타임에 잡힙니다(결정 198).`,
66
+ detail: { file: rel, line: line.i + 1 },
67
+ });
68
+ }
69
+ }
70
+ }
71
+ return { rule: 'no-import-meta-env', issues };
72
+ }
73
+ /** dir 하위 .vue(node_modules·.gaon 제외) 절대경로. */
74
+ async function walkVue(dir) {
75
+ const out = [];
76
+ const walk = async (d) => {
77
+ let entries;
78
+ try {
79
+ entries = await readdir(d, { withFileTypes: true });
80
+ }
81
+ catch {
82
+ return;
83
+ }
84
+ for (const e of entries) {
85
+ const abs = join(d, e.name);
86
+ if (e.isDirectory()) {
87
+ if (e.name === 'node_modules' || e.name === '.gaon')
88
+ continue;
89
+ await walk(abs);
90
+ }
91
+ else if (e.isFile() && e.name.endsWith('.vue')) {
92
+ out.push(abs);
93
+ }
94
+ }
95
+ };
96
+ await walk(dir);
97
+ return out.sort();
98
+ }
@@ -1,4 +1,4 @@
1
- export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations';
1
+ export type DoctorRule = 'response-mixing' | 'n-plus-one' | 'dependency-direction' | 'connections' | 'migration-diff' | 'shared-composable-purity' | 'no-auto-import' | 'schema-filename' | 'agents-doc-index' | 'column-casing' | 'model-filename' | 'page-filename' | 'auth-wiring' | 'ui-kit-wiring' | 'route-registration' | 'static-collision' | 'method-override' | 'csrf-wiring' | 'internal-anchor' | 'pageprops-destructure' | 'async-offload' | 'page-layout-breakpoint' | 'link-button-nesting' | 'seal-security' | 'schema-relations' | 'no-import-meta-env';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
package/dist/doctor.d.ts CHANGED
@@ -25,10 +25,10 @@ export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-bu
25
25
  export { renderHuman, renderJson } from './doctor/reporter.js';
26
26
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
27
27
  /**
28
- * 실행할 검사 이름. 지정 없음(undefined) = 23개 모두.
28
+ * 실행할 검사 이름. 지정 없음(undefined) = 26개 모두.
29
29
  */
30
30
  /**
31
- * doctor 정적 검사 25종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
31
+ * doctor 정적 검사 26종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
32
32
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
33
33
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
34
34
  */
package/dist/doctor.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성 · E-5 확장)
3
3
  *
4
- * 25 검사를 조립한다:
4
+ * 26 검사를 조립한다:
5
5
  * 1) response-mixing (errata E-3 §C · 라이브)
6
6
  * 2) n-plus-one (errata E-4 (e))
7
7
  * 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
@@ -27,6 +27,7 @@
27
27
  * 23) link-button-nesting (결정 113 · Link 로 Button 감싸기 = <a><button> 중첩 경고)
28
28
  * 24) seal-security (결정 121 · seal 클라 배선 · 보안 역전)
29
29
  * 25) schema-relations (§4.5 · 결정 134 · 커넥션 가로지르는 belongsTo·관계 · 대상 부재 error)
30
+ * 26) no-import-meta-env (결정 198 · F-9 ② · `.vue` 의 import.meta.env = TS1470 → env 접근자 안내 error)
30
31
  *
31
32
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
32
33
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -64,6 +65,7 @@ import { checkAsyncOffload } from './doctor/async-offload.js';
64
65
  import { checkSealSecurity } from './doctor/seal-security.js';
65
66
  import { checkPageLayoutBreakpoint } from './doctor/page-layout-breakpoint.js';
66
67
  import { checkLinkButtonNesting } from './doctor/link-button-nesting.js';
68
+ import { checkNoImportMetaEnv } from './doctor/no-import-meta-env.js';
67
69
  import { renderHuman, renderJson } from './doctor/reporter.js';
68
70
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
69
71
  import { makeResult, } from './doctor/types.js';
@@ -92,10 +94,10 @@ export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-bu
92
94
  export { renderHuman, renderJson } from './doctor/reporter.js';
93
95
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
94
96
  /**
95
- * 실행할 검사 이름. 지정 없음(undefined) = 23개 모두.
97
+ * 실행할 검사 이름. 지정 없음(undefined) = 26개 모두.
96
98
  */
97
99
  /**
98
- * doctor 정적 검사 25종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
100
+ * doctor 정적 검사 26종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
99
101
  * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
100
102
  * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
101
103
  */
@@ -125,6 +127,7 @@ export const ALL_RULES = [
125
127
  'link-button-nesting',
126
128
  'seal-security',
127
129
  'schema-relations',
130
+ 'no-import-meta-env',
128
131
  ];
129
132
  const CHECKERS = {
130
133
  'response-mixing': checkResponseMixing,
@@ -152,6 +155,7 @@ const CHECKERS = {
152
155
  'link-button-nesting': checkLinkButtonNesting,
153
156
  'seal-security': checkSealSecurity,
154
157
  'schema-relations': checkSchemaRelations,
158
+ 'no-import-meta-env': checkNoImportMetaEnv,
155
159
  };
156
160
  /**
157
161
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
@@ -0,0 +1,12 @@
1
+ /** `.env` 본문에서 `VITE_*` 키를 접두 제거해 뽑는다(중복 제거·정렬). */
2
+ export declare function parseViteEnvKeys(content: string): string[];
3
+ /** VITE_ 키 유니온을 gaonjs/vue augment d.ts 로 렌더한다. 값 타입은 Vite 관례상 string. */
4
+ export declare function renderEnvDts(keys: readonly string[]): string;
5
+ /** `.env` 가 없을 때의 수리 안내 오류 메시지(결정 198 · C). */
6
+ export declare function missingEnvError(envFile: string): string;
7
+ /**
8
+ * `.env` 에서 .gaon/env.d.ts 를 생성한다. `.env` 가 없으면 **throw**(결정 198 · C ·
9
+ * 프론트 앱 전제). 이 함수를 부르는 쪽(orchestrator)이 프론트 앱 유무로 호출을
10
+ * 게이트하므로, API-only·스키마 전용 프로젝트는 여기 도달하지 않는다. 생성했으면 true.
11
+ */
12
+ export declare function generateEnvDts(envFile: string, out: string): boolean;
@@ -0,0 +1,63 @@
1
+ // @gaonjs/cli · .gaon/env.d.ts 생성기 (결정 198 · F-9 옵션 ②)
2
+ //
3
+ // tables·routes·messages 에 이은 .gaon 파이프라인의 클라이언트 환경변수 축. `.env`
4
+ // 의 `VITE_*` 키(Vite 표준 공개 접두)를 스캔해 `gaonjs/vue` 의 GaonClientEnv 를
5
+ // augment 한다 — `.vue`·클라 `.ts` 에서 `env.<없는키>` 를 컴파일 타임에 잡는다.
6
+ // 생성 파일은 타입만 담는다(규칙 3). 지정자는 파사드 서브패스 `gaonjs/vue`(결정 56·
7
+ // 158 동형 · 사용자는 gaonjs 하나만 의존하므로 @gaonjs/vue 는 루트에서 미해석).
8
+ //
9
+ // 결정 198 · C(회원님): 타입 출처는 `.env`(.env.example 아님). 프론트 앱이 있는데
10
+ // `.env` 가 없으면 gen/dev/build/check 가 **명확 오류**를 낸다 — `.env` 는 앱 실행
11
+ // 전제라, 없으면 설정 부재를 조용히 넘기지 않고 노출한다(우회 X · §7.5.3).
12
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+ const VITE_PREFIX = 'VITE_';
15
+ // KEY=value 라인에서 키만 뽑는다(export 접두 · 앞 공백 허용). 값·따옴표는 안 본다.
16
+ const ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
17
+ /** `.env` 본문에서 `VITE_*` 키를 접두 제거해 뽑는다(중복 제거·정렬). */
18
+ export function parseViteEnvKeys(content) {
19
+ const keys = new Set();
20
+ for (const line of content.split(/\r?\n/)) {
21
+ const m = ENV_LINE.exec(line);
22
+ if (!m)
23
+ continue;
24
+ const key = m[1];
25
+ if (key.startsWith(VITE_PREFIX) && key.length > VITE_PREFIX.length) {
26
+ keys.add(key.slice(VITE_PREFIX.length));
27
+ }
28
+ }
29
+ return [...keys].sort();
30
+ }
31
+ /** VITE_ 키 유니온을 gaonjs/vue augment d.ts 로 렌더한다. 값 타입은 Vite 관례상 string. */
32
+ export function renderEnvDts(keys) {
33
+ const body = keys.length > 0
34
+ ? keys.map((k) => ` readonly ${k}: string`).join('\n')
35
+ : ` // (.env 에 VITE_* 공개 변수 없음 — 내장 필드 env.dev/mode/... 만 사용 가능)`;
36
+ return (`// 이 파일은 gaon 이 .env 의 VITE_* 키에서 생성한다 — 직접 수정하지 마세요.\n` +
37
+ `// 결정 198 · F-9 옵션 ②: 클라이언트 공개 환경변수 타입 브리지(gaonjs/vue env).\n` +
38
+ // gaonjs/vue 서브패스로 augment 해야 사용자 프로젝트에서 병합된다(결정 56·158 동형).
39
+ `declare module 'gaonjs/vue' {\n` +
40
+ ` interface GaonClientEnv {\n${body}\n }\n` +
41
+ `}\n` +
42
+ `export {}\n`);
43
+ }
44
+ /** `.env` 가 없을 때의 수리 안내 오류 메시지(결정 198 · C). */
45
+ export function missingEnvError(envFile) {
46
+ return (`클라이언트 환경변수 타입을 생성할 수 없습니다 — ${envFile} 가 없습니다.\n` +
47
+ `→ 프로젝트 루트에 .env 를 만드세요: cp .env.example .env\n` +
48
+ ` (\`VITE_*\` 접두 변수만 클라 번들·env 접근자에 노출됩니다 · 그 외는 서버-only)`);
49
+ }
50
+ /**
51
+ * `.env` 에서 .gaon/env.d.ts 를 생성한다. `.env` 가 없으면 **throw**(결정 198 · C ·
52
+ * 프론트 앱 전제). 이 함수를 부르는 쪽(orchestrator)이 프론트 앱 유무로 호출을
53
+ * 게이트하므로, API-only·스키마 전용 프로젝트는 여기 도달하지 않는다. 생성했으면 true.
54
+ */
55
+ export function generateEnvDts(envFile, out) {
56
+ if (!existsSync(envFile)) {
57
+ throw new Error(missingEnvError(envFile));
58
+ }
59
+ const keys = parseViteEnvKeys(readFileSync(envFile, 'utf8'));
60
+ mkdirSync(dirname(out), { recursive: true });
61
+ writeFileSync(out, renderEnvDts(keys), 'utf8');
62
+ return true;
63
+ }
package/dist/index.js CHANGED
@@ -96,9 +96,9 @@ function renderHelp(version = VERSION) {
96
96
  " 사용법:",
97
97
  " gaon 로드맵과 개발 상태를 출력",
98
98
  " gaon new <name> 새 프로젝트 스캐폴드 (파일 → 설치 → git · --skip-install · --skip-git · --package-manager <pnpm|npm|yarn>)",
99
- " gaon dev 개발 스택 통합 (Docker · .gaon · serve · tsc/vue-tsc · 재시작 워처)",
99
+ " gaon dev 개발 스택 통합 (Docker · .gaon · serve · work · hub · tsc/vue-tsc · 재시작 워처)",
100
100
  " gaon dev --stop-docker Ctrl+C 시 Docker Compose 도 down",
101
- " gaon dev --no-watch|--no-tsc|--no-vue-tsc|--no-docker 개별 debug 옵션",
101
+ " gaon dev --no-watch|--no-tsc|--no-vue-tsc|--no-docker|--no-work|--no-hub 개별 debug 옵션",
102
102
  " gaon dev --port <n> --host <h> serve 리슨 지정",
103
103
  " gaon dev --json 통합 콘솔을 JSON 라인으로 출력(자동화)",
104
104
  " gaon serve 웹 서버 부팅 (gaon.config.ts 자동 배선 · Fastify listen)",
@@ -146,7 +146,7 @@ function renderHelp(version = VERSION) {
146
146
  * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
147
147
  */
148
148
  export function parseDoctorChecks(argv) {
149
- // 인정 집합은 doctor.ts 의 ALL_RULES(정본 25종)를 단일 출처로 쓴다 — 과거
149
+ // 인정 집합은 doctor.ts 의 ALL_RULES(정본 26종)를 단일 출처로 쓴다 — 과거
150
150
  // 손유지 9종 리스트가 뒤처져 --check=seal-security 같은 16종이 조용히 무시되고
151
151
  // 전체 검사로 되돌아가던 표류를 근본 차단한다(결정 168).
152
152
  const isKnown = (s) => ALL_RULES.includes(s);
@@ -207,9 +207,10 @@ export function runCli(argv, opts = {}) {
207
207
  // 설정된 env 를 덮지 않고 파일이 없으면 조용히 지나가 멱등하다(재호출 안전).
208
208
  loadDotEnv();
209
209
  // `gaon dev` — 통합 개발 오케스트레이션(M9-C · v0.15 §13.5). Docker Compose
210
- // 자동 기동 + .gaon 재생성 + serve 자식 + tsc/vue-tsc watch + 서버 재시작 워처.
211
- // SIGINT/SIGTERM 순서대로 정리(serve tsc 워처 Docker[--stop-docker 시]).
212
- // 개별 debug 옵션은 --no-watch / --no-tsc / --no-vue-tsc / --no-docker 뿐.
210
+ // 자동 기동 + .gaon 재생성 + serve·work·hub 자식(운영 3종 all-in-one · 결정 211)
211
+ // + tsc/vue-tsc watch + 서버 재시작 워처. SIGINT/SIGTERM 순서대로 정리
212
+ // (serve·work·hub tsc 워처 Docker[--stop-docker 시]).
213
+ // 개별 debug 옵션은 --no-watch / --no-tsc / --no-vue-tsc / --no-docker / --no-work / --no-hub.
213
214
  if (argv[0] === "dev") {
214
215
  const portIdx = argv.indexOf("--port");
215
216
  const hostIdx = argv.indexOf("--host");
@@ -222,6 +223,8 @@ export function runCli(argv, opts = {}) {
222
223
  noTsc: argv.includes("--no-tsc"),
223
224
  noVueTsc: argv.includes("--no-vue-tsc"),
224
225
  noDocker: argv.includes("--no-docker"),
226
+ noWork: argv.includes("--no-work"),
227
+ noHub: argv.includes("--no-hub"),
225
228
  timestamp: argv.includes("--timestamp"),
226
229
  port,
227
230
  host,
package/dist/pm.d.ts ADDED
@@ -0,0 +1,13 @@
1
+ export type PackageManager = 'pnpm' | 'npm' | 'yarn';
2
+ /**
3
+ * 프로젝트가 선언한 패키지 매니저를 감지한다. `gaon new` 는 package.json 의
4
+ * `packageManager` 필드(corepack 핀 · 결정 169)에 선택 pm 을 기록하므로 그것을
5
+ * 최우선으로 삼고, 없으면 락파일, 그래도 없으면 pnpm(골든 경로 기본).
6
+ */
7
+ export declare function detectPackageManager(cwd: string): PackageManager;
8
+ /**
9
+ * `<pm> run <script>` 실행 인자를 pm 별 passthrough 관례에 맞춰 만든다. 잔여
10
+ * 인자가 있을 때 pnpm·npm 은 `--` 로 스크립트에 분리 전달해야 하고(그래야 vitest
11
+ * 필터 등이 도달), yarn(classic)은 `--` 없이 직접 전달한다(결정 170 W1).
12
+ */
13
+ export declare function scriptRunArgs(pm: PackageManager, script: string, extras?: readonly string[]): string[];
package/dist/pm.js ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @gaonjs/cli · 패키지 매니저 해상 — pm-awareness 단일 소스 (결정 170)
3
+ *
4
+ * CLI 는 대부분 도구를 직접 spawn 한다(vitest·vite·tsc·vue-tsc·node·docker =
5
+ * pm 무관). pm 이 관여하는 접점은 **딱 셋** 뿐이다:
6
+ * 1) `gaon new` 초기 install (`new.ts` · `--package-manager` 선택)
7
+ * 2) `gaon check` build 등 user script (`commands/check.ts`)
8
+ * 3) `gaon test` test user script (`commands/test.ts`)
9
+ *
10
+ * (2)·(3) 은 사용자 스크립트를 **프로젝트가 선언한 pm** 으로 돌려야 한다 —
11
+ * pnpm 하드코딩은 npm/yarn 로 스캐폴드한 프로젝트에서 pnpm 이 "This project is
12
+ * configured to use npm" 으로 실행을 거부해 깨진다. 이 해상 로직을 한 모듈에
13
+ * 모아 check·test 가 **같은 함수**를 쓴다(중복 하드코딩 재발 차단 · 결정 170 W2).
14
+ *
15
+ * anti-creep(결정 170 W3): 새 CLI 명령은 도구를 직접 부른다(pm 무관). 부득이
16
+ * pm 이 필요하면 4번째 하드코딩을 만들지 말고 **반드시 이 모듈을 경유**한다.
17
+ * pnpm = 골든/보장 경로 · npm/yarn = best-effort 탈출구(§6 · CLAUDE.md).
18
+ */
19
+ import { existsSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ /**
22
+ * 프로젝트가 선언한 패키지 매니저를 감지한다. `gaon new` 는 package.json 의
23
+ * `packageManager` 필드(corepack 핀 · 결정 169)에 선택 pm 을 기록하므로 그것을
24
+ * 최우선으로 삼고, 없으면 락파일, 그래도 없으면 pnpm(골든 경로 기본).
25
+ */
26
+ export function detectPackageManager(cwd) {
27
+ const pkgPath = join(cwd, 'package.json');
28
+ if (existsSync(pkgPath)) {
29
+ try {
30
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
31
+ const pm = pkg.packageManager?.split('@')[0];
32
+ if (pm === 'pnpm' || pm === 'npm' || pm === 'yarn')
33
+ return pm;
34
+ }
35
+ catch {
36
+ // 파싱 실패는 락파일/기본으로 폴백
37
+ }
38
+ }
39
+ if (existsSync(join(cwd, 'pnpm-lock.yaml')))
40
+ return 'pnpm';
41
+ if (existsSync(join(cwd, 'yarn.lock')))
42
+ return 'yarn';
43
+ if (existsSync(join(cwd, 'package-lock.json')))
44
+ return 'npm';
45
+ return 'pnpm';
46
+ }
47
+ /**
48
+ * `<pm> run <script>` 실행 인자를 pm 별 passthrough 관례에 맞춰 만든다. 잔여
49
+ * 인자가 있을 때 pnpm·npm 은 `--` 로 스크립트에 분리 전달해야 하고(그래야 vitest
50
+ * 필터 등이 도달), yarn(classic)은 `--` 없이 직접 전달한다(결정 170 W1).
51
+ */
52
+ export function scriptRunArgs(pm, script, extras = []) {
53
+ const base = ['run', script];
54
+ if (extras.length === 0)
55
+ return base;
56
+ if (pm === 'yarn')
57
+ return [...base, ...extras];
58
+ return [...base, '--', ...extras];
59
+ }
@@ -30,3 +30,9 @@ COOKIE_SECRET=change-me-too-32-char-random-secret!!
30
30
 
31
31
  # 리슨 포트 (gaon serve --port 로 덮음).
32
32
  PORT=3000
33
+
34
+ # 클라이언트 공개 환경변수(결정 198) — `VITE_` 접두 변수만 브라우저 번들에 노출된다(그 외는
35
+ # 서버-only). `.vue`·클라 `.ts` 에서 `import { env } from 'gaonjs/vue'` 로 읽는다(import.meta.env
36
+ # 직접 사용은 vue-tsc TS1470 · doctor no-import-meta-env 가 잡음). 접두는 제거된다: 아래를 켜면
37
+ # `env.API_URL` 로 접근. `.env` 의 VITE_* 키가 `.gaon/env.d.ts` 로 물성화돼 없는 키는 컴파일 에러.
38
+ # VITE_API_URL=https://api.example.com
@@ -108,7 +108,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
108
108
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
109
109
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
110
110
 
111
- ### 2.2 `gaon doctor` 검사 25
111
+ ### 2.2 `gaon doctor` 검사 26
112
112
 
113
113
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
114
114
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -135,6 +135,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
135
135
  23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
136
136
  24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon check --fix` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
137
137
  25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134 · `agents/data.md`)
138
+ 26. `no-import-meta-env` — `.vue`(SFC) `<script>` 에서 `import.meta.env` 직접 사용 = **에러**. SFC 는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부한다(`gaon check` red). 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로 읽으라(VITE_* 접두 제거·타입드 · `.gaon/env.d.ts` 는 `.env` 스캔 생성) — 템플릿 프로즈·주석의 언급은 오탐 제외 (결정 198 · `agents/frontend.md` §9)
138
139
 
139
140
  ## 3. 로직 배치 One Way 판단표
140
141
 
@@ -195,7 +196,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
195
196
  ```bash
196
197
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build + doctor (기본 포함 · --no-doctor 로 뺌 · 결정 157)
197
198
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
198
- gaon doctor # 정적 검사 25종 (§2.2)
199
+ gaon doctor # 정적 검사 26종 (§2.2)
199
200
  ```
200
201
 
201
202
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)
@@ -203,8 +204,8 @@ gaon doctor # 정적 검사 25종 (§2.2)
203
204
  | 명령 | 역할 |
204
205
  |---|---|
205
206
  | `gaon new <name>` | 프로젝트 스캐폴드 |
206
- | `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**코드 변경 감시·재시작**) |
207
- | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
207
+ | `gaon dev` | 통합 개발 오케스트레이션 (Docker·`.gaon` 재생성·**serve·work·hub 자동 기동**·**코드 변경 감시·재시작** · 결정 211) |
208
+ | `gaon serve` / `work` / `hub` | 운영 프로세스 3종 (웹 · 워커 · 실시간 허브) — **감시 없음** · 배포 배치용(`gaon dev` 가 개발 중엔 셋을 내장 기동) · 웹은 `PORT`, 허브는 `GAON_HUB_PORT` |
208
209
  | `gaon g <type> <name>` | 스캐폴드: `auth`·`controller`·`model`·`page`·`job` |
209
210
  | `gaon db <sub>` | `diff`·`migrate`(`down`)·`status`·`reset`·`seed` (`agents/data.md` §10) |
210
211
  | `gaon check` / `test` / `doctor` | 검증 루프 |
@@ -244,8 +244,24 @@ export default schedule((s) => {
244
244
  처리돼도 안전하게).
245
245
  - **`gaon serve` 는 스케줄러를 돌리지 않는다** — 스케줄·리더 선출·아웃박스
246
246
  릴레이는 **`gaon work` 전용**이다. 웹 프로세스는 잡을 **발행**만 할 수 있고
247
- (`.later()`), 처리·스케줄은 워커가 한다. 스케줄이 안 도는 흔한 원인은
248
- `gaon work` 를 안 띄운 것이다.
247
+ (`.later()`), 처리·스케줄은 워커가 한다. **운영에서** 스케줄이 안 도는 흔한
248
+ 원인은 `gaon work` 를 안 띄운 것이다(개발은 `gaon dev` 가 work 를 자동 기동
249
+ 하므로 해당 없음 · 결정 211 · §6).
250
+
251
+ #### 시간대 (결정 202)
252
+
253
+ `s.daily.at('04:00', Job)`·`s.cron('0 9 * * 1', Job)` 는 **서버 로컬 타임존**의
254
+ wall-clock 으로 매치한다(`new Date()` 로컬 시·분·요일). v1 은 **잡별 타임존
255
+ 옵션이 없다** — 특정 TZ 로 돌려야 하면 워커 프로세스의 `TZ` 환경변수를 그
256
+ 타임존으로 띄운다(예: `TZ=Asia/Seoul gaon work`). 여러 인스턴스는 같은 TZ 로
257
+ 맞춘다(리더가 어느 인스턴스든 같은 wall-clock 을 봐야 한다).
258
+
259
+ #### 중첩 방지 (결정 202)
260
+
261
+ 스케줄러는 매 주기 **발행만** 한다(§7 리더). 한 주기보다 오래 걸리는 잡이
262
+ **자기 자신과 겹쳐** 실행되면 안 되면, 잡 핸들러가 `lock(key, fn, { onBusy:
263
+ 'skip' })` 으로 임계구역을 지킨다 — 이미 도는 인스턴스가 있으면 이번 틱은
264
+ 스킵된다(중복 실행 방지). 락은 `gaon work` 워커에서도 배선된다(결정 202).
249
265
 
250
266
  ### 6. 워커 프로세스 (`gaon work`)
251
267
 
@@ -253,15 +269,54 @@ export default schedule((s) => {
253
269
  프로세스 3종(serve·work·hub) 중 하나. SIGTERM/SIGINT 에 graceful
254
270
  drain — 스케줄러 리더를 반납하고 진행 중인 잡을 완료한 뒤 종료한다.
255
271
 
272
+ #### graceful drain 계약 (결정 201)
273
+
274
+ 배포·재시작(k8s 롤링 등)에서 **SIGTERM 이 진행 중 작업을 자르지 않는다**:
275
+
276
+ - **`gaon serve`** — 인플라이트 HTTP 요청을 **완료까지 기다린 뒤** 종료한다(리셋
277
+ 안 함 · 롤링 배포 중 502 방지). 새 연결은 안 받고, 진행 요청이 끝나면 idle
278
+ keep-alive 를 닫아 종료가 즉시 완결된다(결정 201 · `forceCloseConnections:false`
279
+ + close idle 스윕).
280
+ - **`gaon work`** — 신규 잡 pull 을 멈추고 **진행 중 잡을 완료**한 뒤 종료한다
281
+ (`drainTimeoutMs` 상한 · 기본 30s). 스케줄러 리더는 즉시 반납해 다른 인스턴스가
282
+ 승계한다. drain 상한을 넘긴 잡은 ack 되지 않아 재전달(크래시 복구)된다.
283
+ - **컨테이너 기본 워커 1** — `gaon serve` 클러스터(`--workers`)도 SIGTERM 에
284
+ 워커들을 graceful drain 후 종료한다(결정 84).
285
+
286
+ #### 개발 모드는 work·hub 를 자동 기동한다 (결정 211)
287
+
288
+ **`gaon dev` 는 운영 3종(serve·work·hub)을 모두 내장 기동한다.** 개발 중에는
289
+ `gaon work`·`gaon hub` 를 따로 띄울 필요가 없다 — 잡·이벤트·리스너·아웃박스
290
+ 릴레이·스케줄·실시간이 `gaon dev` 하나로 전부 처리된다(Rails-like all-in-one).
291
+ `gaon work`·`gaon hub` **명령 자체는 운영 배치용**으로 남는다(컨테이너·프로세스
292
+ 매니저에서 각각 스케일).
293
+
294
+ - **재시작** — 도메인(잡·리스너·스케줄) 파일을 저장하면 serve 와 함께 work 도
295
+ 재시작된다. hub 는 사용자 도메인을 로드하지 않아(인프라 라우팅) 재시작하지 않는다.
296
+ - **끄기** — 개별 debug 시 `gaon dev --no-work` · `--no-hub`. 기본은 켬.
297
+ - **동시성** — dev 워커는 잡을 병렬 처리하도록 큐 기본 동시성을 4로 올린다
298
+ (`GAON_WORKER_CONCURRENCY` 로 덮음 · 운영 `gaon work` 기본은 1). 그래서 지연
299
+ 잡이 서로 막지 않고, 분산 락 중첩 방지(§7·§5) 같은 동시성 거동을 dev 에서도
300
+ 관측할 수 있다.
301
+ - **NATS 필요** — work·hub 는 NATS 에 붙는다. `gaon dev` 의 compose 가 NATS 를
302
+ 자동 기동하므로 보통 문제없다. `--no-docker` 로 외부 NATS 없이 띄우면 work·hub
303
+ 는 연결 실패로 조용히 종료하고 dev 는 계속된다(안내 로그 · `--no-*` 로 끌 수 있음).
304
+
305
+ > 이전에는 `gaon dev` 가 serve 만 띄워, 잡·이벤트·아웃박스·스케줄이 개발 중
306
+ > 조용히 처리되지 않는 함정이 있었다(work 미기동). 결정 211 이 이를 닫았다 —
307
+ > "스케줄이 안 도는 흔한 원인은 `gaon work` 를 안 띄운 것" 은 이제 **운영에만**
308
+ > 해당하고, 개발에선 `gaon dev` 가 알아서 띄운다.
309
+
256
310
  ### 7. 분산 락 (`lock()`) (결정 147)
257
311
 
258
312
  **동시 실행을 막아야 하면 `lock(key, fn)`** — 같은 `key` 에 대해 전
259
313
  인스턴스를 통틀어 동시 1개의 `fn` 만 임계구역에 들인다. **로컬 뮤텍스는
260
314
  반정본이다** — 멀티 인스턴스(워커 여러 대·serve 여러 대)에서는 프로세스마다
261
315
  따로 놀아 무의미하다(결정 88 ①). 그래서 백엔드는 Redis 다: 설정에 `redis`
262
- 가 있으면 `gaon serve` 분산 락을 자동 배선한다. **`redis` 미설정 상태로
263
- `lock()` 부르면 로컬 뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께
264
- throw** 한다.
316
+ 가 있으면 **`gaon serve` `gaon work` 둘 다** 분산 락을 자동 배선한다(결정
317
+ 202 · wireDomain 공통 경로) 그래서 **잡·서비스 안에서도 `lock()` 을 쓸 수
318
+ 있다**(예: 중첩 방지 · §5). **`redis` 미설정 상태로 `lock()` 을 부르면 로컬
319
+ 뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께 throw** 한다.
265
320
 
266
321
  ```ts
267
322
  import { lock } from 'gaonjs/async'
@@ -344,4 +399,8 @@ async create() {
344
399
  | 결정 103 | doctor `async-offload` 검사 (컨트롤러 인라인 메일·이미지·외부 HTTP 경고) |
345
400
  | 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 모두 정합) |
346
401
  | 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`agents/testing.md`) |
402
+ | 결정 200 | DLQ 재처리 `retryDlq(nats, id)` (이미 설정된 잡 전송 보존 · DLQ 재적재 후 원본 삭제) |
403
+ | 결정 201 | graceful drain 계약 (serve 인플라이트 요청 완결 · work 진행 잡 완결 · §6) |
404
+ | 결정 202 | 락·캐시 백엔드 serve·work 공통 배선(wireDomain) · 잡/서비스 lock() 가능 · 스케줄러 시간대(서버 로컬 TZ)·중첩 방지(§5·§7) |
405
+ | 결정 211 | `gaon dev` all-in-one — serve·work·hub 자동 기동 · dev 워커 동시성 4(`GAON_WORKER_CONCURRENCY`) · `--no-work`/`--no-hub` (§6) |
347
406
  | §7 | 비동기 배터리 원문 (백오프 기본값 = M7 벤치마크 확정) |
@@ -251,6 +251,24 @@ import PageShell from '@shared/components/ui/PageShell.vue'
251
251
  | 원자 (18) | Button · Input · Label · Badge · Card · CardHeader · CardTitle · CardDescription · CardContent · CardFooter · Alert · AlertTitle · AlertDescription · Form · FormField · FormMessage · Dialog · Sheet |
252
252
  | 블록 (4 · 결정 106) | PageShell · PageHeader · EmptyState · Pagination |
253
253
 
254
+ **슬롯·props 요약 (첫 시도용 · 소스 안 읽어도 되게 · O-2):** 카탈로그는 이름만이라 슬롯/prop 을
255
+ 소스에서 찾아야 했다 — 자주 쓰는 표면을 여기 못박는다(전체·정확한 타입은 컴포넌트 소스가 정본).
256
+ **주의: named slot 이름이 비대칭이다** — `PageHeader` 는 `#actions`(**복수**), `EmptyState` 는
257
+ `#action`(**단수**). 기본 슬롯을 잘못 쓰면 조용히 안 그려진다.
258
+
259
+ | 컴포넌트 | props | slots | emits |
260
+ |---|---|---|---|
261
+ | **PageShell** | `size?: 'default'\|'narrow'\|'wide'\|'full'` | 기본 | — |
262
+ | **PageHeader** | `title?` · `description?` | `#title` · `#description` · **`#actions`**(복수) | — |
263
+ | **EmptyState** | `title?` · **`description?`(prop)** | `#icon` · `#title` · `#description` · **`#action`**(단수) | — |
264
+ | **Pagination** | `page`(필수) · `pageCount`(필수) · `siblings?=1` | — | `update:page` (= `v-model:page`) |
265
+ | Button | `variant?='default'` · `size?='default'` · `type?='button'` · `href?` · `external?` · `target?` | 기본 | 네이티브(예 `@click`) |
266
+ | FormField | `label?` · `error?` | 기본(컨트롤) | — |
267
+ | FormMessage | `message?` | — | — |
268
+ | Alert | `variant?: 'default'\|'destructive'` | 기본 | — |
269
+ | Badge | `variant?: 'default'\|'secondary'\|'destructive'\|'outline'` | 기본 | — |
270
+ | Card / CardHeader / CardTitle / CardDescription / CardContent / CardFooter | — | 기본(조합) | — |
271
+
254
272
  - **블록은 성격 중립(결정 106)** — 관리자/프론트를 나누지 않고 전 앱에서 쓴다.
255
273
  `PageShell`(최대폭·여백·세로 리듬) · `PageHeader`(제목+설명+액션) · `EmptyState`
256
274
  (빈 목록) · `Pagination`(페이지 이동 · `v-model:page`). 이외 블록(DataTable·StatCard·
@@ -287,7 +305,7 @@ import PageShell from '@shared/components/ui/PageShell.vue'
287
305
  통째로 넘기면, 블록의 `:page`·`:pageCount` 가 필드명 그대로 붙는다(매핑 보일러플레이트 0).
288
306
  ```vue
289
307
  <script setup lang="ts">
290
- import { Pagination } from '@shared/components/ui'
308
+ import Pagination from '@shared/components/ui/Pagination.vue'
291
309
  import { router } from 'gaonjs/vue'
292
310
  const props = pageProps<'web:posts#index'>() // props.page = paginate 결과
293
311
  function goto(p: number) { router.get('/posts', { page: p }, { preserveState: true }) }
@@ -311,6 +329,37 @@ import PageShell from '@shared/components/ui/PageShell.vue'
311
329
  `tailwind.config.ts` 의 `content` 에 `./shared/**/*.{vue,ts}` 가 있는지도 확인한다
312
330
  (스캐폴드 기본값엔 이미 포함).
313
331
 
332
+ ### 9. 클라이언트 환경변수 — `env` (결정 198 · F-9 옵션 ②)
333
+
334
+ `.vue`·클라 `.ts` 에서 공개 환경변수는 **`env` 접근자**로 읽는다 — `import.meta.env`
335
+ 를 직접 쓰지 않는다. `.vue`(SFC)는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가
336
+ `import.meta` 를 **TS1470** 로 거부하기 때문이다(`gaon check` red). 프레임웍이
337
+ `import.meta.env` 를 대신 읽어 재노출하므로 페이지는 `import.meta` 를 안 쓴다.
338
+
339
+ ```vue
340
+ <script setup lang="ts">
341
+ import { env } from 'gaonjs/vue'
342
+
343
+ const api = env.API_URL // .env 의 VITE_API_URL — VITE_ 접두 제거 · 타입드
344
+ if (env.dev) console.log(env.mode) // 내장: dev·prod·mode·baseUrl(camelCase 정규화)
345
+ </script>
346
+ ```
347
+
348
+ - **노출 정책 = Vite 표준 `VITE_*`** — `.env` 의 `VITE_` 접두 변수만 클라 번들에
349
+ 노출된다(그 외 `SECRET_KEY`·`DATABASE_URL` 등은 **자동 서버-only**, 번들에 절대 안 감).
350
+ 접근자에서 접두는 제거된다: `VITE_API_URL` → `env.API_URL`.
351
+ - **타입 안전** — `.env` 의 VITE_* 키가 `.gaon/env.d.ts`(gen/dev/build/check 재생성)로
352
+ 물성화돼 `gaonjs/vue` 의 `GaonClientEnv` 를 augment 한다. `env.<없는키>` 는 컴파일
353
+ 에러(messages.d.ts 동형 · 결정 158). 키를 추가하면 `.env` 에 `VITE_...` 를 넣는다.
354
+ - **`.env` 필수** — 프론트 앱이 있으면 `.env` 가 없을 때 gen/dev/build/check 가 명확히
355
+ 실패한다(수리 안내: `cp .env.example .env`). `.env` 는 앱 실행 전제다.
356
+ - **내장 필드**: `env.mode`(빌드 모드)·`env.dev`/`env.prod`(불리언)·`env.baseUrl`
357
+ (앱 base · web='/'·admin='/admin/'). `import.meta.env.MODE/DEV/PROD/BASE_URL` 대체.
358
+ - **doctor**: `.vue` 에서 `import.meta.env` 직접 사용은 **no-import-meta-env** 가
359
+ error 로 잡아 `env` 접근자로 안내한다(TS1470 을 읽기 전에).
360
+ - 서버 코드(컨트롤러·domain)의 환경변수는 이 접근자가 아니라 서버 env(`gaon.config.ts`
361
+ 의 `env('KEY')`·`process.env`)로 읽는다 — `env`(gaonjs/vue)는 **클라 전용**이다.
362
+
314
363
  ## 정본 예시
315
364
 
316
365
  ```vue
@@ -406,6 +455,8 @@ async function runSearch(q: string) {
406
455
  | 결정 116 | 공유 prop(currentUser·csrf·flash) 자동 주입 · `useShared()` 로 읽기(라우트 키 불요 · `agents/web.md`) |
407
456
  | 결정 150 | 앱 전역 공유 키 확장 — `app.config` sharedProps 등록 → useShared 로 읽기(코어 3종 고정 · 선언 병합 타입 · hidden 미유출 · `agents/web.md` §4.2) |
408
457
  | 결정 119 | `Pagination` 블록이 `chain.paginate()` 결과에 정합(`:page`·`:pageCount` 필드 그대로 · 매핑 0 · `agents/data.md`) |
458
+ | 결정 198 | 클라 환경변수 접근자 `env`(gaonjs/vue · `.vue` 의 import.meta.env TS1470 회피) · VITE_* 접두만 노출·접두 제거 · `.gaon/env.d.ts`(.env 스캔) 타입 브리지 · doctor no-import-meta-env(§9) |
459
+ | 결정 206 | UI 킷 §8 슬롯·props 요약표(카탈로그가 이름만이라 소스 열람 유발 · O-2 해소) · named slot 비대칭 명시(PageHeader `#actions` 복수 vs EmptyState `#action` 단수) |
409
460
  | E-3 §C | 타입드 `api()` 클라이언트 (routes.d.ts 브리지 재사용) |
410
461
 
411
462
  ## `@gaonjs/seal` 켠 앱의 프론트