@gaonjs/cli 0.15.0 → 0.21.2

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 (69) hide show
  1. package/dist/commands/g.js +8 -0
  2. package/dist/commands/new.js +5 -0
  3. package/dist/dev/health.d.ts +4 -1
  4. package/dist/dev/health.js +5 -3
  5. package/dist/doctor/method-override.d.ts +5 -0
  6. package/dist/doctor/method-override.js +75 -0
  7. package/dist/doctor/route-registration.d.ts +5 -0
  8. package/dist/doctor/route-registration.js +77 -0
  9. package/dist/doctor/static-collision.d.ts +5 -0
  10. package/dist/doctor/static-collision.js +84 -0
  11. package/dist/doctor/types.d.ts +1 -1
  12. package/dist/doctor/types.js +3 -3
  13. package/dist/doctor/ui-kit-wiring.d.ts +5 -0
  14. package/dist/doctor/ui-kit-wiring.js +93 -0
  15. package/dist/doctor.d.ts +1 -0
  16. package/dist/doctor.js +19 -2
  17. package/dist/generate.js +8 -0
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.js +19 -5
  20. package/dist/scaffold/app-wiring.d.ts +12 -0
  21. package/dist/scaffold/app-wiring.js +68 -0
  22. package/dist/serve.d.ts +10 -0
  23. package/dist/serve.js +90 -2
  24. package/dist/templates/auth/Dashboard.vue.tpl +21 -5
  25. package/dist/templates/auth/Login.vue.tpl +37 -8
  26. package/dist/templates/auth/Signup.vue.tpl +40 -9
  27. package/dist/templates/project/.dockerignore.tpl +12 -0
  28. package/dist/templates/project/AGENTS.md.tpl +10 -5
  29. package/dist/templates/project/CLAUDE.md.tpl +4 -3
  30. package/dist/templates/project/Dockerfile.tpl +30 -0
  31. package/dist/templates/project/agents/async.md.tpl +19 -7
  32. package/dist/templates/project/agents/data.md.tpl +37 -15
  33. package/dist/templates/project/agents/frontend.md.tpl +81 -4
  34. package/dist/templates/project/agents/realtime.md.tpl +24 -17
  35. package/dist/templates/project/agents/security.md.tpl +11 -2
  36. package/dist/templates/project/agents/web.md.tpl +5 -0
  37. package/dist/templates/project/apps/web/composables/useGaonHealth.ts.tpl +2 -0
  38. package/dist/templates/project/apps/web/layouts/Default.vue.tpl +19 -94
  39. package/dist/templates/project/apps/web/main.ts.tpl +4 -0
  40. package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +68 -251
  41. package/dist/templates/project/apps/web/static/robots.txt.tpl +4 -0
  42. package/dist/templates/project/apps/web/style.css.tpl +66 -0
  43. package/dist/templates/project/compose.prod.yaml.tpl +98 -0
  44. package/dist/templates/project/package.json.tpl +4 -0
  45. package/dist/templates/project/postcss.config.js.tpl +15 -0
  46. package/dist/templates/project/tailwind.config.ts.tpl +56 -0
  47. package/dist/templates/ui-kit/Alert.vue.tpl +23 -0
  48. package/dist/templates/ui-kit/AlertDescription.vue.tpl +9 -0
  49. package/dist/templates/ui-kit/AlertTitle.vue.tpl +9 -0
  50. package/dist/templates/ui-kit/Badge.vue.tpl +25 -0
  51. package/dist/templates/ui-kit/Button.vue.tpl +39 -0
  52. package/dist/templates/ui-kit/Card.vue.tpl +10 -0
  53. package/dist/templates/ui-kit/CardContent.vue.tpl +9 -0
  54. package/dist/templates/ui-kit/CardDescription.vue.tpl +9 -0
  55. package/dist/templates/ui-kit/CardFooter.vue.tpl +9 -0
  56. package/dist/templates/ui-kit/CardHeader.vue.tpl +9 -0
  57. package/dist/templates/ui-kit/CardTitle.vue.tpl +9 -0
  58. package/dist/templates/ui-kit/Dialog.vue.tpl +68 -0
  59. package/dist/templates/ui-kit/Form.vue.tpl +13 -0
  60. package/dist/templates/ui-kit/FormField.vue.tpl +16 -0
  61. package/dist/templates/ui-kit/FormMessage.vue.tpl +9 -0
  62. package/dist/templates/ui-kit/Input.vue.tpl +22 -0
  63. package/dist/templates/ui-kit/Label.vue.tpl +9 -0
  64. package/dist/templates/ui-kit/Sheet.vue.tpl +73 -0
  65. package/dist/templates/ui-kit/utils.ts.tpl +26 -0
  66. package/dist/uikit.d.ts +28 -0
  67. package/dist/uikit.js +138 -0
  68. package/dist/work.js +2 -0
  69. package/package.json +6 -6
@@ -17,6 +17,7 @@
17
17
  import { existsSync } from 'node:fs';
18
18
  import { join } from 'node:path';
