@gaonjs/cli 0.2.0 → 0.3.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 (37) hide show
  1. package/dist/commands/g.d.ts +26 -0
  2. package/dist/commands/g.js +124 -0
  3. package/dist/doctor/connections.d.ts +14 -0
  4. package/dist/doctor/connections.js +168 -0
  5. package/dist/doctor/dependency-direction.d.ts +11 -0
  6. package/dist/doctor/dependency-direction.js +186 -0
  7. package/dist/doctor/migration-diff.d.ts +11 -0
  8. package/dist/doctor/migration-diff.js +109 -0
  9. package/dist/doctor/n-plus-one.d.ts +5 -0
  10. package/dist/doctor/n-plus-one.js +242 -0
  11. package/dist/doctor/reporter.d.ts +5 -0
  12. package/dist/doctor/reporter.js +28 -0
  13. package/dist/doctor/response-mixing.d.ts +12 -0
  14. package/dist/doctor/response-mixing.js +158 -0
  15. package/dist/doctor/types.d.ts +24 -0
  16. package/dist/doctor/types.js +30 -0
  17. package/dist/doctor.d.ts +26 -23
  18. package/dist/doctor.js +76 -206
  19. package/dist/index.d.ts +9 -1
  20. package/dist/index.js +101 -8
  21. package/dist/scaffold/controller.d.ts +12 -0
  22. package/dist/scaffold/controller.js +50 -0
  23. package/dist/scaffold/index.d.ts +20 -0
  24. package/dist/scaffold/index.js +41 -0
  25. package/dist/scaffold/inflect.d.ts +19 -0
  26. package/dist/scaffold/inflect.js +50 -0
  27. package/dist/scaffold/job.d.ts +3 -0
  28. package/dist/scaffold/job.js +46 -0
  29. package/dist/scaffold/model.d.ts +8 -0
  30. package/dist/scaffold/model.js +66 -0
  31. package/dist/scaffold/page.d.ts +7 -0
  32. package/dist/scaffold/page.js +46 -0
  33. package/dist/serve.d.ts +18 -0
  34. package/dist/serve.js +79 -0
  35. package/dist/templates/auth/auth.wiring.ts.tpl +1 -1
  36. package/dist/templates/auth/session.controller.ts.tpl +1 -1
  37. package/package.json +5 -4
package/dist/doctor.js CHANGED
@@ -1,218 +1,88 @@
1
1
  /**
2
- * @gaonjs/cli · `gaon doctor` — 정적 검사 (M3 · errata E-3)
2
+ * @gaonjs/cli · `gaon doctor` — 정적 검사 (M9-E · CLI DX 완성)
3
3
  *
4
- * v1 검사 항목: **응답 혼용 금지**. 한 컨트롤러 액션은 render/JSON/
5
- * redirect 가지 응답 형태만 낼 수 있다(§C · The One Way). 조건 분기로
6
- * 섞으면 "이 라우트가 무엇을 돌려주는지"가 코드를 읽어야 알게 돼 AI 첫 시도
7
- * 성공률이 떨어진다.
4
+ * 5 검사를 조립한다:
5
+ * 1) response-mixing (errata E-3 §C · 라이브)
6
+ * 2) n-plus-one (errata E-4 (e))
7
+ * 3) dependency-direction (CLAUDE.md §5 · 4 규칙)
8
+ * 4) connections (v0.15 §4.5)
9
+ * 5) migration-diff (최소 감지 · 상세는 M9-D)
8
10
  *
9
- * 검사 방법(정적 분석):
10
- * · apps/<app>/controllers/*.tsTS AST 파싱.
11
- * · export default controller({ ... }) 액션 함수마다 return 노드에서
12
- * 최종 응답 형태를 분류(render | redirect | json | plain).
13
- * · 서로 다른 형태가 두 종류 이상이면 위반.
14
- * · this.render/this.redirect/this.json/평범한 객체 반환의 4종을 구별한다.
11
+ * 검사는 순수 함수(cwd → RuleReport). 상위 runDoctorCommand 가 조립해
12
+ * DoctorResult 로 낸다. --json 은 자동화(CI)위해 반드시 파싱 가능한
13
+ * 구조를 유지한다(CLAUDE.md §4 · 모든 명령 --json).
15
14
  *
16
- * 위반 시: 파일·라인·액션 이름 + 감지된 형태 목록 + "→ 한 액션은 한 응답
17
- * 형태만 유지하세요(errata E-3 §C)" 안내를 낸다.
18
- *
19
- * 나머지 doctor 검사(의존 방향 §1-5, 커넥션 §4.5 등)는 후속 마일스톤에서
20
- * 이 모듈에 규칙을 얹는다.
15
+ * 하위 호환: 기존 응답 혼용 API(`inspectControllerSource`·`runDoctor`)는
16
+ * 그대로 export 기존 테스트가 계속 동작한다.
21
17
  */
