@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
@@ -0,0 +1,50 @@
1
+ // @gaonjs/cli · scaffold · 이름 변환 헬퍼 (M9-B)
2
+ //
3
+ // Rails 관례: 사용자가 아무 형태(Post·post·Posts·posts)로 넣어도 스캐폴드가
4
+ // 필요한 변형(파일명·클래스명·테이블명)을 일관되게 뽑는다. 완벽한 영어
5
+ // 복수형 규칙(children/geese …)은 v1 범위 밖 — 기본 규칙(s 추가/삭제)만
6
+ // 다룬다. 예외가 필요하면 사용자가 --table/--singular 로 덮어쓸 수 있다
7
+ // (v1.1 계획 · 현재는 기본 규칙만).
8
+ /** 파스칼케이스 → 카멜케이스 (`Post` → `post`, `SendEmail` → `sendEmail`). */
9
+ export function toCamel(s) {
10
+ if (!s)
11
+ return s;
12
+ return s.charAt(0).toLowerCase() + s.slice(1);
13
+ }
14
+ /** 카멜/스네이크/케밥 → 파스칼케이스 (`post` → `Post`, `send_email` → `SendEmail`). */
15
+ export function toPascal(s) {
16
+ if (!s)
17
+ return s;
18
+ return s
19
+ .split(/[_\-\s]+/)
20
+ .map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : ''))
21
+ .join('');
22
+ }
23
+ /** 단순 단수화 — 끝의 s 하나만 벗긴다 ('users' → 'user' · 'ss' 는 유지). */
24
+ export function singularize(s) {
25
+ if (s.endsWith('ies') && s.length > 3)
26
+ return s.slice(0, -3) + 'y';
27
+ if (s.endsWith('ss'))
28
+ return s;
29
+ if (s.endsWith('s') && s.length > 1)
30
+ return s.slice(0, -1);
31
+ return s;
32
+ }
33
+ /** 단순 복수화 — 끝에 s 를 붙인다 ('user' → 'users' · 이미 s 로 끝나면 유지). */
34
+ export function pluralize(s) {
35
+ if (s.endsWith('s'))
36
+ return s;
37
+ if (s.endsWith('y') && s.length > 1 && !'aeiou'.includes(s[s.length - 2])) {
38
+ return s.slice(0, -1) + 'ies';
39
+ }
40
+ return s + 's';
41
+ }
42
+ /** 사용자 입력(어느 형태든)에서 세 가지 변형을 파생한다. */
43
+ export function inflectModel(input) {
44
+ const trimmed = input.trim();
45
+ const singular = singularize(toCamel(toPascal(trimmed)));
46
+ const pascal = toPascal(singular);
47
+ const camel = toCamel(pascal);
48
+ const plural = pluralize(camel);
49
+ return { pascal, camel, plural };
50
+ }
@@ -0,0 +1,3 @@
1
+ import type { ScaffoldFile } from './controller.js';
2
+ /** 잡 이름(파스칼) → 파일·잡명 파생. */
3
+ export declare function jobScaffold(pascalName: string): ScaffoldFile;
@@ -0,0 +1,46 @@
1
+ // @gaonjs/cli · scaffold · job (M9-B)
2
+ //
3
+ // `gaon g job <Name>` — 비동기 잡 스캐폴드(§7 M7). domain/jobs/<name>.ts 에
4
+ // 두면 워커가 자동 로드·등록한다(파일 로더가 assignName 으로 이름 채움).
5
+ //
6
+ // 파일 위치 (CLAUDE.md §2 · rule 5): 잡은 domain/jobs/ 아래 둔다 —
7
+ // 여러 앱이 같은 잡을 예약/실행할 수 있어야 하고, 앱→앱 import 는 금지다.
8
+ //
9
+ // 사용 예:
10
+ // import send${Pascal} from '../../domain/jobs/${camel}.js'
11
+ // await send${Pascal}.later({ id: 'abc' }) // 즉시 큐 적재
12
+ // await send${Pascal}.in('5m', { id: 'abc' }) // 5분 지연 후 실행
13
+ // await send${Pascal}.at(new Date(...), args) // 특정 시각 실행
14
+ /** 잡 이름(파스칼) → 파일·잡명 파생. */
15
+ export function jobScaffold(pascalName) {
16
+ const trimmed = pascalName.trim();
17
+ if (!trimmed) {
18
+ throw new Error(`[gaon g job] 잡 이름이 비어 있습니다. 예: gaon g job SendEmail`);
19
+ }
20
+ // 파스칼 정규화(사용자가 send_email/sendEmail 로 넣어도 동작)
21
+ const pascal = trimmed
22
+ .split(/[_\-\s]+/)
23
+ .map((w) => (w ? w.charAt(0).toUpperCase() + w.slice(1) : ''))
24
+ .join('');
25
+ const camel = pascal.charAt(0).toLowerCase() + pascal.slice(1);
26
+ const lines = [
27
+ `// ${pascal} 잡 — gaon g job (M9-B).`,
28
+ `// 반환값을 export 하면 워커가 자동 등록한다(§7). 이름은 { name } 옵션이 우선,`,
29
+ `// 없으면 파일명에서 채워진다(파일 로더 assignName).`,
30
+ `//`,
31
+ `// 실행:`,
32
+ `// import ${camel} from '../../domain/jobs/${camel}.js'`,
33
+ `// await ${camel}.later({ id: 'abc' }) // 즉시 큐 적재`,
34
+ `// await ${camel}.in('5m', { id: 'abc' }) // 5분 지연 후 실행`,
35
+ `// await ${camel}.at(new Date(...), payload) // 특정 시각 실행`,
36
+ `import { job } from 'gaonjs/async'`,
37
+ ``,
38
+ `export default job(async (payload: { id: string }) => {`,
39
+ ` // 실 로직을 여기에.`,
40
+ ` // 예: const record = await SomeModel.where('id', '=', payload.id).first()`,
41
+ ` console.log('[${pascal}] 실행:', payload.id)`,
42
+ `}, { name: '${camel}' })`,
43
+ ``,
44
+ ];
45
+ return { path: `domain/jobs/${camel}.ts`, contents: lines.join('\n') };
46
+ }
@@ -0,0 +1,8 @@
1
+ import type { ScaffoldFile } from './controller.js';
2
+ import type { ModelNames } from './inflect.js';
3
+ /** 스키마 스캐폴드 — E-4 컬럼 타입 예시를 함께 담는다. */
4
+ export declare function schemaScaffold(names: ModelNames): ScaffoldFile;
5
+ /** 모델 스캐폴드 — scopes 예시를 담는다(체이닝 진입점 도우미). */
6
+ export declare function modelScaffold(names: ModelNames): ScaffoldFile;
7
+ /** 모델 스캐폴드 = 스키마 + 모델 두 파일. */
8
+ export declare function modelScaffoldFiles(names: ModelNames): ScaffoldFile[];
@@ -0,0 +1,66 @@
1
+ // @gaonjs/cli · scaffold · model (M9-B)
2
+ //
3
+ // `gaon g model <Name>` — 스키마 + 모델 두 파일을 함께 만든다.
4
+ // 스키마는 errata E-4 신규 컬럼 타입(decimal · enum · uuid)과 수식어(unique ·
5
+ // nullable · default) 예시를 담아 새 개발자가 바로 참고할 수 있게 한다.
6
+ //
7
+ // 파일 위치 (CLAUDE.md §2 · rule 5):
8
+ // · 스키마 → domain/schema/<name>.ts (테이블 정의 · tables.d.ts 원천)
9
+ // · 모델 → domain/models/<name>.ts (Active Record · scopes/methods)
10
+ // 앱→도메인 import 만 허용되므로 이 위치가 유일한 정답이다.
11
+ /** 스키마 스캐폴드 — E-4 컬럼 타입 예시를 함께 담는다. */
12
+ export function schemaScaffold(names) {
13
+ const { pascal, camel, plural } = names;
14
+ const lines = [
15
+ `// ${pascal} 스키마 — gaon g model (M9-B).`,
16
+ `// errata E-4 예시: unique · default · enum · nullable. 컬럼은 자유롭게 추가/삭제한다.`,
17
+ `// .gaon/tables.d.ts 가 자동 재생성돼 모델·컨트롤러·페이지 전 체인이 즉시 반영된다(§6.3).`,
18
+ `import { table, t, type RowOf } from 'gaonjs/data'`,
19
+ ``,
20
+ `export const ${plural} = table('${plural}', {`,
21
+ ` id: t.id(),`,
22
+ ` title: t.string().max(200).unique(),`,
23
+ ` status: t.enum(['draft', 'published']).default('draft'),`,
24
+ ` ...t.timestamps(),`,
25
+ `})`,
26
+ ``,
27
+ `// tables.d.ts 자동 재생성 전(dev 서버 미기동)이라도 이 augmentation 이 있으면`,
28
+ `// 모델·컨트롤러에서 즉시 참조 가능하다. 재생성 후에는 이 블록이 중복돼도 무해하다.`,
29
+ `declare module 'gaonjs/data' {`,
30
+ ` interface GaonTables {`,
31
+ ` ${plural}: RowOf<typeof ${plural}>`,
32
+ ` }`,
33
+ `}`,
34
+ ``,
35
+ ];
36
+ return {
37
+ path: `domain/schema/${camel}.ts`,
38
+ contents: lines.join('\n'),
39
+ };
40
+ }
41
+ /** 모델 스캐폴드 — scopes 예시를 담는다(체이닝 진입점 도우미). */
42
+ export function modelScaffold(names) {
43
+ const { pascal, camel, plural } = names;
44
+ const lines = [
45
+ `// ${pascal} 모델 — gaon g model (M9-B).`,
46
+ `// scopes 는 체인 어느 지점에서든 호출 가능하다(§4.4). E-4 체이닝 예시:`,
47
+ `// ${pascal}.published().orderBy('createdAt', 'desc').limit(20).all()`,
48
+ `import { model } from 'gaonjs/data'`,
49
+ `import { ${plural} } from '../schema/${camel}.js'`,
50
+ ``,
51
+ `export const ${pascal} = model(${plural}, {`,
52
+ ` scopes: {`,
53
+ ` published: (q) => q.where('status', '=', 'published'),`,
54
+ ` },`,
55
+ `})`,
56
+ ``,
57
+ ];
58
+ return {
59
+ path: `domain/models/${camel}.ts`,
60
+ contents: lines.join('\n'),
61
+ };
62
+ }
63
+ /** 모델 스캐폴드 = 스키마 + 모델 두 파일. */
64
+ export function modelScaffoldFiles(names) {
65
+ return [schemaScaffold(names), modelScaffold(names)];
66
+ }
@@ -0,0 +1,7 @@
1
+ import type { ScaffoldFile } from './controller.js';
2
+ /**
3
+ * 페이지 경로에서 스캐폴드를 만든다.
4
+ * @param pagePath 예: 'Posts/Index' · 'Dashboard' · 'admin/Users/Show'.
5
+ * @param app 대상 앱.
6
+ */
7
+ export declare function pageScaffold(pagePath: string, app: string): ScaffoldFile;
@@ -0,0 +1,46 @@
1
+ // @gaonjs/cli · scaffold · page (M9-B)
2
+ //
3
+ // `gaon g page <Path>/<Name>` — Vue 페이지(Inertia SPA · §6).
4
+ // pageProps<K>() 로 컨트롤러 render 결과 타입을 그대로 이어받는다 —
5
+ // 스키마→모델→컨트롤러→(생성된 라우트 지도)→페이지 체인의 마지막 고리(§6.2).
6
+ //
7
+ // 입력 예: `gaon g page Posts/Index` → apps/<app>/pages/Posts/Index.vue.
8
+ // 라우트 키는 소문자·리소스 관례로 `posts#index` 를 기본값으로 넣는다 —
9
+ // 사용자가 라우트를 다르게 걸었으면 수동 수정하면 된다(주석에 안내).
10
+ import { toCamel } from './inflect.js';
11
+ /**
12
+ * 페이지 경로에서 스캐폴드를 만든다.
13
+ * @param pagePath 예: 'Posts/Index' · 'Dashboard' · 'admin/Users/Show'.
14
+ * @param app 대상 앱.
15
+ */
16
+ export function pageScaffold(pagePath, app) {
17
+ const trimmed = pagePath.trim().replace(/^\/+|\/+$/g, '');
18
+ if (!trimmed) {
19
+ throw new Error(`[gaon g page] 페이지 경로가 비어 있습니다.\n` +
20
+ ` → 예: gaon g page Posts/Index (apps/${app}/pages/Posts/Index.vue 생성)`);
21
+ }
22
+ const segments = trimmed.split('/').filter(Boolean);
23
+ const last = segments[segments.length - 1];
24
+ const parent = segments[segments.length - 2] ?? last;
25
+ // 라우트 키 기본값 — 폴더는 리소스명(복수·소문자), 파일은 액션명(소문자).
26
+ const routeKey = `${toCamel(parent).toLowerCase()}#${toCamel(last).toLowerCase()}`;
27
+ const filePath = `apps/${app}/pages/${trimmed}.vue`;
28
+ const lines = [
29
+ `<script setup lang="ts">`,
30
+ `import { pageProps } from 'gaonjs/vue'`,
31
+ ``,
32
+ `// 컨트롤러 ${routeKey} 의 render props 타입이 그대로 흐른다(§6.2).`,
33
+ `// 라우트 키가 다르면 이 리터럴을 바꾼다 — .gaon/routes.d.ts 가 유효한 키를 알려준다.`,
34
+ `const props = pageProps<'${routeKey}'>()`,
35
+ `</script>`,
36
+ ``,
37
+ `<template>`,
38
+ ` <div>`,
39
+ ` <h1>${last}</h1>`,
40
+ ` <pre>{{ props }}</pre>`,
41
+ ` </div>`,
42
+ `</template>`,
43
+ ``,
44
+ ];
45
+ return { path: filePath, contents: lines.join('\n') };
46
+ }
@@ -0,0 +1,18 @@
1
+ export interface ServeCommandOptions {
2
+ readonly cwd?: string;
3
+ readonly json?: boolean;
4
+ /** 리슨 포트. 우선순위: 옵션 > config.web.port > env PORT > 3000. */
5
+ readonly port?: number;
6
+ /** 리슨 호스트. 우선순위: 옵션 > config.web.host > '0.0.0.0'. */
7
+ readonly host?: string;
8
+ /** 프로세스 시그널(테스트 주입). 기본 process. */
9
+ readonly signals?: {
10
+ on(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
11
+ off(sig: 'SIGINT' | 'SIGTERM', fn: () => void): void;
12
+ };
13
+ }
14
+ /**
15
+ * `gaon serve` 진입점. loadDotEnv → loadGaonConfig → wireGaon → listen →
16
+ * SIGINT 대기 → graceful close. 예외는 stderr + exit 1.
17
+ */
18
+ export declare function runServeCommand(opts?: ServeCommandOptions): Promise<void>;
package/dist/serve.js ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * @gaonjs/cli · `gaon serve` — 웹 서버 부팅 (§7 · M9-A)
3
+ *
4
+ * 운영 프로세스 3종 중 하나(serve · work · hub). cwd 관례로 gaon.config.ts
5
+ * 를 로드하고, apps/* 를 스캔해 자동 배선한 뒤 Fastify 를 listen 한다.
6
+ * SIGINT/SIGTERM 에 graceful 종료(Fastify → NATS → Redis → DB 순).
7
+ *
8
+ * env 를 먼저 로드한다 — .env 의 값이 gaon.config.ts 안의 env('KEY') 에
9
+ * 들어갈 수 있어야 하기 때문.
10
+ *
11
+ * 워커 수(--workers, WEB_CONCURRENCY)는 v0.8 정본이지만 컨테이너 안 기본
12
+ * 1워커라 M9-A 는 단일 프로세스만 다룬다. node:cluster 통합은 후속(M9-C).
13
+ */
14
+ import { loadDotEnv } from '@gaonjs/core';
15
+ import { loadGaonConfig, wireGaon, findConfigPath } from '@gaonjs/config';
16
+ import { registerTsResolve } from './tsResolve.js';
17
+ function humanEvent(e) {
18
+ switch (e.kind) {
19
+ case 'starting': {
20
+ const cfg = e.configPath ? `config=${e.configPath}` : 'config=(없음 · 기본값)';
21
+ const apps = e.apps.length > 0 ? `apps=[${e.apps.join(', ')}]` : 'apps=(없음)';
22
+ return ` gaon serve · 시작 — ${cfg} · ${apps}`;
23
+ }
24
+ case 'listening':
25
+ return ` ▶ 리슨 중 — ${e.url} (Ctrl+C 로 종료)`;
26
+ case 'stopping':
27
+ return ' gaon serve · 종료 중 (graceful) ...';
28
+ case 'stopped':
29
+ return ' gaon serve · 종료';
30
+ }
31
+ }
32
+ /**
33
+ * `gaon serve` 진입점. loadDotEnv → loadGaonConfig → wireGaon → listen →
34
+ * SIGINT 대기 → graceful close. 예외는 stderr + exit 1.
35
+ */
36
+ export async function runServeCommand(opts = {}) {
37
+ const cwd = opts.cwd ?? process.cwd();
38
+ const json = opts.json ?? false;
39
+ const signals = opts.signals ?? process;
40
+ // .ts 상대 import 해석기 등록 (사용자 gaon.config.ts, apps/* 로드에 필요).
41
+ registerTsResolve();
42
+ loadDotEnv(cwd);
43
+ const emit = (e) => {
44
+ if (json)
45
+ process.stdout.write(JSON.stringify(e) + '\n');
46
+ else
47
+ process.stdout.write(humanEvent(e) + '\n');
48
+ };
49
+ const configPath = findConfigPath(cwd);
50
+ const config = await loadGaonConfig(cwd);
51
+ const wired = await wireGaon(config, cwd);
52
+ emit({ kind: 'starting', configPath, apps: wired.apps.map((a) => a.name) });
53
+ const host = opts.host ?? config.web?.host ?? '0.0.0.0';
54
+ const port = opts.port ??
55
+ config.web?.port ??
56
+ (process.env.PORT ? Number(process.env.PORT) : 3000);
57
+ await wired.app.listen({ host, port });
58
+ const displayHost = host === '0.0.0.0' ? 'localhost' : host;
59
+ emit({ kind: 'listening', host, port, url: `http://${displayHost}:${port}` });
60
+ await new Promise((resolvePromise) => {
61
+ const stop = () => {
62
+ signals.off('SIGINT', stop);
63
+ signals.off('SIGTERM', stop);
64
+ emit({ kind: 'stopping' });
65
+ void wired
66
+ .close()
67
+ .catch((err) => {
68
+ const msg = err instanceof Error ? err.message : String(err);
69
+ process.stderr.write(` ✗ 종료 중 오류: ${msg}\n`);
70
+ })
71
+ .finally(() => {
72
+ emit({ kind: 'stopped' });
73
+ resolvePromise();
74
+ });
75
+ };
76
+ signals.on('SIGINT', stop);
77
+ signals.on('SIGTERM', stop);
78
+ });
79
+ }
@@ -4,7 +4,7 @@ import { User } from '../../domain/models/user.js'
4
4
 
5
5
  // 세션에 심긴 userId 로 사용자를 로드한다(§7). web 은 도메인을 loadUser 로 받는다.
6
6
  export const loadUser: AuthOptions['loadUser'] = async (id) =>
7
- await User.query().where('id', '=', BigInt(String(id))).first()
7
+ await User.where('id', '=', BigInt(String(id))).first()
8
8
 
9
9
  // this.currentUser 에 User 필드 타입을 얹는다(GaonRouteMap 과 동일 관례).
10
10
  declare module 'gaonjs/web' {
@@ -10,7 +10,7 @@ export default controller({
10
10
  // POST /session — 로그인
11
11
  async create() {
12
12
  const { email, password } = this.params({ _row: {} as { email: string; password: string } })
13
- const user = await User.query().where('email', '=', email).first()
13
+ const user = await User.where('email', '=', email).first()
14
14
  // passwordDigest 는 hidden 이지만 서버 코드에서는 투명하게 읽힌다(§4.2).
15
15
  if (user && (await verifyPassword(password, user.passwordDigest))) {
16
16
  this.auth.login(user)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gaonjs/cli",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Gaon CLI 구현: 제너레이터·스캐폴딩·로드맵 출력 (M1 스텁)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,11 +25,12 @@
25
25
  ],
26
26
  "dependencies": {
27
27
  "typescript": "*",
28
+ "@gaonjs/config": "0.1.0",
28
29
  "@gaonjs/async": "0.2.2",
29
30
  "@gaonjs/core": "0.1.4",
30
- "@gaonjs/data": "0.2.3",
31
- "@gaonjs/mail": "0.1.0",
32
- "@gaonjs/web": "0.3.0"
31
+ "@gaonjs/data": "0.3.0",
32
+ "@gaonjs/web": "0.3.0",
33
+ "@gaonjs/mail": "0.1.0"
33
34
  },
34
35
  "scripts": {
35
36
  "build": "node ../../node_modules/typescript/bin/tsc -p tsconfig.json && node -e \"require('fs').cpSync('src/templates','dist/templates',{recursive:true})\""