19
19
  import { appScaffoldFiles, controllerScaffold, inflectModel, jobScaffold, jobTestScaffold, modelScaffoldFiles, pageScaffold, writeScaffold, } from '../scaffold/index.js';
20
+ import { appWiringFiles, readProjectName } from '../scaffold/app-wiring.js';
20
21
  /** argv 에서 옵션을 뽑는다(간단 파서 · runCli 관례와 일치). */
21
22
  export function parseGenerateArgs(argv) {
22
23
  const type = argv[0];
@@ -114,6 +115,13 @@ export function runGenerateCommand(type, name, opts = {}) {
114
115
  return 1;
115
116
  }
116
117
  }
118
+ // gaon g app: web 앱과 동등한 프론트 배선(style.css·main.ts·index.html)을
119
+ // 함께 심는다 — 이후 gaon g ui-kit --app <name> 이 심는 UI 킷 컴포넌트가
120
+ // Tailwind 유틸을 받아 스타일대로 렌더되도록(결정 76). web 정본 템플릿에서
121
+ // 파생하므로 드리프트가 없다(scaffold/app-wiring).
122
+ if (type === 'app') {
123
+ files = [...files, ...appWiringFiles(name, readProjectName(cwd))];
124
+ }
117
125
  const write = writeScaffold(cwd, files, { overwrite: opts.overwrite });
118
126
  // overwrite 없이 기존 파일이 있으면 실패(사고 방지 · Rails 관례).
119
127
  const failed = write.skipped.length > 0;
@@ -23,6 +23,7 @@ import { spawnSync } from 'node:child_process';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { readFileSync } from 'node:fs';
25
25
  import { renderProjectFiles } from '../templates/index.js';
26
+ import { writeUiKitScaffold } from '../uikit.js';
26
27
  /** 이름 유효성 — npm 패키지명 규칙(단순 부분)만 검사. */