22
- import { readdir, readFile, stat } from 'node:fs/promises';
23
- import { join, relative, resolve } from 'node:path';
24
- import ts from 'typescript';
25
- /** 컨트롤러 파일 하나를 검사한다. */
26
- export function inspectControllerSource(file, source) {
27
- const sf = ts.createSourceFile(file, source, ts.ScriptTarget.ES2022, true);
28
- const usages = [];
29
- const visit = (node) => {
30
- if (ts.isCallExpression(node) && isControllerCall(node)) {
31
- const arg = node.arguments[0];
32
- if (arg && ts.isObjectLiteralExpression(arg)) {
33
- for (const prop of arg.properties) {
34
- const action = actionName(prop);
35
- if (!action)
36
- continue;
37
- const body = actionBody(prop);
38
- if (!body)
39
- continue;
40
- const kinds = classifyReturns(body);
41
- const { line } = sf.getLineAndCharacterOfPosition(prop.getStart(sf));
42
- usages.push({ file, action, line: line + 1, kinds });
43
- }
44
- }
45
- }
46
- ts.forEachChild(node, visit);
47
- };
48
- visit(sf);
49
- return usages;
50
- }
51
- function isControllerCall(node) {
52
- const e = node.expression;
53
- if (ts.isIdentifier(e) && e.text === 'controller')
54
- return true;
55
- if (ts.isPropertyAccessExpression(e) && e.name.text === 'controller')
56
- return true;
57
- return false;
58
- }
59
- function actionName(prop) {
60
- if (ts.isMethodDeclaration(prop) && ts.isIdentifier(prop.name))
61
- return prop.name.text;
62
- if (ts.isPropertyAssignment(prop) &&
63
- ts.isIdentifier(prop.name) &&
64
- (ts.isArrowFunction(prop.initializer) || ts.isFunctionExpression(prop.initializer))) {
65
- return prop.name.text;
66
- }
67
- return undefined;
68
- }
69
- function actionBody(prop) {
70
- if (ts.isMethodDeclaration(prop))
71
- return prop.body;
72
- if (ts.isPropertyAssignment(prop)) {
73
- if (ts.isArrowFunction(prop.initializer))
74
- return prop.initializer.body;
75
- if (ts.isFunctionExpression(prop.initializer))
76
- return prop.initializer.body;
77
- }
78
- return undefined;
79
- }
80
- /** 액션 본문의 모든 return 표현식을 응답 형태로 분류하고 중복을 제거해 반환한다. */
81
- function classifyReturns(body) {
82
- const kinds = new Set();
83
- const classify = (expr) => {
84
- // await x → x 로 되짚어 본다.
85
- if (ts.isAwaitExpression(expr))
86
- return classify(expr.expression);
87
- // this.render / this.redirect / this.json
88
- if (ts.isCallExpression(expr)) {
89
- const c = expr.expression;
90
- if (ts.isPropertyAccessExpression(c) && c.expression.kind === ts.SyntaxKind.ThisKeyword) {
91
- if (c.name.text === 'render')
92
- return 'render';
93
- if (c.name.text === 'redirect')
94
- return 'redirect';
95
- if (c.name.text === 'json')
96
- return 'json';
97
- }
98
- // 삼항·논리 연산 결과라도 호출식이 나오면 함수 반환값에 의존 — 보수적으로 plain.
99
- return 'plain';
100
- }
101
- // 삼항 연산자 — 양쪽 각각 분류
102
- if (ts.isConditionalExpression(expr)) {
103
- kinds.add(classify(expr.whenTrue));
104
- kinds.add(classify(expr.whenFalse));
105
- return classify(expr.whenTrue); // 반환값(대표) — Set 에 이미 다 넣음
106
- }
107
- // 그 외(객체·배열·literal·식별자 등) → plain(JSON 액션)
108
- return 'plain';
109
- };
110
- const visit = (node) => {
111
- if (ts.isReturnStatement(node)) {
112
- if (node.expression)
113
- kinds.add(classify(node.expression));
114
- else
115
- kinds.add('plain'); // 빈 return 은 undefined → 204 (plain 으로 취급, 혼용 검사에서만 의미)
116
- return; // return 안의 하위 함수는 다른 스코프 — 이 return 만 잡는다
117
- }
118
- // 중첩 함수/화살표는 다른 스코프 — 파고들지 않는다.
119
- if (ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node))
120
- return;
121
- ts.forEachChild(node, visit);
122
- };
123
- // 표현식 본문 화살표(=> render(...))는 body 가 표현식.
124
- if (ts.isBlock(body))
125
- visit(body);
126
- else
127
- kinds.add(classify(body));
128
- return [...kinds];
129
- }
18
+ import { resolve } from 'node:path';
19
+ import { checkResponseMixing } from './doctor/response-mixing.js';
20
+ import { checkNPlusOne } from './doctor/n-plus-one.js';
21
+ import { checkDependencyDirection } from './doctor/dependency-direction.js';
22
+ import { checkConnections } from './doctor/connections.js';
23
+ import { checkMigrationDiff } from './doctor/migration-diff.js';
24
+ import { renderHuman, renderJson } from './doctor/reporter.js';
25
+ import { makeResult, } from './doctor/types.js';
26
+ export { inspectControllerSource, checkResponseMixing } from './doctor/response-mixing.js';
27
+ export { inspectControllerForNPlusOne, checkNPlusOne } from './doctor/n-plus-one.js';
28
+ export { extractRelativeImports, checkDependencyDirection } from './doctor/dependency-direction.js';
29
+ export { extractConfigDbKeys, extractKeyUses, checkConnections } from './doctor/connections.js';
30
+ export { scanSchema, checkMigrationDiff } from './doctor/migration-diff.js';
31
+ export { renderHuman, renderJson } from './doctor/reporter.js';
32
+ /**
33
+ * 실행할 검사 이름. 지정 없음(undefined) = 5개 모두.
34
+ */
35
+ const ALL_RULES = [
36
+ 'response-mixing',
37
+ 'n-plus-one',
38
+ 'dependency-direction',
39
+ 'connections',
40
+ 'migration-diff',
41
+ ];
42
+ const CHECKERS = {
43
+ 'response-mixing': checkResponseMixing,
44
+ 'n-plus-one': checkNPlusOne,
45
+ 'dependency-direction': checkDependencyDirection,
46
+ connections: checkConnections,
47
+ 'migration-diff': checkMigrationDiff,
48
+ };
130
49
  /**
131
- * apps/<app>/controllers/ 훑어 응답 혼용 액션을 찾는다. 액션 하나의 kinds
132
- * 배열이 2종 이상이면 위반(단, 'plain' 하나만 있는 것과 render 만 있는 것은 통과).
50
+ * `gaon doctor` 진입점 (M9-E · 확장).
133
51
  *
134
- * 명시적 예외: 반환 없는 return(=undefined→204)만 있는 경우는 통과 — void 액션은
135
- * 흔히 혼용 없이 정상적으로 쓰인다.
52
+ * 반환: DoctorResult({passed, warnings, errors}).
53
+ * exit code 진입점(runCli)이 errors.length 로 결정한다.
136
54
  */
