@gaonjs/cli 0.42.3 → 0.47.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 (42) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/db.js +29 -7
  3. package/dist/commands/new.d.ts +2 -0
  4. package/dist/commands/new.js +8 -3
  5. package/dist/db/reset.js +20 -10
  6. package/dist/db/resolve.d.ts +15 -0
  7. package/dist/db/resolve.js +29 -0
  8. package/dist/doctor/pageprops-destructure.d.ts +2 -2
  9. package/dist/doctor/pageprops-destructure.js +29 -23
  10. package/dist/doctor/schema-relations.js +6 -1
  11. package/dist/doctor.js +1 -1
  12. package/dist/index.d.ts +6 -0
  13. package/dist/index.js +18 -2
  14. package/dist/mcp/tools.js +15 -1
  15. package/dist/scaffold/job.js +3 -1
  16. package/dist/templates/project/.dockerignore.tpl +3 -0
  17. package/dist/templates/project/.env.example.tpl +1 -1
  18. package/dist/templates/project/AGENTS.md.tpl +3 -2
  19. package/dist/templates/project/CLAUDE.md.tpl +4 -3
  20. package/dist/templates/project/Dockerfile.tpl +6 -1
  21. package/dist/templates/project/agents/async.md.tpl +44 -6
  22. package/dist/templates/project/agents/data.md.tpl +206 -15
  23. package/dist/templates/project/agents/frontend.md.tpl +36 -8
  24. package/dist/templates/project/agents/mail.md.tpl +2 -1
  25. package/dist/templates/project/agents/realtime.md.tpl +26 -2
  26. package/dist/templates/project/agents/seal.md.tpl +9 -1
  27. package/dist/templates/project/agents/security.md.tpl +18 -0
  28. package/dist/templates/project/agents/storage.md.tpl +15 -8
  29. package/dist/templates/project/agents/web.md.tpl +38 -2
  30. package/dist/templates/project/apps/web/composables/useApiPing.ts.tpl +4 -3
  31. package/dist/templates/project/apps/web/controllers/home.ts.tpl +1 -1
  32. package/dist/templates/project/apps/web/main.ts.tpl +1 -1
  33. package/dist/templates/project/apps/web/routes.ts.tpl +1 -1
  34. package/dist/templates/project/docker-compose.yaml.tpl +1 -1
  35. package/dist/templates/project/pnpm-workspace.yaml.tpl +1 -1
  36. package/dist/templates/project/vite.config.ts.tpl +1 -1
  37. package/dist/work.d.ts +21 -0
  38. package/dist/work.js +45 -1
  39. package/package.json +11 -6
  40. package/dist/doctor/shared-composable-purity.d.ts +0 -8
  41. package/dist/doctor/shared-composable-purity.js +0 -164
  42. package/dist/templates/index.ts +0 -109
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Gaon CLI — `gaon` 명령. 프로젝트 스캐폴드(`gaon new`), 개발 스택 통합(`gaon dev`),
4
4
  제너레이터(`gaon g auth`·scaffold), 마이그레이션(`gaon db …`), 통합 검사
5
- (`gaon check`), 정적 검사(`gaon doctor` · 20종), 운영 부팅(`gaon serve`). 모든
5
+ (`gaon check`), 정적 검사(`gaon doctor` · `--fix`), 운영 부팅(`gaon serve`). 모든
6
6
  명령은 `--json` 출력을 지원합니다.
7
7
 
