@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.
@@ -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 지정 시 그 단계만
@@ -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
- // 대응). `<pm> run <name>` pnpm·npm·yarn(classic) 동일하게 동작한다.
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 = ['run', scriptName];
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
  }
@@ -5,9 +5,16 @@
5
5
  * 1) Docker Compose — pg · redis · nats · mailpit · minio 자동 up -d
6
6
  * 2) .gaon 워처 — tables.d.ts · routes.d.ts 재생성 (기존 M3)
7
7
  * 3) serve 자식 — 실 웹 서버(Fastify) · 소스 변경 시 재시작
8
- * 4) tsc / vue-tsc --watch 모드 · 타입 에러 즉시 리포트
9
- * 5) restart 워처 apps/ · domain/ · packages/ 변경 → serve 재시작
10
- * 6) 통합 콘솔 [source] 태그·색상별 · JSON 모드 지원
8
+ * 4) work 자식 워커(잡·리스너·아웃박스·스케줄) · 소스 변경 재시작 (결정 211)
9
+ * 5) hub 자식 실시간 허브(프레즌스 권위·중계) (결정 211)
10
+ * 6) tsc / vue-tsc --watch 모드 · 타입 에러 즉시 리포트
11
+ * 7) restart 워처 — apps/ · domain/ · packages/ 변경 → serve·work 재시작
12
+ * 8) 통합 콘솔 — [source] 태그·색상별 · JSON 모드 지원
13
+ *
14
+ * 운영 프로세스 3종(serve·work·hub · §7)을 dev 에서 모두 내장 기동한다
15
+ * (Rails-like all-in-one · 결정 211). 이전에는 serve 만 떠 잡·이벤트·아웃박스·
16
+ * 크론이 dev 에서 조용히 처리되지 않았다(work 부재) — work.ts·hub.ts 주석이
17
+ * 이미 "dev 가 내장 실행" 이라 명시했으나 실제로는 미배선이던 드리프트를 닫는다.
11
18
  *
12
19
  * The One Way: `gaon dev` 하나로 전부 · 옵션은 개별 debug 용만 노출.
13
20
  * fail-closed: Docker 없으면 명확 안내(§7.5.3), 목업 대체 절대 X (§9).
@@ -38,6 +45,10 @@ export interface DevCommandOptions {
38
45
  readonly noDocker?: boolean;
39
46
  /** 프론트 watch 빌드(vite build --watch) 끄기(개별 debug). */
40
47
  readonly noVite?: boolean;
48
+ /** 워커(work) 자동 기동 끄기(개별 debug · 결정 211). 기본 기동. */
49
+ readonly noWork?: boolean;
50
+ /** 실시간 허브(hub) 자동 기동 끄기(개별 debug · 결정 211). 기본 기동. */
51
+ readonly noHub?: boolean;
41
52
  /** 통합 콘솔 타임스탬프 표시. */
42
53
  readonly timestamp?: boolean;
43
54
  /** 시그널(테스트 주입 · 기본 process). */
@@ -5,9 +5,16 @@
5
5
  * 1) Docker Compose — pg · redis · nats · mailpit · minio 자동 up -d
6
6
  * 2) .gaon 워처 — tables.d.ts · routes.d.ts 재생성 (기존 M3)
7
7
  * 3) serve 자식 — 실 웹 서버(Fastify) · 소스 변경 시 재시작
8
- * 4) tsc / vue-tsc --watch 모드 · 타입 에러 즉시 리포트
9
- * 5) restart 워처 apps/ · domain/ · packages/ 변경 → serve 재시작
10
- * 6) 통합 콘솔 [source] 태그·색상별 · JSON 모드 지원
8
+ * 4) work 자식 워커(잡·리스너·아웃박스·스케줄) · 소스 변경 재시작 (결정 211)
9
+ * 5) hub 자식 실시간 허브(프레즌스 권위·중계) (결정 211)
10
+ * 6) tsc / vue-tsc --watch 모드 · 타입 에러 즉시 리포트
11
+ * 7) restart 워처 — apps/ · domain/ · packages/ 변경 → serve·work 재시작
12
+ * 8) 통합 콘솔 — [source] 태그·색상별 · JSON 모드 지원
13
+ *
14
+ * 운영 프로세스 3종(serve·work·hub · §7)을 dev 에서 모두 내장 기동한다
15
+ * (Rails-like all-in-one · 결정 211). 이전에는 serve 만 떠 잡·이벤트·아웃박스·
16
+ * 크론이 dev 에서 조용히 처리되지 않았다(work 부재) — work.ts·hub.ts 주석이
17
+ * 이미 "dev 가 내장 실행" 이라 명시했으나 실제로는 미배선이던 드리프트를 닫는다.
11
18
  *
