@gaonjs/cli 0.35.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.
Files changed (38) hide show
  1. package/dist/commands/check.js +15 -9
  2. package/dist/commands/dev.js +10 -1
  3. package/dist/commands/gen.d.ts +2 -0
  4. package/dist/commands/gen.js +6 -2
  5. package/dist/commands/new.d.ts +7 -1
  6. package/dist/commands/new.js +15 -2
  7. package/dist/commands/test.d.ts +2 -1
  8. package/dist/commands/test.js +11 -5
  9. package/dist/dev.d.ts +13 -1
  10. package/dist/dev.js +21 -1
  11. package/dist/doctor/no-import-meta-env.d.ts +5 -0
  12. package/dist/doctor/no-import-meta-env.js +98 -0
  13. package/dist/doctor/types.d.ts +1 -1
  14. package/dist/doctor.d.ts +9 -0
  15. package/dist/doctor.js +12 -3
  16. package/dist/env-gen.d.ts +12 -0
  17. package/dist/env-gen.js +63 -0
  18. package/dist/index.d.ts +17 -0
  19. package/dist/index.js +54 -23
  20. package/dist/pm.d.ts +13 -0
  21. package/dist/pm.js +59 -0
  22. package/dist/templates/index.d.ts +17 -0
  23. package/dist/templates/index.js +18 -1
  24. package/dist/templates/index.ts +24 -0
  25. package/dist/templates/project/.env.example.tpl +6 -0
  26. package/dist/templates/project/AGENTS.md.tpl +3 -2
  27. package/dist/templates/project/CLAUDE.md.tpl +1 -1
  28. package/dist/templates/project/agents/async.md.tpl +36 -3
  29. package/dist/templates/project/agents/frontend.md.tpl +52 -1
  30. package/dist/templates/project/agents/i18n.md.tpl +26 -0
  31. package/dist/templates/project/agents/mail.md.tpl +31 -0
  32. package/dist/templates/project/agents/realtime.md.tpl +10 -0
  33. package/dist/templates/project/agents/testing.md.tpl +8 -4
  34. package/dist/templates/project/agents/web.md.tpl +36 -0
  35. package/dist/templates/project/package.json.tpl +1 -1
  36. package/package.json +9 -9
  37. package/dist/templates/auth/app.ts.tpl +0 -29
  38. package/dist/templates/auth/server.ts.tpl +0 -14
@@ -2,15 +2,15 @@
2
2
  * @gaonjs/cli · `gaon check` — 통합 검증 (M9-G · v0.15 §13.5)
3
3
  *
4
4
  * 배포 · CI · AI 에이전트가 한 번에 신뢰할 수 있는 검사 묶음. 사용자의
5
- * package.json 관례를 그대로 재사용한다 `pnpm typecheck` · `pnpm vue-tsc`
6
- * · `pnpm build`. 스크립트가 없으면 폴백으로 로컬 바이너리를 직접 부른다
7
- * (`node_modules/.bin/tsc --noEmit` · `node_modules/.bin/vue-tsc --noEmit`
8
- * · `pnpm -r build` → `npm run build`).
5
+ * package.json 스크립트를 그대로 재사용하되, **프로젝트 선언 pm** 으로 돌린다
6
+ * (결정 170 · `<pm> run <name>` — pnpm/npm/yarn). 스크립트가 없으면 폴백으로
7
+ * 로컬 바이너리를 직접 부른다(`node_modules/.bin/tsc --noEmit` ·
8
+ * `node_modules/.bin/vue-tsc --noEmit`).
9
9
  *
10
10
  * The One Way — 하나의 명령이 4 검사를 순서대로 돌린다(결정 157 · doctor 기본 포함):
11
- * 1) typecheck (pnpm typecheck 또는 tsc --noEmit)
12
- * 2) vue-tsc (pnpm vue-tsc 또는 vue-tsc --noEmit)
13
- * 3) build (pnpm build)
11
+ * 1) typecheck (<pm> run typecheck 또는 tsc --noEmit)
12
+ * 2) vue-tsc (<pm> run vue-tsc 또는 vue-tsc --noEmit)
13
+ * 3) build (<pm> run build)
14
14
  * 4) doctor (기본 포함 · 규칙 5 등 doctor 규칙 · --no-doctor 로 뺌 · 코어 재사용)
15
15
  *
16
16
  * 옵션 최소:
@@ -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
  * 정의해 두면 우선 사용한다.
