@gaonjs/cli 0.3.0 → 0.5.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.
Files changed (81) hide show
  1. package/dist/__fixtures__/db-minimal/domain/schema/widgets.d.ts +12 -0
  2. package/dist/__fixtures__/db-minimal/domain/schema/widgets.js +7 -0
  3. package/dist/__fixtures__/db-minimal/gaon.config.d.ts +2 -0
  4. package/dist/__fixtures__/db-minimal/gaon.config.js +11 -0
  5. package/dist/commands/check.d.ts +31 -0
  6. package/dist/commands/check.js +223 -0
  7. package/dist/commands/console.d.ts +46 -0
  8. package/dist/commands/console.js +129 -0
  9. package/dist/commands/db.d.ts +20 -0
  10. package/dist/commands/db.js +74 -0
  11. package/dist/commands/dev.d.ts +68 -0
  12. package/dist/commands/dev.js +287 -0
  13. package/dist/commands/new.d.ts +45 -0
  14. package/dist/commands/new.js +274 -0
  15. package/dist/commands/test.d.ts +11 -0
  16. package/dist/commands/test.js +119 -0
  17. package/dist/db/diff.d.ts +17 -0
  18. package/dist/db/diff.js +57 -0
  19. package/dist/db/index.d.ts +4 -0
  20. package/dist/db/index.js +8 -0
  21. package/dist/db/migrate.d.ts +16 -0
  22. package/dist/db/migrate.js +173 -0
  23. package/dist/db/reset.d.ts +18 -0
  24. package/dist/db/reset.js +150 -0
  25. package/dist/db/resolve.d.ts +32 -0
  26. package/dist/db/resolve.js +130 -0
  27. package/dist/dev/console.d.ts +39 -0
  28. package/dist/dev/console.js +100 -0
  29. package/dist/dev/docker.d.ts +52 -0
  30. package/dist/dev/docker.js +163 -0
  31. package/dist/dev/index.d.ts +14 -0
  32. package/dist/dev/index.js +10 -0
  33. package/dist/dev/tsc.d.ts +41 -0
  34. package/dist/dev/tsc.js +127 -0
  35. package/dist/dev/watcher.d.ts +50 -0
  36. package/dist/dev/watcher.js +95 -0
  37. package/dist/dev.d.ts +1 -16
  38. package/dist/dev.js +10 -66
  39. package/dist/doctor/no-auto-import.d.ts +10 -0
  40. package/dist/doctor/no-auto-import.js +158 -0
  41. package/dist/doctor/reporter.d.ts +1 -1
  42. package/dist/doctor/reporter.js +16 -3
  43. package/dist/doctor/setup.d.ts +26 -0
  44. package/dist/doctor/setup.js +52 -0
  45. package/dist/doctor/shared-composable-purity.d.ts +8 -0
  46. package/dist/doctor/shared-composable-purity.js +164 -0
  47. package/dist/doctor/types.d.ts +14 -1
  48. package/dist/doctor/types.js +10 -5
  49. package/dist/doctor.d.ts +21 -5
  50. package/dist/doctor.js +77 -8
  51. package/dist/index.d.ts +9 -2
  52. package/dist/index.js +175 -27
  53. package/dist/templates/index.d.ts +23 -0
  54. package/dist/templates/index.js +66 -0
  55. package/dist/templates/index.ts +85 -0
  56. package/dist/templates/project/.env.example.tpl +18 -0
  57. package/dist/templates/project/.gitignore.tpl +24 -0
  58. package/dist/templates/project/.npmrc.tpl +4 -0
  59. package/dist/templates/project/CLAUDE.md.tpl +119 -0
  60. package/dist/templates/project/apps/web/channels/.gitkeep.tpl +1 -0
  61. package/dist/templates/project/apps/web/components/.gitkeep.tpl +1 -0
  62. package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +25 -0
  63. package/dist/templates/project/apps/web/controllers/home.ts.tpl +19 -0
  64. package/dist/templates/project/apps/web/layouts/Default.vue.tpl +43 -0
  65. package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +36 -0
  66. package/dist/templates/project/apps/web/routes.ts.tpl +8 -0
  67. package/dist/templates/project/docker-compose.yaml.tpl +73 -0
  68. package/dist/templates/project/domain/events/.gitkeep.tpl +1 -0
  69. package/dist/templates/project/domain/jobs/.gitkeep.tpl +1 -0
  70. package/dist/templates/project/domain/listeners/.gitkeep.tpl +1 -0
  71. package/dist/templates/project/domain/mails/.gitkeep.tpl +1 -0
  72. package/dist/templates/project/domain/models/.gitkeep.tpl +1 -0
  73. package/dist/templates/project/domain/schema/.gitkeep.tpl +1 -0
  74. package/dist/templates/project/domain/services/.gitkeep.tpl +1 -0
  75. package/dist/templates/project/gaon.config.ts.tpl +27 -0
  76. package/dist/templates/project/package.json.tpl +27 -0
  77. package/dist/templates/project/pnpm-workspace.yaml.tpl +11 -0
  78. package/dist/templates/project/shared/components/.gitkeep.tpl +1 -0
  79. package/dist/templates/project/shared/composables/useDebounce.ts.tpl +21 -0
  80. package/dist/templates/project/tsconfig.json.tpl +25 -0
  81. package/package.json +5 -5