137
- export async function runDoctor(appsDir) {
138
- const issues = [];
139
- let checked = 0;
140
- const apps = await safeListDirs(appsDir);
141
- for (const app of apps) {
142
- const ctrlDir = join(appsDir, app, 'controllers');
143
- for (const file of await safeListFiles(ctrlDir)) {
144
- if (!file.endsWith('.ts') || file.endsWith('.d.ts') || file.endsWith('.test.ts'))
145
- continue;
146
- const full = join(ctrlDir, file);
147
- const source = await readFile(full, 'utf8');
148
- const usages = inspectControllerSource(full, source);
149
- checked += usages.length;
150
- for (const u of usages) {
151
- if (u.kinds.length < 2)
152
- continue;
153
- // 'plain' 이 유일한 다른 형태이면서 실제 return 문에 undefined 만 있는 경우는 걸러야
154
- // 하지만, 액션이 render|json 를 하다가 조건에 따라 undefined 반환하면 혼용이다. 유지.
155
- const rel = relative(process.cwd(), full);
156
- issues.push({
157
- file: rel,
158
- line: u.line,
159
- action: u.action,
160
- kinds: u.kinds,
161
- message: `[doctor] 응답 혼용 감지: ${rel}:${u.line} · 액션 '${u.action}' 이 서로 다른 응답 형태를 섞습니다 (${u.kinds.join(', ')}).\n` +
162
- `→ 한 액션은 한 응답 형태만 유지하세요 (render | JSON | redirect · errata E-3 §C).\n` +
163
- `→ 조건 분기가 필요하면 액션을 둘로 나누거나, JSON 반환에는 예외를 던져 상태 코드를 표현하세요.`,
164
- });
165
- }
166
- }
167
- }
168
- return { ok: issues.length === 0, checked, issues };
169
- }
170
- async function safeListDirs(dir) {
171
- try {
172
- const entries = await readdir(dir, { withFileTypes: true });
173
- return entries.filter((e) => e.isDirectory()).map((e) => e.name);
174
- }
175
- catch {
176
- return [];
177
- }
178
- }
179
- async function safeListFiles(dir) {
180
- try {
181
- const entries = await readdir(dir, { withFileTypes: true });
182
- return entries.filter((e) => e.isFile()).map((e) => e.name);
183
- }
184
- catch {
185
- return [];
186
- }
187
- }
188
- // stat 은 API 표면 유지용 — 파일 존재 확인이 필요할 때 씀.
189
- export async function fileExists(file) {
190
- try {
191
- await stat(file);
192
- return true;
193
- }
194
- catch {
195
- return false;
196
- }
197
- }
198
- /** `gaon doctor` 진입점. */
199
55
  export async function runDoctorCommand(opts = {}) {
200
56
  const cwd = opts.cwd ?? process.cwd();
201
- const appsDir = resolve(cwd, 'apps');
202
- const result = await runDoctor(appsDir);
203
- if (opts.json) {
204
- process.stdout.write(JSON.stringify(result) + '\n');
205
- }
206
- else {
207
- process.stdout.write(` 검사한 액션: ${result.checked}개\n`);
208
- if (result.ok) {
209
- process.stdout.write(` ✓ 응답 혼용 없음\n`);
210
- }
211
- else {
212
- process.stdout.write(` ✗ 응답 혼용 ${result.issues.length}건\n`);
213
- for (const i of result.issues)
214
- process.stdout.write(i.message + '\n');
215
- }
57
+ const root = resolve(cwd);
58
+ const rules = opts.checks && opts.checks.length ? opts.checks : ALL_RULES;
59
+ const reports = [];
60
+ for (const rule of rules) {
61
+ const fn = CHECKERS[rule];
62
+ reports.push(await fn(root));
216
63
  }
217
- return result.ok ? 0 : 1;
64
+ const result = makeResult(reports);
65
+ const out = opts.json ? renderJson(result) : renderHuman(result);
66
+ process.stdout.write(out + '\n');
67
+ return result;
68
+ }
69
+ /**
70
+ * 하위 호환: 응답 혼용만 검사해 legacy shape 을 낸다.
71
+ * appsDir 은 프로젝트의 apps/ 절대 경로. 상위(진입점)는 runDoctorCommand
72
+ * 를 쓴다.
73
+ */
74
+ export async function runDoctor(appsDir) {
75
+ // apps/ 의 부모를 cwd 로 간주(관례).
76
+ const cwd = resolve(appsDir, '..');
77
+ const report = await checkResponseMixing(cwd);
78
+ const issues = report.issues.map((i) => ({
79
+ file: i.file ?? '',
80
+ line: i.line ?? 0,
81
+ action: String(i.detail?.action ?? ''),
82
+ kinds: i.detail?.kinds ?? [],
83
+ message: i.message,
84
+ }));
85
+ // checked 는 컨트롤러 액션 수 — 응답 혼용 검사가 순회한 액션 수와 정합.
86
+ // 하위 호환 목적으로 issues 수로 대체(정확한 checked 는 신규 API 를 쓰라).
87
+ return { ok: issues.length === 0, checked: issues.length, issues };
218
88
  }
package/dist/index.d.ts CHANGED
@@ -1,11 +1,14 @@
1
+ import { type DoctorRule } from "./doctor.js";
1
2
  export { runDevCommand, startDev, resolveDevLayout, type DevDeps, type DevLayout, type DevApp, type DevEvent, type DevHandle, type DevCommandOptions, } from "./dev.js";
2
3
  export { runCheck, runCheckCommand, type CheckDeps, type CheckResult, type TypecheckResult, type CheckCommandOptions, } from "./check.js";
3
4
  export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, type AuthScaffoldOptions, type ScaffoldFile, type ScaffoldResult, type GenerateAuthOptions, } from "./generate.js";
