@gaonjs/cli 0.31.1 → 0.33.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 (53) hide show
  1. package/dist/commands/build.d.ts +11 -0
  2. package/dist/commands/build.js +49 -0
  3. package/dist/commands/check.js +41 -0
  4. package/dist/commands/db.d.ts +5 -1
  5. package/dist/commands/db.js +92 -36
  6. package/dist/db/resolve.d.ts +4 -6
  7. package/dist/db/resolve.js +26 -13
  8. package/dist/dev/build.d.ts +34 -0
  9. package/dist/dev/build.js +144 -0
  10. package/dist/dev/frontend-build.d.ts +3 -3
  11. package/dist/dev/frontend-build.js +42 -37
  12. package/dist/doctor/async-offload.d.ts +3 -1
  13. package/dist/doctor/async-offload.js +5 -2
  14. package/dist/doctor/auth-wiring.d.ts +4 -2
  15. package/dist/doctor/auth-wiring.js +13 -6
  16. package/dist/doctor/connections.d.ts +23 -1
  17. package/dist/doctor/connections.js +85 -14
  18. package/dist/doctor/csrf-wiring.d.ts +3 -1
  19. package/dist/doctor/csrf-wiring.js +9 -5
  20. package/dist/doctor/schema-relations.d.ts +7 -0
  21. package/dist/doctor/schema-relations.js +114 -0
  22. package/dist/doctor/seal-security.js +12 -6
  23. package/dist/doctor/source-scan.d.ts +11 -0
  24. package/dist/doctor/source-scan.js +70 -0
  25. package/dist/doctor/types.d.ts +1 -1
  26. package/dist/doctor/types.js +1 -1
  27. package/dist/doctor.d.ts +2 -1
  28. package/dist/doctor.js +8 -2
  29. package/dist/generate.d.ts +1 -1
  30. package/dist/generate.js +55 -13
  31. package/dist/index.d.ts +2 -0
  32. package/dist/index.js +22 -4
  33. package/dist/scaffold/app.js +2 -1
  34. package/dist/templates/auth/Dashboard.vue.tpl +2 -2
  35. package/dist/templates/auth/Login.vue.tpl +2 -2
  36. package/dist/templates/auth/Signup.vue.tpl +2 -2
  37. package/dist/templates/auth/app.config.ts.tpl +3 -3
  38. package/dist/templates/auth/dashboard.controller.ts.tpl +1 -1
  39. package/dist/templates/auth/registration.controller.ts.tpl +3 -3
  40. package/dist/templates/auth/session.controller.ts.tpl +5 -5
  41. package/dist/templates/project/AGENTS.md.tpl +12 -6
  42. package/dist/templates/project/agents/async.md.tpl +26 -5
  43. package/dist/templates/project/agents/data.md.tpl +56 -12
  44. package/dist/templates/project/agents/security.md.tpl +16 -2
  45. package/dist/templates/project/agents/storage.md.tpl +110 -0
  46. package/dist/templates/project/agents/testing.md.tpl +24 -5
  47. package/dist/templates/project/docker-compose.yaml.tpl +7 -5
  48. package/dist/templates/project/gaon.config.ts.tpl +4 -0
  49. package/dist/templates/project/package.json.tpl +1 -1
  50. package/dist/templates/project/test/setup.ts.tpl +10 -7
  51. package/dist/templates/project/vite.config.ts.tpl +6 -5
  52. package/dist/templates/project/vitest.config.ts.tpl +5 -0
  53. package/package.json +7 -7