@@ -0,0 +1,287 @@
1
+ /**
2
+ * @gaonjs/cli · `gaon dev` — 개발 오케스트레이션 (M9-C · v0.15 §13.5)
3
+ *
4
+ * 하나의 명령이 개발 스택 전체를 띄운다:
5
+ * 1) Docker Compose — pg · redis · nats · mailpit · minio 자동 up -d
6
+ * 2) .gaon 워처 — tables.d.ts · routes.d.ts 재생성 (기존 M3)
7
+ * 3) serve 자식 — 실 웹 서버(Fastify) · 소스 변경 시 재시작
8
+ * 4) tsc / vue-tsc — --watch 모드 · 타입 에러 즉시 리포트
9
+ * 5) restart 워처 — apps/ · domain/ · packages/ 변경 → serve 재시작
10
+ * 6) 통합 콘솔 — [source] 태그·색상별 · JSON 모드 지원
11
+ *
12
+ * The One Way: `gaon dev` 하나로 전부 · 옵션은 개별 debug 용만 노출.
13
+ * fail-closed: Docker 없으면 명확 안내(§7.5.3), 목업 대체 절대 X (§9).
14
+ * ctrl-C: graceful — serve → tsc → 워처 순 정리 · Docker 는 유지(재사용)
15
+ * 또는 --stop-docker 시 down.
16
+ *
17
+ * HMR: v1 Vue 어댑터는 아직 Vite 통합이 없어(§6.4) 서버 재시작 방식만
18
+ * 지원한다. 진짜 Vue HMR 은 어댑터 통합 이후 확장 예정.
19
+ */
20
+ import { spawn } from 'node:child_process';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { existsSync } from 'node:fs';
23
+ import { resolve } from 'node:path';
24
+ import { loadDotEnv, VERSION } from '@gaonjs/core';
25
+ import { registerTsResolve } from '../tsResolve.js';
26
+ import { startDev, resolveDevLayout } from '../dev.js';
27
+ import { generateTablesDts, watchDir } from '@gaonjs/data';
28
+ import { generateRoutesDts } from '@gaonjs/web';
29
+ import { createDevConsole } from '../dev/console.js';
30
+ import { ensureInfra, composeDown } from '../dev/docker.js';
31
+ import { startTscWatchers, killChild } from '../dev/tsc.js';
32
+ import { startRestartWatcher } from '../dev/watcher.js';
33
+ /**
34
+ * gaon 셀프 경로를 찾는다. 부모가 gaon 으로 실행됐다면 argv[1] 이
35
+ * gaonjs/dist/cli.js (또는 개발 시 packages/gaonjs/src/cli.ts). 자식
36
+ * serve 도 같은 진입점을 재사용한다 — 사용자 환경에서 강제 폴백 없이
37
+ * 동일 CLI 를 다시 부른다.
38
+ */
39
+ function resolveSelfCliEntry() {
40
+ const scriptPath = process.argv[1];
41
+ if (!scriptPath) {
42
+ throw new Error('gaon dev · CLI 자기참조 실패: process.argv[1] 이 비어 있습니다.\n' +
43
+ '→ gaon 을 npm 스크립트나 bin 을 통해 실행했는지 확인하세요.');
44
+ }
45
+ // .ts 진입점(모노레포 dev)은 --experimental-strip-types 필요.
46
+ const isTs = scriptPath.endsWith('.ts');
47
+ const flags = isTs ? ['--experimental-strip-types', '--no-warnings'] : [];
48
+ return { node: process.execPath, args: [...flags, scriptPath] };
49
+ }
50
+ /**
51
+ * serve 자식을 하나 띄운다. stdout/stderr 을 통합 콘솔로 파이프하고,
52
+ * exit 콜백에서 종료 코드를 알려준다(비정상 종료 감시용).
53
+ */
54
+ function spawnServe(args) {
55
+ const { node, args: nodeArgs } = resolveSelfCliEntry();
56
+ const serveArgs = ['serve'];
57
+ if (args.json)
58
+ serveArgs.push('--json');
59
+ if (args.port != null)
60
+ serveArgs.push('--port', String(args.port));
61
+ if (args.host != null)
62
+ serveArgs.push('--host', args.host);
63
+ const child = spawn(node, [...nodeArgs, ...serveArgs], {
64
+ cwd: args.cwd,
65
+ // detached=false + own signal handling: 부모가 명시적으로 kill 한다.
66
+ // 셸에서 Ctrl+C 를 누르면 자식도 같이 SIGINT 를 받는데(같은 프로세스
67
+ // 그룹), serve 는 자체 handler 로 graceful 종료하므로 안전.
68
+ stdio: ['ignore', 'pipe', 'pipe'],
69
+ env: { ...process.env },
70
+ });
71
+ child.stdout?.on('data', (b) => args.console.pipe('serve', b, 'info'));
72
+ child.stderr?.on('data', (b) => args.console.pipe('serve', b, 'error'));
73
+ child.on('exit', (code, signal) => args.onExit(code, signal));
74
+ return child;
75
+ }
76
+ /**
77
+ * .gaon 재생성 파이프라인(M3)을 띄운다. 로그는 통합 콘솔로 흘리고,
78
+ * 이 모듈은 층분리를 위해 startDev 를 그대로 재사용한다.
79
+ */
80
+ async function startGaonRegen(args) {
81
+ const layout = resolveDevLayout(args.cwd);
82
+ return await startDev({
83
+ layout,
84
+ regenerateTables: generateTablesDts,
85
+ regenerateRoutes: generateRoutesDts,
86
+ watch: watchDir,
87
+ log: (e) => {
88
+ if (e.kind === 'ready') {
89
+ const parts = [];
90
+ if (e.schema)
91
+ parts.push('schema→tables.d.ts');
92
+ for (const a of e.apps)
93
+ parts.push(`${a}→routes.d.ts`);
94
+ args.console.log('watcher', parts.length
95
+ ? `.gaon 재생성 준비 완료 — ${parts.join(', ')}`
96
+ : `.gaon 재생성 대상 없음 (domain/schema · apps/* 확인)`);
97
+ }
98
+ else if (e.kind === 'regen') {
99
+ args.console.log('watcher', e.target === 'tables' ? '↻ tables.d.ts 재생성' : `↻ ${e.app}/.gaon/routes.d.ts 재생성`);
100
+ }
101
+ else {
102
+ args.console.log('watcher', '.gaon 워처 종료');
103
+ }
104
+ },
105
+ onError: (err) => args.console.log('watcher', err.message, 'error'),
106
+ });
107
+ }
108
+ /**
109
+ * `gaon dev` 진입점. 자식들을 순서대로 띄우고, SIGINT/SIGTERM 로 정리한다.
110
+ * 반환 프라미스는 완전 종료 시 resolve — 프로세스는 자식 워처가 살아 있는
111
+ * 동안 유지된다.
112
+ */
113
+ export async function runDevCommand(opts = {}) {
114
+ const cwd = resolve(opts.cwd ?? process.cwd());
115
+ const json = opts.json ?? false;
116
+ const stopDocker = opts.stopDocker ?? false;
117
+ const signals = opts.signals ?? process;
118
+ registerTsResolve();
119
+ loadDotEnv(cwd);
120
+ const consoleOut = createDevConsole({ json, timestamp: opts.timestamp });
121
+ const startedAt = Date.now();
122
+ consoleOut.log('dev', `gaon dev · 시작 (v${VERSION}) — ${cwd}`);
123
+ // ── 1) Docker Compose 자동 기동 ─────────────────────────────────
124
+ let composeFile;
125
+ if (opts.noDocker !== true) {
126
+ try {
127
+ const infra = await ensureInfra({
128
+ cwd,
129
+ onLine: (line, level) => consoleOut.log('docker', line, level),
130
+ });
131
+ if (infra) {
132
+ composeFile = infra.composeFile;
133
+ consoleOut.log('docker', infra.startedNow
134
+ ? `▶ compose up 완료 — ${infra.status.runningCount}/${infra.status.definedCount} 서비스 · ${infra.composeFile}`
135
+ : `▶ 이미 실행 중(재사용) — ${infra.status.runningCount}/${infra.status.definedCount} 서비스 · ${infra.composeFile}`);
136
+ }
137
+ else {
138
+ consoleOut.log('docker', 'compose 파일이 없어 인프라 자동 기동을 건너뜁니다.');
139
+ }
140
+ }
141
+ catch (err) {
142
+ consoleOut.log('docker', err.message, 'error');
143
+ throw err;
144
+ }
145
+ }
146
+ else {
147
+ consoleOut.log('docker', '--no-docker · 인프라 자동 기동 비활성화');
148
+ }
149
+ // ── 2) .gaon 재생성 워처 ─────────────────────────────────────────
150
+ const regen = await startGaonRegen({ cwd, console: consoleOut });
151
+ // ── 3) tsc / vue-tsc 워치 ────────────────────────────────────────
152
+ const tsc = opts.noTsc && opts.noVueTsc
153
+ ? undefined
154
+ : startTscWatchers({
155
+ cwd,
156
+ enableTsc: opts.noTsc !== true,
157
+ enableVueTsc: opts.noVueTsc !== true,
158
+ onLine: (source, line, level) => consoleOut.log(source, line, level),
159
+ onExit: (source, code) => {
160
+ // 비정상 종료(code≠0) 는 알린다 · 정상(SIGTERM=null) 은 조용히.
161
+ if (code != null && code !== 0) {
162
+ consoleOut.log(source, `[${source}] 비정상 종료 (code=${code})`, 'warn');
163
+ }
164
+ },
165
+ });
166
+ // ── 4) serve 자식 · 재시작 스케줄러 ─────────────────────────────
167
+ let serveChild;
168
+ let shuttingDown = false;
169
+ let restartInFlight;
170
+ const startServe = () => {
171
+ consoleOut.log('serve', '▶ serve 자식 프로세스 spawn');
172
+ return spawnServe({
173
+ cwd,
174
+ json,
175
+ port: opts.port,
176
+ host: opts.host,
177
+ console: consoleOut,
178
+ onExit: (code, signal) => {
179
+ if (shuttingDown)
180
+ return;
181
+ // 사용자 편집으로 인한 재시작 중이면 무시 — restart 흐름이 다시 띄운다.
182
+ if (restartInFlight)
183
+ return;
184
+ if (code === 0 || signal === 'SIGTERM' || signal === 'SIGINT')
185
+ return;
186
+ consoleOut.log('serve', `serve 자식이 비정상 종료 (code=${code}, signal=${signal}) — 파일을 저장해 재시도하거나 로그를 확인하세요.`, 'error');
187
+ },
188
+ });
189
+ };
190
+ serveChild = startServe();
191
+ const restartServe = async (changed) => {
192
+ if (shuttingDown)
193
+ return;
194
+ // 이미 재시작 중이면 그 프라미스에 대기(중첩 방지).
195
+ if (restartInFlight)
196
+ return restartInFlight;
197
+ const summary = changed.slice(0, 3).join(', ') + (changed.length > 3 ? ` ... (+${changed.length - 3})` : '');
198
+ consoleOut.log('watcher', `↻ 재시작 — ${summary}`);
199
+ restartInFlight = (async () => {
200
+ try {
201
+ if (serveChild && serveChild.exitCode === null && serveChild.signalCode === null) {
202
+ await killChild(serveChild, 3000);
203
+ }
204
+ if (shuttingDown)
205
+ return;
206
+ serveChild = startServe();
207
+ }
208
+ finally {
209
+ restartInFlight = undefined;
210
+ }
211
+ })();
212
+ return restartInFlight;
213
+ };
214
+ // ── 5) 파일 워처(서버 재시작 트리거) ───────────────────────────
215
+ let restartWatcher;
216
+ if (opts.noWatch !== true) {
217
+ restartWatcher = startRestartWatcher({
218
+ cwd,
219
+ onRestart: (changed) => restartServe(changed),
220
+ onError: (err) => consoleOut.log('watcher', err.message, 'error'),
221
+ });
222
+ consoleOut.log('watcher', restartWatcher.roots.length > 0
223
+ ? `▶ 재시작 워치 — ${restartWatcher.roots.map((r) => shortenPath(r, cwd)).join(', ')}`
224
+ : `▶ 재시작 워치 — 감시 대상 없음(apps/ · domain/ 등 확인)`);
225
+ }
226
+ consoleOut.log('dev', `준비 완료 — 부팅 ${Date.now() - startedAt}ms (Ctrl+C 로 종료)`);
227
+ // ── 6) SIGINT/SIGTERM → graceful ────────────────────────────────
228
+ await new Promise((resolvePromise) => {
229
+ const stop = () => {
230
+ if (shuttingDown)
231
+ return;
232
+ shuttingDown = true;
233
+ signals.off('SIGINT', stop);
234
+ signals.off('SIGTERM', stop);
235
+ consoleOut.log('dev', '종료 신호 수신 — 정리 중...');
236
+ void (async () => {
237
+ try {
238
+ // 순서: 재시작 워처 → serve → tsc → .gaon 워처 → Docker(옵션)
239
+ restartWatcher?.close();
240
+ if (serveChild) {
241
+ await killChild(serveChild, 5000);
242
+ }
243
+ if (tsc) {
244
+ await tsc.stop();
245
+ }
246
+ regen.close();
247
+ if (stopDocker && composeFile) {
248
+ consoleOut.log('docker', '--stop-docker · compose down 실행');
249
+ try {
250
+ await composeDown(composeFile, {
251
+ onLine: (line, level) => consoleOut.log('docker', line, level),
252
+ });
253
+ }
254
+ catch (err) {
255
+ consoleOut.log('docker', err.message, 'error');
256
+ }
257
+ }
258
+ else if (composeFile) {
259
+ consoleOut.log('docker', 'compose 는 유지 · 다음 gaon dev 에서 재사용');
260
+ }
261
+ }
262
+ finally {
263
+ consoleOut.log('dev', 'gaon dev · 종료');
264
+ resolvePromise();
265
+ }
266
+ })();
267
+ };
268
+ signals.on('SIGINT', stop);
269
+ signals.on('SIGTERM', stop);
270
+ });
271
+ }
272
+ /** cwd 기준 상대경로로 짧게(로그 가독성). */
273
+ function shortenPath(p, cwd) {
274
+ const normP = resolve(p);
275
+ const normCwd = resolve(cwd);
276
+ if (normP === normCwd)
277
+ return '.';
278
+ if (normP.startsWith(normCwd + '/'))
279
+ return normP.slice(normCwd.length + 1);
280
+ return normP;
281
+ }
282
+ // (test·doc 목적으로 노출) — 파일이 실제 존재하는지 여부는 호출자 판단.
283
+ export const _internals = {
284
+ resolveSelfCliEntry,
285
+ fileURLToPathSafe: (u) => (u.startsWith('file:') ? fileURLToPath(u) : u),
286
+ existsSync,
287
+ };
@@ -0,0 +1,45 @@
1
+ /** `gaon new` 옵션. 모두 선택적 — 기본값이 The One Way. */
2
+ export interface NewCommandOptions {
3
+ /** 프로젝트 생성 위치의 부모 폴더. 기본 process.cwd(). */
4
+ readonly cwd?: string;
5
+ /** 자동화용 구조화 출력. */
6
+ readonly json?: boolean;
7
+ /** 의존성 설치 스킵(테스트·오프라인). */
8
+ readonly skipInstall?: boolean;
9
+ /** git init · 첫 커밋 스킵(테스트·기존 git 저장소에 삽입). */
10
+ readonly skipGit?: boolean;
11
+ /** 패키지 매니저. 기본 pnpm(모노레포 관례 정합). */
12
+ readonly packageManager?: 'pnpm' | 'npm' | 'yarn';
13
+ /**
14
+ * (테스트 훅) 템플릿의 gaonjs 의존성 버전. 미지정 시 파사드(gaonjs)
15
+ * 패키지의 실 버전을 자동 조회한다(package.json).
16
+ */
17
+ readonly gaonjsVersion?: string;
18
+ }
19
+ /** JSON 리포트(--json). 사람 모드는 콘솔에 사람이 읽는 요약을 출력한다. */
20
+ export interface NewCommandResult {
21
+ readonly ok: boolean;
22
+ readonly project: string;
23
+ readonly path: string;
24
+ readonly filesCreated: number;
25
+ readonly install: {
26
+ readonly ran: boolean;
27
+ readonly skipped: boolean;
28
+ readonly packageManager: string;
29
+ readonly durationMs: number;
30
+ readonly exitCode: number | null;
31
+ };
32
+ readonly git: {
33
+ readonly ran: boolean;
34
+ readonly skipped: boolean;
35
+ readonly initialized: boolean;
36
+ readonly firstCommit: boolean;
37
+ };
38
+ readonly totalMs: number;
39
+ readonly error?: string;
40
+ }
41
+ /**
42
+ * `gaon new <name>` 실행. 파일 생성 → pnpm install → git init 순서.
43
+ * 각 단계는 옵션으로 스킵 가능. 반환값은 프로세스 종료 코드(0=성공).
44
+ */
45
+ export declare function runNewCommand(name: string, opts?: NewCommandOptions): Promise<number>;
@@ -0,0 +1,274 @@
1
+ // @gaonjs/cli · `gaon new <name>` — 프로젝트 스캐폴드 (M9-F).
2
+ //
3
+ // v0.15 §13.5 M9 완료 기준 "설치 후 첫 화면까지 60초" 를 만족시키기 위한
4
+ // One Way. `gaon new demo` → cd demo → `gaon serve` → http://localhost:3000
5
+ // 응답 이 전체 흐름의 목표. 60초 안에 이 왕복이 끝나야 한다.
6
+ //
7
+ // 관례(§2 · errata E-1 · E-3 · E-4 · E-5):
8
+ // · 폴더 배치: apps/<앱>/ + domain/ + shared/ (앱 밖에 domain)
9
+ // · 프론트: Vue + Inertia SPA (`gaonjs/vue`)
10
+ // · 인프라: docker-compose.yaml 5종(pg · redis · nats · mailpit · minio)
11
+ // · 컴포저블·레이아웃: E-5(apps/<앱>/composables + shared/composables, 대칭
12
+ // 구조 · layouts/Default.vue 존재 시 자동 적용)
13
+ // · 자동 import 금지 — 모든 import 는 명시적으로(E-5 §2.4).
14
+ //
15
+ // 옵션은 최소로: --skip-install · --skip-git · --json · --package-manager.
16
+ // The One Way — pnpm workspace monorepo · Vue · Inertia SPA · 5종 인프라.
17
+ //
18
+ // 이 파일은 파일 생성·설치·git init 만 담당하고, argv 파싱과 배선은
19
+ // index.ts 가 통합한다(M9-F 는 편집 X — M1 이 통합 커밋).
20
+ import { existsSync, mkdirSync, readdirSync, writeFileSync, } from 'node:fs';
21
+ import { dirname, join, resolve } from 'node:path';
22
+ import { spawnSync } from 'node:child_process';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { readFileSync } from 'node:fs';
25
+ import { renderProjectFiles } from '../templates/index.js';
26
+ /** 이름 유효성 — npm 패키지명 규칙(단순 부분)만 검사. */
27
+ function validateProjectName(name) {
28
+ if (!name)
29
+ throw new Error('프로젝트 이름이 비어 있습니다.');
30
+ if (name.includes('/') || name.includes('\\')) {
31
+ throw new Error(`프로젝트 이름에 '/' 또는 '\\\\' 는 사용할 수 없습니다: ${name}`);
32
+ }
33
+ if (name.includes('..')) {
34
+ throw new Error(`프로젝트 이름에 '..' 는 사용할 수 없습니다: ${name}`);
35
+ }
36
+ if (!/^[a-z0-9][a-z0-9_-]*$/i.test(name)) {
37
+ throw new Error(`프로젝트 이름은 영문/숫자/-/_ 로 시작해 같은 문자만 씁니다: ${name}\n` +
38
+ ` → 예: gaon new my-app · gaon new demo`);
39
+ }
40
+ }
41
+ /** 파사드(gaonjs) 패키지의 실 버전을 찾는다 — package.json 을 위로 훑는다. */
42
+ function detectGaonjsVersion() {
43
+ // 가장 안전한 방법: 이 파일 위치를 기준으로 packages/gaonjs/package.json 을
44
+ // 상향 탐색. 개발 트리(src)와 배포 트리(dist) 모두에서 동작한다.
45
+ let dir = dirname(fileURLToPath(import.meta.url));
46
+ for (let i = 0; i < 8; i++) {
47
+ const candidate = join(dir, '..', 'gaonjs', 'package.json');
48
+ if (existsSync(candidate)) {
49
+ try {
50
+ const raw = readFileSync(candidate, 'utf8');
51
+ const pkg = JSON.parse(raw);
52
+ if (typeof pkg.version === 'string')
53
+ return `^${pkg.version}`;
54
+ }
55
+ catch {
56
+ /* fall through */
57
+ }
58
+ }
59
+ const parent = dirname(dir);
60
+ if (parent === dir)
61
+ break;
62
+ dir = parent;
63
+ }
64
+ // 발견 못 하면 최근 스텁 배포 라인의 안전 하한.
65
+ return '^0.5.0';
66
+ }
67
+ /** 대상 폴더가 비어 있는지(생성 대상으로 안전한지) 확인. */
68
+ function isEmptyOrMissing(dir) {
69
+ if (!existsSync(dir))
70
+ return true;
71
+ try {
72
+ const entries = readdirSync(dir);
73
+ return entries.length === 0;
74
+ }
75
+ catch {
76
+ return false;
77
+ }
78
+ }
79
+ /** 파일 전체를 디스크에 쓴다. 이미 있으면 예외. */
80
+ function writeAll(root, files) {
81
+ let count = 0;
82
+ for (const f of files) {
83
+ const abs = join(root, f.path);
84
+ mkdirSync(dirname(abs), { recursive: true });
85
+ writeFileSync(abs, f.contents, 'utf8');
86
+ count += 1;
87
+ }
88
+ return count;
89
+ }
90
+ /** 패키지 매니저 install 명령을 동기 실행. 반환값에 종료 코드·시간 포함.
91
+ * json 모드에서는 stdout 을 완전 격리해 JSON 라인이 pnpm progress 로
92
+ * 오염되지 않도록 한다 — 파이프라인 파서 안전 정합. */
93
+ function runInstall(root, pm, json) {
94
+ const args = ['install'];
95
+ const t0 = Date.now();
96
+ // stdio: json 모드에서는 stdout 을 ignore(우리 JSON 을 오염시키지 않게),
97
+ // stderr 는 사용자에게 원인 진단이 필요할 수 있어 inherit.
98
+ // human 모드에서는 모든 스트림을 inherit(사용자가 progress 를 본다).
99
+ const res = spawnSync(pm, args, {
100
+ cwd: root,
101
+ stdio: json ? ['ignore', 'ignore', 'inherit'] : 'inherit',
102
+ env: process.env,
103
+ });
104
+ const ms = Date.now() - t0;
105
+ if (res.error) {
106
+ process.stderr.write(` ✗ ${pm} 을 찾지 못했습니다. → ${pm} 을 설치하거나 --skip-install 로 건너뛰세요.\n`);
107
+ return { code: -1, ms };
108
+ }
109
+ return { code: res.status, ms };
110
+ }
111
+ /** git init + 첫 커밋. git 이 없으면 조용히 스킵(로컬에 git 없이도 스캐폴드는 성공). */
112
+ function runGitInit(root) {
113
+ const opts = { cwd: root, stdio: 'ignore', env: process.env };
114
+ const init = spawnSync('git', ['init', '--initial-branch=main'], opts);
115
+ if (init.error || init.status !== 0)
116
+ return { initialized: false, firstCommit: false };
117
+ spawnSync('git', ['add', '-A'], opts);
118
+ // 첫 커밋 — 사용자 git config 가 없어도 실패하지 않도록 -c 로 임시 identity 주입.
119
+ const commit = spawnSync('git', [
120
+ '-c',
121
+ 'user.email=gaon@gaonjs.dev',
122
+ '-c',
123
+ 'user.name=Gaon',
124
+ 'commit',
125
+ '-m',
126
+ 'chore: gaon new — initial scaffold',
127
+ '--no-gpg-sign',
128
+ ], opts);
129
+ return { initialized: true, firstCommit: commit.status === 0 };
130
+ }
131
+ /**
132
+ * `gaon new <name>` 실행. 파일 생성 → pnpm install → git init 순서.
133
+ * 각 단계는 옵션으로 스킵 가능. 반환값은 프로세스 종료 코드(0=성공).
134
+ */
135
+ export async function runNewCommand(name, opts = {}) {
136
+ const t0 = Date.now();
137
+ const cwd = opts.cwd ?? process.cwd();
138
+ const pm = opts.packageManager ?? 'pnpm';
139
+ const json = opts.json ?? false;
140
+ const emitError = (msg, totalMs) => {
141
+ if (json) {
142
+ const result = {
143
+ ok: false,
144
+ project: name,
145
+ path: resolve(cwd, name),
146
+ filesCreated: 0,
147
+ install: { ran: false, skipped: true, packageManager: pm, durationMs: 0, exitCode: null },
148
+ git: { ran: false, skipped: true, initialized: false, firstCommit: false },
149
+ totalMs,
150
+ error: msg,
151
+ };
152
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
153
+ }
154
+ else {
155
+ process.stderr.write(` ✗ gaon new: ${msg}\n`);
156
+ }
157
+ return 1;
158
+ };
159
+ try {
160
+ validateProjectName(name);
161
+ }
162
+ catch (err) {
163
+ return emitError(err instanceof Error ? err.message : String(err), Date.now() - t0);
164
+ }
165
+ const root = resolve(cwd, name);
166
+ if (!isEmptyOrMissing(root)) {
167
+ return emitError(`대상 폴더가 비어 있지 않습니다: ${root}\n` +
168
+ ` → 다른 이름을 쓰거나 폴더를 비운 뒤 다시 실행하세요.`, Date.now() - t0);
169
+ }
170
+ // 파일 생성 — 실패 시 부분 생성물이 남지 않도록 폴더를 정리하지는 않는다
171
+ // (사용자가 원인 파악 후 rm -rf 로 지우도록). 정상 흐름에서는 문제 없음.
172
+ const gaonjsVersion = opts.gaonjsVersion ?? detectGaonjsVersion();
173
+ const files = renderProjectFiles({ projectName: name, gaonjsVersion });
174
+ let filesCreated = 0;
175
+ try {
176
+ mkdirSync(root, { recursive: true });
177
+ filesCreated = writeAll(root, files);
178
+ }
179
+ catch (err) {
180
+ return emitError(`파일 생성 실패: ${err instanceof Error ? err.message : String(err)}`, Date.now() - t0);
181
+ }
182
+ // pnpm install (또는 npm/yarn) — 스킵 시 안내만.
183
+ const installReport = {
184
+ ran: false,
185
+ skipped: opts.skipInstall === true,
186
+ packageManager: pm,
187
+ durationMs: 0,
188
+ exitCode: null,
189
+ };
190
+ if (!opts.skipInstall) {
191
+ if (!json)
192
+ process.stdout.write(`\n · 의존성 설치 (${pm} install) ...\n`);
193
+ const { code, ms } = runInstall(root, pm, json);
194
+ installReport.ran = true;
195
+ installReport.durationMs = ms;
196
+ installReport.exitCode = code;
197
+ if (code !== 0) {
198
+ // install 실패는 스캐폴드 자체 실패로 취급하지 않는다 — 파일은 이미
199
+ // 만들어졌으므로 사용자가 원인(레지스트리·네트워크) 확인 후 재시도 가능.
200
+ // 종료 코드는 실패로 반환(자동화 파이프라인 정합).
201
+ const total = Date.now() - t0;
202
+ if (json) {
203
+ const result = {
204
+ ok: false,
205
+ project: name,
206
+ path: root,
207
+ filesCreated,
208
+ install: installReport,
209
+ git: { ran: false, skipped: true, initialized: false, firstCommit: false },
210
+ totalMs: total,
211
+ error: `${pm} install 실패 (exit ${code})`,
212
+ };
213
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
214
+ }
215
+ else {
216
+ process.stderr.write(` ✗ ${pm} install 이 실패했습니다 (exit ${code}).\n` +
217
+ ` → 네트워크·레지스트리 상태를 확인한 뒤 ${root} 에서 다시 ${pm} install 을 실행하세요.\n`);
218
+ }
219
+ return 1;
220
+ }
221
+ }
222
+ // git init + 첫 커밋 — 스킵 시 그대로 넘어감.
223
+ const gitReport = { ran: false, skipped: opts.skipGit === true, initialized: false, firstCommit: false };
224
+ if (!opts.skipGit) {
225
+ if (!json)
226
+ process.stdout.write(` · git 저장소 초기화 (git init + 첫 커밋) ...\n`);
227
+ const g = runGitInit(root);
228
+ gitReport.ran = true;
229
+ gitReport.initialized = g.initialized;
230
+ gitReport.firstCommit = g.firstCommit;
231
+ }
232
+ const totalMs = Date.now() - t0;
233
+ if (json) {
234
+ const result = {
235
+ ok: true,
236
+ project: name,
237
+ path: root,
238
+ filesCreated,
239
+ install: installReport,
240
+ git: gitReport,
241
+ totalMs,
242
+ };
243
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
244
+ return 0;
245
+ }
246
+ // 사람 모드 요약 — 다음 단계를 § 방향과 함께 안내(§7.5.3 원칙 정합).
247
+ const lines = [''];
248
+ lines.push(` gaon new ${name} — 스캐폴드 완료 (${filesCreated}개 파일 · ${totalMs}ms)`);
249
+ lines.push('');
250
+ lines.push(` 폴더: ${root}`);
251
+ if (installReport.ran) {
252
+ lines.push(` ${pm} install: ${installReport.exitCode === 0 ? '성공' : '실패'} (${installReport.durationMs}ms)`);
253
+ }
254
+ else {
255
+ lines.push(` ${pm} install: 스킵 (--skip-install)`);
256
+ }
257
+ if (gitReport.ran) {
258
+ lines.push(` git init: ${gitReport.initialized ? '완료' : '실패'} · 첫 커밋: ${gitReport.firstCommit ? '완료' : '실패(수동)'}`);
259
+ }
260
+ else {
261
+ lines.push(` git init: 스킵 (--skip-git)`);
262
+ }
263
+ lines.push('');
264
+ lines.push(' 다음 단계:');
265
+ lines.push(` 1) cd ${name}`);
266
+ lines.push(' 2) cp .env.example .env # 환경 변수 편집');
267
+ lines.push(' 3) gaon dev # Docker + 타입 브리지 + serve 통합');
268
+ lines.push(' 4) http://localhost:3000 # 홈 페이지 확인 (60초 실측 · v0.15 §13.5 M9)');
269
+ lines.push('');
270
+ lines.push(' 문서: https://gaonjs.dev');
271
+ lines.push('');
272
+ process.stdout.write(lines.join('\n') + '\n');
273
+ return 0;
274
+ }
@@ -0,0 +1,11 @@
1
+ export type TestScope = 'unit' | 'integration' | 'all';
2
+ export interface TestCommandOptions {
3
+ readonly cwd?: string;
4
+ readonly json?: boolean;
5
+ readonly scope?: TestScope;
6
+ }
7
+ /**
8
+ * `gaon test` 진입점. args 는 사용자가 넘긴 잔여 인자(필터 문자열 등).
9
+ * pnpm test 는 pnpm 관례상 `pnpm test -- <args>` 로 넘겨야 vitest 까지 도달.
10
+ */
11
+ export declare function runTestCommand(args?: readonly string[], opts?: TestCommandOptions): Promise<number>;