@@ -77,8 +79,11 @@ async function runStep(step, cwd) {
77
79
  // 1) 사용자 스크립트가 정의돼 있으면 그걸 쓴다(한 곳에서 관리).
78
80
  const scriptName = step;
79
81
  if (hasScript(cwd, scriptName)) {
80
- const cmd = 'pnpm';
81
- const args = ['run', scriptName];
82
+ // 결정 170: pnpm 하드코딩 대신 프로젝트 선언 pm 으로 실행(npm/yarn 스캐폴드
83
+ // 대응 · pm 해상은 공유 `pm.ts` 단일 소스). build/typecheck/vue-tsc 는 잔여
84
+ // 인자가 없어 `<pm> run <name>` 만 — pnpm·npm·yarn(classic) 동일 동작.
85
+ const cmd = detectPackageManager(cwd);
86
+ const args = scriptRunArgs(cmd, scriptName);
82
87
  const { exitCode, output } = await runSubprocess(cwd, cmd, args);
83
88
  // 결정 146(12차 W2): build 가 성공했으면 **등록된 앱마다** dist/<앱>/index.html 과
84
89
  // 그 문서가 참조하는 에셋이 실제로 존재하는지 검증한다. build 스크립트가 통과해도
@@ -246,6 +251,7 @@ async function regenerateGaon(cwd) {
246
251
  regenerateTables: generateTablesDts,
247
252
  regenerateRoutes: generateRoutesDts,
248
253
  regenerateMessages: generateMessagesDts,
254
+ regenerateEnv: generateEnvDts,
249
255
  });
250
256
  return { status: 'done', tables: result.tables, apps: result.apps };
251
257
  }
@@ -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
- args.console.log('watcher', e.target === 'tables' ? '↻ tables.d.ts 재생성' : `↻ ${e.app}/.gaon/routes.d.ts 재생성`);
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 워처 종료');
@@ -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
  }