@@ -0,0 +1,11 @@
1
+ export interface BuildCommandOptions {
2
+ readonly cwd?: string;
3
+ readonly json?: boolean;
4
+ }
5
+ export interface BuildCommandResult {
6
+ readonly ok: boolean;
7
+ readonly apps: string[];
8
+ readonly error?: string;
9
+ }
10
+ /** `gaon build` 진입점. .gaon 재생성 + 전 앱 vite build. */
11
+ export declare function runBuildCommand(opts?: BuildCommandOptions): Promise<number>;
@@ -0,0 +1,49 @@
1
+ // @gaonjs/cli · `gaon build` — 멀티 앱 프론트 프로덕션 빌드 (결정 146 · 12차 W2)
2
+ //
3
+ // `gaon gen`(.gaon 재생성) 뒤 apps/* 를 순회해 앱마다 root=apps/<앱>·outDir=dist/<앱>·
4
+ // base=/<앱>/ 로 vite build 한다. 예전 스캐폴드 build 스크립트(`gaon gen && vite build`)는
5
+ // apps/web 하나만 빌드해 둘째 앱(admin)의 dist 가 안 생기거나 base 없이 404 였다 —
6
+ // gaon g app 으로 늘린 앱이 별도 손배선 없이 빌드 파이프라인에 자동 편입되게 한다.
7
+ import { regenerateProjectGaon } from './gen.js';
8
+ import { buildAllApps, listFrontendApps } from '../dev/build.js';
9
+ /** `gaon build` 진입점. .gaon 재생성 + 전 앱 vite build. */
10
+ export async function runBuildCommand(opts = {}) {
11
+ const cwd = opts.cwd ?? process.cwd();
12
+ const json = opts.json ?? false;
13
+ const log = (line) => {
14
+ if (!json)
15
+ process.stdout.write(line + '\n');
16
+ };
17
+ try {
18
+ // 1) .gaon 재생성 — build 산출이 routes.manifest 값 import 를 타므로 fresh 여야 한다(규칙 3).
19
+ await regenerateProjectGaon(cwd);
20
+ // 2) 프론트 앱이 없으면(API 전용) 조용히 통과.
21
+ const apps = await listFrontendApps(cwd);
22
+ if (apps.length === 0) {
23
+ const result = { ok: true, apps: [] };
24
+ if (json)
25
+ process.stdout.write(JSON.stringify({ command: 'build', ...result }) + '\n');
26
+ else
27
+ log('빌드할 프론트 앱(apps/<앱>/index.html)이 없습니다 — 건너뜁니다.');
28
+ return 0;
29
+ }
30
+ // 3) 앱별 빌드.
31
+ log(`gaon build — ${apps.length}개 앱: ${apps.join(', ')}`);
32
+ await buildAllApps(cwd, { onLog: json ? undefined : (l) => process.stdout.write(l + '\n') });
33
+ const result = { ok: true, apps };
34
+ if (json)
35
+ process.stdout.write(JSON.stringify({ command: 'build', ...result }) + '\n');
36
+ else
37
+ log(`✓ 빌드 완료 — ${apps.map((a) => `dist/${a}`).join(' · ')}`);
38
+ return 0;
39
+ }
40
+ catch (err) {
41
+ const msg = err instanceof Error ? err.message : String(err);
42
+ const result = { ok: false, apps: [], error: msg };
43
+ if (json)
44
+ process.stdout.write(JSON.stringify({ command: 'build', ...result }) + '\n');
45
+ else
46
+ process.stderr.write(`✗ gaon build 실패: ${msg}\n`);
47
+ return 1;
48
+ }
49
+ }
@@ -33,6 +33,7 @@ import { generateRoutesDts } from '@gaonjs/web';
33
33
  import { runDoctorCommand } from '../doctor.js';
34
34
  import { regenerateGaonOnce, resolveDevLayout } from '../dev.js';
35
35
  import { registerTsResolve } from '../tsResolve.js';
36
+ import { listFrontendApps, verifyAppDist } from '../dev/build.js';
36
37
  /**
37
38
  * 프로젝트 스크립트 존재 여부. pnpm/npm 어느 쪽이든 `scripts.<name>` 을
38
39
  * 정의해 두면 우선 사용한다.
@@ -78,6 +79,17 @@ async function runStep(step, cwd) {
78
79
  const cmd = 'pnpm';
79
80
  const args = ['run', scriptName];
80
81
  const { exitCode, output } = await runSubprocess(cwd, cmd, args);
82
+ // 결정 146(12차 W2): build 가 성공했으면 **등록된 앱마다** dist/<앱>/index.html 과
83
+ // 그 문서가 참조하는 에셋이 실제로 존재하는지 검증한다. build 스크립트가 통과해도
84
+ // 둘째 앱(admin)의 dist 가 안 생기거나 에셋이 404 면 여기서 잡는다("build passed 인데
85
+ // /admin 은 빈 화면" 사각 제거 · curl data-page 만으로 못 보던 지점).
86
+ if (step === 'build' && exitCode === 0) {
87
+ const verify = await verifyBuildOutput(cwd);
88
+ if (!verify.ok) {
89
+ return { step, status: 'failed', command: `${cmd} ${args.join(' ')}`, exitCode, output: output + '\n' + verify.message };
90
+ }
91
+ return { step, status: 'passed', command: `${cmd} ${args.join(' ')}`, exitCode, output: output + '\n' + verify.message };
92
+ }
81
93
  return {
82
94
  step,
83
95
  status: exitCode === 0 ? 'passed' : 'failed',
@@ -159,6 +171,35 @@ async function runStep(step, cwd) {
159
171
  '→ 프론트 프로덕션 빌드를 검사에 포함하려면 package.json 에 "build": "vite build" 를 정의하세요.',
160
172
  };
161
173
  }
174
+ /**
175
+ * build 산출 검증(결정 146 · 12차 W2). 등록된 프론트 앱마다 dist/<앱>/index.html 과
176
+ * 그 문서가 참조하는 로컬 에셋이 존재하는지 확인한다. 하나라도 빠지면 ok=false.
177
+ */
178
+ async function verifyBuildOutput(cwd) {
179
+ const apps = await listFrontendApps(cwd);
180
+ if (apps.length === 0)
181
+ return { ok: true, message: '프론트 앱이 없어 dist 검증을 건너뜁니다.' };
182
+ const problems = [];
183
+ const okApps = [];
184
+ for (const app of apps) {
185
+ const r = await verifyAppDist(cwd, app);
186
+ if (!r.indexExists) {
187
+ problems.push(`✗ apps/${app}: dist/${app}/index.html 이 없습니다 — 이 앱이 빌드되지 않았습니다.\n` +
188
+ ` → build 가 apps/${app} 를 순회 빌드하는지 확인하세요(gaon build · 결정 146).`);
189
+ }
190
+ else if (r.missing.length > 0) {
191
+ problems.push(`✗ apps/${app}: index.html 이 참조하는 에셋이 dist/${app} 에 없습니다: ${r.missing.join(', ')}\n` +
192
+ ` → base(${app === 'web' ? '/' : `/${app}/`}) 주입이 빠졌을 수 있습니다(비-루트 앱은 /<앱>/ base 필요).`);
193
+ }
194
+ else {
195
+ okApps.push(app);
196
+ }
197
+ }
198
+ if (problems.length > 0) {
199
+ return { ok: false, message: `build 산출 검증 실패(결정 146):\n${problems.join('\n')}` };
200
+ }
201
+ return { ok: true, message: `build 산출 검증 통과 — 앱별 dist: ${okApps.map((a) => `dist/${a}`).join(' · ')}` };
202
+ }
162
203
  /** doctor 는 이미 있는 명령을 재사용 — cwd 만 넘긴다. json 은 상위에서. */
