@gaonjs/cli 0.15.0 → 0.18.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 (52) 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/types.d.ts +1 -1
  6. package/dist/doctor/types.js +3 -3
  7. package/dist/doctor/ui-kit-wiring.d.ts +5 -0
  8. package/dist/doctor/ui-kit-wiring.js +93 -0
  9. package/dist/doctor.d.ts +1 -0
  10. package/dist/doctor.js +7 -2
  11. package/dist/generate.js +8 -0
  12. package/dist/index.d.ts +2 -1
  13. package/dist/index.js +13 -4
  14. package/dist/scaffold/app-wiring.d.ts +12 -0
  15. package/dist/scaffold/app-wiring.js +68 -0
  16. package/dist/templates/auth/Dashboard.vue.tpl +21 -5
  17. package/dist/templates/auth/Login.vue.tpl +37 -8
  18. package/dist/templates/auth/Signup.vue.tpl +40 -9
  19. package/dist/templates/project/AGENTS.md.tpl +3 -2
  20. package/dist/templates/project/CLAUDE.md.tpl +3 -2
  21. package/dist/templates/project/agents/async.md.tpl +15 -7
  22. package/dist/templates/project/agents/frontend.md.tpl +62 -4
  23. package/dist/templates/project/apps/web/composables/useGaonHealth.ts.tpl +2 -0
  24. package/dist/templates/project/apps/web/layouts/Default.vue.tpl +19 -94
  25. package/dist/templates/project/apps/web/main.ts.tpl +4 -0
  26. package/dist/templates/project/apps/web/pages/Home/Index.vue.tpl +68 -251
  27. package/dist/templates/project/apps/web/style.css.tpl +66 -0
  28. package/dist/templates/project/package.json.tpl +3 -0
  29. package/dist/templates/project/postcss.config.js.tpl +15 -0
  30. package/dist/templates/project/tailwind.config.ts.tpl +56 -0
  31. package/dist/templates/ui-kit/Alert.vue.tpl +23 -0
  32. package/dist/templates/ui-kit/AlertDescription.vue.tpl +9 -0
  33. package/dist/templates/ui-kit/AlertTitle.vue.tpl +9 -0
  34. package/dist/templates/ui-kit/Badge.vue.tpl +25 -0
  35. package/dist/templates/ui-kit/Button.vue.tpl +39 -0
  36. package/dist/templates/ui-kit/Card.vue.tpl +10 -0
  37. package/dist/templates/ui-kit/CardContent.vue.tpl +9 -0
  38. package/dist/templates/ui-kit/CardDescription.vue.tpl +9 -0
  39. package/dist/templates/ui-kit/CardFooter.vue.tpl +9 -0
  40. package/dist/templates/ui-kit/CardHeader.vue.tpl +9 -0
  41. package/dist/templates/ui-kit/CardTitle.vue.tpl +9 -0
  42. package/dist/templates/ui-kit/Dialog.vue.tpl +68 -0
  43. package/dist/templates/ui-kit/Form.vue.tpl +13 -0
  44. package/dist/templates/ui-kit/FormField.vue.tpl +16 -0
  45. package/dist/templates/ui-kit/FormMessage.vue.tpl +9 -0
  46. package/dist/templates/ui-kit/Input.vue.tpl +22 -0
  47. package/dist/templates/ui-kit/Label.vue.tpl +9 -0
  48. package/dist/templates/ui-kit/Sheet.vue.tpl +73 -0
  49. package/dist/templates/ui-kit/utils.ts.tpl +26 -0
  50. package/dist/uikit.d.ts +28 -0
  51. package/dist/uikit.js +138 -0
  52. 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 {
@@ -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';
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
+ // 14 검사(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
+ * 14 검사를 조립한다:
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,7 @@
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 배선)
18
19
  *
19
20
  * 각 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
20
21
  * DoctorResult 로 낸다. --json 은 자동화(CI)를 위해 반드시 파싱 가능한
@@ -40,6 +41,7 @@ import { checkColumnCasing } from './doctor/column-casing.js';
40
41
  import { checkModelFilename } from './doctor/model-filename.js';
41
42
  import { checkPageFilename } from './doctor/page-filename.js';
42
43
  import { checkAuthWiring } from './doctor/auth-wiring.js';
44
+ import { checkUiKitWiring } from './doctor/ui-kit-wiring.js';
43
45
  import { renderHuman, renderJson } from './doctor/reporter.js';