12
19
  * The One Way: `gaon dev` 하나로 전부 · 옵션은 개별 debug 용만 노출.
13
20
  * fail-closed: Docker 없으면 명확 안내(§7.5.3), 목업 대체 절대 X (§9).
@@ -27,11 +34,18 @@ import { startDev, resolveDevLayout } from '../dev.js';
27
34
  import { generateTablesDts, watchDir } from '@gaonjs/data';
28
35
  import { generateRoutesDts } from '@gaonjs/web';
29
36
  import { generateMessagesDts } from '../messages-gen.js';
37
+ import { generateEnvDts } from '../env-gen.js';
30
38
  import { createDevConsole } from '../dev/console.js';
31
39
  import { ensureInfra, composeDown } from '../dev/docker.js';
32
40
  import { startTscWatchers, killChild } from '../dev/tsc.js';
33
41
  import { startRestartWatcher } from '../dev/watcher.js';
34
42
  import { startFrontendBuild } from '../dev/frontend-build.js';
43
+ /**
44
+ * dev 워커의 기본 큐 동시성(결정 211). 운영 기본은 1(보수적)이나 dev 는
45
+ * 잡을 병렬 처리해 반복을 빠르게 하고 동시성 속성(분산 락 등)을 단일
46
+ * 워커에서도 관측하게 한다. 사용자가 GAON_WORKER_CONCURRENCY 를 주면 존중.
47
+ */
48
+ const DEV_WORKER_CONCURRENCY = 4;
35
49
  /**
36
50
  * gaon 셀프 경로를 찾는다. 부모가 gaon 으로 실행됐다면 argv[1] 이
37
51
  * gaonjs/dist/cli.js (또는 개발 시 packages/gaonjs/src/cli.ts). 자식
@@ -77,6 +91,26 @@ function spawnServe(args) {
77
91
  child.on('exit', (code, signal) => args.onExit(code, signal));
78
92
  return child;
79
93
  }
94
+ /**
95
+ * 운영 프로세스(work·hub)를 하나 띄운다(결정 211). serve 와 동일하게 셀프 CLI
96
+ * 진입점을 재사용해 사용자 환경에서 강제 폴백 없이 같은 gaon 을 다시 부른다.
97
+ * stdout/stderr 을 통합 콘솔의 해당 소스 태그로 파이프한다.
98
+ */
99
+ function spawnProcess(args) {
100
+ const { node, args: nodeArgs } = resolveSelfCliEntry();
101
+ const cmdArgs = [args.kind];
102
+ if (args.json)
103
+ cmdArgs.push('--json');
104
+ const child = spawn(node, [...nodeArgs, ...cmdArgs], {
105
+ cwd: args.cwd,
106
+ stdio: ['ignore', 'pipe', 'pipe'],
107
+ env: { ...process.env, ...args.extraEnv },
108
+ });
109
+ child.stdout?.on('data', (b) => args.console.pipe(args.kind, b, 'info'));
110
+ child.stderr?.on('data', (b) => args.console.pipe(args.kind, b, 'error'));
111
+ child.on('exit', (code, signal) => args.onExit(code, signal));
112
+ return child;
113
+ }
80
114
  /**
81
115
  * .gaon 재생성 파이프라인(M3)을 띄운다. 로그는 통합 콘솔로 흘리고,
82
116
  * 이 모듈은 층분리를 위해 startDev 를 그대로 재사용한다.
@@ -88,6 +122,7 @@ async function startGaonRegen(args) {
88
122
  regenerateTables: generateTablesDts,
89
123
  regenerateRoutes: generateRoutesDts,
90
124
  regenerateMessages: generateMessagesDts,
125
+ regenerateEnv: generateEnvDts,
91
126
  watch: watchDir,
92
127
  log: (e) => {
93
128
  if (e.kind === 'ready') {
@@ -101,7 +136,14 @@ async function startGaonRegen(args) {
101
136
  : `.gaon 재생성 대상 없음 (domain/schema · apps/* 확인)`);
102
137
  }
103
138
  else if (e.kind === 'regen') {
104
- args.console.log('watcher', e.target === 'tables' ? '↻ tables.d.ts 재생성' : `↻ ${e.app}/.gaon/routes.d.ts 재생성`);
139
+ const label = e.target === 'tables'
140
+ ? '↻ tables.d.ts 재생성'
141
+ : e.target === 'messages'
142
+ ? '↻ messages.d.ts 재생성'
143
+ : e.target === 'env'
144
+ ? '↻ env.d.ts 재생성'
145
+ : `↻ ${e.app}/.gaon/routes.d.ts 재생성`;
146
+ args.console.log('watcher', label);
105
147
  }
106
148
  else {
107
149
  args.console.log('watcher', '.gaon 워처 종료');
@@ -202,6 +244,69 @@ export async function runDevCommand(opts = {}) {
202
244
  });
203
245
  };
204
246
  serveChild = startServe();
247
+ // ── 4') work 자식(잡·리스너·아웃박스·스케줄 · 결정 211) ─────────────
248
+ // work 는 도메인(잡·리스너·스케줄)을 로드하므로 serve 와 같은 파일 변경에
249
+ // 재시작한다. NATS 부재(예: --no-docker + 외부 NATS 없음)면 연결 실패로
250
+ // 종료하는데, 이는 dev 를 죽이지 않고 안내만 남긴다(fail-soft).
251
+ let workChild;
252
+ const abnormalHint = (kind) => `${kind} 자식이 비정상 종료 — NATS 가 떴는지 확인하세요(compose 재사용 또는 --no-${kind} 로 끌 수 있음).`;
253
+ const startWork = () => {
254
+ consoleOut.log('work', '▶ work 자식 프로세스 spawn');
255
+ return spawnProcess({
256
+ kind: 'work',
257
+ cwd,
258
+ json,
259
+ console: consoleOut,
260
+ // dev 워커는 잡을 병렬 처리한다(기본 1 → DEV_WORKER_CONCURRENCY). 헤드오브라인
261
+ // 블로킹을 없애 개발 반복을 빠르게 하고, 분산 락(중첩 방지) 같은 동시성 속성이
262
+ // 단일 dev 워커에서도 관측되게 한다. 사용자가 GAON_WORKER_CONCURRENCY 를 이미
263
+ // 주면 그 값을 존중한다(override 하지 않음).
264
+ extraEnv: process.env.GAON_WORKER_CONCURRENCY == null || process.env.GAON_WORKER_CONCURRENCY === ''
265
+ ? { GAON_WORKER_CONCURRENCY: String(DEV_WORKER_CONCURRENCY) }
266
+ : undefined,
267
+ onExit: (code, signal) => {
268
+ if (shuttingDown)
269
+ return;
270
+ if (restartInFlight)
271
+ return;
272
+ if (code === 0 || signal === 'SIGTERM' || signal === 'SIGINT')
273
+ return;
274
+ consoleOut.log('work', abnormalHint('work'), 'warn');
275
+ },
276
+ });
277
+ };
278
+ if (opts.noWork !== true) {
279
+ workChild = startWork();
280
+ }
281
+ else {
282
+ consoleOut.log('work', '--no-work · 워커 자동 기동 비활성화');
283
+ }
284
+ // ── 4'') hub 자식(실시간 프레즌스 권위·중계 · 결정 211) ─────────────
285
+ // hub 는 사용자 도메인 코드를 로드하지 않는(인프라 라우팅) 프로세스라
286
+ // 파일 변경에 재시작하지 않는다 — 시작 시 한 번 띄우고 종료 때만 내린다.
287
+ let hubChild;
288
+ const startHub = () => {
289
+ consoleOut.log('hub', '▶ hub 자식 프로세스 spawn');
290
+ return spawnProcess({
291
+ kind: 'hub',
292
+ cwd,
293
+ json,
294
+ console: consoleOut,
295
+ onExit: (code, signal) => {
296
+ if (shuttingDown)
297
+ return;
298
+ if (code === 0 || signal === 'SIGTERM' || signal === 'SIGINT')
299
+ return;
300
+ consoleOut.log('hub', abnormalHint('hub'), 'warn');
301
+ },
302
+ });
303
+ };
304
+ if (opts.noHub !== true) {
305
+ hubChild = startHub();
306
+ }
307
+ else {
308
+ consoleOut.log('hub', '--no-hub · 허브 자동 기동 비활성화');
309
+ }
205
310
  const restartServe = async (changed) => {
206
311
  if (shuttingDown)
207
312
  return;
@@ -212,12 +317,20 @@ export async function runDevCommand(opts = {}) {
212
317
  consoleOut.log('watcher', `↻ 재시작 — ${summary}`);
213
318
  restartInFlight = (async () => {
214
319
  try {
320
+ // serve·work 를 함께 재시작한다(둘 다 도메인/앱 코드를 로드).
321
+ const killers = [];
215
322
  if (serveChild && serveChild.exitCode === null && serveChild.signalCode === null) {
216
- await killChild(serveChild, 3000);
323
+ killers.push(killChild(serveChild, 3000));
217
324
  }
325
+ if (workChild && workChild.exitCode === null && workChild.signalCode === null) {
326
+ killers.push(killChild(workChild, 3000));
327
+ }
328
+ await Promise.all(killers);
218
329
  if (shuttingDown)
219
330
  return;
220
331
  serveChild = startServe();
332
+ if (opts.noWork !== true)
333
+ workChild = startWork();
221
334
  }
222
335
  finally {
223
336
  restartInFlight = undefined;
@@ -237,7 +350,8 @@ export async function runDevCommand(opts = {}) {
237
350
  ? `▶ 재시작 워치 — ${restartWatcher.roots.map((r) => shortenPath(r, cwd)).join(', ')}`
238
351
  : `▶ 재시작 워치 — 감시 대상 없음(apps/ · domain/ 등 확인)`);
239
352
  }
240
- consoleOut.log('dev', `준비 완료 부팅 ${Date.now() - startedAt}ms (Ctrl+C 종료)`);
353
+ const procs = ['serve', opts.noWork !== true && 'work', opts.noHub !== true && 'hub'].filter(Boolean).join(' · ');
354
+ consoleOut.log('dev', `준비 완료 — 부팅 ${Date.now() - startedAt}ms · 프로세스 ${procs} (Ctrl+C 로 종료)`);
241
355
  // ── 6) SIGINT/SIGTERM → graceful ────────────────────────────────
242
356
  await new Promise((resolvePromise) => {
243
357
  const stop = () => {
@@ -249,11 +363,15 @@ export async function runDevCommand(opts = {}) {
249
363
  consoleOut.log('dev', '종료 신호 수신 — 정리 중...');
250
364
  void (async () => {
251
365
  try {
252
- // 순서: 재시작 워처 → serve → tsc → .gaon 워처 → Docker(옵션)
366
+ // 순서: 재시작 워처 → serve·work·hub → tsc → .gaon 워처 → Docker(옵션)
253
367
  restartWatcher?.close();
254
- if (serveChild) {
255
- await killChild(serveChild, 5000);
256
- }
368
+ // serve·work·hub 는 서로 독립이라 병렬로 내린다. work 은 graceful
369
+ // drain(진행 중 잡 대기)에 시간이 더 걸릴 수 있어 상한을 넉넉히 준다.
370
+ await Promise.all([
371
+ serveChild ? killChild(serveChild, 5000) : Promise.resolve(),
372
+ workChild ? killChild(workChild, 8000) : Promise.resolve(),
373
+ hubChild ? killChild(hubChild, 5000) : Promise.resolve(),
374
+ ]);
257
375
  if (tsc) {
258
376
  await tsc.stop();
259
377
  }
@@ -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;
@@ -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 });
@@ -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');
@@ -13,7 +13,7 @@
13
13
  * 정책상 chalk 는 쓰지 않는다 — 필요한 코드만 직접 쓴다.
14
14
  */