27
28
  function validateProjectName(name) {
28
29
  if (!name)
@@ -175,6 +176,10 @@ export async function runNewCommand(name, opts = {}) {
175
176
  try {
176
177
  mkdirSync(root, { recursive: true });
177
178
  filesCreated = writeAll(root, files);
179
+ // 결정 74·75: 기본 앱(web)에 UI 킷을 심는다(배터리 포함 · 단일 소스
180
+ // templates/ui-kit). 랜딩(Home/Index.vue)이 Card 를 쓰고, gaon g auth 도
181
+ // 이 세트를 재사용한다. 정적 중복 없이 프로그램적으로 생성한다.
182
+ filesCreated += writeUiKitScaffold(root, { app: 'web' }).created.length;
178
183
  }
179
184
  catch (err) {
180
185
  return emitError(`파일 생성 실패: ${err instanceof Error ? err.message : String(err)}`, Date.now() - t0);
@@ -1,4 +1,4 @@
1
- import { type GaonNats, type StreamStat } from '@gaonjs/async';
1
+ import { type GaonNats, type StreamStat, type ChannelStat } from '@gaonjs/async';
2
2
  import type { GaonConfig } from '@gaonjs/config';
3
3
  /** registerDevHealth 가 프로브에 쓰는 컨텍스트(serve 가 채운다). */
4
4
  export interface DevHealthContext {
@@ -38,7 +38,10 @@ export type HubHealth = {
38
38
  } | {
39
39
  readonly configured: true;
40
40
  readonly connected: boolean;
41
+ /** 잡·이벤트 스트림의 실 대기(pending) 메시지 수(결정 71). */
41
42
  readonly streams?: readonly StreamStat[];
43
+ /** 채널별 실 접속자 수(프레즌스 KV 권위). null = 아직 아무도 접속 안 함(결정 71). */
44
+ readonly channels?: readonly ChannelStat[] | null;
42
45
  readonly error?: string;
43
46
  };
44
47
  export interface DoctorHealth {
@@ -21,7 +21,7 @@ import { readFileSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
22
  import { VERSION } from '@gaonjs/core';
23
23
  import { getConnection, hasConnection, snapshotFromDb, postgresDialect, MIGRATIONS_TABLE, } from '@gaonjs/data';
24
- import { streamStats } from '@gaonjs/async';
24
+ import { streamStats, presenceStats } from '@gaonjs/async';
25
25
  import { computeDoctorResult } from '../doctor.js';
26
26
  /** web 앱은 관례상 프리픽스 '/', 그 외는 '/<앱>'(dispatch.prefixFor 와 정합). */
27
27
  function prefixFor(name) {
@@ -81,8 +81,10 @@ async function probeHub(config, nats) {
81
81
  if (!connected)
82
82
  return { configured: true, connected: false };
83
83
  try {
84
- const streams = await streamStats(nats);
85
- return { configured: true, connected: true, streams };
84
+ // jobs = JetStream 스트림 대기 수 · channels = 프레즌스 KV 접속자 수.
85
+ // introspection(하드코딩·가장 없음 · 결정 71).
86
+ const [streams, channels] = await Promise.all([streamStats(nats), presenceStats(nats)]);
87
+ return { configured: true, connected: true, streams, channels };
86
88
  }
87
89
  catch (err) {
88
90
  return {
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** 소스에 _method 오버라이드 hack 이 있는지(단위 테스트 진입점 · 주석은 제외). */
3
+ export declare function usesMethodOverride(source: string): boolean;
4
+ /** apps/ 의 .vue·.ts 를 훑어 _method 오버라이드 hack 을 경고로 낸다. */
5
+ export declare function checkMethodOverride(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,75 @@
1
+ // @gaonjs/cli · doctor · _method 오버라이드 hack 검출 (결정 89 · 경고)
2
+ //
3
+ // Gaon 은 `_method`(Rails/Laravel 식 HTTP 메서드 스푸핑)를 지원하지 않는다 —
4
+ // Inertia 는 실 DELETE/PUT/PATCH 를 보낸다. `?_method=DELETE` 쿼리·`<input
5
+ // name="_method">` 폼 필드·`{ _method: 'DELETE' }` 를 쓰면 컴파일은 통과하지만
6
+ // 런타임이 조용히 파손된다(서버가 무시). 이 검사가 그 패턴을 경고로 잡는다.
7
+ // (auth-flow 벤치 (d) 실측 근거 · AGENTS 절대 규칙 6). 판정은 소스 텍스트 기반.
8
+ import { readdir, readFile } from 'node:fs/promises';
9
+ import { join, relative } from 'node:path';
10
+ // 메서드 오버라이드 hack 패턴: 쿼리(?_method=DELETE)·폼 필드(name="_method")·
11
+ // 객체 키(_method: 'DELETE' · 따옴표 유무 무관 · 값이 HTTP 메서드일 때만 — 오탐 방지).
12
+ const METHOD_OVERRIDE = /[?&]_method=|name\s*=\s*['"]_method['"]|['"]?\b_method\b['"]?\s*:\s*['"]?(?:DELETE|PUT|PATCH)/i;
13
+ // 주석을 제거한다(줄 주석·블록 주석·HTML/Vue 주석) — 안티패턴을 "쓰지 말라"고
14
+ // 설명하는 주석이 오탐을 내지 않도록. 실제 실행 코드만 남긴다.
15
+ function stripComments(source) {
16
+ return source
17
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
18
+ .replace(/<!--[\s\S]*?-->/g, ' ')
19
+ .replace(/(^|[^:])\/\/[^\n]*/g, '$1'); // // 줄 주석(http:// 는 보존)
20
+ }
21
+ /** 소스에 _method 오버라이드 hack 이 있는지(단위 테스트 진입점 · 주석은 제외). */
22
+ export function usesMethodOverride(source) {
23
+ return METHOD_OVERRIDE.test(stripComments(source));
24
+ }
25
+ /** apps/ 의 .vue·.ts 를 훑어 _method 오버라이드 hack 을 경고로 낸다. */
26
+ export async function checkMethodOverride(cwd) {
27
+ const appsDir = join(cwd, 'apps');
28
+ const issues = [];
29
+ for (const abs of await walkSources(appsDir)) {
30
+ const source = await readFile(abs, 'utf8').catch(() => '');
31
+ if (!usesMethodOverride(source))
32
+ continue;
33
+ issues.push({
34
+ rule: 'method-override',
35
+ level: 'warning',
36
+ file: relative(cwd, abs),
37
+ message: `_method 오버라이드 hack 발견: ${relative(cwd, abs)} 이 \`_method\`(Rails/Laravel 식 HTTP ` +
38
+ `메서드 스푸핑)를 씁니다. Gaon 은 이를 지원하지 않아 컴파일은 통과해도 런타임이 조용히 ` +
39
+ `파손됩니다(서버가 무시 · 결정 89).\n` +
40
+ `→ Gaon 은 \`_method\` 를 지원하지 않습니다. HTML 폼이 못 보내는 메서드는 ` +
41
+ `\`router.delete(...)\` / \`useForm(...).delete(...)\` 를 쓰세요 (AGENTS 절대 규칙 6).`,
42
+ detail: { file: relative(cwd, abs) },
43
+ });
44
+ }
45
+ return { rule: 'method-override', issues };
46
+ }
47
+ /** apps/ 하위 .vue·.ts(선언·테스트 제외) 절대경로. */
48
+ async function walkSources(dir) {
49
+ const out = [];
50
+ const walk = async (d) => {
51
+ let entries;
52
+ try {
53
+ entries = await readdir(d, { withFileTypes: true });
54
+ }
55
+ catch {
56
+ return;
57
+ }
58
+ for (const e of entries) {
59
+ const abs = join(d, e.name);
60
+ if (e.isDirectory()) {
61
+ if (e.name === 'node_modules' || e.name === '.gaon')
62
+ continue;
63
+ await walk(abs);
64
+ }
65
+ else if (e.isFile()) {
66
+ if (e.name.endsWith('.d.ts') || e.name.endsWith('.test.ts'))
67
+ continue;
68
+ if (e.name.endsWith('.vue') || e.name.endsWith('.ts'))
69
+ out.push(abs);
70
+ }
71
+ }
72
+ };
73
+ await walk(dir);
74
+ return out.sort();
75
+ }
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** routes.ts 소스에서 참조된 컨트롤러 이름 집합을 뽑는다(단위 테스트 진입점). */
3
+ export declare function referencedControllers(routesSource: string): Set<string>;
4
+ /** apps/ 를 훑어 라우트에 등록되지 않은(고아) 컨트롤러를 경고로 낸다. */
5
+ export declare function checkRouteRegistration(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,77 @@
1
+ // @gaonjs/cli · doctor · 라우트 미등록(고아 컨트롤러) 검사 (결정 79)
2
+ //
3
+ // apps/<앱>/controllers/<name>.ts 가 있는데 그 앱 routes.ts 어디에서도 참조되지
4
+ // 않으면(= `<name>#action` 대상도, `resource(s)('<name>')` 도 없음) 그 컨트롤러는
5
+ // 어떤 URL 로도 도달할 수 없다 — "라우트 미등록" 상태다. 컴파일은 통과하므로
6
+ // check 로는 안 잡히는 조용한 누락이라, 이 검사가 경고로 낸다(라우트가 없는
7
+ // 컨트롤러 파일은 배선을 깜빡한 흔한 실수 · §7.5.3 수리 안내).
8
+ //
9
+ // routes.ts 가 참조하는데 파일이 없는 반대 경우는 이미 generator 가 하드 에러로
10
+ // 잡으므로(gaon check 중 throw) 여기서는 고아 컨트롤러만 본다. 판정은 소스 텍스트
11
+ // 기반(가벼운 정적 검사) — routes.ts 를 실행하지 않는다.
12
+ import { readdir, readFile } from 'node:fs/promises';
13
+ import { join } from 'node:path';
14
+ /** routes.ts 소스에서 참조된 컨트롤러 이름 집합을 뽑는다(단위 테스트 진입점). */
15
+ export function referencedControllers(routesSource) {
16
+ const names = new Set();
17
+ // 메서드 대상: r.get('/', 'posts#index') → 'posts'
18
+ for (const m of routesSource.matchAll(/['"]([A-Za-z_]\w*)#\w+['"]/g))
19
+ names.add(m[1]);
20
+ // 리소스: r.resource('posts') · r.resources('posts')
21
+ for (const m of routesSource.matchAll(/\bresources?\s*\(\s*['"]([A-Za-z_]\w*)['"]/g))
22
+ names.add(m[1]);
23
+ return names;
24
+ }
25
+ /** apps/ 를 훑어 라우트에 등록되지 않은(고아) 컨트롤러를 경고로 낸다. */
26
+ export async function checkRouteRegistration(cwd) {
27
+ const appsDir = join(cwd, 'apps');
28
+ const issues = [];
29
+ for (const app of await safeListDirs(appsDir)) {
30
+ const routesPath = join(appsDir, app, 'routes.ts');
31
+ const routesSource = await readFile(routesPath, 'utf8').catch(() => undefined);
32
+ // routes.ts 가 없으면 참조 집합을 알 수 없어 diff 불가 — 건너뛴다
33
+ // (routes.ts 자체 부재는 다른 층의 관심사).
34
+ if (routesSource === undefined)
35
+ continue;
36
+ const referenced = referencedControllers(routesSource);
37
+ const controllersDir = join(appsDir, app, 'controllers');
38
+ for (const stem of await controllerStems(controllersDir)) {
39
+ if (referenced.has(stem))
40
+ continue;
41
+ issues.push({
42
+ rule: 'route-registration',
43
+ level: 'warning',
44
+ file: `apps/${app}/controllers/${stem}.ts`,
45
+ message: `라우트 미등록: apps/${app}/controllers/${stem}.ts 가 있는데 ` +
46
+ `apps/${app}/routes.ts 어디에서도 참조되지 않습니다 — 이 컨트롤러는 어떤 URL 로도 ` +
47
+ `도달할 수 없습니다(결정 79).\n` +
48
+ `→ apps/${app}/routes.ts 에 \`r.get('/${stem}', '${stem}#index')\`(또는 \`r.resources('${stem}')\`)를 ` +
49
+ `추가하거나, 쓰지 않는 컨트롤러라면 파일을 지우세요.`,
50
+ detail: { app, controller: stem },
51
+ });
52
+ }
53
+ }
54
+ return { rule: 'route-registration', issues };
55
+ }
56
+ /** apps/<앱>/controllers/*.ts 의 stem 목록(선언·테스트 제외). */
57
+ async function controllerStems(dir) {
58
+ try {
59
+ const entries = await readdir(dir, { withFileTypes: true });
60
+ return entries
61
+ .filter((e) => e.isFile() && e.name.endsWith('.ts') && !e.name.endsWith('.d.ts') && !e.name.endsWith('.test.ts'))
62
+ .map((e) => e.name.slice(0, -'.ts'.length))
63
+ .sort();
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ }
69
+ async function safeListDirs(dir) {
70
+ try {
71
+ const entries = await readdir(dir, { withFileTypes: true });
72
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
73
+ }
74
+ catch {
75
+ return [];
76
+ }
77
+ }
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** routes.ts 소스에서 리터럴 라우트 경로 집합을 뽑는다(단위 테스트 진입점). */
3
+ export declare function routePaths(routesSource: string): Set<string>;
4
+ /** apps/ 를 훑어 라우트/에셋에 가려지는 정적 파일을 경고로 낸다. */
5
+ export declare function checkStaticCollision(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,84 @@
1
+ // @gaonjs/cli · doctor · 정적 파일 ↔ 예약 경로 충돌 검사 (결정 85 · 경고)
2
+ //
3
+ // apps/<앱>/static/ 의 파일은 앱 prefix 아래로 서빙되지만, **명시 라우트와
4
+ // /assets/ 는 항상 우선**한다(결정 85 · appStatic 은 폴백). 따라서 라우트가
5
+ // 이미 쓰는 경로(또는 /assets/*)에 같은 이름의 정적 파일을 두면 그 파일은
6
+ // **영원히 도달 불가**(조용히 라우트에 가려짐)다. 이 검사가 그 상태를 경고한다.
7
+ // 판정은 소스 텍스트 기반(정적 · routes.ts 를 실행하지 않는다).
8
+ import { readdir, readFile } from 'node:fs/promises';
9
+ import { join } from 'node:path';
10
+ /** routes.ts 소스에서 리터럴 라우트 경로 집합을 뽑는다(단위 테스트 진입점). */
11
+ export function routePaths(routesSource) {
12
+ const paths = new Set();
13
+ // r.get('/path', ...) · r.post("/x/:id", ...) 등 — 첫 문자열 인자가 경로.
14
+ for (const m of routesSource.matchAll(/\br\.(?:get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]/g)) {
15
+ paths.add(m[1]);
16
+ }
17
+ // resource(s)('name') → REST 표준 경로의 콜리전 후보(/name).
18
+ for (const m of routesSource.matchAll(/\bresources?\s*\(\s*['"]([A-Za-z_]\w*)['"]/g)) {
19
+ paths.add(`/${m[1]}`);
20
+ }
21
+ return paths;
22
+ }
23
+ /** apps/ 를 훑어 라우트/에셋에 가려지는 정적 파일을 경고로 낸다. */
24
+ export async function checkStaticCollision(cwd) {
25
+ const appsDir = join(cwd, 'apps');
26
+ const issues = [];
27
+ for (const app of await safeListDirs(appsDir)) {
28
+ const staticDir = join(appsDir, app, 'static');
29
+ const files = await listFiles(staticDir, staticDir);
30
+ if (files.length === 0)
31
+ continue;
32
+ const routesSource = await readFile(join(appsDir, app, 'routes.ts'), 'utf8').catch(() => '');
33
+ const routes = routePaths(routesSource);
34
+ for (const rel of files) {
35
+ const urlPath = `/${rel}`;
36
+ const shadowedByRoute = routes.has(urlPath);
37
+ const shadowedByAssets = rel === 'assets' || rel.startsWith('assets/');
38
+ if (!shadowedByRoute && !shadowedByAssets)
39
+ continue;
40
+ const by = shadowedByAssets ? '프론트 에셋(/assets/*)' : `라우트 ${urlPath}`;
41
+ issues.push({
42
+ rule: 'static-collision',
43
+ level: 'warning',
44
+ file: `apps/${app}/static/${rel}`,
45
+ message: `정적 파일 가려짐: apps/${app}/static/${rel} 은 ${by} 에 가려져 서빙되지 않습니다 ` +
46
+ `(결정 85 · 라우트·에셋이 정적 폴더보다 우선).\n` +
47
+ `→ 정적 파일 이름을 바꾸거나, 그 경로를 라우트로 직접 응답하세요.`,
48
+ detail: { app, path: urlPath },
49
+ });
50
+ }
51
+ }
52
+ return { rule: 'static-collision', issues };
53
+ }
54
+ /** staticDir 하위 파일을 상대 경로(posix)로 나열. */
55
+ async function listFiles(dir, base) {
56
+ const out = [];
57
+ const walk = async (d) => {
58
+ let entries;
59
+ try {
60
+ entries = await readdir(d, { withFileTypes: true });
61
+ }
62
+ catch {
63
+ return;
64
+ }
65
+ for (const e of entries) {
66
+ const abs = join(d, e.name);
67
+ if (e.isDirectory())
68
+ await walk(abs);
69
+ else if (e.isFile())
70
+ out.push(abs.slice(base.length + 1).split('\\').join('/'));
71
+ }
72
+ };
73
+ await walk(dir);
74
+ return out;
75
+ }
76
+ async function safeListDirs(dir) {
77
+ try {
78
+ const entries = await readdir(dir, { withFileTypes: true });
79
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
80
+ }
81
+ catch {
82
+ return [];
83
+ }
84
+ }
@@ -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';
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';
2
2
  export type DoctorLevel = 'passed' | 'warning' | 'error';
3
3
  export interface DoctorCheck {
4
4
  readonly rule: DoctorRule;
@@ -1,9 +1,9 @@
1
1
  // @gaonjs/cli · doctor 공용 타입 (M9-E · M9-E-Fix · M9-E 확장 · E-5)
2
2
  //
3
- // 12 검사(response-mixing · n-plus-one · dependency-direction · connections
3
+ // 17 검사(response-mixing · n-plus-one · dependency-direction · connections
4
4
  // · migration-diff · shared-composable-purity · no-auto-import · schema-filename
5
- // · agents-doc-index · column-casing · model-filename · page-filename)가 모두
6
- // 이 DoctorCheck 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
5
+ // · agents-doc-index · column-casing · model-filename · page-filename · auth-wiring
6
+ // · ui-kit-wiring)가 모두 이 DoctorCheck 를 낸다. 상위(runDoctorCommand)는 level 로 passed/
7
7
  // warnings/errors 로 갈라 담는다. 자동화(CI)는 JSON 을 파싱해
8
8
  // errors.length > 0 이면 fail 로 판단한다.
9
9
  //
@@ -0,0 +1,5 @@
1
+ import type { RuleReport } from './types.js';
2
+ /** import 경로가 UI 킷 컴포넌트(components/ui/…)를 가리키는지(단위 테스트 진입점). */
3
+ export declare function importsUiKit(source: string): boolean;
4
+ /** apps/ 를 훑어 UI 킷 import ↔ Tailwind 배선 누락을 낸다. */
5
+ export declare function checkUiKitWiring(cwd: string): Promise<RuleReport>;
@@ -0,0 +1,93 @@
1
+ // @gaonjs/cli · doctor · UI 킷 배선 검사 (결정 76)
2
+ //
3
+ // 앱이 UI 킷 컴포넌트(apps/<앱>/components/ui/*)를 import 하는데 그 앱에
4
+ // Tailwind 배선(apps/<앱>/style.css 의 @tailwind + main.ts 의 style.css import)이
5
+ // 없으면, UI 킷이 쓰는 유틸 클래스(bg-primary·rounded-lg …)가 펼쳐지지 않아
6
+ // 컴포넌트가 스타일 없이 렌더된다 — 컴파일은 통과하므로 check 로는 안 잡히는
7
+ // 조용한 런타임 파손이다(결정 74·75·76). 이 검사가 그 상태를 경고로 낸다.
8
+ //
9
+ // `gaon new`(web)·`gaon g app` 은 이제 배선을 함께 심으므로 정상 경로에서는
10
+ // 걸리지 않는다. 이 규칙은 배선을 지웠거나 구버전 스캐폴드에서 만든 앱을 잡는
11
+ // 안전망이다. 판정은 소스 텍스트 기반(가벼운 정적 검사).
12
+ import { readdir, readFile } from 'node:fs/promises';
13
+ import { join, relative } from 'node:path';
14
+ import { hasAppWiring } from '../scaffold/app-wiring.js';
15
+ /** import 경로가 UI 킷 컴포넌트(components/ui/…)를 가리키는지(단위 테스트 진입점). */
16
+ export function importsUiKit(source) {
17
+ return /from\s+['"][^'"]*\bcomponents\/ui\/[^'"]+['"]/.test(source);
18
+ }
19
+ /** apps/ 를 훑어 UI 킷 import ↔ Tailwind 배선 누락을 낸다. */
20
+ export async function checkUiKitWiring(cwd) {
21
+ const appsDir = join(cwd, 'apps');
22
+ const issues = [];
23
+ for (const app of await safeListDirs(appsDir)) {
24
+ // UI 킷을 import 하는 첫 파일을 찾는다.
25
+ const importer = await findUiKitImporter(join(appsDir, app), cwd);
26
+ if (!importer)
27
+ continue;
28
+ if (hasAppWiring(cwd, app))
29
+ continue;
30
+ issues.push({
31
+ rule: 'ui-kit-wiring',
32
+ level: 'warning',
33
+ file: importer,
34
+ message: `UI 킷 배선 누락: apps/${app} 이 UI 킷 컴포넌트(${importer})를 import 하는데 ` +
35
+ `apps/${app}/style.css 의 @tailwind 배선(또는 main.ts 의 style.css import)이 없습니다. ` +
36
+ `이대로면 UI 킷의 Tailwind 유틸 클래스가 펼쳐지지 않아 컴포넌트가 스타일 없이 렌더됩니다(결정 76).\n` +
37
+ `→ apps/${app}/style.css 에 @tailwind 지시자를 추가하거나 \`gaon g ui-kit --app ${app}\` 을 실행하세요 ` +
38
+ `(후자는 배선까지 멱등으로 보정합니다).`,
39
+ detail: { app, importer },
40
+ });
41
+ }
42
+ return { rule: 'ui-kit-wiring', issues };
43
+ }
44
+ /** 앱 디렉터리를 재귀 스캔해 UI 킷을 import 하는 첫 .vue/.ts 파일(cwd 상대)을 찾는다. */
45
+ async function findUiKitImporter(appDir, cwd) {
46
+ for (const abs of await walkSources(appDir)) {
47
+ // components/ui 안의 컴포넌트 자체는 서로를 import 하므로 제외한다.
48
+ if (abs.includes(`${join(appDir, 'components', 'ui')}`))
49
+ continue;
50
+ const source = await readFile(abs, 'utf8').catch(() => '');
51
+ if (importsUiKit(source))
52
+ return relative(cwd, abs);
53
+ }
54
+ return undefined;
55
+ }
56
+ /** 디렉터리 트리에서 .vue·.ts(테스트·선언 제외) 파일 절대경로를 모은다. */
57
+ async function walkSources(dir) {
58
+ const out = [];
59
+ const walk = async (d) => {
60
+ let entries;
61
+ try {
62
+ entries = await readdir(d, { withFileTypes: true });
63
+ }
64
+ catch {
65
+ return;
66
+ }
67
+ for (const e of entries) {
68
+ const abs = join(d, e.name);
69
+ if (e.isDirectory()) {
70
+ if (e.name === 'node_modules' || e.name === '.gaon')
71
+ continue;
72
+ await walk(abs);
73
+ }
74
+ else if (e.isFile()) {
75
+ if (e.name.endsWith('.d.ts') || e.name.endsWith('.test.ts'))
76
+ continue;
77
+ if (e.name.endsWith('.vue') || e.name.endsWith('.ts'))
78
+ out.push(abs);
79
+ }
80
+ }
81
+ };
82
+ await walk(dir);
83
+ return out.sort();
84
+ }
85
+ async function safeListDirs(dir) {
86
+ try {
87
+ const entries = await readdir(dir, { withFileTypes: true });
88
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
89
+ }
90
+ catch {
91
+ return [];
92
+ }
93
+ }
package/dist/doctor.d.ts CHANGED
@@ -14,6 +14,7 @@ export { expectedColumnName, extractSnakeColumns, checkColumnCasing, } from './d
14
14
  export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
15
15
  export { checkPageFilename } from './doctor/page-filename.js';
16
16
  export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } from './doctor/auth-wiring.js';
17
+ export { importsUiKit, checkUiKitWiring } from './doctor/ui-kit-wiring.js';
17
18
  export { renderHuman, renderJson } from './doctor/reporter.js';
18
19
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
19
20
  export interface DoctorCommandOptions {
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
- * 13 검사를 조립한다:
4
+ * 17 검사를 조립한다:
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 규칙)
@@ -15,6 +15,10 @@
15
15
  * 11) model-filename (§3.4 · 결정 32·46 · 모델 파일명 PascalCase)
16
16
  * 12) page-filename (§3.4 · 결정 32·46 · Vue 페이지 파일명 PascalCase)
17
17
  * 13) auth-wiring (§7 · 결정 59 · requireAuth ↔ app.config.ts auth 배선)
18
+ * 14) ui-kit-wiring (§6.4 · 결정 76 · UI 킷 import ↔ apps/<앱>/style.css Tailwind 배선)
19
+ * 15) route-registration (결정 79 · 고아 컨트롤러 = routes.ts 미참조 경고)
20
+ * 16) static-collision (결정 85 · 정적 파일이 라우트/에셋에 가려짐 경고)
21
+ * 17) method-override (결정 89 · _method HTTP 스푸핑 hack 경고)
18
22
  *
19
23
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
20
24
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -40,6 +44,10 @@ import { checkColumnCasing } from './doctor/column-casing.js';
40
44
  import { checkModelFilename } from './doctor/model-filename.js';
41
45
  import { checkPageFilename } from './doctor/page-filename.js';
42
46
  import { checkAuthWiring } from './doctor/auth-wiring.js';
47
+ import { checkUiKitWiring } from './doctor/ui-kit-wiring.js';
48
+ import { checkRouteRegistration } from './doctor/route-registration.js';
49
+ import { checkStaticCollision } from './doctor/static-collision.js';
50
+ import { checkMethodOverride } from './doctor/method-override.js';
43
51
  import { renderHuman, renderJson } from './doctor/reporter.js';
44
52
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
45
53
  import { makeResult, } from './doctor/types.js';
@@ -57,10 +65,11 @@ export { expectedColumnName, extractSnakeColumns, checkColumnCasing, } from './d
57
65
  export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
58
66
  export { checkPageFilename } from './doctor/page-filename.js';
59
67
  export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } from './doctor/auth-wiring.js';
68
+ export { importsUiKit, checkUiKitWiring } from './doctor/ui-kit-wiring.js';
60
69
  export { renderHuman, renderJson } from './doctor/reporter.js';
61
70
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
62
71
  /**
63
- * 실행할 검사 이름. 지정 없음(undefined) = 13개 모두.
72
+ * 실행할 검사 이름. 지정 없음(undefined) = 17개 모두.
64
73
  */
65
74
  const ALL_RULES = [
66
75
  'response-mixing',
@@ -76,6 +85,10 @@ const ALL_RULES = [
76
85
  'model-filename',
77
86
  'page-filename',
78
87
  'auth-wiring',
88
+ 'ui-kit-wiring',
89
+ 'route-registration',
90
+ 'static-collision',
91
+ 'method-override',
79
92
  ];
80
93
  const CHECKERS = {
81
94
  'response-mixing': checkResponseMixing,
@@ -91,6 +104,10 @@ const CHECKERS = {
91
104
  'model-filename': checkModelFilename,
92
105
  'page-filename': checkPageFilename,
93
106
  'auth-wiring': checkAuthWiring,
107
+ 'ui-kit-wiring': checkUiKitWiring,
108
+ 'route-registration': checkRouteRegistration,
109
+ 'static-collision': checkStaticCollision,
110
+ 'method-override': checkMethodOverride,
94
111
  };
95
112
  /**
96
113
  * 규칙을 순서대로 실행해 RuleReport[] 를 낸다. 규칙 하나가 크래시해도 나머지는
package/dist/generate.js CHANGED
@@ -15,6 +15,7 @@
15
15
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
16
16
  import { dirname, join, resolve } from 'node:path';
17
17
  import { fileURLToPath } from 'node:url';
18
+ import { authUiKitFiles, writeUiKitFiles } from './uikit.js';
18
19
  // ── 템플릿 로드·치환 ───────────────────────────────────────────
19
20
  // 템플릿은 이 모듈과 같은 위치의 templates/auth/ 에 있다(빌드가 dist 로 복사).
20
21
  const TEMPLATE_DIR = join(dirname(fileURLToPath(import.meta.url)), 'templates', 'auth');
@@ -76,6 +77,13 @@ export function writeAuthScaffold(cwd, opts = {}) {
76
77
  const skipped = [];
77
78
  const patched = [];
78
79
  const warnings = [];
80
+ // 결정 75: auth 페이지(Login/Signup/Dashboard)는 UI 킷 컴포넌트를 쓴다.
81
+ // 필요한 최소 세트를 먼저 보장한다 — 이미 있으면(gaon g ui-kit 를 먼저 돌린
82
+ // 경우) skip, 없으면 생성. 이 보장이 없으면 스캐폴드 직후 페이지가 컴포넌트를
83
+ // 해상하지 못해 vue-tsc 가 깨진다(gaon new → g auth 단독 경로 · 결정 60 게이트).
84
+ const ui = writeUiKitFiles(root, authUiKitFiles(app));
85
+ created.push(...ui.created);
86
+ skipped.push(...ui.skipped);
79
87
  for (const file of authScaffoldFiles(opts)) {
80
88
  const abs = join(root, file.path);
81
89
  if (existsSync(abs)) {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { type DoctorRule } from "./doctor.js";
2
- export { startDev, resolveDevLayout, type DevDeps, type DevLayout, type DevApp, type DevEvent, type DevHandle, } from "./dev.js";
2
+ export { startDev, resolveDevLayout, regenerateGaonOnce, type DevDeps, type DevLayout, type DevApp, type DevEvent, type DevHandle, type RegenDeps, type RegenResult, } from "./dev.js";
3
3
  export { runDevCommand, type DevCommandOptions } from "./commands/dev.js";
4
4
  export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, type DevConsole, type DevConsoleOptions, type DevSource, type DevLevel, type ComposeStatus, type ComposeUpOptions, type EnsureInfraResult, type DockerLocateOptions, type TscWatcherOptions, type TscWatcherHandle, type RestartWatcherOptions, type RestartWatcherHandle, } from "./dev/index.js";
5
5
  export { runCheckCommand, type CheckCommandOptions, type CheckStep, type CheckStepStatus, type CheckStepResult, } from "./commands/check.js";
@@ -7,6 +7,7 @@ export { runNewCommand, type NewCommandOptions, type NewCommandResult } from "./
7
7
  export { runConsoleCommand, type ConsoleCommandOptions } from "./commands/console.js";
8
8
  export { runTestCommand, type TestCommandOptions, type TestScope } from "./commands/test.js";
9
9
  export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, type AuthScaffoldOptions, type ScaffoldFile, type ScaffoldResult, type GenerateAuthOptions, } from "./generate.js";
10
+ export { writeUiKitScaffold, writeUiKitFiles, uiKitScaffoldFiles, authUiKitFiles, runGenerateUiKitCommand, type UiKitScaffoldOptions, type UiKitScaffoldFile, type UiKitScaffoldResult, type GenerateUiKitOptions, } from "./uikit.js";
10
11
  export { runGenerateCommand, planScaffold, parseGenerateArgs, type GenerateType, type GenerateOptions, type GenerateResult, } from "./commands/g.js";
11
12
  export { runHubCommand, type HubCommandOptions } from "./hub.js";
12
13
  export { runServeCommand, type ServeCommandOptions } from "./serve.js";