5
+ export { runGenerateCommand, planScaffold, parseGenerateArgs, type GenerateType, type GenerateOptions, type GenerateResult, } from "./commands/g.js";
4
6
  export { runHubCommand, type HubCommandOptions } from "./hub.js";
7
+ export { runServeCommand, type ServeCommandOptions } from "./serve.js";
5
8
  export { runWorkCommand, type WorkCommandOptions } from "./work.js";
6
9
  export { runJobsCommand, type JobsCommandOptions } from "./jobs.js";
7
10
  export { runDbSeedCommand, loadSeed, type DbSeedOptions, type DbSeedResult } from "./db.js";
8
- export { runDoctorCommand, runDoctor, inspectControllerSource, type DoctorResult, type DoctorIssue, type ActionUsage, type ResponseKind, type DoctorCommandOptions, } from "./doctor.js";
11
+ export { runDoctorCommand, runDoctor, inspectControllerSource, checkResponseMixing, checkNPlusOne, checkDependencyDirection, checkConnections, checkMigrationDiff, renderHuman, renderJson, type DoctorResult, type DoctorCheck, type DoctorLevel, type DoctorRule, type RuleReport, type DoctorIssue, type LegacyDoctorResult, type ActionUsage, type ResponseKind, type DoctorCommandOptions, } from "./doctor.js";
9
12
  export { loadDomain, type LoadedDomain } from "./domain.js";
