@gaonjs/cli 0.36.0 → 0.37.1
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/dist/commands/check.d.ts +0 -9
- package/dist/commands/check.js +6 -31
- package/dist/commands/dev.js +10 -1
- package/dist/commands/gen.d.ts +2 -0
- package/dist/commands/gen.js +6 -2
- package/dist/commands/new.js +7 -0
- package/dist/commands/test.d.ts +2 -1
- package/dist/commands/test.js +11 -5
- package/dist/dev.d.ts +13 -1
- package/dist/dev.js +21 -1
- package/dist/doctor/no-import-meta-env.d.ts +5 -0
- package/dist/doctor/no-import-meta-env.js +98 -0
- package/dist/doctor/types.d.ts +1 -1
- package/dist/doctor.d.ts +2 -2
- package/dist/doctor.js +7 -3
- package/dist/env-gen.d.ts +12 -0
- package/dist/env-gen.js +63 -0
- package/dist/index.js +1 -1
- package/dist/pm.d.ts +13 -0
- package/dist/pm.js +59 -0
- package/dist/templates/project/.env.example.tpl +6 -0
- package/dist/templates/project/AGENTS.md.tpl +3 -2
- package/dist/templates/project/agents/async.md.tpl +36 -3
- package/dist/templates/project/agents/frontend.md.tpl +52 -1
- package/dist/templates/project/agents/i18n.md.tpl +26 -0
- package/dist/templates/project/agents/mail.md.tpl +31 -0
- package/dist/templates/project/agents/realtime.md.tpl +10 -0
- package/dist/templates/project/agents/testing.md.tpl +8 -4
- package/dist/templates/project/agents/web.md.tpl +36 -0
- package/package.json +9 -9
package/dist/commands/check.d.ts
CHANGED
|
@@ -49,15 +49,6 @@ export interface CheckReport {
|
|
|
49
49
|
readonly regen: CheckRegen;
|
|
50
50
|
readonly steps: readonly CheckStepResult[];
|
|
51
51
|
}
|
|
52
|
-
/**
|
|
53
|
-
* 결정 170: 프로젝트가 선언한 패키지 매니저를 감지한다. `gaon new` 는
|
|
54
|
-
* package.json 의 `packageManager` 필드(corepack 핀 · 결정 169)에 선택 pm 을
|
|
55
|
-
* 기록하므로, `gaon check` 가 사용자 스크립트(예 build)를 돌릴 때 그 pm 으로
|
|
56
|
-
* 실행해야 한다 — pnpm 하드코딩은 npm/yarn 로 스캐폴드한 프로젝트에서 pnpm 이
|
|
57
|
-
* "This project is configured to use npm" 로 실행을 거부해 build 단계가 깨진다.
|
|
58
|
-
* 우선순위: packageManager 필드 → 락파일 → pnpm(기본).
|
|
59
|
-
*/
|
|
60
|
-
export declare function detectPackageManager(cwd: string): 'pnpm' | 'npm' | 'yarn';
|
|
61
52
|
/**
|
|
62
53
|
* `gaon check` 진입점. 검사 전에 .gaon 을 재생성(규칙 3)한 뒤 각 단계를
|
|
63
54
|
* 순서대로 실행하고, 하나라도 실패하면 exit 1. --only 지정 시 그 단계만
|
package/dist/commands/check.js
CHANGED
|
@@ -35,6 +35,8 @@ import { regenerateGaonOnce, resolveDevLayout } from '../dev.js';
|
|
|
35
35
|
import { registerTsResolve } from '../tsResolve.js';
|
|
36
36
|
import { listFrontendApps, verifyAppDist } from '../dev/build.js';
|
|
37
37
|
import { generateMessagesDts } from '../messages-gen.js';
|
|
38
|
+
import { generateEnvDts } from '../env-gen.js';
|
|
39
|
+
import { detectPackageManager, scriptRunArgs } from '../pm.js';
|
|
38
40
|
/**
|
|
39
41
|
* 프로젝트 스크립트 존재 여부. pnpm/npm 어느 쪽이든 `scripts.<name>` 을
|
|
40
42
|
* 정의해 두면 우선 사용한다.
|
|
@@ -54,35 +56,6 @@ function hasScript(cwd, name) {
|
|
|
54
56
|
function binExists(cwd, name) {
|
|
55
57
|
return existsSync(join(cwd, 'node_modules', '.bin', name));
|
|
56
58
|
}
|
|
57
|
-
/**
|
|
58
|
-
* 결정 170: 프로젝트가 선언한 패키지 매니저를 감지한다. `gaon new` 는
|
|
59
|
-
* package.json 의 `packageManager` 필드(corepack 핀 · 결정 169)에 선택 pm 을
|
|
60
|
-
* 기록하므로, `gaon check` 가 사용자 스크립트(예 build)를 돌릴 때 그 pm 으로
|
|
61
|
-
* 실행해야 한다 — pnpm 하드코딩은 npm/yarn 로 스캐폴드한 프로젝트에서 pnpm 이
|
|
62
|
-
* "This project is configured to use npm" 로 실행을 거부해 build 단계가 깨진다.
|
|
63
|
-
* 우선순위: packageManager 필드 → 락파일 → pnpm(기본).
|
|
64
|
-
*/
|
|
65
|
-
export function detectPackageManager(cwd) {
|
|
66
|
-
const pkgPath = join(cwd, 'package.json');
|
|
67
|
-
if (existsSync(pkgPath)) {
|
|
68
|
-
try {
|
|
69
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
70
|
-
const pm = pkg.packageManager?.split('@')[0];
|
|
71
|
-
if (pm === 'pnpm' || pm === 'npm' || pm === 'yarn')
|
|
72
|
-
return pm;
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
// 파싱 실패는 락파일/기본으로 폴백
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
if (existsSync(join(cwd, 'pnpm-lock.yaml')))
|
|
79
|
-
return 'pnpm';
|
|
80
|
-
if (existsSync(join(cwd, 'yarn.lock')))
|
|
81
|
-
return 'yarn';
|
|
82
|
-
if (existsSync(join(cwd, 'package-lock.json')))
|
|
83
|
-
return 'npm';
|
|
84
|
-
return 'pnpm';
|
|
85
|
-
}
|
|
86
59
|
/**
|
|
87
60
|
* 단일 서브 프로세스를 spawn 해서 stdout+stderr 를 모으고 exit 코드를
|
|
88
61
|
* 돌려준다. 실행 실패(파일 없음)는 exit 127 로 매핑.
|
|
@@ -107,9 +80,10 @@ async function runStep(step, cwd) {
|
|
|
107
80
|
const scriptName = step;
|
|
108
81
|
if (hasScript(cwd, scriptName)) {
|
|
109
82
|
// 결정 170: pnpm 하드코딩 대신 프로젝트 선언 pm 으로 실행(npm/yarn 스캐폴드
|
|
110
|
-
// 대응
|
|
83
|
+
// 대응 · pm 해상은 공유 `pm.ts` 단일 소스). build/typecheck/vue-tsc 는 잔여
|
|
84
|
+
// 인자가 없어 `<pm> run <name>` 만 — pnpm·npm·yarn(classic) 동일 동작.
|
|
111
85
|
const cmd = detectPackageManager(cwd);
|
|
112
|
-
const args =
|
|
86
|
+
const args = scriptRunArgs(cmd, scriptName);
|
|
113
87
|
const { exitCode, output } = await runSubprocess(cwd, cmd, args);
|
|
114
88
|
// 결정 146(12차 W2): build 가 성공했으면 **등록된 앱마다** dist/<앱>/index.html 과
|
|
115
89
|
// 그 문서가 참조하는 에셋이 실제로 존재하는지 검증한다. build 스크립트가 통과해도
|
|
@@ -277,6 +251,7 @@ async function regenerateGaon(cwd) {
|
|
|
277
251
|
regenerateTables: generateTablesDts,
|
|
278
252
|
regenerateRoutes: generateRoutesDts,
|
|
279
253
|
regenerateMessages: generateMessagesDts,
|
|
254
|
+
regenerateEnv: generateEnvDts,
|
|
280
255
|
});
|
|
281
256
|
return { status: 'done', tables: result.tables, apps: result.apps };
|
|
282
257
|
}
|
package/dist/commands/dev.js
CHANGED
|
@@ -27,6 +27,7 @@ import { startDev, resolveDevLayout } from '../dev.js';
|
|
|
27
27
|
import { generateTablesDts, watchDir } from '@gaonjs/data';
|
|
28
28
|
import { generateRoutesDts } from '@gaonjs/web';
|
|
29
29
|
import { generateMessagesDts } from '../messages-gen.js';
|
|
30
|
+
import { generateEnvDts } from '../env-gen.js';
|
|
30
31
|
import { createDevConsole } from '../dev/console.js';
|
|
31
32
|
import { ensureInfra, composeDown } from '../dev/docker.js';
|
|
32
33
|
import { startTscWatchers, killChild } from '../dev/tsc.js';
|
|
@@ -88,6 +89,7 @@ async function startGaonRegen(args) {
|
|
|
88
89
|
regenerateTables: generateTablesDts,
|
|
89
90
|
regenerateRoutes: generateRoutesDts,
|
|
90
91
|
regenerateMessages: generateMessagesDts,
|
|
92
|
+
regenerateEnv: generateEnvDts,
|
|
91
93
|
watch: watchDir,
|
|
92
94
|
log: (e) => {
|
|
93
95
|
if (e.kind === 'ready') {
|
|
@@ -101,7 +103,14 @@ async function startGaonRegen(args) {
|
|
|
101
103
|
: `.gaon 재생성 대상 없음 (domain/schema · apps/* 확인)`);
|
|
102
104
|
}
|
|
103
105
|
else if (e.kind === 'regen') {
|
|
104
|
-
|
|
106
|
+
const label = e.target === 'tables'
|
|
107
|
+
? '↻ tables.d.ts 재생성'
|
|
108
|
+
: e.target === 'messages'
|
|
109
|
+
? '↻ messages.d.ts 재생성'
|
|
110
|
+
: e.target === 'env'
|
|
111
|
+
? '↻ env.d.ts 재생성'
|
|
112
|
+
: `↻ ${e.app}/.gaon/routes.d.ts 재생성`;
|
|
113
|
+
args.console.log('watcher', label);
|
|
105
114
|
}
|
|
106
115
|
else {
|
|
107
116
|
args.console.log('watcher', '.gaon 워처 종료');
|
package/dist/commands/gen.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export interface GenResult {
|
|
|
11
11
|
readonly tables: boolean;
|
|
12
12
|
/** routes.d.ts·routes.manifest.ts 를 재생성한 앱 이름. */
|
|
13
13
|
readonly apps: readonly string[];
|
|
14
|
+
/** env.d.ts 를 재생성했는가(프론트 앱 존재 · 결정 198). */
|
|
15
|
+
readonly env?: boolean;
|
|
14
16
|
/** 실패 시 에러 + 수리 안내. */
|
|
15
17
|
readonly error?: string;
|
|
16
18
|
}
|
package/dist/commands/gen.js
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
import { generateTablesDts } from '@gaonjs/data';
|
|
17
17
|
import { generateRoutesDts } from '@gaonjs/web';
|
|
18
18
|
import { generateMessagesDts } from '../messages-gen.js';
|
|
19
|
+
import { generateEnvDts } from '../env-gen.js';
|
|
19
20
|
import { regenerateGaonOnce, resolveDevLayout } from '../dev.js';
|
|
20
21
|
import { registerTsResolve } from '../tsResolve.js';
|
|
21
22
|
/**
|
|
@@ -26,13 +27,14 @@ import { registerTsResolve } from '../tsResolve.js';
|
|
|
26
27
|
export async function regenerateProjectGaon(cwd) {
|
|
27
28
|
const layout = resolveDevLayout(cwd);
|
|
28
29
|
if (!layout.schemaDir && layout.apps.length === 0 && !layout.localesDir) {
|
|
29
|
-
return { tables: false, apps: [], messages: false, skipped: true };
|
|
30
|
+
return { tables: false, apps: [], messages: false, env: false, skipped: true };
|
|
30
31
|
}
|
|
31
32
|
registerTsResolve();
|
|
32
33
|
const result = await regenerateGaonOnce(layout, {
|
|
33
34
|
regenerateTables: generateTablesDts,
|
|
34
35
|
regenerateRoutes: generateRoutesDts,
|
|
35
36
|
regenerateMessages: generateMessagesDts,
|
|
37
|
+
regenerateEnv: generateEnvDts,
|
|
36
38
|
});
|
|
37
39
|
return { ...result, skipped: false };
|
|
38
40
|
}
|
|
@@ -46,7 +48,7 @@ export async function runGenCommand(opts = {}) {
|
|
|
46
48
|
let result;
|
|
47
49
|
try {
|
|
48
50
|
const r = await regenerateProjectGaon(cwd);
|
|
49
|
-
result = { ok: true, skipped: r.skipped, tables: r.tables, apps: r.apps };
|
|
51
|
+
result = { ok: true, skipped: r.skipped, tables: r.tables, apps: r.apps, env: r.env };
|
|
50
52
|
}
|
|
51
53
|
catch (err) {
|
|
52
54
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -75,6 +77,8 @@ export async function runGenCommand(opts = {}) {
|
|
|
75
77
|
for (const app of result.apps) {
|
|
76
78
|
parts.push(`apps/${app}/.gaon/routes.d.ts`, `apps/${app}/.gaon/routes.manifest.ts`);
|
|
77
79
|
}
|
|
80
|
+
if (result.env)
|
|
81
|
+
parts.push('.gaon/env.d.ts');
|
|
78
82
|
process.stdout.write(` ✓ gaon gen — ${parts.join(', ')}\n`);
|
|
79
83
|
}
|
|
80
84
|
return result.ok ? 0 : 1;
|
package/dist/commands/new.js
CHANGED
|
@@ -179,6 +179,13 @@ export async function runNewCommand(name, opts = {}) {
|
|
|
179
179
|
gaonjsVersion,
|
|
180
180
|
packageManager: PACKAGE_MANAGER_PINS[pm],
|
|
181
181
|
});
|
|
182
|
+
// 결정 198(F-9 ②): env.d.ts 축은 `.env` 를 타입 출처로 요구한다(프론트 앱 전제). 스캐폴드가
|
|
183
|
+
// `.env.example` 만 남기면 `gaon new` 직후 `gaon check` 가 `.env` 부재로 실패한다 — 첫 실행이
|
|
184
|
+
// 바로 green 이도록 `.env.example` 내용 그대로 `.env` 도 함께 심는다(.gitignore 로 커밋 제외).
|
|
185
|
+
const envExample = files.find((f) => f.path === '.env.example');
|
|
186
|
+
if (envExample && !files.some((f) => f.path === '.env')) {
|
|
187
|
+
files.push({ path: '.env', contents: envExample.contents });
|
|
188
|
+
}
|
|
182
189
|
let filesCreated = 0;
|
|
183
190
|
try {
|
|
184
191
|
mkdirSync(root, { recursive: true });
|
package/dist/commands/test.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface TestCommandOptions {
|
|
|
6
6
|
}
|
|
7
7
|
/**
|
|
8
8
|
* `gaon test` 진입점. args 는 사용자가 넘긴 잔여 인자(필터 문자열 등).
|
|
9
|
-
*
|
|
9
|
+
* user script 경로는 프로젝트 선언 pm 으로 실행하며(결정 170), 인자 전달 관례는
|
|
10
|
+
* pm 별로 갈린다(pnpm·npm = `--` 분리 · yarn classic = 직접) — `scriptRunArgs`.
|
|
10
11
|
*/
|
|
11
12
|
export declare function runTestCommand(args?: readonly string[], opts?: TestCommandOptions): Promise<number>;
|
package/dist/commands/test.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* (기본) 둘 다 실행
|
|
13
13
|
*
|
|
14
14
|
* 실행 경로 우선순위:
|
|
15
|
-
* 1) 사용자 package.json 의 `test` 스크립트가 있으면
|
|
15
|
+
* 1) 사용자 package.json 의 `test` 스크립트가 있으면 `<pm> run test`(결정 170 ·
|
|
16
|
+
* 프로젝트 선언 pm · 인자 전달은 pnpm/npm `--` · yarn classic 직접)
|
|
16
17
|
* 2) 로컬 node_modules/.bin/vitest 가 있으면 직접 spawn(`run` 모드)
|
|
17
18
|
* 3) 둘 다 없으면 exit 127 + 설치 안내
|
|
18
19
|
*
|
|
@@ -26,6 +27,7 @@ import { loadGaonConfig } from '@gaonjs/config';
|
|
|
26
27
|
import { deriveTestDatabaseConfig, ensureTestDatabaseExists, destroyAllConnections, } from '@gaonjs/data';
|
|
27
28
|
import { registerTsResolve } from '../tsResolve.js';
|
|
28
29
|
import { runDbMigrate } from '../db/migrate.js';
|
|
30
|
+
import { detectPackageManager, scriptRunArgs } from '../pm.js';
|
|
29
31
|
/** 사용자 프로젝트에 `test` 스크립트가 있는지. */
|
|
30
32
|
function hasTestScript(cwd) {
|
|
31
33
|
const pkgPath = join(cwd, 'package.json');
|
|
@@ -118,7 +120,8 @@ async function provisionTestDatabases(cwd, json) {
|
|
|
118
120
|
}
|
|
119
121
|
/**
|
|
120
122
|
* `gaon test` 진입점. args 는 사용자가 넘긴 잔여 인자(필터 문자열 등).
|
|
121
|
-
*
|
|
123
|
+
* user script 경로는 프로젝트 선언 pm 으로 실행하며(결정 170), 인자 전달 관례는
|
|
124
|
+
* pm 별로 갈린다(pnpm·npm = `--` 분리 · yarn classic = 직접) — `scriptRunArgs`.
|
|
122
125
|
*/
|
|
123
126
|
export async function runTestCommand(args = [], opts = {}) {
|
|
124
127
|
const cwd = opts.cwd ?? process.cwd();
|
|
@@ -135,9 +138,12 @@ export async function runTestCommand(args = [], opts = {}) {
|
|
|
135
138
|
let cmd;
|
|
136
139
|
let spawnArgs;
|
|
137
140
|
if (hasTestScript(cwd)) {
|
|
138
|
-
|
|
139
|
-
//
|
|
140
|
-
|
|
141
|
+
// 결정 170 W1: pnpm 하드코딩 대신 프로젝트 선언 pm 으로 test 스크립트 실행
|
|
142
|
+
// (npm/yarn 스캐폴드 대응 · 공유 `pm.ts` 단일 소스). passthrough(필터 등)는
|
|
143
|
+
// pnpm·npm 은 `--` 로, yarn(classic)은 `--` 없이 전달 — scriptRunArgs 가 처리.
|
|
144
|
+
const pm = detectPackageManager(cwd);
|
|
145
|
+
cmd = pm;
|
|
146
|
+
spawnArgs = scriptRunArgs(pm, 'test', passthrough);
|
|
141
147
|
}
|
|
142
148
|
else {
|
|
143
149
|
const vitestBin = join(cwd, 'node_modules', '.bin', 'vitest');
|
package/dist/dev.d.ts
CHANGED
|
@@ -15,6 +15,12 @@ export interface DevLayout {
|
|
|
15
15
|
readonly localesDir?: string;
|
|
16
16
|
/** .gaon/messages.d.ts */
|
|
17
17
|
readonly messagesOut: string;
|
|
18
|
+
/** 프론트 진입(index.html)이 있는 앱이 하나라도 있는가 — env 축 적용 조건(결정 198). */
|
|
19
|
+
readonly hasFrontendApps: boolean;
|
|
20
|
+
/** 프로젝트 루트 `.env`(env 축 타입 출처 · 결정 198 · C). */
|
|
21
|
+
readonly envFile: string;
|
|
22
|
+
/** .gaon/env.d.ts */
|
|
23
|
+
readonly envOut: string;
|
|
18
24
|
}
|
|
19
25
|
export interface DevDeps {
|
|
20
26
|
readonly layout: DevLayout;
|
|
@@ -22,6 +28,8 @@ export interface DevDeps {
|
|
|
22
28
|
regenerateRoutes(appDir: string, out: string): Promise<unknown>;
|
|
23
29
|
/** locales/ → .gaon/messages.d.ts (결정 158 · W2). i18n 축을 쓰는 호출자만 준다. */
|
|
24
30
|
regenerateMessages?(localesDir: string, out: string): unknown;
|
|
31
|
+
/** `.env` → .gaon/env.d.ts (결정 198 · F-9 ②). env 축을 쓰는 호출자만 준다. */
|
|
32
|
+
regenerateEnv?(envFile: string, out: string): unknown;
|
|
25
33
|
watch(dir: string, opts: WatchOptions): WatchHandle;
|
|
26
34
|
log(event: DevEvent): void;
|
|
27
35
|
onError(err: Error): void;
|
|
@@ -32,7 +40,7 @@ export type DevEvent = {
|
|
|
32
40
|
readonly apps: string[];
|
|
33
41
|
} | {
|
|
34
42
|
readonly kind: 'regen';
|
|
35
|
-
readonly target: 'tables' | 'routes' | 'messages';
|
|
43
|
+
readonly target: 'tables' | 'routes' | 'messages' | 'env';
|
|
36
44
|
readonly app?: string;
|
|
37
45
|
} | {
|
|
38
46
|
readonly kind: 'stopped';
|
|
@@ -52,6 +60,8 @@ export interface RegenDeps {
|
|
|
52
60
|
regenerateRoutes(appDir: string, out: string): Promise<unknown>;
|
|
53
61
|
/** locales/ → .gaon/messages.d.ts (결정 158 · W2). i18n 축을 쓰는 호출자만 준다. */
|
|
54
62
|
regenerateMessages?(localesDir: string, out: string): unknown;
|
|
63
|
+
/** `.env` → .gaon/env.d.ts (결정 198 · F-9 ②). env 축을 쓰는 호출자만 준다. */
|
|
64
|
+
regenerateEnv?(envFile: string, out: string): unknown;
|
|
55
65
|
}
|
|
56
66
|
export interface RegenResult {
|
|
57
67
|
/** tables.d.ts 를 재생성했는가(domain/schema 존재 시에만). */
|
|
@@ -60,6 +70,8 @@ export interface RegenResult {
|
|
|
60
70
|
readonly apps: readonly string[];
|
|
61
71
|
/** messages.d.ts 를 재생성했는가(locales/ 존재 · 결정 158 · W2). */
|
|
62
72
|
readonly messages: boolean;
|
|
73
|
+
/** env.d.ts 를 재생성했는가(프론트 앱 존재 · 결정 198 · F-9 ②). */
|
|
74
|
+
readonly env: boolean;
|
|
63
75
|
}
|
|
64
76
|
/**
|
|
65
77
|
* 워처 없이 .gaon 타입 브리지를 1회 전체 재생성한다. `gaon check` 처럼
|
package/dist/dev.js
CHANGED
|
@@ -42,6 +42,13 @@ export async function startDev(deps) {
|
|
|
42
42
|
await deps.regenerateMessages(layout.localesDir, layout.messagesOut);
|
|
43
43
|
deps.log({ kind: 'regen', target: 'messages' });
|
|
44
44
|
}
|
|
45
|
+
// 결정 198(F-9 ②): 프론트 앱이 있으면 .env → env.d.ts(VITE_* 타입 브리지). `.env` 부재는
|
|
46
|
+
// regenerateEnv 가 throw(수리 안내) → dev 부팅 실패로 노출한다. `.env` 변경은 build-time
|
|
47
|
+
// 이라(Vite 가 서버 재시작으로 반영) 연속 워치는 두지 않는다 — 초기 1회 재생성으로 충분.
|
|
48
|
+
if (layout.hasFrontendApps && deps.regenerateEnv) {
|
|
49
|
+
await deps.regenerateEnv(layout.envFile, layout.envOut);
|
|
50
|
+
deps.log({ kind: 'regen', target: 'env' });
|
|
51
|
+
}
|
|
45
52
|
// 2) 스키마 워치 → tables.d.ts 재생성.
|
|
46
53
|
if (layout.schemaDir) {
|
|
47
54
|
const schemaDir = layout.schemaDir;
|
|
@@ -105,7 +112,12 @@ export async function regenerateGaonOnce(layout, deps) {
|
|
|
105
112
|
if (layout.localesDir && deps.regenerateMessages) {
|
|
106
113
|
messages = (await deps.regenerateMessages(layout.localesDir, layout.messagesOut)) === true;
|
|
107
114
|
}
|
|
108
|
-
|
|
115
|
+
// 결정 198(F-9 ②): 프론트 앱이 있으면 .env → env.d.ts. `.env` 부재는 throw(수리 안내).
|
|
116
|
+
let env = false;
|
|
117
|
+
if (layout.hasFrontendApps && deps.regenerateEnv) {
|
|
118
|
+
env = (await deps.regenerateEnv(layout.envFile, layout.envOut)) === true;
|
|
119
|
+
}
|
|
120
|
+
return { tables: !!layout.schemaDir, apps: layout.apps.map((a) => a.name), messages, env };
|
|
109
121
|
}
|
|
110
122
|
/** cwd 관례로 프로젝트 레이아웃을 해석한다(존재하는 것만 포함). */
|
|
111
123
|
export function resolveDevLayout(cwd) {
|
|
@@ -113,11 +125,16 @@ export function resolveDevLayout(cwd) {
|
|
|
113
125
|
const schemaDirPath = join(root, 'domain', 'schema');
|
|
114
126
|
const appsDir = join(root, 'apps');
|
|
115
127
|
const apps = [];
|
|
128
|
+
// env 축 적용 조건(결정 198) — 프론트 진입(index.html)을 가진 앱이 하나라도 있는가.
|
|
129
|
+
// API-only(index.html 없음)·스키마 전용 프로젝트는 클라 env 접근자를 안 쓰므로 제외한다.
|
|
130
|
+
let hasFrontendApps = false;
|
|
116
131
|
if (existsSync(appsDir)) {
|
|
117
132
|
for (const entry of readdirSync(appsDir, { withFileTypes: true })) {
|
|
118
133
|
if (!entry.isDirectory())
|
|
119
134
|
continue;
|
|
120
135
|
const appDir = join(appsDir, entry.name);
|
|
136
|
+
if (existsSync(join(appDir, 'index.html')))
|
|
137
|
+
hasFrontendApps = true;
|
|
121
138
|
if (!existsSync(join(appDir, 'routes.ts')))
|
|
122
139
|
continue;
|
|
123
140
|
apps.push({
|
|
@@ -134,6 +151,9 @@ export function resolveDevLayout(cwd) {
|
|
|
134
151
|
apps: apps.sort((a, b) => a.name.localeCompare(b.name)),
|
|
135
152
|
localesDir: existsSync(localesDirPath) ? localesDirPath : undefined,
|
|
136
153
|
messagesOut: join(root, '.gaon', 'messages.d.ts'),
|
|
154
|
+
hasFrontendApps,
|
|
155
|
+
envFile: join(root, '.env'),
|
|
156
|
+
envOut: join(root, '.gaon', 'env.d.ts'),
|
|
137
157
|
};
|
|
138
158
|
}
|
|
139
159
|
// runDevCommand · DevCommandOptions 는 M9-C 에서 commands/dev.ts 로 이동.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { RuleReport } from './types.js';
|
|
2
|
+
/** 소스(.vue)에 import.meta.env 코드 사용이 있는지(단위 테스트 진입점 · 스크립트만·주석 제외). */
|
|
3
|
+
export declare function usesImportMetaEnv(source: string): boolean;
|
|
4
|
+
/** apps/·shared/ 의 .vue 를 훑어 import.meta.env 직접 사용을 error 로 낸다(결정 198). */
|
|
5
|
+
export declare function checkNoImportMetaEnv(cwd: string): Promise<RuleReport>;
|
|
@@ -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
|
+
}
|
package/dist/doctor/types.d.ts
CHANGED
|
@@ -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) =
|
|
28
|
+
* 실행할 검사 이름. 지정 없음(undefined) = 26개 모두.
|
|
29
29
|
*/
|
|
30
30
|
/**
|
|
31
|
-
* doctor 정적 검사
|
|
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
|
-
*
|
|
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) =
|
|
97
|
+
* 실행할 검사 이름. 지정 없음(undefined) = 26개 모두.
|
|
96
98
|
*/
|
|
97
99
|
/**
|
|
98
|
-
* doctor 정적 검사
|
|
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;
|
package/dist/env-gen.js
ADDED
|
@@ -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
|
@@ -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(정본
|
|
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);
|
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` 검사
|
|
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 # 정적 검사
|
|
199
|
+
gaon doctor # 정적 검사 26종 (§2.2)
|
|
199
200
|
```
|
|
200
201
|
|
|
201
202
|
### 4.1 CLI 명령 (전 명령 `--json` 지원)
|
|
@@ -247,21 +247,51 @@ export default schedule((s) => {
|
|
|
247
247
|
(`.later()`), 처리·스케줄은 워커가 한다. 스케줄이 안 도는 흔한 원인은
|
|
248
248
|
`gaon work` 를 안 띄운 것이다.
|
|
249
249
|
|
|
250
|
+
#### 시간대 (결정 202)
|
|
251
|
+
|
|
252
|
+
`s.daily.at('04:00', Job)`·`s.cron('0 9 * * 1', Job)` 는 **서버 로컬 타임존**의
|
|
253
|
+
wall-clock 으로 매치한다(`new Date()` 로컬 시·분·요일). v1 은 **잡별 타임존
|
|
254
|
+
옵션이 없다** — 특정 TZ 로 돌려야 하면 워커 프로세스의 `TZ` 환경변수를 그
|
|
255
|
+
타임존으로 띄운다(예: `TZ=Asia/Seoul gaon work`). 여러 인스턴스는 같은 TZ 로
|
|
256
|
+
맞춘다(리더가 어느 인스턴스든 같은 wall-clock 을 봐야 한다).
|
|
257
|
+
|
|
258
|
+
#### 중첩 방지 (결정 202)
|
|
259
|
+
|
|
260
|
+
스케줄러는 매 주기 **발행만** 한다(§7 리더). 한 주기보다 오래 걸리는 잡이
|
|
261
|
+
**자기 자신과 겹쳐** 실행되면 안 되면, 잡 핸들러가 `lock(key, fn, { onBusy:
|
|
262
|
+
'skip' })` 으로 임계구역을 지킨다 — 이미 도는 인스턴스가 있으면 이번 틱은
|
|
263
|
+
스킵된다(중복 실행 방지). 락은 `gaon work` 워커에서도 배선된다(결정 202).
|
|
264
|
+
|
|
250
265
|
### 6. 워커 프로세스 (`gaon work`)
|
|
251
266
|
|
|
252
267
|
잡·리스너·스케줄러·아웃박스 릴레이를 한 프로세스로 조립한다. 운영
|
|
253
268
|
프로세스 3종(serve·work·hub) 중 하나. SIGTERM/SIGINT 에 graceful
|
|
254
269
|
drain — 스케줄러 리더를 반납하고 진행 중인 잡을 완료한 뒤 종료한다.
|
|
255
270
|
|
|
271
|
+
#### graceful drain 계약 (결정 201)
|
|
272
|
+
|
|
273
|
+
배포·재시작(k8s 롤링 등)에서 **SIGTERM 이 진행 중 작업을 자르지 않는다**:
|
|
274
|
+
|
|
275
|
+
- **`gaon serve`** — 인플라이트 HTTP 요청을 **완료까지 기다린 뒤** 종료한다(리셋
|
|
276
|
+
안 함 · 롤링 배포 중 502 방지). 새 연결은 안 받고, 진행 요청이 끝나면 idle
|
|
277
|
+
keep-alive 를 닫아 종료가 즉시 완결된다(결정 201 · `forceCloseConnections:false`
|
|
278
|
+
+ close idle 스윕).
|
|
279
|
+
- **`gaon work`** — 신규 잡 pull 을 멈추고 **진행 중 잡을 완료**한 뒤 종료한다
|
|
280
|
+
(`drainTimeoutMs` 상한 · 기본 30s). 스케줄러 리더는 즉시 반납해 다른 인스턴스가
|
|
281
|
+
승계한다. drain 상한을 넘긴 잡은 ack 되지 않아 재전달(크래시 복구)된다.
|
|
282
|
+
- **컨테이너 기본 워커 1** — `gaon serve` 클러스터(`--workers`)도 SIGTERM 에
|
|
283
|
+
워커들을 graceful drain 후 종료한다(결정 84).
|
|
284
|
+
|
|
256
285
|
### 7. 분산 락 (`lock()`) (결정 147)
|
|
257
286
|
|
|
258
287
|
**동시 실행을 막아야 하면 `lock(key, fn)`** — 같은 `key` 에 대해 전
|
|
259
288
|
인스턴스를 통틀어 동시 1개의 `fn` 만 임계구역에 들인다. **로컬 뮤텍스는
|
|
260
289
|
반정본이다** — 멀티 인스턴스(워커 여러 대·serve 여러 대)에서는 프로세스마다
|
|
261
290
|
따로 놀아 무의미하다(결정 88 ①). 그래서 백엔드는 Redis 다: 설정에 `redis`
|
|
262
|
-
가 있으면
|
|
263
|
-
|
|
264
|
-
|
|
291
|
+
가 있으면 **`gaon serve` 와 `gaon work` 둘 다** 분산 락을 자동 배선한다(결정
|
|
292
|
+
202 · wireDomain 공통 경로) — 그래서 **잡·서비스 안에서도 `lock()` 을 쓸 수
|
|
293
|
+
있다**(예: 중첩 방지 · §5). **`redis` 미설정 상태로 `lock()` 을 부르면 로컬
|
|
294
|
+
뮤텍스로 조용히 떨어지지 않고 수리 안내와 함께 throw** 한다.
|
|
265
295
|
|
|
266
296
|
```ts
|
|
267
297
|
import { lock } from 'gaonjs/async'
|
|
@@ -344,4 +374,7 @@ async create() {
|
|
|
344
374
|
| 결정 103 | doctor `async-offload` 검사 (컨트롤러 인라인 메일·이미지·외부 HTTP 경고) |
|
|
345
375
|
| 결정 32 | 잡 발행 위치 자유 (컨트롤러·서비스·리스너 모두 정합) |
|
|
346
376
|
| 결정 42 | 비동기 테스트 헬퍼 `expectJobProcessed` (`agents/testing.md`) |
|
|
377
|
+
| 결정 200 | DLQ 재처리 `retryDlq(nats, id)` (이미 설정된 잡 전송 보존 · DLQ 재적재 후 원본 삭제) |
|
|
378
|
+
| 결정 201 | graceful drain 계약 (serve 인플라이트 요청 완결 · work 진행 잡 완결 · §6) |
|
|
379
|
+
| 결정 202 | 락·캐시 백엔드 serve·work 공통 배선(wireDomain) · 잡/서비스 lock() 가능 · 스케줄러 시간대(서버 로컬 TZ)·중첩 방지(§5·§7) |
|
|
347
380
|
| §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
|
|
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` 켠 앱의 프론트
|
|
@@ -32,6 +32,29 @@ t('nav.home') // 중첩은 점 표기
|
|
|
32
32
|
| 지원 언어 | `languages(): string[]` | 설정된 supportedLngs |
|
|
33
33
|
| 고정 번역 | `runWithLanguage(lng, fn)` | fn 안의 t() 가 그 언어(메일·비요청 경로 · §mail) |
|
|
34
34
|
|
|
35
|
+
### 1.5 복수형 — `count` 로 자동 선택 (i18next 규약 · 결정 181)
|
|
36
|
+
|
|
37
|
+
복수형은 카탈로그에 **접미사 키**(`<키>_one`·`<키>_other`)를 두고 `t('<키>', { count })`
|
|
38
|
+
로 부른다 — i18next 가 `count` 와 로케일의 CLDR 규칙으로 알맞은 접미사를 고른다. 타입은
|
|
39
|
+
**base 키**(`<키>`)로 검사한다(생성기가 접미사 키에서 base 키를 함께 노출 · 결정 181).
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
// locales/en.json — 영어는 단수/복수 구분(_one·_other)
|
|
43
|
+
{ "cart": { "items_one": "{{count}} item", "items_other": "{{count}} items" } }
|
|
44
|
+
// locales/ko.json — 한국어는 복수 구분 없음(_other 만)
|
|
45
|
+
{ "cart": { "items_other": "상품 {{count}}개" } }
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
t('cart.items', { count: 1 }) // en → "1 item" · ko → "상품 1개"
|
|
50
|
+
t('cart.items', { count: 5 }) // en → "5 items" · ko → "상품 5개"
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
- **base 키로 부른다** — `t('cart.items', { count })`. `t('cart.items_one')` 처럼 접미사를
|
|
54
|
+
직접 부르면 복수 선택이 안 된다(안티패턴).
|
|
55
|
+
- 로케일마다 필요한 접미사만 둔다(영어 `_one`·`_other` / 한국어·일본어 `_other`). 기준
|
|
56
|
+
로케일(§4)에 있는 키가 타입 유니온이 되므로 **기준 로케일 카탈로그에 복수형 키를 둔다**.
|
|
57
|
+
|
|
35
58
|
### 2. 요청별 로케일 자동 협상 (결정 159)
|
|
36
59
|
|
|
37
60
|
`gaon.config.ts` 에 `i18n` 설정이 있으면 `gaon serve`/`gaon dev` 가 **매 요청**
|
|
@@ -97,6 +120,9 @@ export function greetLine(name: string): string {
|
|
|
97
120
|
전환은 `this.setLocale`, 특정 로케일 강제는 `runWithLanguage`.
|
|
98
121
|
- **메일은 요청 로케일이 아니라 수신자 로케일** — `deliver(data, { locale })` 로
|
|
99
122
|
명시한다(`agents/mail.md` · 결정 160).
|
|
123
|
+
- **검증 실패 문안도 로케일화된다** — 예약 namespace `validation.<code>`(예 `validation.required`)
|
|
124
|
+
를 `locales/` 에 넣으면 필드별 사유가 요청 로케일로 번역된다(`agents/web.md` §4.1 · 결정 183).
|
|
125
|
+
프레임웍은 코드만 노출하고 번역은 앱 몫이다(미제공 시 내장 fallback).
|
|
100
126
|
|
|
101
127
|
## 관련 결정 번호
|
|
102
128
|
|
|
@@ -57,6 +57,37 @@ 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
|
+
### 5. `gaon.config.ts` 의 `mail` 블록 (env-gated)
|
|
61
|
+
|
|
62
|
+
메일러는 루트 `gaon.config.ts` 의 `mail` 블록으로 배선된다 — `db`·`redis`·`nats`
|
|
63
|
+
동형으로 **env-gated**(SMTP_HOST 없으면 미배선). 스캐폴드(`gaon new`)가 아래 블록을
|
|
64
|
+
이미 넣어 둔다(결정 160). 발신자 기본값은 **`defaultFrom`**(메시지 `from` 아님 · `from`
|
|
65
|
+
은 개별 메시지 override).
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
// gaon.config.ts
|
|
69
|
+
export default defineConfig({
|
|
70
|
+
mail: process.env.SMTP_HOST
|
|
71
|
+
? {
|
|
72
|
+
host: process.env.SMTP_HOST,
|
|
73
|
+
port: process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : 1025,
|
|
74
|
+
secure: process.env.SMTP_SECURE === 'true', // 운영 TLS
|
|
75
|
+
user: process.env.SMTP_USER, // 선택(인증 SMTP)
|
|
76
|
+
pass: process.env.SMTP_PASS, // 선택
|
|
77
|
+
defaultFrom: process.env.MAIL_FROM, // 발신자 기본값(from 없는 메시지)
|
|
78
|
+
}
|
|
79
|
+
: undefined,
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
| 필드 | 타입 | 비고 |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `host` | `string` | SMTP 호스트(dev = MailPit `127.0.0.1`) |
|
|
86
|
+
| `port` | `number` | SMTP 포트(dev MailPit = 1025) |
|
|
87
|
+
| `secure?` | `boolean` | TLS(운영). 생략 = false |
|
|
88
|
+
| `user?`·`pass?` | `string` | 인증 SMTP 자격증명(생략 가능) |
|
|
89
|
+
| `defaultFrom` | `string` | 발신자 기본값 — 메시지에 `from` 이 없을 때. `configureMailer({ defaultFrom })` 와 동치 |
|
|
90
|
+
|
|
60
91
|
## 정본 예시
|
|
61
92
|
|
|
62
93
|
```ts
|
|
@@ -186,6 +186,15 @@ export function useRoom(roomId: number) {
|
|
|
186
186
|
- 허브가 죽었다 재시작하면 같은 포트로 재bind 하고 KV 에서 상태를
|
|
187
187
|
복원한다.
|
|
188
188
|
- ping 무활동 타임아웃은 네트워크 파티션 백스톱이다.
|
|
189
|
+
- **fail-fast — 리더가 됐는데 포트를 못 잡으면 즉시 종료한다(결정 207).**
|
|
190
|
+
리스는 얻었으나 TCP 포트 bind 에 실패하면(포트를 다른 허브·orphan·
|
|
191
|
+
오설정이 점유) 허브는 **좀비 리더**(리스 보유·프레즌스 서빙 불가)가 되지
|
|
192
|
+
않도록 리스를 사임하고 `process.exit(1)` 한다. 조용히 프레즌스가 죽는
|
|
193
|
+
대신 명확히 종료해 systemd/pm2 가 재시작하게 하고, 대기 인스턴스가 즉시
|
|
194
|
+
승계한다. **운영 함정**: 한 호스트에 허브를 여러 개 띄우면 포트가
|
|
195
|
+
겹친다 — 인스턴스마다 다른 `GAON_HUB_PORT` 를 주거나 호스트를 분리한다.
|
|
196
|
+
`gaon dev` 는 내장 허브를 기본 포트로 띄우므로, 같은 호스트에서 별도
|
|
197
|
+
`gaon hub` 를 돌릴 땐 포트를 바꾼다.
|
|
189
198
|
|
|
190
199
|
## 정본 예시
|
|
191
200
|
|
|
@@ -255,6 +264,7 @@ export default channel({
|
|
|
255
264
|
| §7 (v0.15) | 실시간 v1 포함 — 채널·프레즌스·허브 · KV 영속 · 리스 리더 선출 HA |
|
|
256
265
|
| 결정 126 | 서버 개시 `broadcast(name, data)`(`gaonjs/async`) — 컨트롤러·서비스·잡에서 클라 메시지 없이 채널 발화 · authorize 재실행 없음 · seal 재봉인 자동 |
|
|
257
266
|
| 결정 154 | `useChannel` 앱 프리픽스 자동 주입 — `import.meta.env.BASE_URL`(vite base·에셋 base 단일 소스) 로 `<프리픽스>/gaon/ws/<name>` · `opts.path` 는 탈출구 · 프리픽스 앱 실시간 무한 재연결 제거(§4) |
|
|
267
|
+
| 결정 207 | 허브 fail-fast(§5) — 리스는 얻고 TCP 포트 bind 실패 시 좀비 리더 대신 리스 사임 + `process.exit(1)`(F-13 fix · `onFatal` 훅으로 주입 가능) |
|
|
258
268
|
|
|
259
269
|
## `@gaonjs/seal` 켠 앱의 채널
|
|
260
270
|
|
|
@@ -24,10 +24,13 @@
|
|
|
24
24
|
매 테스트 뒤 전 테이블을 비운다(`truncateAll`). 아래 §5 참고.
|
|
25
25
|
- `gaon test` 가 잡·이벤트 NATS 스트림도 **자동 격리**한다(결정 130) —
|
|
26
26
|
테스트 프로세스에 `GAON_STREAM_PREFIX` 를 주입해 스트림·subject 가
|
|
27
|
-
`GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다.
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
`GAON_TEST_JOBS`·`test.gaon.jobs.>` 로 갈린다. 같은 접두가 **NATS KV 버킷**
|
|
28
|
+
(스케줄러 리스 `gaon_lease`·프레즌스·허브)에도 적용돼(결정 203) `test_gaon_lease`
|
|
29
|
+
처럼 격리된다 — 스트림뿐 아니라 KV 도 개발·운영 스택과 안 겹친다. 그래서
|
|
30
|
+
개발용 `gaon work` 가 떠 있어도 테스트 잡을 훔치지 않고 스케줄러 리더 경합도
|
|
31
|
+
안 생기며, 테스트가 남긴 잡을 개발 워커가 처리하지도 않는다 — **테스트 전에
|
|
32
|
+
워커를 내릴 필요가 없다.** (직접 `vitest` 로 돌리면 이 격리가 없어 개발 스택과
|
|
33
|
+
섞이니 `gaon test` 를 쓴다.)
|
|
31
34
|
- SQLite 는 Docker 가 불가능한 환경의 폴백으로만 남고 공식 경로가
|
|
32
35
|
아니다.
|
|
33
36
|
|
|
@@ -256,5 +259,6 @@ hidden 값은 **서버 코드에서는 여전히 읽힌다**(직렬화 경계에
|
|
|
256
259
|
| 결정 122 | 직렬화 경계 테스트는 관계 경유 hidden 을 반드시 포함(재귀 no-leak 단언) |
|
|
257
260
|
| 결정 111 | `gaon test` 테스트 DB 자동 준비 + `connectTestDatabase`·`truncateAll` 격리(truncate · service COMMIT 실측) |
|
|
258
261
|
| 결정 130 | `gaon test` 가 NATS 스트림도 자동 격리(`GAON_STREAM_PREFIX` → `GAON_TEST_JOBS`·`test.gaon.jobs.>`) — 개발 워커 병행 시 잡 누출 방지(규칙 10 이행) |
|
|
262
|
+
| 결정 203 | 같은 접두를 **NATS KV 버킷**에도 적용(`gaon_lease`·프레즌스·허브 → `test_gaon_lease` 등) — 스트림만 격리하던 결정 130 의 빈틈(KV 미격리)을 메움. 스케줄러 리스 경합·크로스-프리픽스 KV 누출 방지 |
|
|
259
263
|
| §9 (v0.15) | 실 인프라 필수 · 목업/인메모리 금지 |
|
|
260
264
|
| 결정 32 | 잡 발행 위치 자유 — publish 함수가 서비스 경유여도 검증 대상 |
|
|
@@ -239,6 +239,35 @@ async create() {
|
|
|
239
239
|
|
|
240
240
|
순수 JSON/API 앱(X-Inertia 아님·세션 없음)은 기존대로 **422 JSON** 을 받는다.
|
|
241
241
|
|
|
242
|
+
#### 검증 사유 로케일화 — 예약 namespace `validation.*` (결정 183)
|
|
243
|
+
|
|
244
|
+
검증 실패의 **필드별 사유**(`form.errors.<필드>` 로 엔드유저에 노출되는 부분)는 요청
|
|
245
|
+
로케일로 번역된다 — `i18n` 이 설정돼 있고 앱이 `locales/<lng>.json` 의 **예약 namespace
|
|
246
|
+
`validation.<code>`** 로 번역을 제공하면. 프레임웍은 **안정적 코드만** 노출하고 번역은
|
|
247
|
+
앱 몫이다(The One Way: 코드는 프레임웍, 문안은 앱). 키가 없으면 내장 fallback(한국어)로
|
|
248
|
+
떨어져 거동이 보존된다. i18n 미설정이면 항상 fallback.
|
|
249
|
+
|
|
250
|
+
| code | 파라미터 | 언제 |
|
|
251
|
+
|---|---|---|
|
|
252
|
+
| `required` | — | 필수 컬럼 누락 |
|
|
253
|
+
| `too_long` | `{ max, len }` | `.max(n)` 초과 |
|
|
254
|
+
| `not_allowed` | `{ value, allowed }` | enum 밖 값 |
|
|
255
|
+
| `not_integer` | `{ value }` | bigint 변환 실패 |
|
|
256
|
+
| `not_boolean` | `{ value }` | boolean 변환 실패 |
|
|
257
|
+
| `not_date` | `{ value }` | 날짜 변환 실패 |
|
|
258
|
+
|
|
259
|
+
```json
|
|
260
|
+
// locales/en.json — 앱이 검증 문안을 로케일별로 준다(i18next {{max}} 보간).
|
|
261
|
+
{ "validation": {
|
|
262
|
+
"required": "This field is required.",
|
|
263
|
+
"too_long": "At most {{max}} characters (got {{len}})."
|
|
264
|
+
} }
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
주의: 상단 개발자용 422 `message`(스키마 어느 파일을 고치라는 §7.5.3 **수리 안내**)는
|
|
268
|
+
로케일화하지 않는다 — AI/개발자용 고정 안내다. 로케일화 대상은 **엔드유저에 닿는 필드별
|
|
269
|
+
사유**뿐이다.
|
|
270
|
+
|
|
242
271
|
#### 세션/CSRF 실패·415 도 코어가 Inertia-네이티브로 마감한다 (결정 165)
|
|
243
272
|
|
|
244
273
|
폼 검증(결정 109)과 **대칭**으로, 컨트롤러 액션 밖(디스패처 try-catch 밖)에서 나는
|
|
@@ -423,6 +452,13 @@ const ok = await verifyPassword(plain, user.passwordDigest) // Promise<boolean>
|
|
|
423
452
|
|
|
424
453
|
API 앱(JWT)은 세션 대신 `this.jwt.issue(user)` / `this.jwt.refresh(token)` 를 쓴다.
|
|
425
454
|
|
|
455
|
+
- **API 앱은 프론트엔드가 없다(JSON 전용).** `apps/<app>/app.config.ts`(`auth: { strategy:'jwt', … }`)
|
|
456
|
+
+ `routes.ts` + `controllers/` 만 두면 된다 — `index.html`·`main.ts`·`pages/` 는 만들지 않는다.
|
|
457
|
+
앱 발견은 `routes.ts` 기준이라 `gaon serve` 가 이 앱을 `/<app>` 프리픽스로 정상 마운트하고,
|
|
458
|
+
`gaon build`·`gaon check` 는 프론트 앱(=`index.html` 보유)만 검사하므로 API 앱을 프론트로
|
|
459
|
+
오검사하지 않는다. JWT 앱의 표준 형태다("프론트엔드는 프로젝트당 하나" 규칙은 Vue 앱 한정 ·
|
|
460
|
+
API 앱은 그 규칙 밖).
|
|
461
|
+
|
|
426
462
|
- **`this.auth.user`/`requireAuth()` 사용자 타입은 앱이 증강한다** (결정 58).
|
|
427
463
|
`GaonCurrentUser` 는 빈 인터페이스라 증강 없이는 `user.id` 접근이 타입에러다.
|
|
428
464
|
`gaon g auth` 가 `apps/<app>/auth.ts` 에 심는다:
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gaonjs/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Gaon CLI
|
|
3
|
+
"version": "0.37.1",
|
|
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",
|
|
@@ -27,13 +27,13 @@
|
|
|
27
27
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
28
28
|
"typescript": "^5.9.0",
|
|
29
29
|
"vite": "^7.0.0",
|
|
30
|
-
"@gaonjs/async": "0.
|
|
31
|
-
"@gaonjs/
|
|
32
|
-
"@gaonjs/core": "0.2.
|
|
33
|
-
"@gaonjs/
|
|
34
|
-
"@gaonjs/i18n": "0.2.
|
|
35
|
-
"@gaonjs/
|
|
36
|
-
"@gaonjs/
|
|
30
|
+
"@gaonjs/async": "0.12.0",
|
|
31
|
+
"@gaonjs/config": "0.15.2",
|
|
32
|
+
"@gaonjs/core": "0.2.2",
|
|
33
|
+
"@gaonjs/mail": "0.2.1",
|
|
34
|
+
"@gaonjs/i18n": "0.2.1",
|
|
35
|
+
"@gaonjs/data": "0.16.1",
|
|
36
|
+
"@gaonjs/web": "0.18.1"
|
|
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})\""
|