8
8
  이 패키지는 [`gaonjs`](https://www.npmjs.com/package/gaonjs) 파사드에 포함됩니다 —
@@ -11,7 +11,7 @@ import { runDbDiff } from '../db/diff.js';
11
11
  import { runDbMigrate } from '../db/migrate.js';
12
12
  import { runDbReset } from '../db/reset.js';
13
13
  import { runDbStatus } from '../db/status.js';
14
- import { listConnectionKeys } from '../db/resolve.js';
14
+ import { listConnectionKeysByKind } from '../db/resolve.js';
15
15
  import { runDbSeedCommand } from '../db.js';
16
16
  /** 전 커넥션 순회 대상 서브커맨드(결정 139 · reset 은 파괴적이라 제외 · 단일 유지). */
17
17
  const MULTI_CONN_SUBS = new Set(['diff', 'migrate', 'status', 'seed']);
@@ -57,14 +57,29 @@ export async function runDbCommand(subcommand, opts = {}) {
57
57
  // `--db <키>` 는 단일 좁힘. reset(파괴적)은 항상 단일(main 폴백).
58
58
  const wantsAll = subcommand !== 'reset' && (opts.db === undefined || opts.db === 'all');
59
59
  if (wantsAll && MULTI_CONN_SUBS.has(subcommand)) {
60
- const keys = await listConnectionKeys(cwd, configPath);
61
- // 단일 커넥션이면 종전 출력과 100% 동일(하위 호환).
62
- if (keys.length === 1) {
60
+ // 결정 292: 문서형(mongodb) 커넥션은 마이그레이션이 없어(§7.4) 순회에서 **건너뛴다** —
61
+ // 종전에는 mongo 키가 실패로 계상돼 mongo 커넥션이 하나라도 있으면 항상 exit 1 이었다.
62
+ // `--db <mongo키>` 명시 지정은 아래 단일 경로에서 여전히 fail-loud(resolveDbTarget).
63
+ const { sql: keys, mongo: mongoKeys } = await listConnectionKeysByKind(cwd, configPath);
64
+ if (keys.length === 0) {
65
+ // 문서형만 있는 프로젝트 — 마이그레이션 대상 없음 = 성공 no-op(수리 대상 아님).
66
+ const note = `문서형(mongodb) 커넥션 ${mongoKeys.join(', ')} 은 마이그레이션 대상이 아닙니다(§7.4) — SQL 커넥션 없음, ${subcommand} 할 것 없음.`;
67
+ emit(` ○ ${note}`, {
68
+ command: subcommand,
69
+ connections: [],
70
+ skipped: mongoKeys.map((db) => ({ db, adapter: 'mongodb', reason: 'documental-no-migration' })),
71
+ ok: true,
72
+ results: [],
73
+ });
74
+ return 0;
75
+ }
76
+ // 단일 SQL 커넥션 + 문서형 없음이면 종전 출력과 100% 동일(하위 호환).
77
+ if (keys.length === 1 && mongoKeys.length === 0) {
63
78
  const r = await runOne(keys[0]);
64
79
  emit(r.text, r.json);
65
80
  return r.exitCode;
66
81
  }
67
- return runAcrossConnections(subcommand, keys, runOne, emit, json);
82
+ return runAcrossConnections(subcommand, keys, runOne, emit, json, mongoKeys);
68
83
  }
69
84
  // 단일 커넥션 경로(--db <키> · reset · env-only 폴백) — 종전 동작 그대로.
70
85
  if (subcommand === 'reset' && opts.db === 'all') {
@@ -97,7 +112,7 @@ export async function runDbCommand(subcommand, opts = {}) {
97
112
  * 실패해도 나머지를 계속한다(부분 적용 방지가 아니라 부분 진행 후 전체 보고 — 어느
98
113
  * 커넥션이 성공/실패했는지가 사용자에게 필요). exitCode = 하나라도 실패면 1.
99
114
  */
100
- async function runAcrossConnections(subcommand, keys, runOne, emit, json) {
115
+ async function runAcrossConnections(subcommand, keys, runOne, emit, json, skippedMongo = []) {
101
116
  const results = [];
102
117
  for (const key of keys) {
103
118
  try {
@@ -121,12 +136,19 @@ async function runAcrossConnections(subcommand, keys, runOne, emit, json) {
121
136
  emit('', {
122
137
  command: subcommand,
123
138
  connections: keys,
139
+ // 결정 292: 문서형(mongodb) 커넥션은 순회 대상이 아니다 — skipped 로 명시 보고.
140
+ ...(skippedMongo.length > 0
141
+ ? { skipped: skippedMongo.map((db) => ({ db, adapter: 'mongodb', reason: 'documental-no-migration' })) }
142
+ : {}),
124
143
  ok: failed.length === 0,
125
144
  results: results.map((r) => ({ db: r.db, ok: r.ok, ...r.json })),
126
145
  });
127
146
  return exitCode;
128
147
  }
129
- const header = ` gaon db ${subcommand} · 커넥션 ${keys.length}개 순회: ${keys.join(', ')}`;
148
+ const skippedNote = skippedMongo.length > 0
149
+ ? `\n ○ 문서형(mongodb) 커넥션 건너뜀: ${skippedMongo.join(', ')} — 문서형은 마이그레이션이 없습니다(§7.4 · 인덱스는 Mongoose 스키마 선언).`
150
+ : '';
151
+ const header = ` gaon db ${subcommand} · 커넥션 ${keys.length}개 순회: ${keys.join(', ')}${skippedNote}`;
130
152
  const sections = results.map((r) => ` ── [${r.db}] ${'─'.repeat(Math.max(0, 40 - r.db.length))}\n${r.text}`);
131
153
  const footer = failed.length === 0
132
154
  ? ` ✓ 전 커넥션 완료(${keys.length}개).`
@@ -44,6 +44,8 @@ export interface NewCommandResult {
44
44
  readonly totalMs: number;
45
45
  readonly error?: string;
46
46
  }
47
+ /** 이름 유효성 — npm 패키지명 규칙(단순 부분)만 검사. */
48
+ export declare function validateProjectName(name: string): void;
47
49
  /**
48
50
  * `gaon new <name>` 실행. 파일 생성 → pnpm install → git init 순서.
49
51
  * 각 단계는 옵션으로 스킵 가능. 반환값은 프로세스 종료 코드(0=성공).
@@ -26,7 +26,7 @@ import { renderProjectFiles, PACKAGE_MANAGER_PINS, } from '../templates/index.js
26
26
  import { writeUiKitScaffold } from '../uikit.js';
27
27
  import { regenerateProjectGaon } from './gen.js';
28
28
  /** 이름 유효성 — npm 패키지명 규칙(단순 부분)만 검사. */
29
- function validateProjectName(name) {
29
+ export function validateProjectName(name) {
30
30
  if (!name)
31
31
  throw new Error('프로젝트 이름이 비어 있습니다.');
32
32
  if (name.includes('/') || name.includes('\\')) {
@@ -35,8 +35,13 @@ function validateProjectName(name) {
35
35
  if (name.includes('..')) {
36
36
  throw new Error(`프로젝트 이름에 '..' 는 사용할 수 없습니다: ${name}`);
37
37
  }
38
- if (!/^[a-z0-9][a-z0-9_-]*$/i.test(name)) {
39
- throw new Error(`프로젝트 이름은 영문/숫자/-/_ 시작해 같은 문자만 씁니다: ${name}\n` +
38
+ // 결정 315: 소문자·숫자·하이픈만 — 이름이 compose 프로젝트명(소문자만 유효)
39
+ // MinIO/S3 버킷명(`_`·대문자 불허)으로 그대로 치환되므로(docker-compose.yaml.tpl),
40
+ // 대문자·언더스코어를 받으면 gaon dev 인프라 기동·첫 업로드가 뒤늦게 깨진다.
41
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
42
+ throw new Error(`프로젝트 이름은 소문자 영문/숫자로 시작해 소문자/숫자/하이픈(-)만 씁니다: ${name}\n` +
43
+ ` → 이름은 Docker Compose 프로젝트명과 스토리지 버킷명으로 그대로 쓰여\n` +
44
+ ` 대문자·언더스코어(_)는 인프라 기동·버킷 생성에서 실패합니다.\n` +
40
45
  ` → 예: gaon new my-app · gaon new demo`);
41
46
  }
42
47
  }
package/dist/db/reset.js CHANGED
@@ -10,7 +10,7 @@
10
10
  import { existsSync } from 'node:fs';
11
11
  import { join } from 'node:path';
12
12
  import { sql } from 'kysely';
13
- import { snapshotFromDb, MIGRATIONS_TABLE } from '@gaonjs/data';
13
+ import { snapshotFromDb, orderTableDropsByFk, MIGRATIONS_TABLE } from '@gaonjs/data';
14
14
  import { resolveDbTarget } from './resolve.js';
15
15
  import { runDbMigrate } from './migrate.js';
16
16
  import { runDbSeedCommand } from '../db.js';
@@ -71,7 +71,8 @@ export async function runDbReset(opts) {
71
71
  // 현재 DB 스냅샷 — 지울 테이블 목록. dialect.introspect 는 이미
72
72
  // _gaon_migrations 를 제외하지만, reset 은 이력도 지워야 하므로 별도 처리.
73
73
  const snapshot = await snapshotFromDb(target.db, target.dialect);
74
- const userTables = Object.keys(snapshot).sort();
74
+ // FK 역순(자식 먼저)으로 정렬해 mysql(CASCADE 없음) 다중 drop 이 FK 위반 없이 돌게 한다(F4 · 결정 275).
75
+ const userTables = orderTableDropsByFk(snapshot, Object.keys(snapshot).sort());
75
76
  // dry-run: 무엇을 지울지·만들지를 계산해 출력. 실제 파괴는 안 함.
76
77
  if (opts.dryRun) {
77
78
  const dropSqls = userTables.map((t) => target.dialect.dropTable(t));
@@ -93,15 +94,24 @@ export async function runDbReset(opts) {
93
94
  };
94
95
  }
95
96
  else {
96
- // DROP ALL — 이력 테이블도 함께. 순서: 외래키 참조 대상이 나중에 지워지도록
97
- // dialect.dropTable CASCADE 를 붙인다(postgres). mysql 은 SET FOREIGN_KEY_CHECKS
98
- // 우회가 필요하지만, 스키마 대상 테이블만 다루므로 대체로 안전.
99
- for (const t of userTables) {
100
- await sql.raw(target.dialect.dropTable(t)).execute(target.db);
101
- droppedTables.push(t);
97
+ // DROP ALL — 이력 테이블도 함께. pg dropTable CASCADE, mysql 은 CASCADE 가 없어
98
+ // FK 역순 정렬(위 orderTableDropsByFk) 안전하게 지운다. 순환 FK 방어로 mysql 은
99
+ // FOREIGN_KEY_CHECKS 잠시 끈다(F4 · 결정 275).
100
+ const isMysql = target.dialect.name === 'mysql';
101
+ if (isMysql)
102
+ await sql.raw('set foreign_key_checks = 0').execute(target.db);
103
+ try {
104
+ for (const t of userTables) {
105
+ await sql.raw(target.dialect.dropTable(t)).execute(target.db);
106
+ droppedTables.push(t);
107
+ }
108
+ // 이력 테이블 삭제 — 존재하지 않아도 에러 없게.
109
+ await sql.raw(`drop table if exists ${MIGRATIONS_TABLE}`).execute(target.db);
110
+ }
111
+ finally {
112
+ if (isMysql)
113
+ await sql.raw('set foreign_key_checks = 1').execute(target.db);
102
114
  }
103
- // 이력 테이블 삭제 — 존재하지 않아도 에러 없게.
104
- await sql.raw(`drop table if exists ${MIGRATIONS_TABLE}`).execute(target.db);
105
115
  }
106
116
  }
107
117
  finally {
@@ -33,4 +33,19 @@ export interface ResolvedDbTarget {
33
33
  * 폴백(GAON_DATABASE_URL 관례) — 단일 커넥션 프로젝트의 종전 동작과 정합.
34
34
  */
35
35
  export declare function listConnectionKeys(cwd: string, configPath?: string): Promise<string[]>;
36
+ /** SQL 키 / 문서형(mongodb) 키 분리 목록 — 전 커넥션 순회의 skip 판단용(결정 292). */
37
+ export interface ConnectionKeysByKind {
38
+ /** SQL(postgres·mysql) 커넥션 키 — diff/migrate/status/seed 순회 대상. */
39
+ readonly sql: string[];
40
+ /** 문서형(mongodb) 커넥션 키 — 마이그레이션 없음(§7.4) · 순회에서 건너뛴다. */
41
+ readonly mongo: string[];
42
+ }
43
+ /**
44
+ * 전 커넥션 순회(결정 139)용으로 커넥션 키를 SQL/문서형으로 갈라 돌려준다(결정 292).
45
+ * 문서형(mongodb)은 마이그레이션이 없어(§7.4) 순회에서 **건너뛰는** 대상이다 —
46
+ * 종전에는 순회가 mongo 키를 실패로 계상해 `gaon db migrate` 가 항상 exit 1 이었다.
47
+ * `--db <mongo키>` 명시 지정은 여전히 resolveDbTarget 이 fail-loud 로 안내한다.
48
+ * config 선언이 없으면(env-only) SQL ['main'] 폴백 — listConnectionKeys 와 정합.
49
+ */
50
+ export declare function listConnectionKeysByKind(cwd: string, configPath?: string): Promise<ConnectionKeysByKind>;
36
51
  export declare function resolveDbTarget(opts: ResolveDbOptions): Promise<ResolvedDbTarget>;
@@ -97,6 +97,28 @@ export async function listConnectionKeys(cwd, configPath) {
97
97
  const keys = Object.keys(config.db ?? {});
98
98
  return keys.length ? keys : ['main'];
99
99
  }
100
+ /**
101
+ * 전 커넥션 순회(결정 139)용으로 커넥션 키를 SQL/문서형으로 갈라 돌려준다(결정 292).
102
+ * 문서형(mongodb)은 마이그레이션이 없어(§7.4) 순회에서 **건너뛰는** 대상이다 —
103
+ * 종전에는 순회가 mongo 키를 실패로 계상해 `gaon db migrate` 가 항상 exit 1 이었다.
104
+ * `--db <mongo키>` 명시 지정은 여전히 resolveDbTarget 이 fail-loud 로 안내한다.
105
+ * config 선언이 없으면(env-only) SQL ['main'] 폴백 — listConnectionKeys 와 정합.
106
+ */
107
+ export async function listConnectionKeysByKind(cwd, configPath) {
108
+ const { config } = await loadDbConfig(resolvePath(cwd), configPath);
109
+ const entries = Object.entries(config.db ?? {});
110
+ if (entries.length === 0)
111
+ return { sql: ['main'], mongo: [] };
112
+ const sql = [];
113
+ const mongo = [];
114
+ for (const [key, cfg] of entries) {
115
+ if (cfg?.adapter === 'mongodb')
116
+ mongo.push(key);
117
+ else
118
+ sql.push(key);
119
+ }
120
+ return { sql, mongo };
121
+ }
100
122
  export async function resolveDbTarget(opts) {
101
123
  const cwd = resolvePath(opts.cwd);
102
124
  // 사용자가 --config <path> 를 준 경우 그 경로에서 default export 를 로드한다.
@@ -128,6 +150,13 @@ export async function resolveDbTarget(opts) {
128
150
  ` 등록된 키: ${known.length ? known.join(', ') : '(없음)'}\n` +
129
151
  ` → gaon.config.ts 의 db.${dbKey} 를 선언하고 다시 실행하세요.`);
130
152
  }
153
+ // 결정 279(§7.4): 문서형(mongodb) 커넥션은 마이그레이션이 없다 — SQL 전용 경로(diff/migrate/
154
+ // introspect)로 흘리지 않고 명확히 안내한다. 몽고 인덱스는 Mongoose 스키마 선언으로 관리한다.
155
+ if (connCfg.adapter === 'mongodb') {
156
+ throw new Error(`[gaon db] 커넥션 '${dbKey}' 은 문서형(mongodb)입니다 — 문서형은 마이그레이션이 없습니다(§7.4).\n` +
157
+ ` → 몽고 인덱스는 Mongoose 스키마 선언(index:true / schema.index())으로 관리합니다(mongoose autoIndex 기본값이 부팅 시 보장).\n` +
158
+ ` → gaon db diff/migrate 는 SQL 커넥션(postgres·mysql)에만 실행하세요(키 생략 시 순회가 문서형을 자동으로 건너뜁니다 · 결정 292).`);
159
+ }
131
160
  const db = createDb(connCfg);
132
161
  registerConnection(dbKey, db, connCfg.adapter);
133
162
  const dialect = dialectFor(connCfg.adapter);
@@ -1,5 +1,5 @@
1
1
  import type { RuleReport } from './types.js';
2
- /** 소스에 pageProps() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
2
+ /** 소스에 pageProps()/useShared() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
3
3
  export declare function usesPagePropsDestructure(source: string): boolean;
4
- /** apps/ 의 .vue 를 훑어 pageProps() 구조분해를 경고로 낸다. */
4
+ /** apps/ 의 .vue + .ts(컴포저블 등)를 훑어 pageProps()/useShared() 구조분해를 경고로 낸다. */
5
5
  export declare function checkPagePropsDestructure(cwd: string): Promise<RuleReport>;
@@ -1,11 +1,16 @@
1
- // @gaonjs/cli · doctor · pageProps() 구조분해 검출 (결정 99 · 경고)
1
+ // @gaonjs/cli · doctor · pageProps()/useShared() 구조분해 검출 (결정 99 · 302 · 경고)
2
2
  //
3
- // `pageProps()` 는 반응형 프록시다 — 같은 페이지로의 리다이렉트/리로드로 서버가
4
- // 새 props 를 주면(댓글 작성·삭제 등) 재마운트 없이 화면이 즉시 갱신된다. 하지만
5
- // `const { posts } = pageProps<...>()` 처럼 **구조분해**하면 순간 값을 한 번
6
- // 읽어 반응성이 끊긴다(Vue defineProps 구조분해하면 되는 것과 같은 함정 ·
7
- // 실사용 블로그에서 preserveState:false 강제 리로드 우회로 나타난 근본 원인).
8
- // 검사가 구조분해를 경고로 잡는다. 판정은 소스 텍스트 기반(주석 제외).
3
+ // `pageProps()`·`useShared()` 는 반응형 프록시다 — 같은 페이지로의 리다이렉트/
4
+ // 리로드로 서버가 새 props 를 주면(댓글 작성·삭제·로그인 등) 재마운트 없이 화면이
5
+ // 즉시 갱신된다. 하지만 `const { posts } = pageProps<...>()`·`const { csrf } =
6
+ // useShared()` 처럼 **구조분해**하면 순간 값을 읽어 반응성이 끊긴다
7
+ // (Vue defineProps 구조분해하면 되는 것과 같은 함정 · 첫 실사용 블로그에서
8
+ // preserveState:false 강제 리로드 우회로 나타난 근본 원인). 검사가 구조분해를
9
+ // 경고로 잡는다. 판정은 소스 텍스트 기반(주석 제외).
10
+ //
11
+ // 결정 302: 문서(agents/frontend.md)는 useShared 구조분해도 같은 함정으로 경고해
12
+ // 왔는데 검사는 pageProps·`.vue` 만 봤다 — useShared 를 정규식에 더하고, pageProps/
13
+ // useShared 사용이 허용되는 앱 전용 컴포저블(`apps/**/*.ts`)까지 순회를 넓힌다.
9
14
  //
10
15
  // 오탐 방지: `const props = pageProps<...>()`(변수 바인딩)는 정상이므로 건드리지
11
16
  // 않는다 — 여는 중괄호 `{` 로 시작하는 구조분해 바인딩만 잡는다.
@@ -20,44 +25,45 @@ function stripCommentsKeepLines(source) {
20
25
  .replace(/<!--[\s\S]*?-->/g, blank)
21
26
  .replace(/(^|[^:])\/\/[^\n]*/g, (_m, p1) => p1 + ' '.repeat(_m.length - p1.length));
22
27
  }
23
- // `const|let|var { ... } = pageProps` — 구조분해 바인딩의 초기화가 pageProps 호출.
24
- // 제네릭 인자(`<'web:posts#index'>`)·공백·중첩 중괄호는 허용하되, `{` 로 시작하는
25
- // 구조분해만 잡는다(변수 바인딩 `const props =` 은 제외).
26
- const DESTRUCTURE_RE = /\b(?:const|let|var)\s*\{[^}]*\}\s*=\s*pageProps\b/g;
27
- /** 소스에 pageProps() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
28
+ // `const|let|var { ... } = pageProps|useShared` — 구조분해 바인딩의 초기화가 반응형
29
+ // 프록시 호출. 제네릭 인자(`<'web:posts#index'>`)·공백·중첩 중괄호는 허용하되,
30
+ // `{` 로 시작하는 구조분해만 잡는다(변수 바인딩 `const props =` 은 제외 · 결정 302).
31
+ const DESTRUCTURE_RE = /\b(?:const|let|var)\s*\{[^}]*\}\s*=\s*(pageProps|useShared)\b/g;
32
+ /** 소스에 pageProps()/useShared() 구조분해가 있는지(단위 테스트 진입점 · 주석은 제외). */
28
33
  export function usesPagePropsDestructure(source) {
29
34
  DESTRUCTURE_RE.lastIndex = 0;
30
35
  return DESTRUCTURE_RE.test(stripCommentsKeepLines(source));
31
36
  }
32
- /** apps/ 의 .vue 를 훑어 pageProps() 구조분해를 경고로 낸다. */
37
+ /** apps/ 의 .vue + .ts(컴포저블 등)를 훑어 pageProps()/useShared() 구조분해를 경고로 낸다. */
33
38
  export async function checkPagePropsDestructure(cwd) {
34
39
  const appsDir = join(cwd, 'apps');
35
40
  const issues = [];
36
- for (const abs of await walkVue(appsDir)) {
41
+ for (const abs of await walkFrontendSources(appsDir)) {
37
42
  const source = await readFile(abs, 'utf8').catch(() => '');
38
43
  const stripped = stripCommentsKeepLines(source);
39
44
  const rel = relative(cwd, abs);
40
45
  for (const m of stripped.matchAll(DESTRUCTURE_RE)) {
46
+ const callee = m[1] ?? 'pageProps';
41
47
  const line = stripped.slice(0, m.index ?? 0).split('\n').length;
42
48
  issues.push({
43
49
  rule: 'pageprops-destructure',
44
50
  level: 'warning',
45
51
  file: rel,
46
52
  line,
47
- message: `pageProps() 구조분해 발견: ${rel}:${line} 이 \`const { … } = pageProps(…)\` 로 ` +
48
- `props 구조분해합니다. pageProps() 는 반응형 프록시라, 구조분해하면 그 순간 값을 ` +
49
- `한 번 읽어 반응성이 끊깁니다 — 같은 페이지로의 리다이렉트/리로드(댓글 작성·삭제 등) ` +
50
- `후 새 데이터가 화면에 반영되지 않습니다(결정 99).\n` +
51
- `→ 변수로 받아 속성으로 접근하세요: \`const props = pageProps<''>()\` 후 ` +
52
- `\`props.posts\`. (Vue defineProps 를 구조분해하면 안 되는 것과 같은 이유입니다.)`,
53
+ message: `${callee}() 구조분해 발견: ${rel}:${line} 이 \`const { … } = ${callee}(…)\` 로 ` +
54
+ `값을 구조분해합니다. ${callee}() 는 반응형 프록시라, 구조분해하면 그 순간 값을 ` +
55
+ `한 번 읽어 반응성이 끊깁니다 — 같은 페이지로의 리다이렉트/리로드(댓글 작성·삭제·로그인 등) ` +
56
+ `후 새 데이터가 화면에 반영되지 않습니다(결정 99 · 302).\n` +
57
+ `→ 변수로 받아 속성으로 접근하세요: \`const v = ${callee}(…)\` 후 ` +
58
+ `\`v.<필드>\`. (Vue defineProps 를 구조분해하면 안 되는 것과 같은 이유입니다.)`,
53
59
  detail: { file: rel, line },
54
60
  });
55
61
  }
56
62
  }
57
63
  return { rule: 'pageprops-destructure', issues };
58
64
  }
59
- /** apps/ 하위 .vue 절대경로. */
60
- async function walkVue(dir) {
65
+ /** apps/ 하위 .vue·.ts 절대경로(결정 302 — 컴포저블 .ts 포함 · 생성 파일 .gaon 제외). */
66
+ async function walkFrontendSources(dir) {
61
67
  const out = [];
62
68
  const walk = async (d) => {
63
69
  let entries;
@@ -74,7 +80,7 @@ async function walkVue(dir) {
74
80
  continue;
75
81
  await walk(abs);
76
82
  }
77
- else if (e.isFile() && e.name.endsWith('.vue')) {
83
+ else if (e.isFile() && (e.name.endsWith('.vue') || (e.name.endsWith('.ts') && !e.name.endsWith('.d.ts')))) {
78
84
  out.push(abs);
79
85
  }
80
86
  }
@@ -13,12 +13,16 @@
13
13
  // belongsTo 를 검출할 수 있다(resolve.ts scanTables 는 단일 dbKey 로 거르므로 부적합).
14
14
  import { existsSync } from 'node:fs';
15
15
  import { join, relative } from 'node:path';
16
- import { scanSchemaDir, checkCrossConnectionRelations, checkRelationTargets, } from '@gaonjs/data';
16
+ import { scanSchemaDir, checkCrossConnectionRelations, checkRelationTargets, checkPartitions, } from '@gaonjs/data';
17
17
  import { registerTsResolve } from '../tsResolve.js';
18
18
  /** TableDef 판별 — scanSchemaDir 이 준 모듈 네임스페이스에서 table() 산출만 고른다. */
19
19
  function isTableDef(v) {
20
20
  return (typeof v === 'object' &&
21
21
  v !== null &&
22
+ // 결정 279(§4.3·§6.2): 문서형 collection() 산출(__gaonKind)은 크로스커넥션 관계 로드에서
23
+ // 제외한다 — SQL↔문서형 belongsTo/hasMany 는 애초에 관계 그래프에 오르지 않는다(추가 코드
24
+ // 없이 격리 · generator.ts isTableDef 와 동일 판별).
25
+ !('__gaonKind' in v) &&
22
26
  'name' in v &&
23
27
  'defs' in v &&
24
28
  'db' in v &&
@@ -108,6 +112,7 @@ export async function checkSchemaRelations(cwd) {
108
112
  const diags = [
109
113
  ...checkCrossConnectionRelations(tables),
110
114
  ...checkRelationTargets(tables),
115
+ ...checkPartitions(tables), // 결정 277 — 파티션 키 컬럼 존재 검사
111
116
  ];
112
117
  const issues = diags.map((d) => toCheck(d, fileOf, ownerOf(d.message)));
113
118
  return { rule: 'schema-relations', issues };
package/dist/doctor.js CHANGED
@@ -21,7 +21,7 @@
21
21
  * 17) method-override (결정 89 · _method HTTP 스푸핑 hack 경고)
22
22
  * 18) csrf-wiring (결정 93 · 비-GET 라우트 + session 미배선 = CSRF 무방비 경고)
23
23
  * 19) internal-anchor (결정 96 · 앱 내부 이동 일반 <a> = 풀 리로드 경고)
24
- * 20) pageprops-destructure (결정 99 · pageProps() 구조분해 = 반응성 끊김 경고)
24
+ * 20) pageprops-destructure (결정 99·302 · pageProps()/useShared() 구조분해 = 반응성 끊김 경고 · .vue+.ts)
25
25
  * 21) async-offload (결정 102·103 · 컨트롤러 인라인 메일·이미지·외부 HTTP = 응답 지연 경고)
26
26
  * 22) page-layout-breakpoint (결정 107 · 페이지 레이아웃 브레이크포인트 직접 사용 = 안내 경고)
27
27
  * 23) link-button-nesting (결정 113 · Link 로 Button 감싸기 = <a><button> 중첩 경고)
package/dist/index.d.ts CHANGED
@@ -64,6 +64,12 @@ export interface ParsedNewArgs {
64
64
  readonly packageManager?: "pnpm" | "npm" | "yarn";
65
65
  /** pm 값이 주어졌으나 pnpm·npm·yarn 이 아닐 때 그 원문(에러 안내용). */
66
66
  readonly unknownPm?: string;
67
+ /**
68
+ * 결정 314: 파사드 버전 스펙(예 `^1.21.0`). create-gaon 이 자기 발행 메타에서
69
+ * 해석해 주입한다 — npx 설치 트리엔 gaonjs 패키지가 없어 상향 탐색(결정 242)이
70
+ * fail-loud 로 죽기 때문. 미지정이면 기존 자동 감지 경로 그대로.
71
+ */
72
+ readonly gaonjsVersion?: string;
67
73
  }
68
74
  /**
69
75
  * `gaon new <name>` 의 인자를 파싱한다.
package/dist/index.js CHANGED
@@ -172,12 +172,22 @@ export function parseDoctorChecks(argv) {
172
172
  export function parseNewArgs(rest) {
173
173
  let name;
174
174
  let pmRaw;
175
+ let gaonjsVersion;
175
176
  for (let i = 0; i < rest.length; i++) {
176
177
  const a = rest[i];
177
178
  if (a === "--package-manager" || a === "--pm") {
178
179
  pmRaw = rest[++i];
179
180
  continue;
180
181
  }
182
+ // 결정 314: 값을 취하는 플래그라 pm 과 같은 이유로 값을 건너뛴다(이름 오인 방지).
183
+ if (a === "--gaonjs-version") {
184
+ gaonjsVersion = rest[++i];
185
+ continue;
186
+ }
187
+ if (a.startsWith("--gaonjs-version=")) {
188
+ gaonjsVersion = a.slice("--gaonjs-version=".length);
189
+ continue;
190
+ }
181
191
  const eqPrefix = a.startsWith("--package-manager=")
182
192
  ? "--package-manager="
183
193
  : a.startsWith("--pm=")
@@ -192,10 +202,15 @@ export function parseNewArgs(rest) {
192
202
  if (name === undefined)
193
203
  name = a;
194
204
  }
205
+ const version = gaonjsVersion === undefined || gaonjsVersion === "" ? undefined : gaonjsVersion;
195
206
  if (pmRaw === "pnpm" || pmRaw === "npm" || pmRaw === "yarn") {
196
- return { name, packageManager: pmRaw };
207
+ return { name, packageManager: pmRaw, gaonjsVersion: version };
197
208
  }
198
- return { name, unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw };
209
+ return {
210
+ name,
211
+ unknownPm: pmRaw === undefined || pmRaw === "" ? undefined : pmRaw,
212
+ gaonjsVersion: version,
213
+ };
199
214
  }
200
215
  /**
201
216
  * `gaon db <sub>` 의 인자를 파싱한다(argv = 전체 · argv[0]='db', argv[1]=sub).
@@ -588,6 +603,7 @@ export function runCli(argv, opts = {}) {
588
603
  skipInstall: argv.includes("--skip-install"),
589
604
  skipGit: argv.includes("--skip-git"),
590
605
  packageManager,
606
+ gaonjsVersion: parsed.gaonjsVersion,
591
607
  })
592
608
  .then((code) => {
593
609
  process.exitCode = code;
package/dist/mcp/tools.js CHANGED
@@ -201,6 +201,7 @@ export async function getSchemaTool(args, cwd) {
201
201
  index: t.constraints.index,
202
202
  check: t.constraints.check,
203
203
  },
204
+ partitionBy: t.partitionBy ? { strategy: t.partitionBy.strategy, columns: [...t.partitionBy.columns] } : undefined,
204
205
  }));
205
206
  const lines = [];
206
207
  if (tables.length === 0) {
@@ -231,10 +232,23 @@ export async function getSchemaTool(args, cwd) {
231
232
  const chk = t.constraints.check ?? [];
232
233
  if (uniq.length > 0)
233
234
  lines.push(` unique: ${uniq.map((u) => `[${u.join(', ')}]`).join(', ')}`);
235
+ // 인덱스 원소는 문자열 배열(btree) 또는 method/partial/표현식 객체(결정 273).
234
236
  if (idx.length > 0)
235
- lines.push(` index: ${idx.map((u) => `[${u.join(', ')}]`).join(', ')}`);
237
+ lines.push(` index: ${idx
238
+ .map((u) => {
239
+ if (Array.isArray(u))
240
+ return `[${u.join(', ')}]`;
241
+ const o = u;
242
+ const target = o.expr ?? `[${(o.cols ?? []).join(', ')}]`;
243
+ const usingSfx = o.using ? ` using ${o.using}` : '';
244
+ const whereSfx = o.where ? ` where ${o.where}` : '';
245
+ return `${target}${usingSfx}${whereSfx}`;
246
+ })
247
+ .join(', ')}`);
236
248
  if (chk.length > 0)
237
249
  lines.push(` check: ${chk.map(([n]) => n).join(', ')}`);
250
+ if (t.partitionBy)
251
+ lines.push(` partition: ${t.partitionBy.strategy} (${t.partitionBy.columns.join(', ')})`);
238
252
  }
239
253
  lines.push('');
240
254
  lines.push(`총 ${tables.length}개 테이블`);
@@ -67,7 +67,9 @@ export function jobTestScaffold(pascalName) {
67
67
  ``,
68
68
  `describe('${pascal} (실 NATS JetStream)', () => {`,
69
69
  ` it('발행한 잡이 워커에서 처리된다', async () => {`,
70
- ` const nats = await connectNats(process.env.NATS_URL ?? 'nats://localhost:4222')`,
70
+ // 결정 317: 무인자 connectNats 는 옵션 객체만 받는다(문자열은 조용히 무시됐다).
71
+ // 접속지는 NATS_URL env 폴백이 정본이다(agents/testing.md 와 동일).
72
+ ` const nats = await connectNats()`,
71
73
  ` try {`,
72
74
  ` await expectJobProcessed(${camel}, () => ${camel}.later({ id: 'test' }), { nats })`,
73
75
  ` } finally {`,
@@ -8,5 +8,8 @@ dist
8
8
  .git
9
9
  .env
10
10
  .env.*
11
+ # .env.example 은 예외로 포함한다 — gaon build 는 .gaon/env.d.ts 생성에 .env 가
12
+ # 필수(결정 198)라, 빌드 스테이지가 이를 복제해 임시 .env 를 만든다(결정 313).
13
+ !.env.example
11
14
  npm-debug.log*
12
15
  *.log
@@ -1,5 +1,5 @@
1
1
  # {{PROJECT_NAME}} 환경 변수 — gaon new 가 .env 로 자동 복제(결정 198). 값만 채워 쓰세요.
2
- # gaon.config.ts env('KEY')참조된다. gaon dev · gaon serve 가 자동 로드.
2
+ # gaon.config.ts process.env.KEY 로 참조한다. gaon dev · gaon serve 가 자동 로드.
3
3
 
4
4
  # DB — docker-compose.yaml 의 postgres 서비스와 정합.
5
5
  DATABASE_URL=postgres://{{PROJECT_NAME}}:{{PROJECT_NAME}}@127.0.0.1:5432/{{PROJECT_NAME}}_dev
@@ -132,12 +132,12 @@ Gaon 의 제1 설계 목표는 **"AI 가 개발을 가장 잘하는 프레임웍
132
132
  17. `method-override` — `_method` HTTP 메서드 스푸핑 hack(Gaon 미지원 · router.delete 를 쓰라) (결정 89 · 경고)
133
133
  18. `csrf-wiring` — 비-GET 라우트(POST/PUT/PATCH/DELETE)가 있는데 `app.config.ts` 에 session 미배선 = CSRF 무방비 (결정 93 · 경고)
134
134
  19. `internal-anchor` — 앱 내부 경로 일반 `<a href="/...">`(풀 리로드로 SPA 파손 · `Link`/`router.visit` 를 쓰라 · 외부 URL·`target="_blank"` 는 제외) (결정 96 · 경고)
135
- 20. `pageprops-destructure` — `const { x } = pageProps(…)` 구조분해(반응성 끊김 · 리다이렉트/리로드 후 갱신 안 됨 · `const props = pageProps(…)` 후 `props.x` 로 접근하라) (결정 99 · 경고)
135
+ 20. `pageprops-destructure` — `const { x } = pageProps(…)`/`useShared(…)` 구조분해(반응성 끊김 · 리다이렉트/리로드 후 갱신 안 됨 · `const props = pageProps(…)` 후 `props.x` 로 접근하라 · `.vue`+앱 `.ts` 공통) (결정 99 · 302 · 경고)
136
136
  21. `async-offload` — 컨트롤러 액션 인라인의 무거운/외부 작업(메일 SDK·이미지 처리 sharp/jimp·외부 HTTP)이 응답을 지연 (`domain/jobs/` 잡 + `.later()` 로 빼라 · JSON/API 앱 외부 호출·빠른 내부 호출은 오탐 방지로 제외) (결정 102·103 · 경고)
137
137
  22. `page-layout-breakpoint` — 페이지 파일이 레이아웃 브레이크포인트(`sm:flex-row`·`md:grid-cols-2` 등)를 직접 사용(반응형은 UI 킷 블록이 책임 · `PageShell` 등으로 감싸라 · 킷에 없는 표현이면 그대로 둬도 됨 · 표시/타이포/여백 반응형은 오탐 방지로 제외) (결정 107 · 안내 경고)
138
138
  23. `link-button-nesting` — `<Link><Button>…</Button></Link>` 이중 감싸기(`<a><button>` 중첩 · HTML 비준수·접근성 결함 · 버튼 모양 링크는 `<Button href="…">` 한 표면을 쓰라 · Link 직계 자식 Button 만 검출) (결정 113 · 경고)
139
139
  24. `seal-security` — `@gaonjs/seal` 을 켠 앱에서 (a) `gaon.config.ts` 가 진짜 방어층(rate limit·보안 헤더·CORS)을 **명시적으로 껐을** 때 = 봉인을 켜고 방어를 끄는 역전 **경고**, (b) `main.ts` 가 seal 클라이언트를 배선(`@gaonjs/seal/client` 정적 import + `createGaonApp` sealClient)하지 않았을 때 = 봉인 문서를 브라우저가 못 열어 blank 가 되는 **에러**(`gaon doctor --fix --yes` 의 `seal-client-wiring` fixer 가 자동 배선). seal 은 서버 검증을 대체하지 않는다 (결정 121·124 · `agents/seal.md`)
140
- 25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134 · `agents/data.md`)
140
+ 25. `schema-relations` — 커넥션을 가로지르는 belongsTo·역방향 관계(SQL 조인이 커넥션을 못 넘음)와 존재하지 않는 관계 대상 = **에러**(§4.5). **파티션 키 컬럼이 실제 컬럼인지도 검사**(결정 277 · `checkPartitions` — 오타·유령 컬럼). data 패키지 검사(`checkCrossConnectionRelations`·`checkRelationTargets`·`checkPartitions`)를 CLI 러너가 배선 — 배포 후 raw postgres 에러 대신 doctor 가 잡는다 (결정 134·277 · `agents/data.md`)
141
141
  26. `no-import-meta-env` — `.vue`(SFC) `<script>` 에서 `import.meta.env` 직접 사용 = **에러**. SFC 는 nodenext 아래 CommonJS 출력으로 분류돼 vue-tsc 가 TS1470 로 거부한다(`gaon check` red). 클라 공개 환경변수는 `import { env } from 'gaonjs/vue'` 로 읽으라(VITE_* 접두 제거·타입드 · `.gaon/env.d.ts` 는 `.env` 스캔 생성) — 템플릿 프로즈·주석의 언급은 오탐 제외 (결정 198 · `agents/frontend.md` §9)
142
142
  27. `locale-parity` — `locales/` 의 로케일 간 키 부분 누락 = **경고**. 어떤 키가 특정 로케일에만 빠지면 `messages.d.ts`(기준 로케일 기준)는 컴파일을 통과하고, 런타임에 그 로케일 사용자는 fallback(대개 다른 언어) 번역을 조용히 본다. 검사가 로케일 간 키 diff 를 계산해 빠진 파일·키를 짚는다(`--json` 은 `detail.missing` 으로 구조화). 로케일이 0·1개면 무소음 (결정 216 · `agents/i18n.md`)
143
143
 
@@ -252,6 +252,7 @@ gaon doctor # 정적 검사 27종 (§2.2)
252
252
  | `@gaonjs/core` | 메타데이터 · 직렬화 프리미티브(Hidden) |
253
253
  | `@gaonjs/mail` / `storage` / `i18n` | 메일 · 파일 스토리지 · 다국어 |
254
254
  | `@gaonjs/seal` | 페이로드 봉인(선택 플러그인 · `agents/seal.md`) |
255
+ | `@gaonjs/adapter-mongo` | MongoDB 문서형 `collection()` 어댑터(선택 · mongoose optional peer · `agents/data.md`) |
255
256
 
256
257
  ## 6. 원칙 · 엄수
257
258
 
@@ -9,7 +9,7 @@
9
9
 
10
10
  Gaon 프레임웍 문서: https://gaonjs.dev
11
11
 
12
- - 설계 정본(v0.15) · errata E-1(파사드 = `gaonjs`) · E-3(JSON 액션 +
12
+ - 설계 정본(v0.17) · errata E-1(파사드 = `gaonjs`) · E-3(JSON 액션 +
13
13
  `api()`) · E-4(컬럼 확장) · E-5(컴포저블·레이아웃)
14
14
  - 앱 내 One Way(§1) — 선택지가 있는 것을 만들지 않는다. 하나로 정한다.
15
15
 
@@ -17,8 +17,9 @@ Gaon 프레임웍 문서: https://gaonjs.dev
17
17
 
18
18
  1. **TypeScript 전용.** JS 파일 추가 금지. 데코레이터 금지 — 함수·객체
19
19
  스타일(`model()`·`controller()`·`job()`) 만 쓴다.
20
- 2. **`.gaon/` 자동 생성 파일 편집 금지.** `routes.d.ts`·`tables.d.ts`
21
- 는 `gaon check` / `gaon dev` 가 재생성한다.
20
+ 2. **`.gaon/` 자동 생성 파일 편집 금지.** `routes.d.ts`·`routes.manifest.ts
21
+ `tables.d.ts`·`messages.d.ts`·`env.d.ts` 는 `gaon check` / `gaon dev` /
22
+ `gaon gen` 이 재생성한다.
22
23
  3. **의존 방향 4규칙**(doctor 강제): 앱→domain 허용 · domain→앱 금지 ·
23
24
  앱→앱 금지 · 앱→shared 허용(shared 는 앱 import 금지, domain 은
24
25
  타입 import 만).
@@ -18,7 +18,12 @@ RUN pnpm install --frozen-lockfile
18
18
  FROM base AS build
19
19
  COPY --from=deps /app/node_modules ./node_modules
20
20
  COPY . .
21
- RUN pnpm build
21
+ # gaon build 는 .gaon/env.d.ts 생성에 .env 가 필수(결정 198)인데 .env 는 이미지에
22
+ # 넣지 않는다(.dockerignore) — .env.example 을 임시 복제해 빌드하고 즉시 지운다.
23
+ # 지우는 이유: example 의 placeholder(SESSION_SECRET 등)가 런타임에 남으면 compose
24
+ # 가 env 를 안 넘겼을 때 fail-loud 검증을 조용히 통과시킨다(결정 313). 런타임 env
25
+ # 는 compose.prod.yaml 의 environment 가 단일 소스다.
26
+ RUN cp .env.example .env && pnpm build && rm -f .env
22
27
 
23
28
  # 3) 런타임 — 소스 + 번들 + 의존을 그대로 실행.
24
29
  FROM base AS runtime