10
13
  export interface RoadmapReport {
11
14
  readonly name: "gaon";
@@ -27,5 +30,10 @@ export declare function renderRoadmap(version?: string): string;
27
30
  export interface RunOptions {
28
31
  readonly version?: string;
29
32
  }
33
+ /**
34
+ * `gaon doctor --check=<이름>[,<이름>...]` 파싱(M9-E).
35
+ * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
36
+ */
37
+ export declare function parseDoctorChecks(argv: readonly string[]): DoctorRule[] | undefined;
30
38
  /** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
31
39
  export declare function runCli(argv: readonly string[], opts?: RunOptions): void;
package/dist/index.js CHANGED
@@ -13,7 +13,9 @@ import { MILESTONES, VERSION, HOMEPAGE } from "@gaonjs/core";
13
13
  import { runDevCommand } from "./dev.js";
14
14
  import { runCheckCommand } from "./check.js";
15
15
  import { runGenerateAuthCommand } from "./generate.js";
16
+ import { runGenerateCommand } from "./commands/g.js";
16
17
  import { runHubCommand } from "./hub.js";
18
+ import { runServeCommand } from "./serve.js";
17
19
  import { runWorkCommand } from "./work.js";
18
20
  import { runJobsCommand } from "./jobs.js";
19
21
  import { runDbSeedCommand } from "./db.js";
@@ -21,11 +23,13 @@ import { runDoctorCommand } from "./doctor.js";
21
23
  export { runDevCommand, startDev, resolveDevLayout, } from "./dev.js";
22
24
  export { runCheck, runCheckCommand, } from "./check.js";
23
25
  export { writeAuthScaffold, authScaffoldFiles, patchRoutes, runGenerateAuthCommand, } from "./generate.js";
26
+ export { runGenerateCommand, planScaffold, parseGenerateArgs, } from "./commands/g.js";
24
27
  export { runHubCommand } from "./hub.js";
28
+ export { runServeCommand } from "./serve.js";
25
29
  export { runWorkCommand } from "./work.js";
26
30
  export { runJobsCommand } from "./jobs.js";
27
31
  export { runDbSeedCommand, loadSeed } from "./db.js";
28
- export { runDoctorCommand, runDoctor, inspectControllerSource, } from "./doctor.js";
32
+ export { runDoctorCommand, runDoctor, inspectControllerSource, checkResponseMixing, checkNPlusOne, checkDependencyDirection, checkConnections, checkMigrationDiff, renderHuman, renderJson, } from "./doctor.js";
29
33
  export { loadDomain } from "./domain.js";
30
34
  /** `--json` 출력용 구조화 리포트. */
31
35
  export function roadmapReport(version = VERSION) {
@@ -72,9 +76,18 @@ function renderHelp(version = VERSION) {
72
76
  " gaon 로드맵과 개발 상태를 출력",
73
77
  " gaon dev .gaon 타입 브리지를 감시·재생성 (스키마·라우트)",
74
78
  " gaon dev --json 재생성 이벤트를 JSON 으로 출력",
79
+ " gaon serve 웹 서버 부팅 (gaon.config.ts 자동 배선 · Fastify listen)",
80
+ " gaon serve --port <n> --host <h> 리슨 포트·호스트 (config 값을 덮음)",
75
81
  " gaon check .gaon 재생성 후 타입 검사 (CI·AI 정합)",
76
- " gaon doctor 정적 검사 (응답 혼용·의존 방향 등)",
82
+ " gaon doctor 정적 검사 (5 검사 · 응답 혼용·N+1·의존 방향·커넥션·마이그)",
83
+ " gaon doctor --json 자동화용 JSON 출력",
84
+ " gaon doctor --check=n-plus-one,connections 선택 검사만 실행",
77
85
  " gaon g auth 인증 스캐폴드 생성 (회원가입·로그인·세션·보호 라우트)",
86
+ " gaon g controller <name> 컨트롤러 스캐폴드 (Rails 관례 · 페이지+JSON 액션)",
87
+ " gaon g model <Name> 모델 스캐폴드 (스키마+모델 · E-4 컬럼 예시)",
88
+ " gaon g page <Path/Name> Vue 페이지 (Inertia SPA · pageProps 브리지)",
89
+ " gaon g job <Name> 비동기 잡 (domain/jobs · later/in/at)",
90
+ " gaon g <type> --overwrite 기존 파일 덮어쓰기 · --app <이름> · --json",
78
91
  " gaon hub 실시간 허브 프로세스 (프레즌스 권위·중계 · 리더 선출 HA)",
79
92
  " gaon work 워커 프로세스 (잡·리스너·스케줄·아웃박스 · graceful drain)",
80
93
  " gaon jobs list --failed DLQ(실패 잡) 목록",
@@ -87,6 +100,30 @@ function renderHelp(version = VERSION) {
87
100
  "",
88
101
  ].join("\n");
89
102
  }
103
+ /**
104
+ * `gaon doctor --check=<이름>[,<이름>...]` 파싱(M9-E).
105
+ * 지정 없음(undefined) = 5 검사 모두 실행. 알 수 없는 이름은 무시(안전).
106
+ */
107
+ export function parseDoctorChecks(argv) {
108
+ const known = [
109
+ "response-mixing",
110
+ "n-plus-one",
111
+ "dependency-direction",
112
+ "connections",
113
+ "migration-diff",
114
+ ];
115
+ const isKnown = (s) => known.includes(s);
116
+ const out = [];
117
+ for (const a of argv) {
118
+ if (a.startsWith("--check=")) {
119
+ for (const nm of a.slice("--check=".length).split(",")) {
120
+ if (isKnown(nm) && !out.includes(nm))
121
+ out.push(nm);
122
+ }
123
+ }
124
+ }
125
+ return out.length ? out : undefined;
126
+ }
90
127
  /** CLI 진입점. argv 는 실행 인자(process.argv.slice(2))를 받는다. */
91
128
  export function runCli(argv, opts = {}) {
92
129
  const version = opts.version ?? VERSION;
@@ -100,6 +137,19 @@ export function runCli(argv, opts = {}) {
100
137
  });
101
138
  return;
102
139
  }
140
+ // `gaon serve` — 웹 서버 부팅(§7 · M9-A). gaon.config.ts 자동 배선 후 listen.
141
+ if (argv[0] === "serve") {
142
+ const portIdx = argv.indexOf("--port");
143
+ const hostIdx = argv.indexOf("--host");
144
+ const port = portIdx >= 0 ? Number(argv[portIdx + 1]) : undefined;
145
+ const host = hostIdx >= 0 ? argv[hostIdx + 1] : undefined;
146
+ void runServeCommand({ json: argv.includes("--json"), port, host }).catch((err) => {
147
+ const msg = err instanceof Error ? err.message : String(err);
148
+ process.stderr.write(` ✗ gaon serve 실패: ${msg}\n`);
149
+ process.exitCode = 1;
150
+ });
151
+ return;
152
+ }
103
153
  // `gaon check` — .gaon 재생성 후 타입 검사(§13.4-5). 종료 코드로 결과 전달.
104
154
  if (argv[0] === "check") {
105
155
  void runCheckCommand({ json: argv.includes("--json") })
@@ -113,11 +163,13 @@ export function runCli(argv, opts = {}) {
113
163
  });
114
164
  return;
115
165
  }