@@ -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;
@@ -8,7 +8,13 @@ export interface NewCommandOptions {
8
8
  readonly skipInstall?: boolean;
9
9
  /** git init · 첫 커밋 스킵(테스트·기존 git 저장소에 삽입). */
10
10
  readonly skipGit?: boolean;
11
- /** 패키지 매니저. 기본 pnpm(모노레포 관례 정합). */
11
+ /**
12
+ * 초기 `install` 을 돌릴 패키지 매니저(`--package-manager <pm>` · 별칭 `--pm`).
13
+ * 기본 pnpm(모노레포 관례 정합). blessed 툴체인(Dockerfile·pnpm-workspace)은
14
+ * The One Way 로 pnpm 을 유지하지만, package.json 의 `packageManager` 필드는
15
+ * 선택한 pm 에 맞춰 적는다 — 항상 pnpm 을 적으면 `--pm yarn` 시 yarn 1.22
16
+ * corepack enforcement 가 install 을 거부한다(결정 169 · 167 개정).
17
+ */
12
18
  readonly packageManager?: 'pnpm' | 'npm' | 'yarn';
13
19
  /**
14
20
  * (테스트 훅) 템플릿의 gaonjs 의존성 버전. 미지정 시 파사드(gaonjs)
@@ -22,7 +22,7 @@ import { dirname, join, resolve } from 'node:path';
22
22
  import { spawnSync } from 'node:child_process';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { readFileSync } from 'node:fs';
25
- import { renderProjectFiles } from '../templates/index.js';
25
+ import { renderProjectFiles, PACKAGE_MANAGER_PINS, } from '../templates/index.js';
26
26
  import { writeUiKitScaffold } from '../uikit.js';
27
27
  import { regenerateProjectGaon } from './gen.js';
28
28
  /** 이름 유효성 — npm 패키지명 규칙(단순 부분)만 검사. */
@@ -172,7 +172,20 @@ export async function runNewCommand(name, opts = {}) {
172
172
  // 파일 생성 — 실패 시 부분 생성물이 남지 않도록 폴더를 정리하지는 않는다
173
173
  // (사용자가 원인 파악 후 rm -rf 로 지우도록). 정상 흐름에서는 문제 없음.
174
174
  const gaonjsVersion = opts.gaonjsVersion ?? detectGaonjsVersion();
175
- const files = renderProjectFiles({ projectName: name, gaonjsVersion });
175
+ // 결정 169: packageManager 필드 = 선택한 pm corepack 핀. 항상 pnpm 을
176
+ // 적던 옛 관례는 --pm yarn 시 yarn 1.22 corepack 이 install 을 거부시켰다.
177
+ const files = renderProjectFiles({
178
+ projectName: name,
179
+ gaonjsVersion,
180
+ packageManager: PACKAGE_MANAGER_PINS[pm],
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
+ }
176
189
  let filesCreated = 0;
177
190
  try {
178
191
  mkdirSync(root, { recursive: true });
@@ -6,6 +6,7 @@ export interface TestCommandOptions {
6
6
  }
7
7
  /**
8
8
  * `gaon test` 진입점. args 는 사용자가 넘긴 잔여 인자(필터 문자열 등).
9
- * pnpm test pnpm 관례상 `pnpm test -- <args>` 넘겨야 vitest 까지 도달.
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>;
@@ -12,7 +12,8 @@
12
12
  * (기본) 둘 다 실행
13
13
  *
14
14
  * 실행 경로 우선순위:
15
- * 1) 사용자 package.json 의 `test` 스크립트가 있으면 `pnpm test -- <args>`
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
- * pnpm test pnpm 관례상 `pnpm test -- <args>` 넘겨야 vitest 까지 도달.
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
- cmd = 'pnpm';
139
- // pnpm test -- <extras> `--` 인자를 스크립트에 전달.
140
- spawnArgs = ['test', ...(passthrough.length ? ['--', ...passthrough] : [])];
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
- return { tables: !!layout.schemaDir, apps: layout.apps.map((a) => a.name), messages };
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
+ }
@@ -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
@@ -24,6 +24,15 @@ export { usesLayoutBreakpoint, checkPageLayoutBreakpoint, } from './doctor/page-
24
24
  export { usesLinkButtonNesting, checkLinkButtonNesting, } from './doctor/link-button-nesting.js';
25
25
  export { renderHuman, renderJson } from './doctor/reporter.js';
26
26
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
27
+ /**
28
+ * 실행할 검사 이름. 지정 없음(undefined) = 26개 모두.
29
+ */
30
+ /**
31
+ * doctor 정적 검사 26종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
32
+ * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
33
+ * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
34
+ */
35
+ export declare const ALL_RULES: readonly DoctorRule[];
27
36
  export interface DoctorCommandOptions {
28
37
  readonly cwd?: string;
29
38
  readonly json?: boolean;
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,9 +94,14 @@ 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
- const ALL_RULES = [
99
+ /**
100
+ * doctor 정적 검사 26종의 정본 목록(§2.2). `--check=` 필터의 인정 집합도
101
+ * 이 배열을 단일 출처로 삼는다(parseDoctorChecks) — 새 규칙 추가 시 여기만
102
+ * 늘리면 실행·필터·타입이 함께 정합된다(손유지 중복 리스트 표류 방지).
103
+ */
104
+ export const ALL_RULES = [
98
105
  'response-mixing',
99
106
  'n-plus-one',
100
107
  'dependency-direction',
@@ -120,6 +127,7 @@ const ALL_RULES = [
120
127
  'link-button-nesting',
121
128
  'seal-security',
122
129
  'schema-relations',
130
+ 'no-import-meta-env',
123
131
  ];
124
132
  const CHECKERS = {
125
133
  'response-mixing': checkResponseMixing,
@@ -147,6 +155,7 @@ const CHECKERS = {
147
155
  'link-button-nesting': checkLinkButtonNesting,
148
156
  'seal-security': checkSealSecurity,
149
157
  'schema-relations': checkSchemaRelations,
158
+ 'no-import-meta-env': checkNoImportMetaEnv,
150
159
  };
151
160
  /**
152
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.d.ts CHANGED
@@ -53,5 +53,22 @@ export interface RunOptions {
53
53
  * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
54
54
  */
55
55
  export declare function parseDoctorChecks(argv: readonly string[]): DoctorRule[] | undefined;
56
+ /** `gaon new` argv 파싱 결과. name 부재·pm 오타를 호출부가 분기 처리한다. */
57
+ export interface ParsedNewArgs {
58
+ /** 프로젝트 이름(첫 위치 인자). 부재 시 undefined. */
59
+ readonly name?: string;
60
+ /** 검증 통과한 패키지 매니저. 미지정 시 undefined(호출부가 pnpm 기본). */
61
+ readonly packageManager?: "pnpm" | "npm" | "yarn";
62
+ /** pm 값이 주어졌으나 pnpm·npm·yarn 이 아닐 때 그 원문(에러 안내용). */
63
+ readonly unknownPm?: string;
64
+ }
65
+ /**
66
+ * `gaon new <name>` 의 인자를 파싱한다.
67
+ * 패키지 매니저 플래그: `--package-manager <pm>`(정본) · `--pm <pm>`(별칭) ·
68
+ * 둘 다 `=` 형(`--package-manager=npm`)도 허용. 값을 취하는 플래그이므로
69
+ * 이름 위치 인자를 고를 때 그 값을 건너뛴다 — `gaon new --pm npm demo` 의
70
+ * npm 이 프로젝트 이름으로 오인되지 않도록(결정 167 · O-1 근본 fix).
71
+ */
72
+ export declare function parseNewArgs(rest: readonly string[]): ParsedNewArgs;
56
73
  /** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
57
74
  export declare function runCli(argv: readonly string[], opts?: RunOptions): void;