15
15
  /** 콘솔이 구분하는 로그 소스. */
16
- export type DevSource = 'dev' | 'docker' | 'serve' | 'watcher' | 'tsc' | 'vue-tsc' | 'vite';
16
+ export type DevSource = 'dev' | 'docker' | 'serve' | 'work' | 'hub' | 'watcher' | 'tsc' | 'vue-tsc' | 'vite';
17
17
  /** 로그 레벨 — human 은 색상 강조, json 은 필드로 실린다. */
18
18
  export type DevLevel = 'info' | 'warn' | 'error';
19
19
  export interface DevConsoleOptions {
@@ -23,6 +23,8 @@ const SOURCE_COLOR = {
23
23
  dev: '\x1b[35m', // magenta — 오케스트레이터
24
24
  docker: '\x1b[34m', // blue — 인프라
25
25
  serve: '\x1b[32m', // green — 웹 서버
26
+ work: '\x1b[93m', // bright yellow — 워커(잡·리스너·아웃박스·스케줄)
27
+ hub: '\x1b[94m', // bright blue — 실시간 허브
26
28
  watcher: '\x1b[36m', // cyan — 파일 감시
27
29
  tsc: '\x1b[33m', // yellow — 타입 검사
28
30
  'vue-tsc': '\x1b[95m', // bright magenta — vue 전용
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>;