163
204
  async function runDoctorStep(cwd) {
164
205
  try {
@@ -4,7 +4,11 @@ export interface DbCommandOptions {
4
4
  readonly cwd?: string;
5
5
  /** 자동화 출력. 기본 false(사람 텍스트). */
6
6
  readonly json?: boolean;
7
- /** 커넥션 키(§4.5). 기본 'main'. */
7
+ /**
8
+ * 커넥션 키(§4.5). 결정 139: diff/migrate/status/seed 는 생략(또는 'all') 시
9
+ * 등록된 **전 커넥션**을 순회 적용한다 · `--db <키>` 는 단일 좁힘. reset(파괴적)은
10
+ * 항상 단일이며 생략 시 'main'.
11
+ */
8
12
  readonly db?: string;
9
13
  /** --config <path> 오버라이드. 없으면 cwd/gaon.config.ts 관례. */
10
14
  readonly config?: string;
@@ -11,7 +11,12 @@ import { runDbDiff } from '../db/diff.js';
11
11
  import { runDbMigrate } from '../db/migrate.js';
12
12
  import { runDbReset } from '../db/reset.js';
13
13
  import { runDbStatus } from '../db/status.js';
14
+ import { listConnectionKeys } from '../db/resolve.js';
14
15
  import { runDbSeedCommand } from '../db.js';
16
+ /** 전 커넥션 순회 대상 서브커맨드(결정 139 · reset 은 파괴적이라 제외 · 단일 유지). */
17
+ const MULTI_CONN_SUBS = new Set(['diff', 'migrate', 'status', 'seed']);
18
+ /** 알려진 서브커맨드. dispatcher 방어(모르는 값이 오면 seed 로 새지 않게). */
19
+ const KNOWN_SUBS = new Set(['diff', 'migrate', 'reset', 'seed', 'status']);
15
20
  /**
16
21
  * `gaon db <sub>` 진입점. 사람/JSON 출력을 자체 처리하고 exitCode 를
17
22
  * 돌려준다. seed 는 기존 M8 runDbSeedCommand 로 위임(신규 서명에 정합).
@@ -19,7 +24,6 @@ import { runDbSeedCommand } from '../db.js';
19
24
  export async function runDbCommand(subcommand, opts = {}) {
20
25
  const cwd = opts.cwd ?? process.cwd();
21
26
  const json = opts.json ?? false;
22
- const dbKey = opts.db ?? 'main';
23
27
  const configPath = opts.config;
24
28
  const dryRun = opts.dryRun ?? false;
25
29
  const emit = (text, jsonValue) => {
@@ -28,45 +32,55 @@ export async function runDbCommand(subcommand, opts = {}) {
28
32
  else
29
33
  process.stdout.write(text + '\n');
30
34
  };
31
- try {
32
- if (subcommand === 'diff') {
33
- const r = await runDbDiff({ cwd, dbKey, json, configPath });
34
- emit(r.text, r.json);
35
- return r.exitCode;
36
- }
37
- if (subcommand === 'migrate') {
38
- const r = await runDbMigrate({ cwd, dbKey, json, dryRun, down: opts.down ?? false, configPath });
39
- emit(r.text, r.json);
40
- return r.exitCode;
41
- }
42
- if (subcommand === 'status') {
43
- const r = await runDbStatus({ cwd, dbKey, json, configPath });
44
- emit(r.text, r.json);
45
- return r.exitCode;
46
- }
47
- if (subcommand === 'reset') {
48
- const r = await runDbReset({
49
- cwd,
50
- dbKey,
51
- json,
52
- yes: opts.yes ?? false,
53
- dryRun,
54
- configPath,
55
- });
56
- emit(r.text, r.json);
57
- return r.exitCode;
58
- }
59
- if (subcommand === 'seed') {
60
- // 결정 101: seed 도 diff/migrate 와 같은 설정 해석 경로(config → env)를 쓴다.
61
- const r = await runDbSeedCommand({ root: cwd, json, configPath, dbKey });
62
- emit(r.text, r.json);
63
- return r.exitCode;
64
- }
65
- // 방어 — dispatcher 라 도달할 수 없지만 컴파일러 만족용.
35
+ // 한 커넥션에 대해 서브커맨드를 실행한다(순회 루프의 단위).
36
+ const runOne = async (subKey) => {
37
+ if (subcommand === 'diff')
38
+ return runDbDiff({ cwd, dbKey: subKey, json, configPath });
39
+ if (subcommand === 'migrate')
40
+ return runDbMigrate({ cwd, dbKey: subKey, json, dryRun, down: opts.down ?? false, configPath });
41
+ if (subcommand === 'status')
42
+ return runDbStatus({ cwd, dbKey: subKey, json, configPath });
43
+ if (subcommand === 'reset')
44
+ return runDbReset({ cwd, dbKey: subKey, json, yes: opts.yes ?? false, dryRun, configPath });
45
+ // seed (결정 101: diff/migrate 와 같은 설정 해석 경로 config → env).
46
+ return runDbSeedCommand({ root: cwd, json, configPath, dbKey: subKey });
47
+ };
48
+ // 방어 — dispatcher 라 도달할 수 없지만, 모르는 서브커맨드가 seed 로 새지 않게.
49
+ if (!KNOWN_SUBS.has(subcommand)) {
66
50
  process.stderr.write(` ✗ 알 수 없는 db 서브커맨드: ${String(subcommand)}\n` +
67
51
  ` → 지원: diff | migrate | reset | seed | status\n`);
68
52
  return 1;
69
53
  }
54
+ try {
55
+ // 결정 139: diff/migrate/status/seed 는 --db 생략(또는 'all') 시 등록된 전
56
+ // 커넥션을 순회 적용한다(One Way — 커넥션 하나를 잊어 빈 채 배포하는 사고 방지).
57
+ // `--db <키>` 는 단일 좁힘. reset(파괴적)은 항상 단일(main 폴백).
58
+ const wantsAll = subcommand !== 'reset' && (opts.db === undefined || opts.db === 'all');
59
+ if (wantsAll && MULTI_CONN_SUBS.has(subcommand)) {
60
+ const keys = await listConnectionKeys(cwd, configPath);
61
+ // 단일 커넥션이면 종전 출력과 100% 동일(하위 호환).
62
+ if (keys.length === 1) {
63
+ const r = await runOne(keys[0]);
64
+ emit(r.text, r.json);
65
+ return r.exitCode;
66
+ }
67
+ return runAcrossConnections(subcommand, keys, runOne, emit, json);
68
+ }
69
+ // 단일 커넥션 경로(--db <키> · reset · env-only 폴백) — 종전 동작 그대로.
70
+ if (subcommand === 'reset' && opts.db === 'all') {
71
+ // reset 은 전 커넥션 순회를 하지 않는다(파괴적). 명시적으로 거부한다.
72
+ const msg = `reset 은 전 커넥션 순회를 지원하지 않습니다(파괴적).\n` +
73
+ `→ 커넥션을 하나씩 지정하세요: 'gaon db reset --db main --yes'.`;
74
+ if (json)
75
+ process.stdout.write(JSON.stringify({ command: 'reset', ok: false, error: msg }) + '\n');
76
+ else
77
+ process.stderr.write(` ✗ ${msg}\n`);
78
+ return 1;
79
+ }
80
+ const r = await runOne(opts.db ?? 'main');
81
+ emit(r.text, r.json);
82
+ return r.exitCode;
83
+ }
70
84
  catch (err) {
71
85
  const msg = err instanceof Error ? err.message : String(err);
72
86
  if (json) {
@@ -78,3 +92,45 @@ export async function runDbCommand(subcommand, opts = {}) {
78
92
  return 1;
79
93
  }
80
94
  }
95
+ /**
96
+ * 전 커넥션 순회 실행(결정 139). 각 커넥션을 등록 순서대로 돌리고, 한 커넥션이
97
+ * 실패해도 나머지를 계속한다(부분 적용 방지가 아니라 부분 진행 후 전체 보고 — 어느
98
+ * 커넥션이 성공/실패했는지가 사용자에게 필요). exitCode = 하나라도 실패면 1.
99
+ */
100
+ async function runAcrossConnections(subcommand, keys, runOne, emit, json) {
101
+ const results = [];
102
+ for (const key of keys) {
103
+ try {
104
+ const r = await runOne(key);
105
+ results.push({ db: key, ok: r.exitCode === 0, exitCode: r.exitCode, text: r.text, json: r.json });
106
+ }
107
+ catch (err) {
108
+ const msg = err instanceof Error ? err.message : String(err);
109
+ results.push({
110
+ db: key,
111
+ ok: false,
112
+ exitCode: 1,
113
+ text: ` ✗ [${key}] gaon db ${subcommand} 실패: ${msg}`,
114
+ json: { command: subcommand, db: key, ok: false, error: msg },
115
+ });
116
+ }
117
+ }
118
+ const failed = results.filter((r) => !r.ok);
119
+ const exitCode = failed.length > 0 ? 1 : 0;
120
+ if (json) {
121
+ emit('', {
122
+ command: subcommand,
123
+ connections: keys,
124
+ ok: failed.length === 0,
125
+ results: results.map((r) => ({ db: r.db, ok: r.ok, ...r.json })),
126
+ });
127
+ return exitCode;
128
+ }
129
+ const header = ` gaon db ${subcommand} · 커넥션 ${keys.length}개 순회: ${keys.join(', ')}`;
130
+ const sections = results.map((r) => ` ── [${r.db}] ${'─'.repeat(Math.max(0, 40 - r.db.length))}\n${r.text}`);
131
+ const footer = failed.length === 0
132
+ ? ` ✓ 전 커넥션 완료(${keys.length}개).`
133
+ : ` ✗ ${failed.length}/${keys.length} 커넥션 실패: ${failed.map((r) => r.db).join(', ')}`;
134
+ emit([header, ...sections, footer].join('\n'), null);
135
+ return exitCode;
136
+ }
@@ -28,11 +28,9 @@ export interface ResolvedDbTarget {
28
28
  close(): Promise<void>;
29
29
  }
30
30
  /**
31
- * gaon.config.ts 로드해 target db 커넥션을 등록하고 스키마 테이블을 모아 온다.
32
- *
33
- * 커넥션 결정 순서:
34
- * 1) config.db[dbKey] 가 있으면 그 어댑터·URL 사용
35
- * 2) dbKey='main' 이고 GAON_DATABASE_URL 이 있으면 그 URL 로 main 을 세움
36
- * 3) 그 외 → 수리 안내 에러
31
+ * 프로젝트의 커넥션 목록을 돌려준다(결정 139 · `gaon db <sub>` 전 커넥션 순회용).
32
+ * config.db 에 선언된 키 순서 그대로. 선언이 없으면(env-only 프로젝트) `['main']`
33
+ * 폴백(GAON_DATABASE_URL 관례) — 단일 커넥션 프로젝트의 종전 동작과 정합.
37
34
  */
35
+ export declare function listConnectionKeys(cwd: string, configPath?: string): Promise<string[]>;
38
36
  export declare function resolveDbTarget(opts: ResolveDbOptions): Promise<ResolvedDbTarget>;
@@ -65,15 +65,15 @@ function isTableDef(v) {
65
65
  * 2) dbKey='main' 이고 GAON_DATABASE_URL 이 있으면 그 URL 로 main 을 세움
66
66
  * 3) 그 외 → 수리 안내 에러
67
67
  */
68
- export async function resolveDbTarget(opts) {
69
- const cwd = resolvePath(opts.cwd);
68
+ /**
69
+ * gaon.config 를 로드해 config 값과 실 경로를 돌려준다. --config <path> 가 있으면
70
+ * 그 파일의 default export 를, 없으면 cwd 관례(gaon.config.ts)를 쓴다.
71
+ * resolveDbTarget·listConnectionKeys 가 공유한다(결정 139).
72
+ */
73
+ async function loadDbConfig(cwd, configPathOpt) {
70
74
  registerTsResolve();
71
- // 사용자가 --config <path> 를 준 경우 그 경로에서 default export 를 로드한다.
72
- // 없으면 cwd 관례.
73
- let configPath;
74
- let config;
75
- if (opts.configPath) {
76
- configPath = resolvePath(opts.configPath);
75
+ if (configPathOpt) {
76
+ const configPath = resolvePath(configPathOpt);
77
77
  if (!existsSync(configPath)) {
78
78
  throw new Error(`[gaon db] --config 로 지정한 파일이 없습니다: ${configPath}\n` +
79
79
  `→ 경로를 확인하거나 --config 없이 실행해 cwd/gaon.config.ts 를 쓰세요.`);
@@ -83,12 +83,25 @@ export async function resolveDbTarget(opts) {
83
83
  throw new Error(`[gaon db] --config 파일의 default export 가 객체가 아닙니다: ${configPath}\n` +
84
84
  `→ export default defineConfig({ db: { main: { adapter: 'postgres', url: '...' } } }) 형태로 두세요.`);
85
85
  }
86
- config = mod.default;
87
- }
88
- else {
89
- configPath = findConfigPath(cwd);
90
- config = await loadGaonConfig(cwd);
86
+ return { config: mod.default, configPath };
91
87
  }
88
+ return { config: await loadGaonConfig(cwd), configPath: findConfigPath(cwd) };
89
+ }
90
+ /**
91
+ * 프로젝트의 커넥션 키 목록을 돌려준다(결정 139 · `gaon db <sub>` 전 커넥션 순회용).
92
+ * config.db 에 선언된 키 순서 그대로. 선언이 없으면(env-only 프로젝트) `['main']`
93
+ * 폴백(GAON_DATABASE_URL 관례) — 단일 커넥션 프로젝트의 종전 동작과 정합.
94
+ */
95
+ export async function listConnectionKeys(cwd, configPath) {
96
+ const { config } = await loadDbConfig(resolvePath(cwd), configPath);
97
+ const keys = Object.keys(config.db ?? {});
98
+ return keys.length ? keys : ['main'];
99
+ }
100
+ export async function resolveDbTarget(opts) {
101
+ const cwd = resolvePath(opts.cwd);
102
+ // 사용자가 --config <path> 를 준 경우 그 경로에서 default export 를 로드한다.
103
+ // 없으면 cwd 관례.
104
+ const { config, configPath } = await loadDbConfig(cwd, opts.configPath);
92
105
  const dbKey = opts.dbKey;
93
106
  const cfg = config.db?.[dbKey];
94
107
  let connCfg;
@@ -0,0 +1,34 @@
1
+ /** 앱의 URL base — web 은 루트('/'), 그 외는 '/<app>/'(결정 146). vite build 시 주입. */
2
+ export declare function appBase(app: string): string;
3
+ /** apps/* 중 프론트 진입(index.html)이 있는 앱 이름들. web 을 먼저(루트 앱 관례). */
4
+ export declare function listFrontendApps(cwd: string): Promise<string[]>;
5
+ /** vite.config 를 프로젝트 루트·앱 루트 순으로 찾는다(configFile 명시용 · vue 플러그인 보장). */
6
+ export declare function findViteConfig(cwd: string, appRoot: string): string | undefined;
7
+ export interface BuildAppOptions {
8
+ /** watch 모드(dev) — RollupWatcher 를 담은 핸들을 돌려준다(close 로 정리). */
9
+ readonly watch?: boolean;
10
+ /** 통합 콘솔 로그 훅(생략 = vite 가 stdout 으로 직접 출력). */
11
+ readonly onLog?: (line: string, level: 'info' | 'warn' | 'error') => void;
12
+ }
13
+ export interface BuildAppHandle {
14
+ /** watch 모드에서 감시를 종료한다. non-watch 는 no-op. */
15
+ close(): Promise<void>;
16
+ }
17
+ /** 한 앱을 vite 로 빌드한다(programmatic · configFile 명시 → vue 플러그인·alias 보장). */
18
+ export declare function buildApp(cwd: string, app: string, opts?: BuildAppOptions): Promise<BuildAppHandle>;
19
+ /** 등록된 프론트 앱을 전부 빌드한다(build 커맨드·게이트 공용). 빌드한 앱 이름을 돌려준다. */
20
+ export declare function buildAllApps(cwd: string, opts?: BuildAppOptions): Promise<string[]>;
21
+ /** 한 앱의 dist 검증 결과. missing 가 비어 있지 않으면 게이트 실패(결정 146). */
22
+ export interface DistVerifyResult {
23
+ readonly app: string;
24
+ readonly indexExists: boolean;
25
+ /** index.html 이 참조하는데 실제 파일이 없는 로컬 에셋 경로(base 기준). */
26
+ readonly missing: string[];
27
+ }
28
+ /**
29
+ * 앱의 dist/<앱>/index.html 과 그 문서가 참조하는 로컬 에셋(script src·link href)이
30
+ * 실제로 존재하는지 검증한다(결정 146 · gaon check build 게이트). base(/<앱>/)로 시작하는
31
+ * 참조만 검사한다(외부 URL 제외). curl data-page 만으로는 못 보던 "문서는 뜨는데 스크립트
32
+ * 404" 를 정면으로 막는다.
33
+ */
34
+ export declare function verifyAppDist(cwd: string, app: string): Promise<DistVerifyResult>;
@@ -0,0 +1,144 @@
1
+ // @gaonjs/cli · dev/build — 멀티 앱 프론트 빌드 파이프라인 (결정 146 · 12차 W2)
2
+ //
3
+ // gaon new 프로젝트는 앱이 하나 이상일 수 있다(gaon g app admin). 각 앱은 자기
4
+ // prefix 아래로 서빙되고(web→'/' · admin→'/admin/'), serve 는 앱별 dist/<앱> 를
5
+ // 기대한다(결정 67). 그런데 예전 파이프라인은 apps/web 하나만 빌드해(vite.config
6
+ // root 하드코딩) 둘째 앱의 dist 가 안 생기고, 생겨도 base 가 없어 index.html 이
7
+ // 절대경로 `/assets/*` 를 박아 비-루트 앱에서 404 였다. 이 모듈이 apps/* 를 순회해
8
+ // 앱마다 root=apps/<앱>·outDir=dist/<앱>·base=/<앱>/ 로 빌드한다(결정 146).
9
+ //
10
+ // vite 는 **프로그램적 build()** 로 부른다 — configFile 을 명시로 넘겨야 vue 플러그인·
11
+ // @shared alias 가 로드된다(positional root 만 주면 앱 폴더에 vite.config 가 없어
12
+ // vue 플러그인이 빠지는 함정 · 결정 146). inline root/base/outDir 가 configFile 을
13
+ // 덮어써 앱별 override 가 확실하다.
14
+ import { existsSync } from 'node:fs';
15
+ import { readdir, readFile } from 'node:fs/promises';
16
+ import { join, resolve } from 'node:path';
17
+ /** 앱의 URL base — web 은 루트('/'), 그 외는 '/<app>/'(결정 146). vite build 시 주입. */
18
+ export function appBase(app) {
19
+ return app === 'web' ? '/' : `/${app}/`;
20
+ }
21
+ /** apps/* 중 프론트 진입(index.html)이 있는 앱 이름들. web 을 먼저(루트 앱 관례). */
22
+ export async function listFrontendApps(cwd) {
23
+ const appsDir = join(resolve(cwd), 'apps');
24
+ let entries;
25
+ try {
26
+ entries = (await readdir(appsDir, { withFileTypes: true }));
27
+ }
28
+ catch {
29
+ return [];
30
+ }
31
+ const apps = entries
32
+ .filter((e) => e.isDirectory() && existsSync(join(appsDir, e.name, 'index.html')))
33
+ .map((e) => e.name);
34
+ return apps.sort((a, b) => (a === 'web' ? -1 : b === 'web' ? 1 : a.localeCompare(b)));
35
+ }
36
+ /** vite.config 를 프로젝트 루트·앱 루트 순으로 찾는다(configFile 명시용 · vue 플러그인 보장). */
37
+ export function findViteConfig(cwd, appRoot) {
38
+ for (const dir of [appRoot, resolve(cwd)]) {
39
+ for (const name of ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs']) {
40
+ const p = join(dir, name);
41
+ if (existsSync(p))
42
+ return p;
43
+ }
44
+ }
45
+ return undefined;
46
+ }
47
+ /** 한 앱을 vite 로 빌드한다(programmatic · configFile 명시 → vue 플러그인·alias 보장). */
48
+ export async function buildApp(cwd, app, opts = {}) {
49
+ const root = resolve(cwd);
50
+ const appRoot = join(root, 'apps', app);
51
+ const vite = await loadVite();
52
+ const result = await vite.build({
53
+ configFile: findViteConfig(root, appRoot),
54
+ root: appRoot,
55
+ base: appBase(app),
56
+ // onLog 있으면 vite 출력을 그 훅으로 라우팅(customLogger 가 stdout 대신 훅으로).
57
+ // 없으면(예: `gaon build --json`) 완전히 침묵 — vite 진행 로그가 stdout 의 JSON 을
58
+ // 오염시키면 안 된다(클린룸이 마지막 줄을 JSON 으로 파싱 · 결정 146 실측).
59
+ logLevel: opts.onLog ? 'info' : 'silent',
60
+ customLogger: opts.onLog ? makeLogger(opts.onLog) : undefined,
61
+ build: {
62
+ outDir: join(root, 'dist', app),
63
+ emptyOutDir: true,
64
+ watch: opts.watch ? {} : null,
65
+ },
66
+ });
67
+ const watcher = result;
68
+ if (opts.watch && watcher && typeof watcher.close === 'function') {
69
+ return { close: async () => void (await watcher.close()) };
70
+ }
71
+ return { close: async () => { } };
72
+ }
73
+ /** 등록된 프론트 앱을 전부 빌드한다(build 커맨드·게이트 공용). 빌드한 앱 이름을 돌려준다. */
74
+ export async function buildAllApps(cwd, opts = {}) {
75
+ const apps = await listFrontendApps(cwd);
76
+ for (const app of apps) {
77
+ opts.onLog?.(`▶ vite build — apps/${app} → dist/${app} (base=${appBase(app)})`, 'info');
78
+ await buildApp(cwd, app, { onLog: opts.onLog });
79
+ }
80
+ return apps;
81
+ }
82
+ /**
83
+ * 앱의 dist/<앱>/index.html 과 그 문서가 참조하는 로컬 에셋(script src·link href)이
84
+ * 실제로 존재하는지 검증한다(결정 146 · gaon check build 게이트). base(/<앱>/)로 시작하는
85
+ * 참조만 검사한다(외부 URL 제외). curl data-page 만으로는 못 보던 "문서는 뜨는데 스크립트
86
+ * 404" 를 정면으로 막는다.
87
+ */
88
+ export async function verifyAppDist(cwd, app) {
89
+ const distDir = join(resolve(cwd), 'dist', app);
90
+ const indexPath = join(distDir, 'index.html');
91
+ if (!existsSync(indexPath))
92
+ return { app, indexExists: false, missing: [] };
93
+ const html = await readFile(indexPath, 'utf8');
94
+ const base = appBase(app);
95
+ const missing = [];
96
+ const refs = new Set();
97
+ // <script ... src="..."> · <link ... href="..."> 의 값 추출.
98
+ for (const m of html.matchAll(/<(?:script|link)\b[^>]*\b(?:src|href)\s*=\s*["']([^"']+)["']/gi)) {
99
+ refs.add(m[1]);
100
+ }
101
+ for (const ref of refs) {
102
+ // 이 앱 base 로 시작하는 로컬 에셋만 검사(외부·절대 URL·data: 제외).
103
+ if (!ref.startsWith(base))
104
+ continue;
105
+ const rel = ref.slice(base.length); // base 를 벗겨 dist 상대 경로로.
106
+ const file = join(distDir, rel.split('?')[0].split('#')[0]);
107
+ if (!existsSync(file))
108
+ missing.push(ref);
109
+ }
110
+ return { app, indexExists: true, missing };
111
+ }
112
+ async function loadVite() {
113
+ try {
114
+ return await import('vite');
115
+ }
116
+ catch (err) {
117
+ throw new Error(`[gaon build · vite] vite 모듈을 찾을 수 없습니다.\n` +
118
+ `→ 프로젝트 루트에서 설치하세요: pnpm add -D vite @vitejs/plugin-vue\n` +
119
+ `원인: ${err instanceof Error ? err.message : String(err)}`);
120
+ }
121
+ }
122
+ /** vite 로거 어댑터 — 라인을 통합 콘솔 훅으로 흘린다(필요 메서드만). */
123
+ function makeLogger(onLog) {
124
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
125
+ let hasWarned = false;
126
+ const seen = new WeakSet();
127
+ return {
128
+ info: (m) => onLog(strip(m), 'info'),
129
+ warn: (m) => {
130
+ hasWarned = true;
131
+ onLog(strip(m), 'warn');
132
+ },
133
+ warnOnce: (m) => {
134
+ hasWarned = true;
135
+ onLog(strip(m), 'warn');
136
+ },
137
+ error: (m) => onLog(strip(m), 'error'),
138
+ clearScreen: () => { },
139
+ hasErrorLogged: (e) => seen.has(e),
140
+ get hasWarned() {
141
+ return hasWarned;
142
+ },
143
+ };
144
+ }
@@ -1,5 +1,5 @@
1
1
  export interface FrontendBuildHandle {
2
- /** watch 빌드 프로세스를 종료한다. */
2
+ /** watch 빌드(모든 앱)를 종료한다. */
3
3
  close(): void;
4
4
  }
5
5
  export interface StartFrontendBuildOptions {
@@ -8,7 +8,7 @@ export interface StartFrontendBuildOptions {
8
8
  readonly onLog: (line: string, level: 'info' | 'warn' | 'error') => void;
9
9
  }
10
10
  /**
11
- * vite build --watch 띄운다. 프론트가 없거나 vite 가 없으면 아무 것도 하지
12
- * 않고 no-op 핸들을 돌려준다(dev 는 계속 진행).
11
+ * apps/* 를 순회해 앱마다 vite watch 빌드를 띄운다. 프론트가 없거나 vite 가 없으면
12
+ * 아무 것도 하지 않고 no-op 핸들을 돌려준다(dev 는 계속 진행 · fail-open).
13
13
  */
14
14
  export declare function startFrontendBuild(opts: StartFrontendBuildOptions): FrontendBuildHandle;