116
- // `gaon doctor` — 정적 검사(응답 혼용 등 · errata E-3 §C). --fix 는 후속.
166
+ // `gaon doctor` — 정적 검사(M9-E · 5 검사). --check=<이름>[,<이름>...]
167
+ // 선택 실행, --json 은 자동화 파싱용. exit code: errors.length > 0 이면 1.
117
168
  if (argv[0] === "doctor") {
118
- void runDoctorCommand({ json: argv.includes("--json") })
119
- .then((code) => {
120
- process.exitCode = code;
169
+ const checks = parseDoctorChecks(argv);
170
+ void runDoctorCommand({ json: argv.includes("--json"), checks })
171
+ .then((result) => {
172
+ process.exitCode = result.errors.length > 0 ? 1 : 0;
121
173
  })
122
174
  .catch((err) => {
123
175
  const msg = err instanceof Error ? err.message : String(err);
@@ -174,7 +226,9 @@ export function runCli(argv, opts = {}) {
174
226
  });
175
227
  return;
176
228
  }
177
- // `gaon g auth`인증 스캐폴드 제너레이터(§7 M5). 다른 제너레이터는 후속.
229
+ // `gaon g <type> <name>` — 스캐폴드(§7 M5·M9-B).
230
+ // · auth 는 인증 스캐폴드(별도 진입점)
231
+ // · controller/model/page/job 은 M9-B 통합 라우터
178
232
  if (argv[0] === "g" || argv[0] === "generate") {
179
233
  if (argv[1] === "auth") {
180
234
  const appIdx = argv.indexOf("--app");
@@ -183,8 +237,47 @@ export function runCli(argv, opts = {}) {
183
237
  process.exitCode = code;
184
238
  return;
185
239
  }
240
+ const known = ["controller", "model", "page", "job"];
241
+ const type = argv[1];
242
+ if (type && known.includes(type)) {
243
+ const rest = argv.slice(2);
244
+ // name = 첫 위치 인자(--플래그 제외 · --app <v> 스킵)
245
+ let name;
246
+ let app;
247
+ let overwrite = false;
248
+ let json = false;
249
+ for (let i = 0; i < rest.length; i++) {
250
+ const a = rest[i];
251
+ if (a === "--json") {
252
+ json = true;
253
+ continue;
254
+ }
255
+ if (a === "--overwrite") {
256
+ overwrite = true;
257
+ continue;
258
+ }
259
+ if (a === "--app") {
260
+ app = rest[++i];
261
+ continue;
262
+ }
263
+ if (a?.startsWith("--"))
264
+ continue;
265
+ if (name === undefined)
266
+ name = a;
267
+ }
268
+ if (!name) {
269
+ process.stderr.write(` ✗ gaon g ${type}: 이름이 없습니다.\n` +
270
+ ` → 예: gaon g ${type} ${type === "page" ? "Posts/Index" : "Post"}\n`);
271
+ process.exitCode = 1;
272
+ return;
273
+ }
274
+ const code = runGenerateCommand(type, name, { app, overwrite, json });
275
+ process.exitCode = code;
276
+ return;
277
+ }
186
278
  process.stderr.write(` ✗ 알 수 없는 제너레이터: ${argv[1] ?? "(없음)"}\n` +
187
- ` → 현재 지원: gaon g auth [--app <이름>]\n`);
279
+ ` → 현재 지원: gaon g auth | controller | model | page | job\n` +
280
+ ` → 옵션: --app <이름> · --overwrite · --json\n`);
188
281
  process.exitCode = 1;
189
282
  return;
190
283
  }
@@ -0,0 +1,12 @@
1
+ import type { ModelNames } from './inflect.js';
2
+ /** 스캐폴드가 만들 파일 하나 — path 는 프로젝트 루트 기준 상대 경로. */
3
+ export interface ScaffoldFile {
4
+ readonly path: string;
5
+ readonly contents: string;
6
+ }
7
+ /**
8
+ * 컨트롤러 스캐폴드 파일을 만든다.
9
+ * @param names 이름 변형(카멜/파스칼/복수) — inflect() 결과.
10
+ * @param app 대상 앱 폴더(apps/<app>/).
11
+ */
12
+ export declare function controllerScaffold(names: ModelNames, app: string): ScaffoldFile;
@@ -0,0 +1,50 @@
1
+ // @gaonjs/cli · scaffold · controller (M9-B)
2
+ //
3
+ // `gaon g controller <name>` — Rails 관례의 컨트롤러 스캐폴드.
4
+ // 페이지 액션(this.render)과 JSON 액션(반환값=응답 · errata E-3) 두 예시를
5
+ // 함께 담는다 — 실 프로젝트에서 가장 자주 쓰는 두 패턴이라 첫 코드에서
6
+ // 노출하는 편이 AI/사람 첫 시도 성공률에 유리하다.
7
+ //
8
+ // 응답 혼용 주의: 한 컨트롤러 안에서 페이지 액션과 JSON 액션을 섞는 것은
9
+ // doctor 가 경고한다(errata E-3 §C). 스캐폴드는 이유를 주석으로 남기고,
10
+ // 규모가 커지면 분리하도록 안내한다.
11
+ //
12
+ // 모델 참조: apps/<app>/controllers/*.ts → domain/models/*.ts. 프로젝트 관습
13
+ // (CLAUDE.md §2): 모델·잡은 domain/ 아래 두어 앱 간 재사용을 허용한다
14
+ // (apps→apps 금지 · rule 5).
15
+ /**
16
+ * 컨트롤러 스캐폴드 파일을 만든다.
17
+ * @param names 이름 변형(카멜/파스칼/복수) — inflect() 결과.
18
+ * @param app 대상 앱 폴더(apps/<app>/).
19
+ */
20
+ export function controllerScaffold(names, app) {
21
+ const { pascal, camel, plural } = names;
22
+ const lines = [
23
+ `// ${plural} 컨트롤러 — gaon g controller (M9-B).`,
24
+ `// 페이지 액션(this.render)과 JSON 액션(반환값=응답 · errata E-3) 예시를 담는다.`,
25
+ `// 응답 종류를 한 컨트롤러에서 섞으면 doctor 가 경고한다(§C) — 규모가 커지면 분리.`,
26
+ `import { controller } from 'gaonjs/web'`,
27
+ `import { ${pascal} } from '../../../domain/models/${camel}.js'`,
28
+ ``,
29
+ `export default controller({`,
30
+ ` // GET /${plural} — 목록 페이지 (Inertia render).`,
31
+ ` // E-4 체이닝 예시: orderBy · limit · all.`,
32
+ ` async index() {`,
33
+ ` const items = await ${pascal}.orderBy('createdAt', 'desc').limit(20).all()`,
34
+ ` return this.render('${pascal}/Index', { items })`,
35
+ ` },`,
36
+ ``,
37
+ ` // GET /${plural}/count.json — JSON 액션 (errata E-3).`,
38
+ ` // 반환값 = 응답. api('${plural}#count') 클라이언트가 Serialized<> 로 받는다.`,
39
+ ` // this.params() 안전 규칙: 라우트 > body > query (errata E-3 §5.1).`,
40
+ ` async count() {`,
41
+ ` return { total: await ${pascal}.count() }`,
42
+ ` },`,
43
+ `})`,
44
+ ``,
45
+ ];
46
+ return {
47
+ path: `apps/${app}/controllers/${plural}.ts`,
48
+ contents: lines.join('\n'),
49
+ };
50
+ }
@@ -0,0 +1,20 @@
1
+ export type { ScaffoldFile } from './controller.js';
2
+ export { controllerScaffold } from './controller.js';
3
+ export { modelScaffold, modelScaffoldFiles, schemaScaffold } from './model.js';
4
+ export { pageScaffold } from './page.js';
5
+ export { jobScaffold } from './job.js';
6
+ export { inflectModel, toCamel, toPascal, singularize, pluralize, type ModelNames, } from './inflect.js';
7
+ import type { ScaffoldFile } from './controller.js';
8
+ export interface WriteResult {
9
+ readonly created: string[];
10
+ readonly overwritten: string[];
11
+ /** overwrite=false 로 스킵된 기존 파일. */
12
+ readonly skipped: string[];
13
+ }
14
+ /**
15
+ * 파일 계획을 실제로 쓴다. 기본은 기존 파일 skip(멱등 · 사고 방지).
16
+ * overwrite=true 이면 덮어쓴다(기존 파일 목록을 overwritten 에 반환).
17
+ */
18
+ export declare function writeScaffold(cwd: string, files: readonly ScaffoldFile[], opts?: {
19
+ overwrite?: boolean;
20
+ }): WriteResult;
@@ -0,0 +1,41 @@
1
+ // @gaonjs/cli · scaffold · public export (M9-B)
2
+ //
3
+ // `gaon g <type> <name>` 스캐폴드 진입점 모음. runCli 가 여기서 팩토리를
4
+ // 골라 파일을 쓴다. 각 팩토리는 순수 함수 — 디스크 접근 없이 파일 계획을
5
+ // 반환한다. 실제 쓰기는 writeScaffold 가 담당(멱등·overwrite 옵션 처리).
6
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join, resolve } from 'node:path';
8
+ export { controllerScaffold } from './controller.js';
9
+ export { modelScaffold, modelScaffoldFiles, schemaScaffold } from './model.js';
10
+ export { pageScaffold } from './page.js';
11
+ export { jobScaffold } from './job.js';
12
+ export { inflectModel, toCamel, toPascal, singularize, pluralize, } from './inflect.js';
13
+ /**
14
+ * 파일 계획을 실제로 쓴다. 기본은 기존 파일 skip(멱등 · 사고 방지).
15
+ * overwrite=true 이면 덮어쓴다(기존 파일 목록을 overwritten 에 반환).
16
+ */
17
+ export function writeScaffold(cwd, files, opts = {}) {
18
+ const root = resolve(cwd);
19
+ const created = [];
20
+ const overwritten = [];
21
+ const skipped = [];
22
+ for (const file of files) {
23
+ const abs = join(root, file.path);
24
+ const exists = existsSync(abs);
25
+ if (exists && !opts.overwrite) {
26
+ skipped.push(file.path);
27
+ continue;
28
+ }
29
+ mkdirSync(dirname(abs), { recursive: true });
30
+ writeFileSync(abs, file.contents, 'utf8');
31
+ if (exists)
32
+ overwritten.push(file.path);
33
+ else
34
+ created.push(file.path);
35
+ }
36
+ return {
37
+ created: created.sort(),
38
+ overwritten: overwritten.sort(),
39
+ skipped: skipped.sort(),
40
+ };
41
+ }
@@ -0,0 +1,19 @@
1
+ /** 파스칼케이스 → 카멜케이스 (`Post` → `post`, `SendEmail` → `sendEmail`). */
2
+ export declare function toCamel(s: string): string;
3
+ /** 카멜/스네이크/케밥 → 파스칼케이스 (`post` → `Post`, `send_email` → `SendEmail`). */
4
+ export declare function toPascal(s: string): string;
5
+ /** 단순 단수화 — 끝의 s 하나만 벗긴다 ('users' → 'user' · 'ss' 는 유지). */
6
+ export declare function singularize(s: string): string;
7
+ /** 단순 복수화 — 끝에 s 를 붙인다 ('user' → 'users' · 이미 s 로 끝나면 유지). */
8
+ export declare function pluralize(s: string): string;
9
+ /** 모델 이름 · 표준 변형 묶음. 스캐폴드가 파일명·클래스명·테이블명에 쓴다. */
10
+ export interface ModelNames {
11
+ /** 파스칼 단수 — 클래스/const 명 (`Post`). */
12
+ readonly pascal: string;
13
+ /** 카멜 단수 — 파일 stem·변수명 (`post`). */
14
+ readonly camel: string;
15
+ /** 카멜 복수 — 테이블명·경로 (`posts`). */
16
+ readonly plural: string;
17
+ }
18
+ /** 사용자 입력(어느 형태든)에서 세 가지 변형을 파생한다. */
19
+ export declare function inflectModel(input: string): ModelNames;