44
46
  import { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
45
47
  import { makeResult, } from './doctor/types.js';
@@ -57,10 +59,11 @@ export { expectedColumnName, extractSnakeColumns, checkColumnCasing, } from './d
57
59
  export { isPascalCase, checkModelFilename } from './doctor/model-filename.js';
58
60
  export { checkPageFilename } from './doctor/page-filename.js';
59
61
  export { usesAuthSurface, hasAuthWiring, hasSessionWiring, checkAuthWiring, } from './doctor/auth-wiring.js';
62
+ export { importsUiKit, checkUiKitWiring } from './doctor/ui-kit-wiring.js';
60
63
  export { renderHuman, renderJson } from './doctor/reporter.js';
61
64
  export { checkTypeScriptApi, detectProject, fatalNoProject, fatalTsApiMissing, } from './doctor/setup.js';
62
65
  /**
63
- * 실행할 검사 이름. 지정 없음(undefined) = 13개 모두.
66
+ * 실행할 검사 이름. 지정 없음(undefined) = 14개 모두.
64
67
  */
65
68
  const ALL_RULES = [
66
69
  'response-mixing',
@@ -76,6 +79,7 @@ const ALL_RULES = [
76
79
  'model-filename',
77
80
  'page-filename',
78
81
  'auth-wiring',
82
+ 'ui-kit-wiring',
79
83
  ];
80
84
  const CHECKERS = {
81
85
  'response-mixing': checkResponseMixing,
@@ -91,6 +95,7 @@ const CHECKERS = {
91
95
  'model-filename': checkModelFilename,
92
96
  'page-filename': checkPageFilename,
93
97
  'auth-wiring': checkAuthWiring,
98
+ 'ui-kit-wiring': checkUiKitWiring,
94
99
  };
95
100
  /**
96
101
  * 규칙을 순서대로 실행해 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";
package/dist/index.js CHANGED
@@ -17,6 +17,7 @@ import { runNewCommand } from "./commands/new.js";
17
17
  import { runConsoleCommand } from "./commands/console.js";
18
18
  import { runTestCommand } from "./commands/test.js";
19
19
  import { runGenerateAuthCommand } from "./generate.js";
20
+ import { runGenerateUiKitCommand } from "./uikit.js";
20
21
  import { runGenerateCommand } from "./commands/g.js";
21
22
  import { runHubCommand } from "./hub.js";
22
23
  import { runServeCommand } from "./serve.js";
@@ -25,7 +26,7 @@ import { runJobsCommand } from "./jobs.js";
25
26
  import { runDbCommand } from "./commands/db.js";
26
27
  import { runDoctorCommand } from "./doctor.js";
27
28
  import { runMcpCommand } from "./commands/mcp.js";
28
- export { startDev, resolveDevLayout, } from "./dev.js";
29
+ export { startDev, resolveDevLayout, regenerateGaonOnce, } from "./dev.js";
29
30
  export { runDevCommand } from "./commands/dev.js";
30
31
  export { createDevConsole, findComposeFile, isDockerAvailable, inspectCompose, composeUp, composeDown, ensureInfra, startTscWatchers, killChild, startRestartWatcher, isRestartChange, resolveWatchRoots, } from "./dev/index.js";
31
32
  export { runCheckCommand, } from "./commands/check.js";
@@ -33,6 +34,7 @@ export { runNewCommand } from "./commands/new.js";
33
34
  export { runConsoleCommand } from "./commands/console.js";
34
35
  export { runTestCommand } from "./commands/test.js";
35
36
  export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, } from "./generate.js";
37
+ export { writeUiKitScaffold, writeUiKitFiles, uiKitScaffoldFiles, authUiKitFiles, runGenerateUiKitCommand, } from "./uikit.js";
36
38
  export { runGenerateCommand, planScaffold, parseGenerateArgs, } from "./commands/g.js";
37
39
  export { runHubCommand } from "./hub.js";
38
40
  export { runServeCommand } from "./serve.js";
@@ -101,7 +103,7 @@ function renderHelp(version = VERSION) {
101
103
  " gaon check typecheck · vue-tsc · build 통합 검사 (--only <step> · --include-doctor)",
102
104
  " gaon console 프로젝트 컨텍스트 REPL (--no-config)",
103
105
  " gaon test 테스트 러너 (--scope unit|integration|all · -- vitest 인자)",
104
- " gaon doctor 정적 검사 (13 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선)",
106
+ " gaon doctor 정적 검사 (14 검사 · 응답 혼용·N+1·의존·커넥션·마이그·컴포저블 순수·자동 import·파일명/컬럼 관례·인증 배선·UI 킷 배선)",
105
107
  " gaon doctor --json 자동화용 JSON 출력",
106
108
  " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
107
109
  " gaon doctor --fix 기계 정정 가능한 위반 계획(dry-run · v0.16 §7.5.3)",
@@ -228,7 +230,7 @@ export function runCli(argv, opts = {}) {
228
230
  });
229
231
  return;
230
232
  }
231
- // `gaon doctor` — 정적 검사(M9-E · 13 검사). --check=<이름>[,<이름>...] 로
233
+ // `gaon doctor` — 정적 검사(M9-E · 14 검사). --check=<이름>[,<이름>...] 로
232
234
  // 선택 실행, --json 은 자동화 파싱용.
233
235
  // exit code (M9-E-Fix): fatal → 2(사용자 오류) / errors > 0 → 1 / 그 외 → 0.
234
236
  if (argv[0] === "doctor") {
@@ -351,6 +353,13 @@ export function runCli(argv, opts = {}) {
351
353
  process.exitCode = code;
352
354
  return;
353
355
  }
356
+ if (argv[1] === "ui-kit" || argv[1] === "ui") {
357
+ const appIdx = argv.indexOf("--app");
358
+ const app = appIdx >= 0 ? argv[appIdx + 1] : undefined;
359
+ const code = runGenerateUiKitCommand({ app, json: argv.includes("--json") });
360
+ process.exitCode = code;
361
+ return;
362
+ }
354
363
  const known = ["controller", "model", "page", "job", "app"];
355
364
  const type = argv[1];
356
365
  if (type && known.includes(type)) {
@@ -395,7 +404,7 @@ export function runCli(argv, opts = {}) {
395
404
  return;
396
405
  }
397
406
  process.stderr.write(` ✗ 알 수 없는 제너레이터: ${argv[1] ?? "(없음)"}\n` +
398
- ` → 현재 지원: gaon g auth | controller | model | page | job | app\n` +
407
+ ` → 현재 지원: gaon g auth | ui-kit | controller | model | page | job | app\n` +
399
408
  ` → 옵션: --app <이름> · --overwrite · --json\n`);
400
409
  process.exitCode = 1;
401
410
  return;
@@ -0,0 +1,12 @@
1
+ import type { ScaffoldFile } from './controller.js';
2
+ /**
3
+ * 대상 앱의 프론트 배선 파일(main.ts·style.css·index.html)을 web 정본에서
4
+ * 파생해 반환한다. 실제 쓰기는 writeScaffold/writeUiKitFiles 가 담당(멱등).
5
+ */
6
+ export declare function appWiringFiles(app: string, projectName: string): ScaffoldFile[];
7
+ /** 배선 파일 3종의 대상 경로(존재 검사용). */
8
+ export declare function appWiringPaths(app: string): string[];
9
+ /** 대상 앱에 Tailwind 배선(style.css + main.ts 의 style.css import)이 있는지. */
10
+ export declare function hasAppWiring(cwd: string, app: string): boolean;
11
+ /** 프로젝트 package.json 의 name — 배선 title 치환용. 없으면 'app'. */
12
+ export declare function readProjectName(cwd: string): string;
@@ -0,0 +1,68 @@
1
+ // @gaonjs/cli · scaffold · app-wiring — 앱 프론트 배선 (결정 76)
2
+ //
3
+ // 새 앱(`gaon g app admin`)과 web 앱(`gaon new`)이 프론트 배선에서 동등해야
4
+ // 한다: Tailwind CSS 파이프라인(style.css)·Vite 진입(main.ts·index.html).
5
+ // 이 배선이 없으면 그 앱에서 `gaon g ui-kit --app admin` 으로 심은 UI 킷
6
+ // 컴포넌트가 Tailwind 유틸을 못 받아 스타일 없이 렌더된다(결정 74·75).
7
+ //
8
+ // 단일 소스: web 앱의 정본 템플릿(templates/project/apps/web/{main.ts,style.css,
9
+ // index.html}.tpl)을 그대로 읽어 `{{PROJECT_NAME}}` 만 치환하고 대상 앱으로
10
+ // 재타겟한다. style.css 는 앱 무관(토큰만)이라 web 과 바이트 동일하고, main.ts·
11
+ // index.html 은 프로젝트명 title 만 다르다 — 템플릿을 복제하지 않아 드리프트가
12
+ // 없다(결정 76).
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { join } from 'node:path';
15
+ import { renderTemplate, templateDir } from '../templates/index.js';
16
+ /** web 정본 템플릿을 대상 앱으로 재타겟하는 배선 파일 3종의 원본 경로. */
17
+ const WIRING = [
18
+ { tpl: 'apps/web/main.ts.tpl', out: 'main.ts' },
19
+ { tpl: 'apps/web/style.css.tpl', out: 'style.css' },
20
+ { tpl: 'apps/web/index.html.tpl', out: 'index.html' },
21
+ ];
22
+ /**
23
+ * 대상 앱의 프론트 배선 파일(main.ts·style.css·index.html)을 web 정본에서
24
+ * 파생해 반환한다. 실제 쓰기는 writeScaffold/writeUiKitFiles 가 담당(멱등).
25
+ */
26
+ export function appWiringFiles(app, projectName) {
27
+ const base = templateDir();
28
+ const tokens = { projectName, gaonjsVersion: readGaonjsVersion() };
29
+ return WIRING.map(({ tpl, out }) => {
30
+ const raw = readFileSync(join(base, tpl), 'utf8');
31
+ // 주석 안 `apps/web/…` 경로 표기를 대상 앱으로 맞춘다(import 는 상대라 무관).
32
+ const retargeted = renderTemplate(raw, tokens).replaceAll('apps/web/', `apps/${app}/`);
33
+ return { path: `apps/${app}/${out}`, contents: retargeted };
34
+ });
35
+ }
36
+ /** 배선 파일 3종의 대상 경로(존재 검사용). */
37
+ export function appWiringPaths(app) {
38
+ return WIRING.map(({ out }) => `apps/${app}/${out}`);
39
+ }
40
+ /** 대상 앱에 Tailwind 배선(style.css + main.ts 의 style.css import)이 있는지. */
41
+ export function hasAppWiring(cwd, app) {
42
+ const styleCss = join(cwd, 'apps', app, 'style.css');
43
+ const mainTs = join(cwd, 'apps', app, 'main.ts');
44
+ if (!existsSync(styleCss) || !existsSync(mainTs))
45
+ return false;
46
+ const css = readFileSync(styleCss, 'utf8');
47
+ const main = readFileSync(mainTs, 'utf8');
48
+ return /@tailwind\b/.test(css) && /['"]\.\/style\.css['"]/.test(main);
49
+ }
50
+ /** 프로젝트 package.json 의 name — 배선 title 치환용. 없으면 'app'. */
51
+ export function readProjectName(cwd) {
52
+ return readPackageField(cwd, (pkg) => (typeof pkg.name === 'string' ? pkg.name : undefined)) ?? 'app';
53
+ }
54
+ /** 설치된 gaonjs 버전(배선 템플릿엔 미사용이나 렌더 계약상 채운다). */
55
+ function readGaonjsVersion() {
56
+ return '';
57
+ }
58
+ function readPackageField(cwd, pick) {
59
+ const pj = join(cwd, 'package.json');
60
+ if (!existsSync(pj))
61
+ return undefined;
62
+ try {
63
+ return pick(JSON.parse(readFileSync(pj, 'utf8')));
64
+ }
65
+ catch {
66
+ return undefined;
67
+ }
68
+ }
@@ -1,5 +1,12 @@
1
1
  <script setup lang="ts">
2
2
  import { pageProps, router } from 'gaonjs/vue'
3
+ import Card from '../components/ui/Card.vue'
4
+ import CardHeader from '../components/ui/CardHeader.vue'
5
+ import CardTitle from '../components/ui/CardTitle.vue'
6
+ import CardDescription from '../components/ui/CardDescription.vue'
7
+ import CardContent from '../components/ui/CardContent.vue'
8
+ import CardFooter from '../components/ui/CardFooter.vue'
9
+ import Button from '../components/ui/Button.vue'
3
10
 
4
11
  // dashboard#show 의 render props — user 는 직렬화되며 passwordDigest 는 없다(§4.2).
5
12
  const { user, csrf } = pageProps<'{{APP_NAME}}:dashboard#show'>()
@@ -14,9 +21,18 @@ function logout(): void {
14
21
  </script>
15
22
 
16
23
  <template>
17
- <main>
18
- <h1>환영합니다, {{ user.name }}님</h1>
19
- <p>{{ user.email }}</p>
20
- <button type="button" @click="logout">로그아웃</button>
21
- </main>
24
+ <div class="mx-auto max-w-2xl px-4 py-10">
25
+ <Card>
26
+ <CardHeader>
27
+ <CardTitle>환영합니다, {{ user.name }}님</CardTitle>
28
+ <CardDescription>{{ user.email }}</CardDescription>
29
+ </CardHeader>
30
+ <CardContent>
31
+ <p class="text-sm text-muted-foreground">보호된 페이지입니다 — this.requireAuth() 로 지킵니다.</p>
32
+ </CardContent>
33
+ <CardFooter>
34
+ <Button variant="outline" @click="logout">로그아웃</Button>
35
+ </CardFooter>
36
+ </Card>
37
+ </div>
22
38
  </template>
@@ -1,5 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  import { pageProps, useForm } from 'gaonjs/vue'
3
+ import Card from '../../components/ui/Card.vue'
4
+ import CardHeader from '../../components/ui/CardHeader.vue'
5
+ import CardTitle from '../../components/ui/CardTitle.vue'
6
+ import CardDescription from '../../components/ui/CardDescription.vue'
7
+ import CardContent from '../../components/ui/CardContent.vue'
8
+ import Form from '../../components/ui/Form.vue'
9
+ import FormField from '../../components/ui/FormField.vue'
10
+ import Input from '../../components/ui/Input.vue'
11
+ import Button from '../../components/ui/Button.vue'
12
+ import Alert from '../../components/ui/Alert.vue'
13
+ import AlertDescription from '../../components/ui/AlertDescription.vue'
3
14
 
4
15
  // 컨트롤러 session#new 의 render props 타입이 그대로 흐른다(§6.2).
5
16
  const { error, csrf } = pageProps<'{{APP_NAME}}:session#new'>()
@@ -10,12 +21,30 @@ const form = useForm({ email: '', password: '', _csrf: csrf })
10
21
  </script>
11
22
 
12
23
  <template>
13
- <form @submit.prevent="form.post('/session')">
14
- <h1>로그인</h1>
15
- <p v-if="error" class="error">{{ error }}</p>
16
- <label>이메일 <input v-model="form.email" type="email" required /></label>
17
- <label>비밀번호 <input v-model="form.password" type="password" required /></label>
18
- <button type="submit" :disabled="form.processing">로그인</button>
19
- <a href="/registration/new">회원가입</a>
20
- </form>
24
+ <div class="mx-auto flex min-h-[70vh] max-w-sm items-center px-4">
25
+ <Card class="w-full">
26
+ <CardHeader>
27
+ <CardTitle>로그인</CardTitle>
28
+ <CardDescription>계정으로 로그인하세요.</CardDescription>
29
+ </CardHeader>
30
+ <CardContent>
31
+ <Alert v-if="error" variant="destructive" class="mb-4">
32
+ <AlertDescription>{{ error }}</AlertDescription>
33
+ </Alert>
34
+ <Form @submit="form.post('/session')">
35
+ <FormField label="이메일" :error="form.errors.email">
36
+ <Input v-model="form.email" type="email" required />
37
+ </FormField>
38
+ <FormField label="비밀번호" :error="form.errors.password">
39
+ <Input v-model="form.password" type="password" required />
40
+ </FormField>
41
+ <Button type="submit" class="w-full" :disabled="form.processing">로그인</Button>
42
+ </Form>
43
+ <p class="mt-4 text-center text-sm text-muted-foreground">
44
+ 계정이 없으신가요?
45
+ <a href="/registration/new" class="font-medium text-primary underline-offset-4 hover:underline">회원가입</a>
46
+ </p>
47
+ </CardContent>
48
+ </Card>
49
+ </div>
21
50
  </template>
@@ -1,5 +1,16 @@
1
1
  <script setup lang="ts">
2
2
  import { pageProps, useForm } from 'gaonjs/vue'
3
+ import Card from '../../components/ui/Card.vue'
4
+ import CardHeader from '../../components/ui/CardHeader.vue'
5
+ import CardTitle from '../../components/ui/CardTitle.vue'
6
+ import CardDescription from '../../components/ui/CardDescription.vue'
7
+ import CardContent from '../../components/ui/CardContent.vue'
8
+ import Form from '../../components/ui/Form.vue'
9
+ import FormField from '../../components/ui/FormField.vue'
10
+ import Input from '../../components/ui/Input.vue'
11
+ import Button from '../../components/ui/Button.vue'
12
+ import Alert from '../../components/ui/Alert.vue'
13
+ import AlertDescription from '../../components/ui/AlertDescription.vue'
3
14
 
4
15
  const { error, csrf } = pageProps<'{{APP_NAME}}:registration#new'>()
5
16
 
@@ -8,13 +19,33 @@ const form = useForm({ name: '', email: '', password: '', _csrf: csrf })
8
19
  </script>
9
20
 
10
21
  <template>
11
- <form @submit.prevent="form.post('/registration')">
12
- <h1>회원가입</h1>
13
- <p v-if="error" class="error">{{ error }}</p>
14
- <label>이름 <input v-model="form.name" required /></label>
15
- <label>이메일 <input v-model="form.email" type="email" required /></label>
16
- <label>비밀번호 <input v-model="form.password" type="password" required /></label>
17
- <button type="submit" :disabled="form.processing">회원가입</button>
18
- <a href="/session/new">로그인</a>
19
- </form>
22
+ <div class="mx-auto flex min-h-[70vh] max-w-sm items-center px-4">
23
+ <Card class="w-full">
24
+ <CardHeader>
25
+ <CardTitle>회원가입</CardTitle>
26
+ <CardDescription>새 계정을 만드세요.</CardDescription>
27
+ </CardHeader>
28
+ <CardContent>
29
+ <Alert v-if="error" variant="destructive" class="mb-4">
30
+ <AlertDescription>{{ error }}</AlertDescription>
31
+ </Alert>
32
+ <Form @submit="form.post('/registration')">
33
+ <FormField label="이름" :error="form.errors.name">
34
+ <Input v-model="form.name" required />
35
+ </FormField>
36
+ <FormField label="이메일" :error="form.errors.email">
37
+ <Input v-model="form.email" type="email" required />
38
+ </FormField>
39
+ <FormField label="비밀번호" :error="form.errors.password">
40
+ <Input v-model="form.password" type="password" required />
41
+ </FormField>
42
+ <Button type="submit" class="w-full" :disabled="form.processing">회원가입</Button>
43
+ </Form>
44
+ <p class="mt-4 text-center text-sm text-muted-foreground">
45
+ 이미 계정이 있으신가요?
46
+ <a href="/session/new" class="font-medium text-primary underline-offset-4 hover:underline">로그인</a>
47
+ </p>
48
+ </CardContent>
49
+ </Card>
50
+ </div>
20
51
  </template>
@@ -104,7 +104,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
104
104
  컬럼명 · 스키마 파일 ↔ 테이블 ↔ `tables.d.ts` 키 변환 규칙)은
105
105
  `agents/data.md` "DB 네이밍" 표가 정본이다 — 먼저 읽는다.
106
106
 
107
- ### 2.2 `gaon doctor` 검사 13
107
+ ### 2.2 `gaon doctor` 검사 14
108
108
 
109
109
  1. `response-mixing` — 한 액션 안 render/JSON/redirect 혼용 (E-3)
110
110
  2. `n-plus-one` — include 미사용 · loop 안 관계 호출 (E-4)
@@ -119,6 +119,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
119
119
  11. `model-filename` — 모델 파일명 PascalCase 관례 (결정 32·46 · `--fix` 지원)
120
120
  12. `page-filename` — Vue 페이지 파일명 PascalCase 관례 (결정 32·46)
121
121
  13. `auth-wiring` — requireAuth/this.auth 사용 ↔ `app.config.ts` 인증 배선 (결정 59)
122
+ 14. `ui-kit-wiring` — UI 킷 컴포넌트 import ↔ `apps/<앱>/style.css` Tailwind 배선 (결정 76 · 경고)
122
123
 
123
124
  ## 3. 로직 배치 One Way 판단표
124
125
 
@@ -159,7 +160,7 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
159
160
  ```bash
160
161
  gaon check # .gaon 재생성 → typecheck + vue-tsc + build (+doctor)
161
162
  gaon test # vitest — DB·NATS 는 실 인프라 (agents/testing.md)
162
- gaon doctor # 정적 검사 13종 (§2.2)
163
+ gaon doctor # 정적 검사 14종 (§2.2)
163
164
  ```
164
165
 
165
166
  ### 4.1 CLI 명령 (전 명령 `--json` 지원)